Midway 後端程式碼的設計建議

ES2049發表於2022-03-22

Midway 是阿里巴巴內部開發的基於 TypeScript 的 Node.js 研發框架,在集團內部,由於其整合了內部的各類基礎服務與穩定性監控,同時支援 FaaS 函式部署,所以是內部 Node.js 應用研發的首選框架。

雖然 Midway 結合了物件導向(OOP + Class + IoC)與函式式(FP + Function + Hooks)兩種程式設計正規化,但考慮到一般專案大多采用物件導向的開發方式,所以本文也重點闡述針對物件導向這種正規化,在工程開發中可以參考的程式碼設計。

基於 MVC 的工程目錄設計

在 Midway 工程開發中,一般建議採用如下工作目錄組織業務程式碼。config 目錄中的程式碼內容,根據自身需要,結合官方的配置文件即可正確標準的完成配置,在本文中不做過多講解。

- config  配置檔案目錄,存放不同環境的差異配置資訊
- constant 常量存放目錄,存放業務常量及國際化業務文案
- controller 控制器存放目錄,存放核心業務邏輯程式碼
- dto 資料傳輸物件目錄,存放外部資料的校驗規則
- entity 資料實體目錄,存放資料庫集合對應的資料實體
- middleware 中介軟體目錄,存放專案中介軟體程式碼
- service 服務邏輯存放目錄,存放資料儲存、區域性通用邏輯程式碼
- util 工具程式碼存放目錄,存放業務通用工具方法

當請求進入時,各目錄對應的程式碼發揮瞭如下的功能作用:

  1. Middleware 作為起始邏輯進行通用性邏輯執行。
  2. 接著 DTO 層對引數進行校驗。
  3. 引數校驗無異常進入 Controller 執行整體業務邏輯。
  4. 資料庫的呼叫,或者整體性比較強的通用業務邏輯會被封裝到 Service 層方便複用。
  5. 工具方法、常量、配置和資料庫實體則作為工程的底層支撐,向 Service 或 Controller 返回資料。
  6. Controller 吐出響應結果,如果響應異常,Middleware 進行邏輯兜底。
  7. 最終吐出響應資料返回給使用者。

整理一下,你可以這麼分類,在 MVC 中,C 層對應為 Middleware + DTO + ControllerM 層對應為 Service;V 層由於一般後端只提供對外的介面,不會有太多靜態頁面透出,所以暫時可以忽略。當然,Service 層有一定的邊界混淆,它不僅僅只包含 Model 模型層,否則我們就直接起名成 Model 層好了,在 Service 中,我也會把一些可抽象、可複用的邏輯放入其中,來緩解一下複雜業務中 Controller 邏輯過於繁瑣的問題。

image.png

瞭解上述 Midway 程式碼目錄的設計思考後,就分別對每一個部分展開程式碼設計上的一些經驗分享。

Middleware 層的程式碼建議

在開發中,業務中介軟體可以自行設計開發,這依賴於你的業務訴求。但是,程式碼執行異常,可以通過下述方案比較優雅的完成處理。

異常兜底容錯中介軟體

程式碼執行異常,是指在執行業務程式碼過程中,可能產生的執行錯誤。一般來說,為了解決這種潛在的風險,我們可以在邏輯外層增加 try catch 語句進行處理。在很多工程中,由於為了做異常處理,增加了大量的 try catch 語句;還有很多工程中,沒有考慮異常處理的問題,根本就沒有做 try catch 的兜底容錯。

// 以下程式碼缺少異常兜底冗錯
@Get('/findOne')@Validate()
async findOne(@Query(ALL) appParams: AppFindDTO) {
  const { id } = appParams;
  const app = await this.appService.findOneAppById(id);
  return getReturnValue(true, app);
}

// 以下程式碼每個函式都要有一個 try catch 包裹
@Get('/findOne')@Validate()
async findOne(@Query(ALL) appParams: AppFindDTO) {
  try {
    const { id } = appParams;
    const app = await this.appService.findOneAppById(id);
    return getReturnValue(true, app);
  } catch(e) {
    return getReturnValue(false, e.message);
  }
}

使用中介軟體,就可以解決上面的兩個問題,你可以編寫如下中介軟體:

@Provide('errorResponse')
@Scope(ScopeEnum.Singleton)
export class ErrorResponseMiddleware {
  resolve() {
    return async (ctx: FaaSContext, next: () => Promise<any>) => {
      try {
        await next();
      } catch (error) {
        ctx.body = getReturnValue(
          false,
          null,
          error.message || '系統發生錯誤,請聯絡管理員'
        );
      }
    };
  }
}

將這段中介軟體程式碼加入到程式的執行邏輯中,編寫程式碼時,你就無需再關注程式碼執行異常的問題,中介軟體會幫你捕獲程式執行異常並標準化返回。同時,你也可以在這裡統一做異常的日誌收集或實時預警,擴充套件更多的功能。所以這個中介軟體設計,強烈推薦在工程中統一使用。

DTO 層的程式碼建議

DTO 層,也就是資料傳輸物件層,在 Midway 中,主要是通過它來對 POST、GET 等請求的請求引數進行校驗。在實踐的過程中,有兩方面的問題需要在設計中著重關注:合理的程式碼複用、明確的程式碼職責劃分。

合理的程式碼複用

首先我們看一下不合理的 DTO 層的程式碼設計:

// 第一種問題:
// 分頁的校驗,看起來很難懂,未來很多地方都要用,這麼寫無法複用
export class AppsPageFindDTO {
  @Rule(RuleType.string().required())
  siteId: number;
  
  @Rule(RuleType.number().integer().empty('').default(1).greater(0))
  pageNum: number;

  @Rule(RuleType.number().integer().empty('').default(20).greater(0))
  pageSize: number;
}

// 第二種問題
// 對引數的校驗,本身應該是 DTO 層面校驗的,放到業務中不合理
// 同時,對逗號間隔的 id 進行校驗,這是常見功能,放在這難以複用
@Get('/findMany')@Validate()
async findMany(@Query(ALL) appParams: AppsFindDTO) {
  const { ids } = appParams;
  const newIds = ids.split(',');
  if(!Array.isArray(newIds)) {
    return getReturnValue(false, null, 'ids 引數不符合要求');
  }
  const app = await this.appService.findOneAppByIds(newIds);
  return getReturnValue(true, app);
}

建議使用如下的方式進行 DTO 層的程式碼編寫,首先對可複用的常見規則進行封裝:

// 必填字串規則
export const requiredStringRule = RuleType.string().required();
// 頁碼校驗規則
export const pageNoRule = RuleType.number().integer().default(1).greater(0);
// 單頁顯示內容數量校驗規則
export const pageSizeRule = RuleType.number().integer().default(20).greater(0);

// 逗號間隔的 id 進行校驗的規則擴充套件,起名為 stringArray
RuleType.extend(joi => ({
  base: joi.array(),
  type: 'stringArray',
  coerce: value => ({
    value: value.split ? value.split(',') : value,
  }),
}));

接著在你的 DTO 定義檔案中,程式碼就可以精簡為:

// 分頁的校驗的邏輯可以精簡為這種寫法
export class AppsPageFindDTO {
  @Rule(requiredStringRule)
  siteId: number;
  @Rule(pageNoRule)
  pageNum: number;
  @Rule(pageSizeRule)
  pageSize: number;
}

// 逗號間隔的 id 字串校驗,可以改為如下寫法
export class AppsFindDTO {
  @Rule(RuleType.stringArray())
  ids: number;
}

@Get('/findMany')@Validate()
async findMany(@Query(ALL) appParams: AppsFindDTO) {
    const { ids } = appParams;
    const app = await this.appService.findOneAppByIds(ids);
    return getReturnValue(true, app);
}

比起初始的程式碼,要精簡非常多,而且所有的校驗規則,都可以未來複用,這是比較推薦的 DTO 層程式碼設計。

明確的職責劃分

DTO 的核心職責是對入參進行校驗,它的職責僅限於此,但是很多時候,我們能看到這樣的程式碼:

// Controller 層程式碼邏輯
@Post('/createRelatedService')
@Validate()
async createRelatedService(@Body(ALL) requestBody: AppRelationSaveDTO) {
  // 判斷當前站點和應用的關聯是否存在
  const appRelation = new AppRelation();
  const saveResult = await this.appServiceService.saveAppRelation(
    appRelation,
    requestBody,
  );
  return getReturnValue(true, saveResult);
}

// Service 層的程式碼邏輯
async saveAppRelation(
  relation: AppRelation,
  params: AppRelationSaveDTO,
) {
  const { appId, serviceId } = params;
  appId && (relation.appId = appId);
  serviceId && (relation.serviceId = serviceId);
  const result = await this.appServiceRelation.save(relation);
  return result;
}

在 Service 層中的方法中,使用了 AppRelationSaveDTO 這個 DTO 作為 Typescript 的型別來幫助做程式碼型別校驗。這段程式碼問題在於,讓 DTO 層承擔了資料校驗外的額外職責,本身 Service 層關注資料怎麼存,現在 Service 層還要關注外部資料怎麼傳,很顯然程式碼職責就比較混亂。

優化的方式也很簡單,我們可以改進一下程式碼:

// Controller 層程式碼邏輯
@Post('/createRelatedService')
@Validate()
async createRelatedService(@Body(ALL) requestBody: AppRelationSaveDTO) {
  // 判斷當前站點和應用的關聯是否存在
  const { appId, serviceId } = requestBody;
  const appRelation = new AppRelation();
  const saveResult = await this.appServiceService.saveAppRelation(
    appRelation,
    appId,
    serviceId
  );
  return getReturnValue(true, saveResult);
}

// Service 層的程式碼邏輯
async saveAppRelation(
  relation: AppRelation,
  appId: string,
  serviceId: stirng
) {
  const { appId, serviceId } = params;
  appId && (relation.appId = appId);
  serviceId && (relation.serviceId = serviceId);
  const result = await this.appServiceRelation.save(relation);
  return result;
}

Service 層的引數型別,不再使用 DTO 進行描述,程式碼邏輯很清晰:Controller 層負責摘取必要資料;Service 層,負責拿到必要的資料進行增刪改查即可;而 DTO 層,也只承擔資料校驗的職責。

控制層和服務層的程式碼建議

Controller 和 Service 層的設計建議可能會有比較大的爭議,這裡僅表達一下個人的觀點:Controller 是控制器,所以業務邏輯都應該放在 Controller 中進行編寫,Service 層作為服務層,應該把抽象沉澱的邏輯放在其中(比如說資料庫操作,或者複用性程式碼)。也就是說,Controller 層應該存放業務定製的一次性邏輯,而 Service 層則存放可複用性的業務邏輯

控制層和服務層的職責明確

圍繞這個思路,給一個優化程式碼的設計例子供參考:

// 控制器層程式碼
@Post('/create')
@Validate()
async update(@Body(ALL) appBody: AppSaveDTO) {
  const { code, name, description } = appBody;
  const saveResult = await this.appService.saveApp(
    code, name, description
  );
  return getReturnValue(true, saveResult);
}

// 服務層程式碼
async saveApp(code: sting, name: string, description: string) {
  const app = await this.findOneAppByCode(code);
  app.code = code;
  app.name = name;
  app.description = description;
  const result = await this.appModel.save(app);
  return result;
}

這段程式碼,其實是要更新一條資訊,而且一下子必須更新 code,name 和 description,這樣做 Service 層其實是和 Controller 有耦合的,到底怎麼存實際上是業務邏輯,應該由 Controller 來決定,所以建議修改成如下程式碼:

// 控制器層程式碼
@Post('/create')
@Validate()
async update(@Body(ALL) appBody: AppSaveDTO) {
  const { code, name, description } = appBody;
  const app = await this.appService.findOneAppByCode(code);
  app.code = code;
  app.name = name;
  app.description = description;
  const saveResult = await this.appService.saveApp(app);
  return getReturnValue(true, saveResult);
}

// 服務層程式碼
async saveApp(app: App) {
  const result = await this.appModel.save(app);
  return result;
}

這樣寫,相對於之前的程式碼,Controller 更聚焦業務;Service 更聚焦服務,而且能夠得到更好的複用。這是在控制器和服務層寫程式碼時可以參考的設計思路。

控制器層和服務層一對一匹配

在編寫 Midway 程式碼的時候,存在這樣的一種靈活性:控制器可以呼叫多個服務,而服務之間也可以互相呼叫。也就是說,服務層的一段程式碼,可能在任何的控制器中被呼叫,也可能在任何的服務層被呼叫。這種比較強的靈活度,最終一定會導致程式碼的層次結構不清晰,編碼方式不統一,最終導致系統可維護性減弱。

為了規避過度靈活可能帶來的問題,我們可以從規範上進行一定的約束。目前我的想法是,控制器只呼叫自己的服務層,如果需要其他服務層的能力,在自己的服務層進行轉發。這樣做後,一個服務層的程式碼,只能被自己的控制器呼叫,或者被其他的服務層呼叫,呼叫的靈活度從 N2 降低到 N,程式碼也就相對更可控。

依然通過程式碼舉例來說:

// 控制器中的函式方法
@Post('/create')
@Validate()
async create(@Body(ALL) siteBody: SiteCreateDTO) {
  // 用到一個 ACL 的服務層
  const hasPermission = await this.aclService.checkManagePermission('site');
  if (!hasPermission) {
    return getReturnValue(false, null, '您無許可權,無法建立');
  }
  const { name, code } = siteBody;
  const site = new Site();
  site.name = name;
  site.code = code;
  // 用到自身的服務層
  const result = await this.siteService.createSite(site);
  return getReturnValue(true, result);
}

如果程式碼這樣設計,業務程式碼中,用到 ACL 的服務,校驗許可權,那麼隨著業務的發展,aclService 層可能會耦合越來越多的定製邏輯,因為所有的許可權校驗都由著一個方法提供,如果呼叫場景多,肯定會存在定製化需求。

所以更合理、更可擴充套件的程式碼可以改變成下面的樣子:

// 控制器中的函式方法
@Post('/create')
@Validate()
async create(@Body(ALL) siteBody: SiteCreateDTO) {
  // 用到自身的服務層
  const hasPermission = await this.siteService.checkManagePermission();
  if (!hasPermission) {
    return getReturnValue(false, null, '您無許可權,無法建立');
  }
  const { name, code } = siteBody;
  const site = new Site();
  site.name = name;
  site.code = code;
  // 用到自身的服務層
  const result = await this.siteService.createSite(site);
  return getReturnValue(true, result);
}

// 自身服務層的程式碼
async checkManagePermission(): Promise<boolean> {
  const hasPermission = await this.aclService.checkUserPermission('site');
  return hasPermission;
}

在自身的服務層,增加一層轉發程式碼,不僅可以約束程式碼的靈活度,當定製性邏輯增加的時候,也可以直接在這裡擴充套件,所以是一種更合理的程式碼設計。

資料庫查詢的程式碼設計

使用邏輯表關聯

在 Midway 中,整合的 TypeORM 的資料庫框架,裡面提供了 OneToOne ,OneToMany 這樣的資料庫操作語法,幫助你自動生成 Join 語句,管理表之間的關聯。

但在業務系統中,我不建議使用這種直接的表連線語句,因為這很容易產生慢 SQL,影響系統的效能,所以建議在資料庫操作中,統一採用邏輯表關聯的方式進行關聯資料查詢,這裡直接給出程式碼例子:

@Get('/findRelatedServices')
  @Validate()
  async findRelatedServices(@Query(ALL) appParams: AppServicesFindDTO) {
    const { id } = appParams;
    // 尋找關聯關係內容
    const relations = await this.appService.findAppRelatedServices(id);
    // 從關聯關係中找到另一張表關聯的 id 合集
    const serviceIds = relations.map(item => item.serviceId);
    // 去另一張表取資料拼裝
    const services = await this.appService.findServicesByIds(serviceIds);
    // 返回最終資料
    return getReturnValue(true, {services});
  }

雖然這種查詢,相對於 Join,程式碼更多,但是邏輯全部在程式碼中體現,而且效能很好,所以在開發中,推薦使用這種資料庫操作的設計。

常量的用法

常量在服務端開發中非常常用,通過常量語義化的表述一些列舉,這種基礎內容不再累述,主要講一下使用常量管理業務提示的想法。

業務提示文案抽離

複雜的專案,最終有可能走向國際化的路線,如果在程式碼中,寫死的文字提示太多,最後做國際化,還是要投入精力修改,所以不如在開發開始,就對專案做一個提前準備,很簡單,你只要把所有的文字提示抽離到常量檔案裡管理就可以了。

// 不推薦這種寫法,文字和業務耦合
@Post('/create')
@Validate()
async create(@Body(ALL) appBody: AppSaveDTO) {
  const { code, name } = appBody;
  const appWithSameCode = await this.appService.findOneAppByCode(code);
  if (appWithSameCode) {
    // 文字和業務耦合在一起
    return getReturnValue(false, null, 'code 已存在,無法重複建立!');
  }
  const app = new App();
  const saveResult = await this.appService.saveApp(app, name);
  return getReturnValue(true, saveResult);
}

// 推薦這種寫法,文字和業務解耦
@Post('/create')
@Validate()
async create(@Body(ALL) appBody: AppSaveDTO) {
  const { code, name } = appBody;
  const appWithSameCode = await this.appService.findOneAppByCode(code);
  if (appWithSameCode) {
    // 文字拆離到常量中管理,實現解耦
    return getReturnValue(false, null, APP_MESSAGES.CODE_EXIST);
  }
  const app = new App();
  const saveResult = await this.appService.saveApp(app, name);
  return getReturnValue(true, saveResult);
}

很小的一個改動,就可以讓你的程式碼看起來有很大的變化,非常建議使用這個技巧。

總結

在複雜的專案開發中,選擇好開發框架只是第一步,真正把程式碼寫好才是最困難的事情,本篇文章總結了過去一年在使用 Midway 框架開發過程中,我對如何寫好服務端程式碼自己的一些思考和編碼技巧,希望能夠對你有一定的啟發,如果有挑戰或疑問,歡迎留言討論。

作者:ES2049 | Dell

文章可隨意轉載,但請保留原文連結。
非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj@alibaba-inc.com

相關文章