本文同步自我的公衆號 「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 應用特別重要,因此計劃接下來要 寫一些文章來解釋這種設計模式,而後再搭配一個依賴注入工具庫的源碼解讀來加深理解,敬請期待。