nest後端開發實戰(二)——分層

前言

分層是解決軟件複雜度很好的方法,它能夠降低耦合、增加複用。典型的java後端開發大多分爲三層,幾乎成了標準模式,但是node社區對於分層的討論卻很少。node後端是否需要分層?如何分層?本文將從個人的角度提供一些思路。

是否必要分層?如何分層?

個人的結論是:如果想做一個正兒八經的node後臺應用,一定需要分層,java的三層架構,同樣適用於node。結構如下:
clipboard.png

dao層

dao(data access object),數據訪問對象,位於最下層,和數據庫打交道。它的基本職責是封裝數據的訪問細節,爲上層提供友好的數據存取接口。一般是各種數據庫查詢語句,緩存也可以在這層做。

無論是nest還是egg,官方demo裏都沒有明確提到dao層,直接在service層操作數據庫了。這對於簡單的業務邏輯沒問題,如果業務邏輯變得複雜,service層的維護將會變得非常困難。業務一開始一般都很簡單,它一定會向着複雜的方向演化,如果從長遠考慮,一開始就應該保留dao層。

分享兩點dao層的建議:

1、以實體爲中心定義類型描述。
後端建模的一大產出是領域實體模型,後續的業務邏輯其實就是對實體模型的增刪改查。利用ts對類型的豐富支持,可以先將實體模型的類型描述定義出來,這將極大的方便上層業務邏輯的實現。我一般會將實體相關的類型、常量等都定義到一個文件,命名爲xxx.types.ts。定義到一個文件的好處是,編碼規範好落實,書寫和引用也非常方便,由於沒有太多邏輯,即使文件稍微大一點,可讀性也不會降低太多。

用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提供了強大的類型系統,如果不能總結出一套最佳實踐出來,同樣會越寫越亂。全盤使用不是一個好的選擇,因爲這樣會失去很多的靈活性。我們需要的是在某些必須的場景,堅持使用。

2、不推薦orm框架
orm的初心很好,它試圖完全將對象和數據庫映射自動化,讓使用者不再關心數據庫。過度的封裝一定會帶來另外一個問題——隱藏複雜度的上升。個人覺得,比起查詢語句,隱藏複雜度更可怕。有很多漂亮的orm框架,比如java界曾經非常流行的hibernate,功能非常強大,社區也很火,但實際在生產中使用的人卻很少,反倒是一些簡單、輕量的被大規模應用了。而且互聯網應用,對性能的要求較高,因此對sql的控制也需要更直接和精細。很多互聯網公司也不推薦使用外鍵,因爲db往往是瓶頸,關係的維護可以在應用服務器做,所以orm框架對應關係的定義不一定能用得上。

node社區有typeorm,sequelizejs等優秀的orm框架,個人其實並不喜歡用。我覺得比較好的是egg mysql插件所使用的ali-rds。它雖然簡單,卻能滿足我大部分的需求。所以我們需要的是一個好用的mysql client,而不是orm。我也造了一個類似的輪子bsql,我希望api的設計更加接近sql的語意。目前第一個版本還比較簡單,核心接口已經實現,還在迭代,歡迎關注。下面是user.dao的示例。

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層

service位於dao之上,使用dao提供的接口,也可以調用其它service。service層也比較簡單,主要是弄清其職責和邊界。

1、實現業務邏輯。
service負責業務邏輯這點毋庸置疑,核心是如何將業務邏輯抽象成接口及其粒度。service層應該儘量提供功能相對單一的基礎方法,更多的場景和變化可以在controller層實現。這樣設計有利於service層的複用和穩定。

2、處理異常。
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();

3、參數校驗。
dao層設計很簡單,幾乎不做參數校驗,同時dao也一般不會開放給外部直接調用,而是開放service。所以service層應該做好參數校驗,起到保護的作用。

4、事務控制。
dao層可以針對單個的持久化做事物控制,粒度比較小,而基於業務原則的事物處理就應該在service層。nest目前貌似沒有在service層提供事務的支持。接下來我準備做個裝飾器,在service層提供數據庫本地事物的支持。分佈式事務比較複雜,有專門的方法,後面有機會再介紹。

controller層

controller位於最上層,和外部系統打交道。把這層叫做“業務場景層”可能更貼切一點,它的職責是通過service提供的服務,實現某個特定的業務場景,並以http、rpc等方式暴露給外部調用。

1、聚合參數
前端傳參方式有多種: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);
    }
}

2、統一返回結構
一個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' })));
    }
}

所有的返回將會包裹在如下的結構中:
clipboard.png

3、參數校驗還是留給service層吧
nest提供了一套針對請求參數的校驗機制,功能很強大。但使用起來會稍微繁瑣一點,實際上也不會有太多複雜的參數校驗。個人覺得參數校驗可以統一留給service,assert庫可能就把這個事情搞定了。

小結

本文講的都是一些很小的點,大多是既有的理論。這些東西不想清楚,寫代碼時就會非常難受。大家可以把這裏當做一個規範建議,希望能提供一些參考價值。

上一篇:nestjs後端開發實戰(一)——依賴注入

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章