本文同步自集体公众号“JSCON 简时空”,欢送关注:https://mp.weixin.qq.com/s/y74j1gxcJndxD21W5v-NUw
1. 前言
恰逢最近须要编写一个简略的后端 Node.js 利用,因为是全新的小利用,没有历史包袱,所以趁着这次机会换了一种全新的开发模式:
- 语言应用 TypeScript,不仅仅是强类型那么简略,它还提供很多高级语法糖,进步编程效率。
- 兼顾 Restful + GraphQL 形式提供数据接口,前两年 GraphQL 特地风行,最近这段时间有些平淡下来(当初比拟炽热的是 Serverless);GraphQL 这种查询语言对前端来讲还是很敌对的,本人写的话能缩小不少的接口开发量;
- 应用 Decorator(装璜器语法)+ DI(依赖注入)格调写业务逻辑。因后端 Java 开发服务的模式曾经十分成熟,前端在 Node.js 的开发模式基本上是按照 Java 那套开发模子来的,尤其是 DI(依赖注入)设计模式的编程思维。这几年随着 ECMAScript 的规范迭代,以及 TypeScript 的成熟倒退,在语言层面提供了很多现代化语法糖的反对,当初也能够利用 Decorator(装璜器)+ DI(依赖注入)格调来写了,集体认为这种格调也将成为书写 Node.js 利用的罕用范式之一。
- 选用反对 TS + Decorator + DI 的 Node.js 框架。市面上成熟的框架,如 Nest.js, Midway.js 等能够 —— 这类框架性能都很弱小,而且提供欠缺的工具链和生态,就算你不熟,通读他们的官网文档都能播种很多;本文 因工作内容缘故选用 Midway 框架。
前端外部写的后端利用基本上性能并不会太多(太业余的后端服务交给后端开发来做),绝大部分是根底的操作,在这样的状况下会波及到很多反复工作量要做,根本都是一样的套路:
- 初始化我的项目脚手架
- 数据库的连贯操作 + CRUD 操作
- 创立数据 Model 层 + Service 层
- 提供诸如 Restful 接口供多端生产
- …
这意味着每次开发新利用都得从新来一遍 —— 这就跟前端平时切页面一样,重复劳动多了之后就心田还是比较烦的,甚至有抗拒心理。繁琐的事大略波及在工程链路 & 业务代码这么两方面,如果有良好的解决方案,将大大晋升开发的幸福感:
- 第一个方面是构造目录的生成。这个问题比拟好解决,市面上成熟的框架(Nest.js, Midway.js,Prisma.io 等)都提供了相应的脚手架工具,间接生成相应的服务端代码构造,写代码既牢靠又高效。同时这类成熟框架都能一键搞定部署公布等流程,这样咱们就能够将大部分工夫用在业务代码上、而不是折腾环境搭建细节上。
- 第二个方面是业务代码的书写格调 。同样是写业务代码,语言格调不一样,代码效率也是不同的,你用 JS 写业务代码,跟 TypeScript + Decorator 来写的效率天壤之别 —— 这也就是技术倒退带来的福利。
本文着重解说第二局部 ,即如何应用 TypeScript + Decorator + DI 格调 编写 Node.js 利用,让你感触到应用这些技术框架带来的畅快感。本文波及的知识点比拟多,次要是叙述逻辑思路,最初会以实现常见的 分页性能 作为案例解说。
本文选用技术框架是 Midway.js,设计思路能够迁徙到 Nest.js 等框架上,改变量应该不会太大。
2. 数据库 ORM
首先咱们须要解决数据库相干的技术选项,这里说的技术选型是指 ORM 相干的技术选型(数据库固定应用 MySQL),选型的根本准则是能力弱小、用法简略。
2.1 ORM 选型
除了间接拼 SQL 语句这种稍微硬核的形式外,Node.js 利用开发者更多地会抉择应用开源的 ORM 库,如 Sequelize。而在 Typescript 背后,工具库层面目前两种可选项,能够应用 sequelize-typescript 或者 TypeORM 来进行数据库的治理。做了一下技术调研后,决定选用 TypeORM,总结起因如下:
- 原生类型申明,与 Typescript 有更好的相容性
- 反对装璜器写法,用法上简略直观;且足够强的扩大能力,能反对简单的数据操作;
- 该库足够受欢迎,Github Star 数量高达 20.3k(截止此文撰写 2020.08 时),且官网文档敌对
并非说 Sequelize-typescript 不行,这两个工具库都很弱小,都能满足业务技术需要;Sequelize 一方面是 Model 定义形式比拟 JS 化在 Typescript 人造的类型环境中显得有些怪异,所以我集体更加偏向于用 TypeORM。
2.2. 两种操作模式
这里简略阐明一下,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()
办法,长久化操作由一个两头类来实现。
这两种模式没有谁比谁好之分,只有适不适宜之别:
- 简略的 CRUD、试水型的 Demo 我的项目,用
Active Records
模式的 ORM 框架更好 - 业务流程和规定较多的、成熟的我的项目革新用
Data Mapper
型,其容许将业务规定绑定到实体。
Active Records
模式最大长处是简略 , 直观,一个类就包含了数据拜访和业务逻辑,恰好我当初这个小利用根本都是单表操作,所以就用 Active Records
模式了。
3. TypeORM 的应用
3.1 数据库连贯
这里次要波及到批改 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);
}
}
阐明:
- 抉择在 app 的配置加载结束之后来启动自定义的数据库服务,具体参考《Egg.js – 启动动自定义的申明周期参考文档》阐明
- 为了不侵入
AppBootHook
代码太多,我把初始化数据库服务实例的代码放在了DatabaseService
类的静态方法中。
3.2 数据库操作
数据库连贯上之后,就能够间接应用 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
示例上而已。
3.3 MVC 架构
整个服务器的设计模式,就是经典的 MVC 架构,次要就是通过 Controller
、Service
、Model
、View
独特作用,造成了一套架构体系;
此图来源于《Express 教程 4:路由和控制器》https://developer.mozilla.org/zh-CN/docs/learn/Server-side/Express_Nodejs/routes
上图是最为根底的 MVC 架构,理论开发过程中还会有更细分的优化,次要体现两方面:
- 为了不便前期扩大,还会引入 中间件(middleware) 机制,这些概念置信凡是写过 Koa/Express 的都晓得 —— 不过这里还是重述一下,因为前面 GraphQL 就是通过 中间件 形式引入的。
- 个别不举荐间接让 Controller 调用到 Model 对象,而是要两头增加一层 Service 层来进行解耦(具体的劣势详见 Egg.js 官网文档《服务(Service)》,外面有具体的解释); 简略来讲,这样的益处在于解耦 Model 和 Controller,同时放弃业务逻辑的独立性(从而带来更好的扩展性、更不便的单元测试等),形象进去的 Service 能够被多个 Controller 反复调用 —— 比方,GraphQL Resolver 和 Controller 就能够共用同一份 Service;
古代 Node.js 框架初始化的时候都默认帮你做了这事件 —— Midway 也不例外,初始化后去看一下它的目录构造就基本上懂了。
更多对于该架构的实战可参考以下文章:
- Node Service-oriented Architecture: 介绍面向 Service 的 Node.js 架构
- Designing a better architecture for a Node.js API:初学者教程,从实际中感触面向 Service 架构
- Bulletproof node.js project architecture: 如何打造一个坚硬的 Node.js 服务端架构
3.4 RESTful API
在 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};
}
}
4. GraphQL
RESTful API 形式用得比拟多,不过我还是想在本人的小我的项目里应用 GraphQL,具体的长处我就不多说了,能够参考《GraphQL 和 Apollo 为什么能帮忙你更快地实现开发需要?》等相干文章。
GraphQL 的了解老本和接入老本还是有一些的,倡议间接通读官网文档《GraphQL 入门》去理解 GraphQL 中的概念和应用。
4.1 接入 GraphQL 服务中间件
整体的技术选型阵容就是 apollo-server-koa 和 type-graphql:
- apollo-server 是一个在 Node.js 上构建 GraphQL 服务端的 Web 中间件,反对 Koa 也就人造的反对了 Midway
- TypeGraphQL:它通过一些 TypeScript + Decorator 标准了 Schema 的定义,防止在 GraphQL 中别离写 Schema Type DSL 和数据 Modal 的重复劳动。
只须要将 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
并导出该函数。 - 咱们所有的 GraphQL Resolver 都放在 ‘app/resolver’ 目录下
因为 Midway 默认集成了 CSRF 的平安校验,咱们针对 /graphql
门路的这层平安须要疏忽掉:
export const security = {
csrf: {
// 疏忽 graphql 路由下的 csrf 报错
ignore: '/graphql'
}
}
接入的筹备工作到这里就算差不多了,接下来就是编写 GraphQL 的 Resolver
相干逻辑
4.2 Resolvers
对于 Resolver
的解决,TypeGraphQL 提供了一些列的 Decorator
来申明和解决数据。通过 Resolver 类的办法来申明 Query
和 Mutation
,以及动静字段的解决 FieldResolver
。几个次要的 Decorator 阐明如下:
- @Resolver:来申明以后类是数据处理的
- @Query:申明改办法是一个 Query 查问操作
- @Mutation:申明改办法是一个 Mutation 批改操作
- @FieldResovler:对
@Resolver(of => Recipe)
返回的对象增加一个字段解决
办法参数相干的 Decorator:
- @Root:获取以后查问对象
- @Ctx:获取以后上下文,这里能够拿到 egg 的 Context(见下面中间件集成中的解决)
- @Arg:定义 input 参数
这里波及到比拟多的知识点,不可能一一列举完,还是倡议先去官网 https://typegraphql.com/docs/introduction.html 浏览一遍
接下来咱们从接入开始,而后以如何创立一个 分页 (Pagination) 性能为案例来演示在如何在 Midway 框架里应用 GraphQL,以及如何利用上述这些装璜器。
5. 案例:利用 GraphQL 实现分页性能
5.1 分页的数据结构
从使用者角度来,咱们心愿传递的参数只有两个 pageNo
和 pageSize
,比方我想拜访第 2 页、每页返回 10 条内容,入参格局就是:
{
pageNo: 2,
pageSize: 10
}
而分页返回的数据结构如下:
{
articles {
totalCount # 总数
pageNo # 当前页号
pageSize # 每页后果数
pages # 总页数
list: { # 分页后果
title,
author
}
}
}
5.2 Schema 定义
首先利用 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]
)
- 根底的
5.3 Resolver 办法
基于上述的 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);
}
}
- 理论解析用户申请,调用的是 Service 层中
articleService.getArticleList
办法,只有让返回的后果跟咱们想要的Pagination
类型统一就行。 - 这里的
articleService
对象就是通过容器注入 (inject
) 到以后 Resolver,该对象的提供来自 Service 层
5.4 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')
绝对应 - 因为咱们想要返回的是 Pagination 类实例,所以须要调用
plainToClass
办法进行一层转化
5.5 Model 层
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 分页性能就开发结束,从流程步骤来看,一路下来简直都是装璜器语法,整个编写过程干净利落,很利于前期的扩大和保护。
6. 小结
间隔上次写 Node.js 后盾利用有段时间了,过后的技术栈和当初的没法比,当初尤其得益于 应用 Decorator(装璜器语法)+ DI(依赖注入)格调写业务逻辑 ,再搭配应用 typeorm
(数据库的连贯)、type-graphql
(GraphQL 的解决)工具库来应用, 整体代码格调更加简洁,同样的业务性能,代码量减少十分可观且维护性也晋升显著。
emm,这种感觉怎么形容适合呢?之前写 Node.js 利用时,能用,然而总感觉哪里很憋屈 —— 就像是白天在交通拥挤的路线上堵车,那种感觉有点糟;而这次混搭了这几种技术,会感触神清气爽 —— 就像是在高速公路上行车,畅通无阻。
前端的技术倒退迭代相对来说迭代比拟快,这是坏事,能让你用新技术做得更少、播种地更多 ;当然不可否认这对前端同学也是挑战, 须要你都放弃一直学习的心态,去及时补充这些新的常识 。学无止境,与君共勉。
本文完。
文章预报 :因为依赖注入和管制反转的思维在 Node.js 利用特地重要,所以打算接下来要 写一些文章来解释这种设计模式,而后再搭配一个依赖注入工具库的源码解读来加深了解,敬请期待。
参考文章
- ORM 实例教程:阮一峰教程,解释 ORM,通俗易懂
- 架构模式中的 Active Record 和 Data Mapper
- 什么是 ActiveRecord 模式
- typeorm 数据库 ORM 框架中文文档
- Active Record vs Data Mapper:官网文档对两者的解释
- TypeGraphQL – Resolvers 章节,具体的代码参考能够返回 recipe-resolver
- TypeScript + GraphQL = TypeGraphQL:阿里 CCO 体验技术部的文章,介绍地比拟具体到位,举荐浏览(联合 egg.js 的开发实际)
- Apollo Server: GraphQL 数据分页概述
- How to implement pagination in nestjs with typeorm:这里给出了应用 Repository API 实现的形式
- TypeORM Find 选项:官网 Find API 文档