混搭 TypeScript + GraphQL + DI + Decorator 風(fēng)格寫(xiě) Node.js 應(yīng)用
點(diǎn)擊上方?“JSCON簡(jiǎn)時(shí)空” 關(guān)注,獲取走心的前端原創(chuàng)文章
閱讀本文的知識(shí)前提:熟悉 TypeScript + GraphQL + Node.js + Decorator + Dependency Inject 等概念。本文選用技術(shù)框架是 Midway.js,設(shè)計(jì)思路可以遷移到 Nest.js 等框架上,改動(dòng)量應(yīng)該不會(huì)太大。
本文長(zhǎng)約 1.3w 字,閱讀時(shí)間約?20min?
1. 前言
恰逢最近需要編寫(xiě)一個(gè)簡(jiǎn)單的后端 Node.js 應(yīng)用,由于是全新的小應(yīng)用,沒(méi)有歷史包袱 ,所以趁著這次機(jī)會(huì)換了一種全新的開(kāi)發(fā)模式:
語(yǔ)言使用 TypeScript,不僅僅是強(qiáng)類型那么簡(jiǎn)單,它還提供很多高級(jí)語(yǔ)法糖,提高編程效率。 兼顧 Restful + GraphQL 方式提供數(shù)據(jù)接口,前兩年 GraphQL 特別流行,最近這段時(shí)間有些平淡下來(lái)(現(xiàn)在比較火熱的是 Serverless);GraphQL 這種查詢語(yǔ)言對(duì)前端來(lái)講還是很友好的,自己寫(xiě)的話能減少不少的接口開(kāi)發(fā)量; 使用 Decorator(裝飾器語(yǔ)法) + DI(依賴注入)風(fēng)格寫(xiě)業(yè)務(wù)邏輯。因后端 Java 開(kāi)發(fā)服務(wù)的模式已經(jīng)非常成熟,前端在 Node.js 的開(kāi)發(fā)模式基本上是依照 Java 那套開(kāi)發(fā)模子來(lái)的,尤其是 DI(依賴注入)設(shè)計(jì)模式的編程思想。這幾年隨著 ECMAScript 的標(biāo)準(zhǔn)迭代,以及 TypeScript 的成熟發(fā)展,在語(yǔ)言層面提供了很多現(xiàn)代化語(yǔ)法糖的支持,現(xiàn)在也可以利用 Decorator(裝飾器)+ DI(依賴注入)風(fēng)格來(lái)寫(xiě)了,個(gè)人認(rèn)為這種風(fēng)格也將成為書(shū)寫(xiě) Node.js 應(yīng)用的常用范式之一。 選用支持 TS + Decorator + DI 的 Node.js框架。市面上成熟的框架,如 Nest.js, Midway.js 等可以 —— 這類框架功能都很強(qiáng)大,而且提供完善的工具鏈和生態(tài),就算你不熟,通讀他們的官方文檔都能收獲很多;本文**因工作內(nèi)容緣故選用 Midway 框架。
前端內(nèi)部寫(xiě)的后端應(yīng)用基本上功能并不會(huì)太多(太專業(yè)的后端服務(wù)交給后端開(kāi)發(fā)來(lái)做),絕大部分是基礎(chǔ)的操作,在這樣的情況下會(huì)涉及到很多重復(fù)工作量要做,基本都是一樣的套路:
初始化項(xiàng)目腳手架 數(shù)據(jù)庫(kù)的連接操作 + CRUD 操作 創(chuàng)建數(shù)據(jù) Model 層 + Service 層 提供諸如 Restful 接口供多端消費(fèi) ...
這意味著每次開(kāi)發(fā)新應(yīng)用都得重新來(lái)一遍 —— 這就跟前端平時(shí)切頁(yè)面一樣,重復(fù)勞動(dòng)多了之后就內(nèi)心還是比較煩的,甚至有抗拒心理。繁瑣的事大概涉及在工程鏈路 & 業(yè)務(wù)代碼這么兩方面,如果有良好的解決方案,將大大提升開(kāi)發(fā)的幸福感:
第一個(gè)方面是結(jié)構(gòu)目錄的生成。這個(gè)問(wèn)題比較好解決,市面上成熟的框架(Nest.js, Midway.js,Prisma.io 等)都提供了相應(yīng)的腳手架工具,直接生成相應(yīng)的服務(wù)端代碼結(jié)構(gòu),寫(xiě)代碼既可靠又高效。同時(shí)這類成熟框架都能一鍵搞定部署發(fā)布等流程,這樣我們就可以將大部分時(shí)間用在業(yè)務(wù)代碼上、而不是折騰環(huán)境搭建細(xì)節(jié)上。 第二個(gè)方面是業(yè)務(wù)代碼的書(shū)寫(xiě)風(fēng)格。同樣是寫(xiě)業(yè)務(wù)代碼,語(yǔ)言風(fēng)格不一樣,代碼效率也是不同的,你用 JS 寫(xiě)業(yè)務(wù)代碼,跟 **TypeScript + Decorator **來(lái)寫(xiě)的效率大相徑庭 —— 這也就是技術(shù)發(fā)展帶來(lái)的福利。
本文著重講解第二部分,即如何使用 TypeScript + Decorator ?+ DI 風(fēng)格編寫(xiě) Node.js 應(yīng)用,讓你感受到使用這些技術(shù)框架帶來(lái)的暢快感。本文涉及的知識(shí)點(diǎn)比較多,主要是敘述邏輯思路,最后會(huì)以實(shí)現(xiàn)常見(jiàn)的 分頁(yè)功能 作為案例講解。
2. 數(shù)據(jù)庫(kù) ORM
首先我們需要解決數(shù)據(jù)庫(kù)相關(guān)的技術(shù)選項(xiàng),這里說(shuō)的技術(shù)選型是指 ORM 相關(guān)的技術(shù)選型(數(shù)據(jù)庫(kù)固定使用 MySQL),選型的基本原則是能力強(qiáng)大、用法簡(jiǎn)單。
2.1 ORM 選型
除了直接拼 SQL 語(yǔ)句這種略微硬核的方式外,Node.js 應(yīng)用開(kāi)發(fā)者更多地會(huì)選擇使用開(kāi)源的 ORM 庫(kù),如 Sequelize。而在 Typescript 面前,工具庫(kù)層面目前兩種可選項(xiàng),可以使用?sequelize-typescript?或者?TypeORM?來(lái)進(jìn)行數(shù)據(jù)庫(kù)的管理。做了一下技術(shù)調(diào)研后,決定選用 TypeORM ,總結(jié)原因如下:
原生類型聲明,與 Typescript 有更好的相容性 支持裝飾器寫(xiě)法,用法上簡(jiǎn)單直觀;且足夠強(qiáng)的擴(kuò)展能力,能支持復(fù)雜的數(shù)據(jù)操作; 該庫(kù)足夠受歡迎,Github Star 數(shù)量高達(dá) 20.3k(截止此文撰寫(xiě) 2020.08 時(shí)),且官方文檔友好
并非說(shuō) Sequelize-typescript 不行,這兩個(gè)工具庫(kù)都很強(qiáng)大,都能滿足業(yè)務(wù)技術(shù)需求;Sequelize 一方面是 Model 定義方式比較 JS 化在 Typescript 天然的類型環(huán)境中顯得有些怪異,所以我個(gè)人更加傾向于用?TypeORM?。
2.2. 兩種操作模式
這里簡(jiǎn)單說(shuō)明一下,ORM 架構(gòu)模式中,最流行的實(shí)現(xiàn)模式有兩種:Active Record 和 ?Data Mapper。比如 Ruby 的 ORM 采取了 Active Record 的模式是這樣的:
$user?=?new?User;
$user->username?=?'philipbrown';
$user->save();
再來(lái)看使用 Data Mapper 的 ORM 是這樣的:
$user?=?new?User;
$user->username?=?'philipbrown';
EntityManager::persist($user);
現(xiàn)在我們察看到了它們最基本的區(qū)別:在 Active Record 中,領(lǐng)域?qū)ο笥幸粋€(gè) save() 方法,領(lǐng)域?qū)ο笸ǔ?huì)繼承一個(gè) ActiveRecord 的基類來(lái)實(shí)現(xiàn)。而在 Data Mapper 模式中,領(lǐng)域?qū)ο蟛淮嬖?save() 方法,持久化操作由一個(gè)中間類來(lái)實(shí)現(xiàn)。
這兩種模式?jīng)]有誰(shuí)比誰(shuí)好之分,只有適不適合之別:
簡(jiǎn)單的 CRUD、試水型的 Demo 項(xiàng)目,用 Active Records模式的 ORM 框架更好業(yè)務(wù)流程和規(guī)則較多的、成熟的項(xiàng)目改造用 Data Mapper型,其允許將業(yè)務(wù)規(guī)則綁定到實(shí)體。
Active Records 模式最大優(yōu)點(diǎn)是簡(jiǎn)單 , 直觀, 一個(gè)類就包括了數(shù)據(jù)訪問(wèn)和業(yè)務(wù)邏輯,恰好我現(xiàn)在這個(gè)小應(yīng)用基本都是單表操作,所以就用 Active Records 模式了。
3. TypeORM 的使用
3.1 數(shù)據(jù)庫(kù)連接
這里主要涉及到修改 3 處地方。
首先,提供數(shù)據(jù)庫(kù)初始化 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;
??/**?初始化數(shù)據(jù)庫(kù)服務(wù)實(shí)例?*/
??static?async?initInstance(app:?Application)?{
????const?applicationContext:?ApplicationContext?=?app.applicationContext;
????const?logger:?EggLogger?=?app.getLogger();
????//?手動(dòng)實(shí)例化一次,啟動(dòng)數(shù)據(jù)庫(kù)連接
????const?databaseService?=?await?applicationContext.getAsync(DatabaseService.identifier);
????const?testResult?=?await?databaseService.getConnection().query('SELECT?1+1');
????logger.info('數(shù)據(jù)庫(kù)連接測(cè)試: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]?數(shù)據(jù)庫(kù)連接成功~',?DatabaseService.name);
????}?catch?(err)?{
??????this.logger.error('[%s]?數(shù)據(jù)庫(kù)連接失??!',?DatabaseService.name);
??????this.logger.info('數(shù)據(jù)庫(kù)鏈接信息:',?options);
??????this.logger.error(err);
????}
??}
??/**
???*?獲取數(shù)據(jù)庫(kù)鏈接
???*?@param?connectionName?數(shù)據(jù)庫(kù)鏈接名稱
???*/
??getConnection(connectionName?:?string)?{
????return?getConnection(connectionName);
??}
}
說(shuō)明:
這里一定是單例 @scope(ScopeEnum.Singleton),因?yàn)閿?shù)據(jù)庫(kù)連接服務(wù)只能有一個(gè)。但是可以初始化多個(gè)連接,比如用于多個(gè)數(shù)據(jù)庫(kù)連接或讀寫(xiě)分離默認(rèn)配置項(xiàng) defaultOptions中的entities表示數(shù)據(jù)庫(kù)實(shí)體對(duì)象存放的路徑,推薦專門(mén)創(chuàng)建一個(gè) entity 目錄用來(lái)存放:

其次,在 Midway 的配置文件中指定數(shù)據(jù)庫(kù)連接配置:
?//?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}`],
}
說(shuō)明:
因?yàn)橐獏^(qū)分線上環(huán)境運(yùn)行和本地開(kāi)發(fā),所以需要配置兩份 entities的配置項(xiàng)本地和線上配置是不同的,本地直接用src/entity就行,而 aone 環(huán)境需要使用${appInfo.baseDir}變量
最后,在應(yīng)用啟動(dòng)時(shí)觸發(fā)實(shí)例化:
//?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;
??}
??//?所有的配置已經(jīng)加載完畢
??//?可以用來(lái)加載應(yīng)用自定義的文件,啟動(dòng)自定義的服務(wù)
??async?didLoad()?{
????await?DatabaseService.initInstance(this.app);
??}
}
說(shuō)明:
選擇在 app 的配置加載完畢之后來(lái)啟動(dòng)自定義的數(shù)據(jù)庫(kù)服務(wù),具體參考 《Egg.js - 啟動(dòng)動(dòng)自定義的聲明周期參考文檔》 說(shuō)明 為了不侵入 AppBootHook代碼太多,我把初始化數(shù)據(jù)庫(kù)服務(wù)實(shí)例的代碼放在了DatabaseService類的靜態(tài)方法中。
3.2 數(shù)據(jù)庫(kù)操作
數(shù)據(jù)庫(kù)連接上之后,就可以直接使用 ORM 框架進(jìn)行數(shù)據(jù)庫(kù)操作。不同于現(xiàn)有的所有其他 JavaScript ORM 框架,TypeORM 支持 Active Record 和 Data Mapper 模式(在我這次寫(xiě)的項(xiàng)目中,使用的是 ?Active Record 模式),這意味著你可以根據(jù)實(shí)際情況選用合適有效的方法編寫(xiě)高質(zhì)量的、松耦合的、可擴(kuò)展的應(yīng)用程序。
首先看一下用 Active Records 模式的寫(xiě)法:
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;
}
說(shuō)明:
類需要用 @Entity()裝飾需要繼承 BaseEntity這個(gè)基類
對(duì)應(yīng)的業(yè)務(wù)域?qū)懛ǎ?/p>
const?user?=?new?User();
user.firstName?=?"Timber";
user.lastName?=?"Saw";
user.age?=?25;
await?user.save();
------
其次看一下 Data Mapper 型的寫(xiě)法:
//?模型定義
import?{Entity,?PrimaryGeneratedColumn,?Column}?from?"typeorm";
@Entity()
export?class?User?{
????@PrimaryGeneratedColumn()
????id:?number;
????@Column()
????firstName:?string;
????@Column()
????lastName:?string;
????@Column()
????age:?number;
}
說(shuō)明:
類同樣需要用 @Entity()裝飾不需要繼承 BaseEntity這個(gè)基類
對(duì)應(yīng)的業(yè)務(wù)域邏輯是這樣的:
const?user?=?new?User();
user.firstName?=?"Timber";
user.lastName?=?"Saw";
user.age?=?25;
await?repository.save(user);
無(wú)論是 Active Record 模式還是 Data Mapper 模式,**TypeORM 在 API 上的命名使用上幾乎是保持一致,這大大降低了使用者記憶上的壓力:**比如上方保存操作,都稱為 save 方法,只不過(guò)前者是放在 Entity 實(shí)例上,后者是放在 Repository 示例上而已。
3.3 MVC架構(gòu)
整個(gè)服務(wù)器的設(shè)計(jì)模式,就是經(jīng)典的 MVC 架構(gòu),主要就是通過(guò) Controller、Service、Model 、View 共同作用,形成了一套架構(gòu)體系;

此圖來(lái)源于 《Express 教程 4:路由和控制器》https://developer.mozilla.org/zh-CN/docs/learn/Server-side/Express_Nodejs/routes
上圖是最為基礎(chǔ)的 MVC 架構(gòu),實(shí)際開(kāi)發(fā)過(guò)程中還會(huì)有更細(xì)分的優(yōu)化,主要體現(xiàn)兩方面:
為了方便后期擴(kuò)展,還會(huì)引入 中間件(middleware) 機(jī)制,這些概念相信但凡寫(xiě)過(guò) Koa/Express 的都知道 —— 不過(guò)這里還是重述一下,因?yàn)楹竺?GraphQL 就是通過(guò)中間件方式引入的。 一般不推薦直接讓 Controller 調(diào)用到 Model 對(duì)象,**而是要中間添加一層 Service 層來(lái)進(jìn)行解耦(具體的優(yōu)勢(shì)詳見(jiàn) Egg.js 官方文檔《服務(wù)(Service)》,里面有詳細(xì)的解釋); **簡(jiǎn)單來(lái)講,這樣的好處在于解耦 Model 和 Controller,同時(shí)保持業(yè)務(wù)邏輯的獨(dú)立性(從而帶來(lái)更好的擴(kuò)展性、更方便的單元測(cè)試等),抽象出來(lái)的 Service 可以被多個(gè) Controller 重復(fù)調(diào)用 —— 比如,GraphQL Resolver 和 Controller 就可以共用同一份 Service;
現(xiàn)代 Node.js 框架初始化的時(shí)候都默認(rèn)幫你做了這事情 —— Midway 也不例外,初始化后去看一下它的目錄結(jié)構(gòu)就基本上懂了。
更多關(guān)于該架構(gòu)的實(shí)戰(zhàn)可參考以下文章:
Node Service-oriented Architecture: 介紹面向 Service 的 Node.js 架構(gòu) Designing a better architecture for a Node.js API:初學(xué)者教程,從實(shí)踐中感受面向 Service 架構(gòu) Bulletproof node.js project architecture: 如何打造一個(gè)堅(jiān)固的 Node.js 服務(wù)端架構(gòu)
3.4 RESTful API
在 Midway 初始化項(xiàng)目的時(shí)候,其實(shí)已經(jīng)具備完整的 RESTful API 的能力,你只要照樣去擴(kuò)展就可以了,而且基于裝飾器語(yǔ)法和 DI 風(fēng)格,編寫(xiě)路由非常的方便直觀,正如官方《Midway - 路由裝飾器》里所演示的代碼那樣,幾行代碼下來(lái)就輸出標(biāo)準(zhǔn)的 RESTful 風(fēng)格的 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 方式用得比較多,不過(guò)我還是想在自己的小項(xiàng)目里使用 GraphQL,具體的優(yōu)點(diǎn)我就不多說(shuō)了,可以參考《GraphQL 和 Apollo 為什么能幫助你更快地完成開(kāi)發(fā)需求?》等相關(guān)文章。
GraphQL 的理解成本和接入成本還是有一些的,建議直接通讀官方文檔 《GraphQL 入門(mén)》 去了解 GraphQL 中的概念和使用。
4.1 接入 GraphQL 服務(wù)中間件
整體的技術(shù)選型陣容就是 apollo-server-koa 和 type-graphql :
apollo-server 是一個(gè)在 Node.js 上構(gòu)建 GraphQL 服務(wù)端的 Web 中間件,支持 Koa 也就天然的支持了 Midway TypeGraphQL:它通過(guò)一些 TypeScript + Decorator 規(guī)范了 Schema 的定義,避免在 GraphQL 中分別寫(xiě) Schema Type DSL 和數(shù)據(jù) Modal 的重復(fù)勞動(dòng)。
只需要將 Koa 中間件 轉(zhuǎn) Midway 中間件就行。根據(jù) Midway項(xiàng)目目錄約定,在 /src/app/middleware/ 下新建文件?graphql.ts,將 apollo-server-koa 中間件簡(jiǎn)單包裝一下:
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);
};
說(shuō)明:
利用? apollo-server-koa?暴露的?getMiddleware?方法取得中間件函數(shù),注入 TypeGraphQL 所管理的?schema?并導(dǎo)出該函數(shù)。我們所有的 GraphQL Resolver 都放在 'app/resolver' 目錄下

由于 Midway 默認(rèn)集成了 CSRF 的安全校驗(yàn),我們針對(duì) /graphql 路徑的這層安全需要忽略掉:
export?const?security?=?{
????csrf:?{
??????//?忽略?graphql?路由下的?csrf?報(bào)錯(cuò)
??????ignore:?'/graphql'
????}
??}
接入的準(zhǔn)備工作到這里就算差不多了,接下來(lái)就是編寫(xiě) GraphQL 的 Resolver 相關(guān)邏輯
4.2 Resolvers
對(duì)于 Resolver 的處理,TypeGraphQL 提供了一些列的 Decorator 來(lái)聲明和處理數(shù)據(jù)。通過(guò) Resolver 類的方法來(lái)聲明 Query 和 Mutation,以及動(dòng)態(tài)字段的處理 FieldResolver。幾個(gè)主要的 Decorator 說(shuō)明如下:
@Resolver:來(lái)聲明當(dāng)前類是數(shù)據(jù)處理的 @Query:聲明改方法是一個(gè) Query 查詢操作 @Mutation:聲明改方法是一個(gè) Mutation 修改操作 @FieldResovler:對(duì) @Resolver(of => Recipe)返回的對(duì)象添加一個(gè)字段處理
方法參數(shù)相關(guān)的 Decorator:
@Root:獲取當(dāng)前查詢對(duì)象 @Ctx:獲取當(dāng)前上下文,這里可以拿到 egg 的 Context (見(jiàn)上面中間件集成中的處理) @Arg:定義 input 參數(shù)
這里涉及到比較多的知識(shí)點(diǎn),不可能一一羅列完,還是建議先去官網(wǎng) https://typegraphql.com/docs/introduction.html 閱讀一遍
接下來(lái)我們從接入開(kāi)始,然后以如何創(chuàng)建一個(gè) 分頁(yè)(Pagination) 功能為案例來(lái)演示在如何在 Midway 框架里使用 GraphQL,以及如何應(yīng)用上述這些裝飾器 。
5. 案例:利用 GraphQL 實(shí)現(xiàn)分頁(yè)功能
5.1 分頁(yè)的數(shù)據(jù)結(jié)構(gòu)
從使用者角度來(lái),我們希望傳遞的參數(shù)只有兩個(gè) pageNo 和 pageSize ,比如我想訪問(wèn)第 2 頁(yè)、每頁(yè)返回 10 條內(nèi)容,入?yún)⒏袷骄褪牵?/p>
{
??pageNo:?2,
??pageSize:?10
}
而分頁(yè)返回的數(shù)據(jù)結(jié)構(gòu)如下:
{
??articles?{
????totalCount?#?總數(shù)
????pageNo?????#?當(dāng)前頁(yè)號(hào)
????pageSize???#?每頁(yè)結(jié)果數(shù)
????pages???????#?總頁(yè)數(shù)
????list:?{?????#?分頁(yè)結(jié)果
??????title,
??????author
????}
??}
}
5.2 Schema 定義
首先利用 TypeGraphQL 提供的 Decorator 來(lái)聲明入?yún)㈩愋鸵约胺祷亟Y(jié)果類型:
//?src/entity/pagination.ts
import?{?ObjectType,?Field,?ID,?InputType?}?from?'type-graphql';
import?{?Article?}?from?'./article';
//?查詢分頁(yè)的入?yún)?/span>
@InputType()
export?class?PaginationInput?{
??@Field({?nullable:?true?})
??pageNo?:?number;
??@Field({?nullable:?true?})
??pageSize?:?number;
}
//?查詢結(jié)果的類型
@ObjectType()
export?class?Pagination?{
??//?總共有多少條
??@Field()
??totalCount:?number;
??//?總共有多少頁(yè)
??@Field()
??pages:?number;
??//?當(dāng)前頁(yè)數(shù)
??@Field()
??pageNo:?number;
??//?每頁(yè)包含多少條數(shù)據(jù)
??@Field()
??pageSize:?number;
??//?列表
??@Field(type?=>?[Article]!,?{?nullable:?"items"?})
??list:?Article[];
}
export?interface?IPaginationInput?extends?PaginationInput?{?}
說(shuō)明:
通過(guò)這里的 @ObjectType()、@Field()裝飾注解后,會(huì)自動(dòng)幫你生成 GraphQL 所需的 Schema 文件,可以說(shuō)非常方便,這樣就不用擔(dān)心自己寫(xiě)的代碼跟 Schema 不一致;對(duì) list字段,它的類型是Article[],在使用@Field注解時(shí)需要注意,因?yàn)槲覀兿氡硎緮?shù)組一定存在但有可能為空數(shù)組情況,需要使用{nullable: "items"}(即[Item]!),具體查閱 官方文檔 - Types and Fields 另外還有兩種配置:基礎(chǔ)的 { nullable: true | false }只能表示整個(gè)數(shù)組是否存在(即[Item!]或者[Item!]!)如果想表示數(shù)組或元素都有可能為空時(shí),需要使用 {nullable: "itemsAndList"}(即[Item])
5.3 Resolver 方法
基于上述的 Schema 定義,接下來(lái)我們要寫(xiě) Resolver,用來(lái)解析用戶實(shí)際的請(qǐng)求:
//?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);
??}
}
實(shí)際解析用戶請(qǐng)求,調(diào)用的是 Service 層中 articleService.getArticleList方法,只要讓返回的結(jié)果跟我們想要的Pagination類型一致就行。這里的 articleService對(duì)象就是通過(guò)容器注入(inject)到當(dāng)前 Resolver ,該對(duì)象的提供來(lái)自 Service 層
5.4 Service 層
從上可以看到,請(qǐng)求參數(shù)是傳到 GraphQL 服務(wù)器,而真正進(jìn)行分頁(yè)操作的還是 Service 層,內(nèi)部利用 ORM 提供的方法;在TypeORM 中的分頁(yè)功能實(shí)現(xiàn),可以參考一下官方的 find 選項(xiàng)的完整示例:
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
});
其中和 分頁(yè) 相關(guān)的就是 skip 和 take 兩個(gè)參數(shù)( where 參數(shù)是跟 過(guò)濾 有關(guān),order 參數(shù)跟排序有關(guān))。
所以最終我們的 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?列表,支持分頁(yè)
???*/
??async?getArticleList(query:?IPaginationInput):?Promise?{
????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,
????})
??}
??...
}
這里通過(guò) @provide('articleService')向容器提供articleService對(duì)象實(shí)例,這就上面 Resolver 中的@inject('articleService')相對(duì)應(yīng)由于我們想要返回的是 Pagination 類實(shí)例,所以需要調(diào)用 plainToClass方法進(jìn)行一層轉(zhuǎn)化
5.5 Model 層
Service 層其實(shí)也是調(diào)用 ORM 中的實(shí)體方法 Article.findAndCount(由于我們是用** Active Records **模式的),這個(gè) Article 類就是 ORM 中的實(shí)體,其定義也非常簡(jiǎn)單:
//?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;
}
仔細(xì)觀察,這里的 Article 類,同時(shí)接受了 TypeORM 和 TypeGraphQL 兩個(gè)庫(kù)的裝飾器,寥寥幾行代碼就支持了 GraphQL 類型聲明和 ORM 實(shí)體映射,非常清晰明了。
到這里一個(gè)簡(jiǎn)單的 GraphQL 分頁(yè)功能就開(kāi)發(fā)完畢,從流程步驟來(lái)看,一路下來(lái)幾乎都是裝飾器語(yǔ)法,整個(gè)編寫(xiě)過(guò)程干凈利落,很利于后期的擴(kuò)展和維護(hù)。
6. 小結(jié)
距離上次寫(xiě) Node.js 后臺(tái)應(yīng)用有段時(shí)間了,當(dāng)時(shí)的技術(shù)棧和現(xiàn)在的沒(méi)法比,現(xiàn)在尤其得益于使用 Decorator(裝飾器語(yǔ)法) + DI(依賴注入)風(fēng)格寫(xiě)業(yè)務(wù)邏輯,再搭配使用 typeorm (數(shù)據(jù)庫(kù)的連接)、 type-graphql (GraphQL的處理)工具庫(kù)來(lái)使用,整體代碼風(fēng)格更加簡(jiǎn)潔,同樣的業(yè)務(wù)功能,代碼量減少非??捎^且維護(hù)性也提升明顯。
emm,這種感覺(jué)怎么描述合適呢?之前寫(xiě) Node.js 應(yīng)用時(shí),能用,但是總覺(jué)得哪里很憋屈 —— 就像是白天在交通擁擠的道路上堵車,那種感覺(jué)有點(diǎn)糟;而這次混搭了這幾種技術(shù),會(huì)感受神清氣爽 —— 就像是在高速公路上行車,暢通無(wú)阻。
前端的技術(shù)發(fā)展迭代相對(duì)來(lái)說(shuō)迭代比較快,這是好事,能讓你用新技術(shù)做得更少、收獲地更多;當(dāng)然不可否認(rèn)這對(duì)前端同學(xué)也是挑戰(zhàn),需要你都保持不斷學(xué)習(xí)的心態(tài),去及時(shí)補(bǔ)充這些新的知識(shí)。學(xué)無(wú)止境,與君共勉。
本文完。
文章預(yù)告:因?yàn)橐蕾囎⑷牒涂刂品崔D(zhuǎn)的思想在 Node.js 應(yīng)用特別重要,所以計(jì)劃接下來(lái)要寫(xiě)一些文章來(lái)解釋這種設(shè)計(jì)模式,然后再搭配一個(gè)依賴注入工具庫(kù)的源碼解讀來(lái)加深理解,敬請(qǐng)期待。
參考文章
ORM 實(shí)例教程:阮一峰教程,解釋 ORM,通俗易懂 架構(gòu)模式中的 Active Record 和 Data Mapper 什么是 ActiveRecord 模式 typeorm數(shù)據(jù)庫(kù)ORM框架中文文檔 Active Record vs Data Mapper :官方文檔對(duì)兩者的解釋 TypeGraphQL - Resolvers 章節(jié),具體的代碼參考可以前往 recipe-resolver TypeScript + GraphQL = TypeGraphQL:阿里 CCO 體驗(yàn)技術(shù)部的文章,介紹地比較詳細(xì)到位,推薦閱讀(結(jié)合 egg.js 的開(kāi)發(fā)實(shí)踐) Apollo Server: GraphQL 數(shù)據(jù)分頁(yè)概述 How to implement pagination in nestjs with typeorm :這里給出了使用 Repository API 實(shí)現(xiàn)的方式 TypeORM Find 選項(xiàng):官方 Find API 文檔
分享前端好文,點(diǎn)亮?在看?
