分層是解決軟件複雜度很好的方法,它可以下降耦合、增長複用。典型的java後端開發大多分爲三層,幾乎成了標準模式,可是node社區對於分層的討論卻不多。node後端是否須要分層?如何分層?本文將從我的的角度提供一些思路。前端
我的的結論是:若是想作一個正兒八經的node後臺應用,必定須要分層,java的三層架構,一樣適用於node。結構以下:java
dao(data access object),數據訪問對象,位於最下層,和數據庫打交道。它的基本職責是封裝數據的訪問細節,爲上層提供友好的數據存取接口。通常是各類數據庫查詢語句,緩存也能夠在這層作。node
不管是nest仍是egg,官方demo裏都沒有明確提到dao層,直接在service層操做數據庫了。這對於簡單的業務邏輯沒問題,若是業務邏輯變得複雜,service層的維護將會變得很是困難。業務一開始通常都很簡單,它必定會向着複雜的方向演化,若是從長遠考慮,一開始就應該保留dao層。mysql
分享兩點dao層的建議:git
一、以實體爲中心定義類型描述。
後端建模的一大產出是領域實體模型,後續的業務邏輯其實就是對實體模型的增刪改查。利用ts對類型的豐富支持,能夠先將實體模型的類型描述定義出來,這將極大的方便上層業務邏輯的實現。我通常會將實體相關的類型、常量等都定義到一個文件,命名爲xxx.types.ts。定義到一個文件的好處是,編碼規範好落實,書寫和引用也很是方便,因爲沒有太多邏輯,即便文件稍微大一點,可讀性也不會下降太多。github
用po和dto來描述實體及其周邊。po是持久化對象和數據庫的表結構一一對應;dto數據傳輸對象則很靈活,能夠在豐富的場景描述入參或返回值。下面是個user實體的例子:算法
// user.types.ts /** * 用戶持久化對象 */ export interface UserPo { id: number; name: string; // 姓名 gender: Gender; // 性別 desc: string; // 介紹 } /** * 新建用戶傳輸對象 */ export interface UserAddDto { name: string; gender?: Gender; desc?: string; } /** * 性別 */ export enum Gender { Unknown, Male, Female, }
雖然ts提供了強大的類型系統,若是不能總結出一套最佳實踐出來,一樣會越寫越亂。全盤使用不是一個好的選擇,由於這樣會失去不少的靈活性。咱們須要的是在某些必須的場景,堅持使用。sql
二、不推薦orm框架
orm的初心很好,它試圖徹底將對象和數據庫映射自動化,讓使用者再也不關心數據庫。過分的封裝必定會帶來另一個問題——隱藏複雜度的上升。我的以爲,比起查詢語句,隱藏複雜度更可怕。有不少漂亮的orm框架,好比java界曾經很是流行的hibernate,功能很是強大,社區也很火,但實際在生產中使用的人卻不多,反卻是一些簡單、輕量的被大規模應用了。並且互聯網應用,對性能的要求較高,所以對sql的控制也須要更直接和精細。不少互聯網公司也不推薦使用外鍵,由於db每每是瓶頸,關係的維護能夠在應用服務器作,因此orm框架對應關係的定義不必定能用得上。數據庫
node社區有typeorm,sequelizejs等優秀的orm框架,我的其實並不喜歡用。我以爲比較好的是egg mysql插件所使用的ali-rds。它雖然簡單,卻能知足我大部分的需求。因此咱們須要的是一個好用的mysql client,而不是orm。我也造了一個相似的輪子bsql,我但願api的設計更加接近sql的語意。目前第一個版本還比較簡單,核心接口已經實現,還在迭代,歡迎關注。下面是user.dao的示例。json
import { Injectable } from '@nestjs/common'; import { BsqlClient } from 'bsql'; import { UserPo, UserAddDto } from './user.types'; @Injectable() export class UserDao { constructor( private readonly db: BsqlClient, ) { } /** * 添加用戶 * @param userAddDto */ async addUser(userAddDto: UserAddDto): Promise<number> { const result = await this.db.insertInto('user').values([userAddDto]); return result.insertId; } /** * 查詢用戶列表 * @param limit * @param offset */ async listUsers(limit: number, offset: number): Promise<UserPo[]> { return this.db.select<UserPo>('*').from('user').limit(limit).offset(offset); } /** * 查詢單個用戶 * @param id */ async getUserById(id: number): Promise<UserPo> { const [user] = await this.db.select<UserPo>('*').from('user').where({ id }).limit(1); return user; } }
從廣義的角度看,dao層很像公式「程序=數據結構+算法」中的數據結構。「數據結構」的實現直接關係到上層的「算法」(業務邏輯)。
service位於dao之上,使用dao提供的接口,也能夠調用其它service。service層也比較簡單,主要是弄清其職責和邊界。
一、實現業務邏輯。
service負責業務邏輯這點毋庸置疑,核心是如何將業務邏輯抽象成接口及其粒度。service層應該儘可能提供功能相對單一的基礎方法,更多的場景和變化能夠在controller層實現。這樣設計有利於service層的複用和穩定。
二、處理異常。
service應該合理的捕獲異常並將其轉化成業務異常,由於service層是業務邏輯層,他的調用方更關心業務邏輯進行到哪一步了,而不是一些系統異常。
在實現上,能夠定義一個business.exception.ts,裏面包含常見的業務異常。當遇到業務邏輯執行不下去的問題時,拋出便可,調用方既能根據異常的類型採起行動。
// common/business.exception.ts /** * 業務異常 */ export class BusinessException { constructor( private readonly code: number, private readonly message: string, private readonly detail?: string, ) { } } /** * 參數異常 */ export class ParamException extends BusinessException { constructor(message: string = '參數錯誤', detail?: string) { super(400, message, detail); } } /** * 權限異常 */ export class AuthException extends BusinessException { constructor(message: string = '無權訪問', detail?: string) { super(403, message, detail); } }
對於業務異常,還須要一個兜底的地方全局捕獲,由於不是每一個調用方都會捕獲並處理異常,兜底以後就能夠記錄日誌(方便排查問題)同時給與一些友好的返回。在nest中統一捕獲異常是定義一個全局filter,代碼以下:
// common/business-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common'; import { BusinessException } from './business.exception'; /** * 業務異常統一處理 */ @Catch(BusinessException) export class BusinessExceptionFilter implements ExceptionFilter { catch(exception: BusinessException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); response.json({ code: exception.code, message: exception.message }); console.error(// tslint:disable-line 'BusinessException code:%s message:%s \n%s', exception.code, exception.message, exception.detail); } } // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { BusinessExceptionFilter } from './common/business-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); // 註冊爲全局filter app.useGlobalFilters(new BusinessExceptionFilter()); await app.listen(3000); } bootstrap();
三、參數校驗。
dao層設計很簡單,幾乎不作參數校驗,同時dao也通常不會開放給外部直接調用,而是開放service。因此service層應該作好參數校驗,起到保護的做用。
四、事務控制。
dao層能夠針對單個的持久化作事物控制,粒度比較小,而基於業務原則的事物處理就應該在service層。nest目前貌似沒有在service層提供事務的支持。接下來我準備作個裝飾器,在service層提供數據庫本地事物的支持。分佈式事務比較複雜,有專門的方法,後面有機會再介紹。
controller位於最上層,和外部系統打交道。把這層叫作「業務場景層」可能更貼切一點,它的職責是經過service提供的服務,實現某個特定的業務場景,並以http、rpc等方式暴露給外部調用。
一、聚合參數
前端傳參方式有多種:query、body、param。有時搞不清楚到底應該從哪區,很不方便。我通常是自定義一個@Param()裝飾器,把這幾種參數對象聚合到一個。實現和使用方式以下:
// common/param.ts import { createParamDecorator } from '@nestjs/common'; export const Param = createParamDecorator((data, req) => { const param = { ...req.query, ...req.body, ...req.param }; return data ? param[data] : param; }); // user/user.controller.ts import { All, Controller } from '@nestjs/common'; import { UserService } from './user.service'; import { UserAddDto } from './user.types'; import { Param } from '../common/param'; @Controller('api/user') export class UserController { constructor(private readonly userService: UserService) { } @All('add') async addUser(@Param() user: UserAddDto) { return this.userService.addUser(user); } @All('list') async listUsers( @Param('pageNo') pageNo: number = 1, @Param('pageSize') pageSize: number = 20) { return this.userService.listUsers(pageNo, pageSize); } }
二、統一返回結構
一個api調用,每每都有個固定的結構,好比有狀態碼和數據。能夠將controller的返回包裝一層,省去一部分樣板代碼。下面是用Interceptor的一種實現:
// common/result.ts import { Injectable, NestInterceptor, ExecutionContext } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; export interface Response<T> { data: T; code: number; message: string; } @Injectable() export class ResultInterceptor<T> implements NestInterceptor<T, Response<T>> { intercept( context: ExecutionContext, call$: Observable<T>, ): Observable<Response<T>> { return call$.pipe(map(data => ({ code: 200, data, message: 'success' }))); } }
全部的返回將會包裹在以下的結構中:
三、參數校驗仍是留給service層吧
nest提供了一套針對請求參數的校驗機制,功能很強大。但使用起來會稍微繁瑣一點,實際上也不會有太多複雜的參數校驗。我的以爲參數校驗能夠統一留給service,assert庫可能就把這個事情搞定了。
本文講的都是一些很小的點,大可能是既有的理論。這些東西不想清楚,寫代碼時就會很是難受。你們能夠把這裏當作一個規範建議,但願能提供一些參考價值。