阅读本文的知识前提:熟悉 TypeScript + GraphQL + Node.js + Decorator + Dependency Inject 等概念。本文选用技术框架是 Midway.js,设计思路可以迁移到 Nest.js 等框架上,改动量应该不会太大。 本文长约 1.3w 字,阅读时间约 20min
恰逢最近需要编写一个简单的后端 Node.js 应用,由于是全新的小应用,没有历史包袱 ,所以趁着这次机会换了一种全新的开发模式:
前端内部写的后端应用基本上功能并不会太多(太专业的后端服务交给后端开发来做),绝大部分是基础的操作,在这样的情况下会涉及到很多重复工作量要做,基本都是一样的套路:
这意味着每次开发新应用都得重新来一遍 —— 这就跟前端平时切页面一样,重复劳动多了之后就内心还是比较烦的,甚至有抗拒心理。繁琐的事大概涉及在工程链路 & 业务代码这么两方面,如果有良好的解决方案,将大大提升开发的幸福感:
本文着重讲解第二部分,即如何使用 TypeScript + Decorator + DI 风格编写 Node.js 应用,让你感受到使用这些技术框架带来的畅快感。本文涉及的知识点比较多,主要是叙述逻辑思路,最后会以实现常见的 分页功能 作为案例讲解。
首先我们需要解决数据库相关的技术选项,这里说的技术选型是指 ORM 相关的技术选型(数据库固定使用 MySQL),选型的基本原则是能力强大、用法简单。
除了直接拼 SQL 语句这种略微硬核的方式外,Node.js 应用开发者更多地会选择使用开源的 ORM 库,如 Sequelize。而在 Typescript 面前,工具库层面目前两种可选项,可以使用 sequelize-typescript 或者 TypeORM 来进行数据库的管理。做了一下技术调研后,决定选用 TypeORM ,总结原因如下:
并非说 Sequelize-typescript 不行,这两个工具库都很强大,都能满足业务技术需求;Sequelize 一方面是 Model 定义方式比较 JS 化在 Typescript 天然的类型环境中显得有些怪异,所以我个人更加倾向于用 TypeORM 。
这里简单说明一下,ORM 架构模式中,最流行的实现模式有两种:Active Record
和 Data Mapper
。比如 Ruby 的 ORM 采取了 Active Record
的模式是这样的:
$user = new User;
$user->username = 'philipbrown';
$user->save();
再来看使用 Data Mapper
的 ORM 是这样的:
$user = new User;
$user->username = 'philipbrown';
EntityManager::persist($user);
现在我们察看到了它们最基本的区别:在 Active Record
中,领域对象有一个 save()
方法,领域对象通常会继承一个 ActiveRecord
的基类来实现。而在 Data Mapper
模式中,领域对象不存在 save()
方法,持久化操作由一个中间类来实现。
这两种模式没有谁比谁好之分,只有适不适合之别:
Active Records
模式的 ORM 框架更好Data Mapper
型,其允许将业务规则绑定到实体。
Active Records
模式最大优点是简单 , 直观, 一个类就包括了数据访问和业务逻辑,恰好我现在这个小应用基本都是单表操作,所以就用 Active Records
模式了。
这里主要涉及到修改 3 处地方。 首先,提供数据库初始化 service 类:
// src/lib/database/service.ts
import { config, EggLogger, init, logger, provide, scope, ScopeEnum, Application, ApplicationContext } from '@ali/midway';
import { ConnectionOptions, createConnection, createConnections, getConnection } from 'typeorm';
const defaultOptions: any = {
type: 'mysql',
synchronize: false,
logging: false,
entities: [
'src/app/entity/**/*.ts'
],
};
@scope(ScopeEnum.Singleton)
@provide()
export default class DatabaseService {
static identifier = 'databaseService';
// private connection: Connection;
/** 初始化数据库服务实例 */
static async initInstance(app: Application) {
const applicationContext: ApplicationContext = app.applicationContext;
const logger: EggLogger = app.getLogger();
// 手动实例化一次,启动数据库连接
const databaseService = await applicationContext.getAsync<DatabaseService>(DatabaseService.identifier);
const testResult = await databaseService.getConnection().query('SELECT 1+1');
logger.info('数据库连接测试:SELECT 1+1 =>', testResult);
}
@config('typeorm')
private ormconfig: ConnectionOptions | ConnectionOptions[];
@logger()
logger: EggLogger;
@init()
async init() {
const options = {
...defaultOptions,
...this.ormconfig
};
try {
if (Array.isArray(options)) {
await createConnections(options);
} else {
await createConnection(options);
}
this.logger.info('[%s] 数据库连接成功~', DatabaseService.name);
} catch (err) {
this.logger.error('[%s] 数据库连接失败!', DatabaseService.name);
this.logger.info('数据库链接信息:', options);
this.logger.error(err);
}
}
/**
* 获取数据库链接
* @param connectionName 数据库链接名称
*/
getConnection(connectionName?: string) {
return getConnection(connectionName);
}
}
说明:
@scope(ScopeEnum.Singleton)
,因为数据库连接服务只能有一个。但是可以初始化多个连接,比如用于多个数据库连接或读写分离defaultOptions
中的 entities
表示数据库实体对象存放的路径,推荐专门创建一个 entity 目录用来存放:其次,在 Midway 的配置文件中指定数据库连接配置:
// src/config/config.default.ts
export const typeorm = {
type: 'mysql',
host: 'xxxx',
port: 3306,
username: 'xxx',
password: 'xxxx',
database: 'xxxx',
charset: 'utf8mb4',
logging: ['error'], // ["query", "error"]
entities: [`${appInfo.baseDir}/entity/**/!(*.d|base){.js,.ts}`],
};
// server/src/config/config.local.ts
export const typeorm = {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
username: 'xxxx',
password: 'xxxx',
database: 'xxxx',
charset: 'utf8mb4',
synchronize: false,
logging: false,
entities: [`src/entity/**/!(*.d|base){.js,.ts}`],
}
说明:
entities
的配置项本地和线上配置是不同的,本地直接用 src/entity
就行,而 aone 环境需要使用 ${appInfo.baseDir}
变量最后,在应用启动时触发实例化:
// src/app.ts
import { Application } from '@ali/midway';
import "reflect-metadata";
import DatabaseService from './lib/database/service';
export default class AppBootHook {
readonly app: Application;
constructor(app: Application) {
this.app = app;
}
// 所有的配置已经加载完毕
// 可以用来加载应用自定义的文件,启动自定义的服务
async didLoad() {
await DatabaseService.initInstance(this.app);
}
}
说明:
AppBootHook
代码太多,我把初始化数据库服务实例的代码放在了 DatabaseService
类的静态方法中。数据库连接上之后,就可以直接使用 ORM 框架进行数据库操作。不同于现有的所有其他 JavaScript ORM 框架,TypeORM 支持 Active Record
和 Data Mapper
模式(在我这次写的项目中,使用的是 Active Record
模式),这意味着你可以根据实际情况选用合适有效的方法编写高质量的、松耦合的、可扩展的应用程序。
首先看一下用 Active Records
模式的写法:
import {Entity, PrimaryGeneratedColumn, Column, BaseEntity} from "typeorm";
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
age: number;
}
说明:
@Entity()
装饰BaseEntity
这个基类对应的业务域写法:
const user = new User();
user.firstName = "Timber";
user.lastName = "Saw";
user.age = 25;
await user.save();
------
其次看一下 Data Mapper
型的写法:
// 模型定义
import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
age: number;
}
说明:
@Entity()
装饰BaseEntity
这个基类对应的业务域逻辑是这样的:
const user = new User();
user.firstName = "Timber";
user.lastName = "Saw";
user.age = 25;
await repository.save(user);
无论是 Active Record
模式还是 Data Mapper
模式,**TypeORM 在 API 上的命名使用上几乎是保持一致,这大大降低了使用者记忆上的压力:**比如上方保存操作,都称为 save
方法,只不过前者是放在 Entity
实例上,后者是放在 Repository
示例上而已。
整个服务器的设计模式,就是经典的 MVC 架构,主要就是通过 Controller
、Service
、Model
、View
共同作用,形成了一套架构体系;
image.png
此图来源于 《Express 教程 4:路由和控制器》https://developer.mozilla.org/zh-CN/docs/learn/Server-side/Express_Nodejs/routes
上图是最为基础的 MVC 架构,实际开发过程中还会有更细分的优化,主要体现两方面:
现代 Node.js 框架初始化的时候都默认帮你做了这事情 —— Midway 也不例外,初始化后去看一下它的目录结构就基本上懂了。 更多关于该架构的实战可参考以下文章:
在 Midway 初始化项目的时候,其实已经具备完整的 RESTful API 的能力,你只要照样去扩展就可以了,而且基于装饰器语法和 DI 风格,编写路由非常的方便直观,正如官方《Midway - 路由装饰器》里所演示的代码那样,几行代码下来就输出标准的 RESTful 风格的 API:
import { provide, controller, inject, get } from 'midway';
@provide()
@controller('/user')
export class UserController {
@inject('userService')
service: IUserService;
@inject()
ctx;
@get('/:id')
async getUser(): Promise<void> {
const id: number = this.ctx.params.id;
const user: IUserResult = await this.service.getUser({id});
this.ctx.body = {success: true, message: 'OK', data: user};
}
}
RESTful API 方式用得比较多,不过我还是想在自己的小项目里使用 GraphQL,具体的优点我就不多说了,可以参考《GraphQL 和 Apollo 为什么能帮助你更快地完成开发需求?》等相关文章。 GraphQL 的理解成本和接入成本还是有一些的,建议直接通读官方文档 《GraphQL 入门》 去了解 GraphQL 中的概念和使用。
整体的技术选型阵容就是 apollo-server-koa 和 type-graphql :
只需要将 Koa 中间件 转 Midway 中间件就行。根据 Midway项目目录约定,在 /src/app/middleware/
下新建文件 graphql.ts
,将 apollo-server-koa 中间件简单包装一下:
import * as path from 'path';
import { Context, async, Middleware } from '@ali/midway';
import { ApolloServer, ServerRegistration } from 'apollo-server-koa';
import { buildSchemaSync } from 'type-graphql';
export default (options: ServerRegistration, ctx: Context) => {
const server = new ApolloServer({
schema: buildSchemaSync({
resolvers: [path.resolve(ctx.baseDir, 'resolver/*.ts')],
container: ctx.applicationContext
})
});
return server.getMiddleware(options);
};
说明:
apollo-server-koa
暴露的 getMiddleware
方法取得中间件函数,注入 TypeGraphQL 所管理的 schema
并导出该函数。
由于 Midway 默认集成了 CSRF 的安全校验,我们针对 /graphql
路径的这层安全需要忽略掉:
export const security = {
csrf: {
// 忽略 graphql 路由下的 csrf 报错
ignore: '/graphql'
}
}
接入的准备工作到这里就算差不多了,接下来就是编写 GraphQL 的 Resolver
相关逻辑
对于 Resolver
的处理,TypeGraphQL 提供了一些列的 Decorator
来声明和处理数据。通过 Resolver 类的方法来声明 Query
和 Mutation
,以及动态字段的处理 FieldResolver
。几个主要的 Decorator 说明如下:
@Resolver(of => Recipe)
返回的对象添加一个字段处理方法参数相关的 Decorator:
这里涉及到比较多的知识点,不可能一一罗列完,还是建议先去官网 https://typegraphql.com/docs/introduction.html 阅读一遍 接下来我们从接入开始,然后以如何创建一个 分页(Pagination) 功能为案例来演示在如何在 Midway 框架里使用 GraphQL,以及如何应用上述这些装饰器 。
从使用者角度来,我们希望传递的参数只有两个 pageNo
和 pageSize
,比如我想访问第 2 页、每页返回 10 条内容,入参格式就是:
{
pageNo: 2,
pageSize: 10
}
而分页返回的数据结构如下:
{
articles {
totalCount # 总数
pageNo # 当前页号
pageSize # 每页结果数
pages # 总页数
list: { # 分页结果
title,
author
}
}
}
首先利用 TypeGraphQL 提供的 Decorator
来声明入参类型以及返回结果类型:
// src/entity/pagination.ts
import { ObjectType, Field, ID, InputType } from 'type-graphql';
import { Article } from './article';
// 查询分页的入参
@InputType()
export class PaginationInput {
@Field({ nullable: true })
pageNo?: number;
@Field({ nullable: true })
pageSize?: number;
}
// 查询结果的类型
@ObjectType()
export class Pagination {
// 总共有多少条
@Field()
totalCount: number;
// 总共有多少页
@Field()
pages: number;
// 当前页数
@Field()
pageNo: number;
// 每页包含多少条数据
@Field()
pageSize: number;
// 列表
@Field(type => [Article]!, { nullable: "items" })
list: Article[];
}
export interface IPaginationInput extends PaginationInput { }
说明:
@ObjectType()
、@Field()
装饰注解后,会自动帮你生成 GraphQL 所需的 Schema 文件,可以说非常方便,这样就不用担心自己写的代码跟 Schema 不一致;list
字段,它的类型是 Article[]
,在使用 @Field
注解时需要注意,因为我们想表示数组一定存在但有可能为空数组情况,需要使用 {nullable: "items"}
(即 [Item]!
),具体查阅 官方文档 - Types and Fields 另外还有两种配置:{ nullable: true | false }
只能表示整个数组是否存在(即[Item!]
或者 [Item!]!
){nullable: "itemsAndList"}
(即 [Item]
)基于上述的 Schema 定义,接下来我们要写 Resolver
,用来解析用户实际的请求:
// src/app/resolver/pagination.ts
import { Context, inject, provide } from '@ali/midway';
import { Resolver, Query, Arg, Root, FieldResolver, Mutation } from 'type-graphql';
import { Pagination, PaginationInput } from '../../entity/pagination';
import { ArticleService } from '../../service/article';
@Resolver(of => Articles)
@provide()
export class PaginationResolver {
@inject('articleService')
articleService: ArticleService;
@Query(returns => Articles)
async articles(@Arg("query") pageInput: PaginationInput) {
return this.articleService.getArticleList(pageInput);
}
}
articleService.getArticleList
方法,只要让返回的结果跟我们想要的 Pagination
类型一致就行。articleService
对象就是通过容器注入(inject
)到当前 Resolver ,该对象的提供来自 Service 层从上可以看到,请求参数是传到 GraphQL 服务器,而真正进行分页操作的还是 Service 层,内部利用 ORM 提供的方法;在TypeORM 中的分页功能实现,可以参考一下官方的 find
选项的完整示例:
userRepository.find({
select: ["firstName", "lastName"],
relations: ["profile", "photos", "videos"],
where: {
firstName: "Timber",
lastName: "Saw"
},
order: {
name: "ASC",
id: "DESC"
},
skip: 5,
take: 10,
cache: true
});
其中和 分页 相关的就是 skip
和 take
两个参数( where
参数是跟 过滤 有关,order
参数跟排序有关)。
所以最终我们的 Service 核心层代码如下:
// server/src/service/article.ts
import { provide, logger, EggLogger, inject, Context } from '@ali/midway';
import { plainToClass } from 'class-transformer';
import { IPaginationInput, Pagination } from '../../entity/pagination';
...
@provide('articleService')
export class ArticleService {
...
/**
* 获取 list 列表,支持分页
*/
async getArticleList(query: IPaginationInput): Promise<Pagination> {
const {pageNo = 1, pageSize = 10} = query;
const [list, total] = await Article.findAndCount({
order: { create_time: "DESC" },
take: pageSize,
skip: (pageNo - 1) * pageSize
});
return plainToClass(Pagination, {
totalCount: total,
pages: Math.floor(total / pageSize) + 1,
pageNo: pageNo,
pageSize: pageSize,
list: list,
})
}
...
}
@provide('articleService')
向容器提供 articleService
对象实例,这就上面 Resolver 中的 @inject('articleService')
相对应plainToClass
方法进行一层转化Service 层其实也是调用 ORM 中的实体方法 Article.findAndCount
(由于我们是用** Active Records
**模式的),这个 Article
类就是 ORM 中的实体,其定义也非常简单:
// src/entity/article.ts
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";
import { InterfaceType, ObjectType, Field, ID } from 'type-graphql';
@Entity()
@InterfaceType()
export class Article extends BaseEntity {
@PrimaryGeneratedColumn()
@Field(type => ID)
id: number;
@Column()
@Field()
title: string;
@Column()
@Field()
author: string;
}
仔细观察,这里的 Article
类,同时接受了 TypeORM 和 TypeGraphQL 两个库的装饰器,寥寥几行代码就支持了 GraphQL 类型声明和 ORM 实体映射,非常清晰明了。
到这里一个简单的 GraphQL 分页功能就开发完毕,从流程步骤来看,一路下来几乎都是装饰器语法,整个编写过程干净利落,很利于后期的扩展和维护。
距离上次写 Node.js 后台应用有段时间了,当时的技术栈和现在的没法比,现在尤其得益于使用 Decorator(装饰器语法) + DI(依赖注入)风格写业务逻辑,再搭配使用 typeorm
(数据库的连接)、 type-graphql
(GraphQL的处理)工具库来使用,整体代码风格更加简洁,同样的业务功能,代码量减少非常可观且维护性也提升明显。
emm,这种感觉怎么描述合适呢?之前写 Node.js 应用时,能用,但是总觉得哪里很憋屈 —— 就像是白天在交通拥挤的道路上堵车,那种感觉有点糟;而这次混搭了这几种技术,会感受神清气爽 —— 就像是在高速公路上行车,畅通无阻。
前端的技术发展迭代相对来说迭代比较快,这是好事,能让你用新技术做得更少、收获地更多;当然不可否认这对前端同学也是挑战,需要你都保持不断学习的心态,去及时补充这些新的知识。学无止境,与君共勉。
本文完。
文章预告:因为依赖注入和控制反转的思想在 Node.js 应用特别重要,所以计划接下来要写一些文章来解释这种设计模式,然后再搭配一个依赖注入工具库的源码解读来加深理解,敬请期待。