本文同步自我的公众号 “JSCON简时空”,欢迎关注: https://mp.weixin.qq.com/s/y74j1gxcJndxD21W5v-NUw
恰逢最近须要编写一个简单的后端 Node.js 应用,因为是全新的小应用,没有历史包袱 ,因此趁着此次机会换了一种全新的开发模式:javascript
前端内部写的后端应用基本上功能并不会太多(太专业的后端服务交给后端开发来作),绝大部分是基础的操做,在这样的状况下会涉及到不少重复工做量要作,基本都是同样的套路:html
这意味着每次开发新应用都得从新来一遍 —— 这就跟前端平时切页面同样,重复劳动多了以后就心里仍是比较烦的,甚至有抗拒心理。繁琐的事大概涉及在工程链路 & 业务代码这么两方面,若是有良好的解决方案,将大大提高开发的幸福感:前端
本文着重讲解第二部分,即如何使用 TypeScript + Decorator + DI 风格编写 Node.js 应用,让你感觉到使用这些技术框架带来的畅快感。本文涉及的知识点比较多,主要是叙述逻辑思路,最后会以实现常见的 分页功能 做为案例讲解。
java
本文选用技术框架是 Midway.js,设计思路能够迁移到 Nest.js 等框架上,改动量应该不会太大。
首先咱们须要解决数据库相关的技术选项,这里说的技术选型是指 ORM 相关的技术选型(数据库固定使用 MySQL),选型的基本原则是能力强大、用法简单。
node
除了直接拼 SQL 语句这种略微硬核的方式外,Node.js 应用开发者更多地会选择使用开源的 ORM 库,如 Sequelize。而在 Typescript 面前,工具库层面目前两种可选项,可使用 sequelize-typescript 或者 TypeORM 来进行数据库的管理。作了一下技术调研后,决定选用 TypeORM ,总结缘由以下:mysql
并不是说 Sequelize-typescript 不行,这两个工具库都很强大,都能知足业务技术需求; Sequelize 一方面是 Model 定义方式比较 JS 化在 Typescript 自然的类型环境中显得有些怪异,因此我我的更加倾向于用 TypeORM 。
这里简单说明一下,ORM 架构模式中,最流行的实现模式有两种:Active Record
和 Data Mapper
。好比 Ruby 的 ORM 采起了 Active Record
的模式是这样的:git
$user = new User; $user->username = 'philipbrown'; $user->save();
再来看使用 Data Mapper
的 ORM 是这样的:github
$user = new User; $user->username = 'philipbrown'; EntityManager::persist($user);
如今咱们察看到了它们最基本的区别:在 Active Record
中,领域对象有一个 save()
方法,领域对象一般会继承一个 ActiveRecord
的基类来实现。而在 Data Mapper
模式中,领域对象不存在 save()
方法,持久化操做由一个中间类来实现。
这两种模式没有谁比谁好之分,只有适不适合之别:sql
Active Records
模式的 ORM 框架更好Data Mapper
型,其容许将业务规则绑定到实体。Active Records
模式最大优势是简单 , 直观, 一个类就包括了数据访问和业务逻辑,刚好我如今这个小应用基本都是单表操做,因此就用 Active Records
模式了。
typescript
这里主要涉及到修改 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
共同做用,造成了一套架构体系;
此图来源于 《 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 应用特别重要,因此计划接下来要 写一些文章来解释这种设计模式,而后再搭配一个依赖注入工具库的源码解读来加深理解,敬请期待。