從 Egg.js 到 NestJS,愛碼客後端選型之路

愛碼客3.0 開始開發到如今已通過去快整整一年了,雖然我投入其中的時間只有短短4個月,可是在最初後端幾乎只有我一我的投入的狀況下,能夠說也是研究了一些東西,蹚了二三次渾水,來來回回改過五六次結構,內心七上八下的時間也很多,固然最後折騰出來的東西確定到不了九十分。但,這些都不重要了,事了拂衣去,深藏功(辛)與名(酸)。現在回頭,只是把當時一些探索的歷程簡單記錄一下,權當給這段經歷畫下一個省略號。。。javascript

兩小無猜

愛碼客是一個 Node 應用,在當時的阿里經濟體裏,提到 Node 應用的框架,Egg.js 可謂無人不知,無人不曉。做爲阿里聲名在外的一個重要開源產品,這幾年它在集團內也是獨佔鰲頭的一個態勢。故而,Egg.js 固然是咱們第一眼的選擇。而且以前在 圖靈計劃 和 UTT 中我都與它並肩做戰,如今再次相遇,那必然是得心應手,三下五除二便能把一整個框架給創建起來。因而說幹就幹,立馬根據 Egg.js 的規範,整理了一個代碼框架進行了第一次彙報。css

主管之命,媒妁之言

第一次彙報,主管天然是欲揚先抑,因而在主管的耳提面命之下,我總結出了兩個須要改進的點,而且知道了主管最終想要的是什麼:一個標準化,可是高度可擴展的服務框架。最終的想法且先不提,讓咱們先看看這兩個痛點是什麼。html

第一點,Egg.js 是一個約定大於配置的框架前端

Egg 奉行『 約定優於配置』,按照 一套統一的約定進行應用開發,團隊內部採用這種方式能夠減小開發人員的學習成本,開發人員再也不是『釘子』,能夠流動起來。

正由於如此,Egg.js 中對於目錄的規範是有一個約束的,一個基礎的 Egg.js 項目的目錄結構以下:java

egg-project
├── package.json
├── app.js (可選)
├── agent.js (可選)
├── app
|   ├── router.js
│   ├── controller
│   |   └── home.js
│   ├── service (可選)
│   |   └── user.js
│   ├── middleware (可選)
│   |   └── response_time.js
│   ├── schedule (可選)
│   |   └── my_task.js
│   ├── public (可選)
│   |   └── reset.css
│   ├── view (可選)
│   |   └── home.tpl
│   └── extend (可選)
│       ├── helper.js (可選)
│       ├── request.js (可選)
│       ├── response.js (可選)
│       ├── context.js (可選)
│       ├── application.js (可選)
│       └── agent.js (可選)
├── config
|   ├── plugin.js
|   ├── config.default.js
│   ├── config.prod.js
|   ├── config.test.js (可選)
|   ├── config.local.js (可選)
|   └── config.unittest.js (可選)
└── test
    ├── middleware
    |   └── response_time.test.js
    └── controller
        └── home.test.js

你們能夠看到,在咱們的代碼目錄 app 中,全部的代碼文件是按照功能來歸類的,好比全部的控制器代碼都會放置在同一個目錄下,全部的服務代碼也所有放置在 service 目錄下。誠然,這是一個合理的分類方式。但有時候對於一些開發團隊來講,在模塊衆多的狀況下,開發時須要來回切換分散在不一樣目錄下的文件,給開發帶來了不便,而且相同模塊的代碼的分散也會致使閱讀項目的障礙。那麼,咱們能不可以讓 Egg.js 支持像下面同樣按模塊來歸類的目錄結構呢?node

egg-project
├── package.json
├── app.js (可選)
├── agent.js (可選)
├── src
│   ├── router.js
│   ├── home
│   │   ├── home.controller.ts
│   │   ├── home.service.ts
│   │   └── home.tpl
│   └── user
│       ├── user.controller.ts
│       └── user.service.ts
├── config
|   ├── plugin.js
|   ├── config.default.js
│   ├── config.prod.js
|   ├── config.test.js (可選)
|   ├── config.local.js (可選)
|   └── config.unittest.js (可選)
---

通過對 Egg.js 文檔和 egg-core 源碼的一番研究,發現它提供了擴展 Loader 的方式來自定義加載目錄的行爲,可是因爲如下的約束,因此咱們要自定義 loader 的話,必須基於 Egg 創建一個新的框架,而後再基於這個框架進行開發。git

Egg 基於 Loader 實現了  AppWorkerLoader 和  AgentWorkerLoader,上層框架基於這兩個類來擴展, Loader 的擴展只能在框架進行

所以,咱們須要作的事情大概是:github

  1. 使用 npm init egg --type=framework  創建一個框架
  2. lib/loader 中編寫本身的 loader
  3. 在咱們的項目中指定 egg 的 framework 爲該框架便可
'use strict';
const fs = require('fs');
const path = require('path');
const egg = require('egg');
const extend = require('extend2');

class AiMakeAppWorkerLoader extends egg.AppWorkerLoader {
  constructor(opt) {
    super(opt);
    this.opt = opt;
  }

  loadControllers() {
    super.loadController({
      directory: [ path.join(this.opt.baseDir, 'src/') ],
      match: '**/*.controller.(js|ts)',
      caseStyle: filepath => {
        return customCamelize(filepath, '.controller');
      },
    });
  }

  loadServices() {
    super.loadService({
      directory: [ path.join(this.opt.baseDir, 'src/') ],
      match: '**/*.service.(js|ts)',
      caseStyle: filepath => {
        return customCamelize(filepath, '.service');
      },
    });
  }


  load() {
    this.loadApplicationExtend();
    this.loadRequestExtend();
    this.loadResponseExtend();
    this.loadContextExtend();
    this.loadHelperExtend();

    this.loadCustomLoader();

    // app > plugin
    this.loadCustomApp();
    // app > plugin
    this.loadServices();
    // app > plugin > core
    this.loadMiddleware();
    // app
    this.loadControllers();
    // app
    this.loadRouter(); // Dependent on controllers
  }
}

//...略過工具函數代碼

module.exports = AiMakeAppWorkerLoader;

到此,咱們攻破了第一個痛點。第二點,Egg.js 是一個基於 JavaScript 開發的框架,但如今時間已經到了 2019 年,TypeScript 做爲 JavaScript 的一個超集,可以給咱們帶來強類型系統的各類優點,而且提供了更完善的面向對象編程的實現。咱們在開發一個通用的服務框架時,沒有理由不選擇 TypeScript。然而 Egg.js 卻沒有原生提供 TypeScript 的支持,這裏面可能有其歷史緣由,但對於咱們來講是不可接受的。因而,在一番搜索以後,根據 這個 Issue 中的思路,我又一次找到了在 Egg.js 中使用 TypeScript 的方法。具體的步驟在連接裏已經很詳細了,其實主要就是兩點:npm

  1. 在初始化 egg 項目時,加上 --type=ts 參數
  2. 開發時使用 egg-ts-helper 來幫助自動生成 d.ts 文件

這樣下來就能比較愉快地使用 TypeScript 來編寫 Egg.js 的代碼了。編程

終於,兩個痛點被我基本上解決了,因而我開開心心,不知天高地厚地又跑去進行了第二次彙報。

釵頭鳳

第二次彙報可就沒那麼輕鬆了,主管對於個人思考深度進行了毀滅性的批判。更讓我認識到了,採用自定義 loader 這種方式雖然可以解決個人表面問題,可是根本性的約束仍是沒有消失,而且這種方式毫無靈活性,用戶不可能爲了讓咱們服務框架適應本身的組織文件的習慣而動手去寫一個新的基於 Egg.js 的框架。而且,Egg.js 對於 TypeScript 的支持天生殘疾,即使是使用了 egg-ts-helper 可以寫出 ts 代碼,各類三方庫的支持也不受控制,用戶仍是須要承擔很大的風險。

沒有辦法了,Egg.js,相濡以沫,不如相忘於江湖。

滿堂兮美人,忽獨與餘兮目成

「分手」後的我,在 github 上處處尋找合適的框架,雖然也找到了好些個備胎,但卻老是沒有讓我眼前一亮的那個。正在焦慮糾結之時,一塊兒討論的北京團隊的小夥伴提到了它,NestJS。在我仔細查看了它的 github 主頁以後,頓時有種被欽定的感受。嗯,沒錯,就是它了!

既然有了新歡,那確定要給大夥介紹一下,讓咱們先聽聽它的自述:

Nest 是一個用於構建高效,可擴展的 Node.js 服務器端應用程序的框架。它使用漸進式 JavaScript,內置並徹底支持 TypeScript(但仍然容許開發人員使用純 JavaScript 編寫代碼)並結合了 OOP(面向對象編程),FP(函數式編程)和 FRP(函數式響應編程)的元素。
在底層,Nest使用強大的 HTTP Server 框架,如 Express(默認)和 Fastify。Nest 在這些框架之上提供了必定程度的抽象,同時也將其 API 直接暴露給開發人員。這樣能夠輕鬆使用每一個平臺的無數第三方模塊。

注意到了吧,它但是一個原生支持 TypeScript 的框架,這意味着 NestJS 以及它生態圈中的全部插件,都必然會是 TypeScript 的,這一會兒就解決了個人第二個問題。那第一個問題有解嗎?別急,讓我慢慢給你道來。

初看 NestJS ,咱們你們可能都以爲面生的很,這很正常,對於咱們 Vue 和 React 技術棧的人來講,NestJS 的思惟方式確實不那麼容易理解。可是假如你接觸過 AngularJS,也許會有一些熟悉感。那要是你曾經是一個後端開發人員,熟練使用 Java 和 Spring 的話,可能就會跳起來大喊一聲:這不就是個 Spring boot 嗎!

你的直覺沒錯,NestJS 和 AngularJS,Spring 相似,都是基於控制反轉(IoC = Inversion of Control)原則來設計的框架,而且都使用了依賴注入(DI = Dependency Injection)的方式來解決耦合的問題。

何爲依賴注入呢?簡單舉個例子,假設咱們有個類 Car,和一個類 Engine,咱們以下組織代碼:

// 引擎 
export class Engine {
  public cylinders = '引擎發動機1';
}

export class Car {
  public engine: Engine;
  public description = 'No DI';

  constructor() {
    this.engine = new Engine();
  }

  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders`;
  }
}

// 示例參考 https://juejin.im/post/6844903740953067534

此時咱們的引擎是在 Car 的實例中本身初始化的。那麼假若有一天引擎進行了升級,在構造器中新增了一個參數:

// 引擎  
export class Engine {
  public cylinders = '';
  constructor(_cylinders:string) {
    this.cylinders = _cylinders;
  }
}

那麼使用該引擎的汽車,就必須修改 Car 類中的構造器代碼來適配引擎的改變。這很不合理,由於對汽車來講,應該不在乎引擎的實現細節。此時咱們說 Car 類中依賴了 Engine。

那假如咱們使用依賴注入的方式來實現 Car 類:

export class Engine {
  public cylinders = '引擎發動機1';
}

export class Car {
  public description = 'DI'; 

  // 經過構造函數注入Engine和Tires
  constructor(public engine: Engine) {}  

  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders`;
  }
}

此時 Car 類再也不親自建立 Engine ,只是接收而且消費一個 Engine 的實例。而 Engine 的實例是在實例化 Car 類時經過構造函數注入進去的。因而 Car 類和 Engine 類就解除了耦合。假如咱們要升級 Engine 類,也只須要在 Car 的實例化語句中作出修改便可。

export class Engine {
  public cylinders = '';
  constructor(_cylinders:string) {
    this.cylinders = _cylinders;
  }
}

export class Car {
  public description = 'DI'; 

  // 經過構造函數注入Engine和Tires
  constructor(public engine: Engine) {}  

  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders`;
  }
}

main(){
    const car = new Car(new Engine('引擎啓動機2'), new Tires1());
    car.drive();
}

這就是依賴注入。

固然,這只是一個最簡單的例子,實際狀況下,NestJS 中的類實例化過程是委派給 IoC 容器(即 NestJS 運行時系統)的。並不須要咱們每次手動注入。

那麼說了這麼多,依賴注入和咱們的第一個問題有關係嗎?固然有!咱們知道,爲何 Egg.js 須要規定目錄結構,是由於在 egg-core 的 loader 代碼中,對於 Controller,Service,Config 等的加載是由不一樣的 load 函數查找指定目錄來完成的。所以若是在指定的地方沒有找到,那麼 Egg.js 就沒法獲取並將它們掛載到 ctx 下。而 NestJS 則不一樣,依賴是咱們自行在容器中註冊的,也就是說,NestJS 並不須要自行去按指定位置尋找依賴。咱們只須要將所需執行的 Controller,Service 等注入到模塊中,模塊便可獲取它們而且使用。

// app.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';

@Controller()
export class AppController {
  @Inject('appService')
  private readonly appService;

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}


// app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}


// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app/app.controller';
import { AppService } from './app/app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [{
    provide: 'appService',
    useClass: AppService,
  }],
})
export class AppModule {}

如上,咱們能夠看到,使用 @Injectable 修飾後的 Service,在咱們註冊以後,在 app.controller.ts 中使用的時候能夠直接用 @Inject('appService')  來將 Service 實例注入到屬性中。此時在使用時咱們根本不用關心 app.service.ts 到底在哪裏,目錄能夠隨便組織,惟一的要求是在容器中完成註冊。 有了依賴注入,咱們能夠在開發時靈活注入配置,而且因爲脫離了依賴的耦合,可測試性也更強。

固然,NestJS 的優點並不只僅只有這兩點,做爲 Node 端對標 Java Spring 的框架,它的設計理念和開發約束在規模較大的項目開發中可以起到很大的幫助。而且,它天生支持微服務,對於大規模的項目,後續擴展也會比較方便。結合以上的優點,咱們最後毅然決然地選擇了 NestJS。

皇天不負有心人,此次主管沒有棒打鴛鴦,終於走完了這一條選型之路。

驀然回首,那人卻在燈火闌珊處

時間過去了許久,Egg.js 和 NestJS 之爭也早就有告終果。愛碼客也如火如荼開發了半年有餘。一天傍晚,收到了 Midway 的郵件,Egg.js 終於完成了他的歷史使命,Midway 接過了這條接力棒,成爲了集團內的標準框架。回想起當時在調研時,也曾看過 Midway ,還專門請教過負責的大神。固然最後因爲對它還不是很熟悉,再加上以爲集團內部仍是 Egg.js 爲主就沒有選擇。現在早已沒有選型的重擔,閒下來再研究了一下如今的 Midway。確實已是一個跟得上時代的框架了。

原生支持 TypeScript 的 Midway,不再用像 Egg.js 同樣備受詬病。而兼容了 Egg.js 的衆多插件,也讓它在集團內各場景的開發中遊刃有餘。基於 DI 的設計,讓它在架構上也脫胎換骨。更加激進的是,Midway 對於依賴採用了自動掃描的機制,連手動註冊依賴的一步均可以省去,這比起 NestJS ,對我來講確實能夠算個驚喜。

Midway 內部使用了自動掃描的機制,在應用初始化以前,會掃描全部的文件,包含裝飾器的文件會  自動綁定 到容器。

若是使用 Midway 的話,可能咱們當時的一些痛點能夠迎刃而解,而且代碼還精簡了很多呢。此時的我不由馬後炮的想着。然而,既然歷史讓我選擇了 NestJS ,仍是從一而終吧。

// app/controller/user.ts
import { Context, controller, get, inject, provide } from '@ali/midway';

@provide()
@controller('/user')
export class UserController {

  @inject()
  ctx: Context;

  @inject('userService')
  service;

  @get('/:id')
  async getUser(): Promise<void> {
    const id: number = this.ctx.params.id;
    const user = await this.service.getUser({id});
    this.ctx.body = {success: true, message: 'OK', data: user};
  }
}


// service/user.ts
import { provide } from '@ali/midway';
import { IUserService, IUserOptions, IUserResult } from '../interface';

@provide('userService')
export class UserService implements IUserService {

  async getUser(options: IUserOptions): Promise<IUserResult> {
    return {
      id: options.id,
      username: 'mockedName',
      phone: '12345678901',
      email: 'xxx.xxx@xxx.com',
    };
  }
}

武陵人

離開發已過將近一年,前端的發展突飛猛進,技術的選擇也沒有絕對的對錯。遠離了 Node 大半年,早已不知魏晉。記錄便只是記錄,寫給你們的是一個故事,寫給個人是一個念想。做者語云:不足爲外人道也

文章可隨意轉載,但請保留此原文連接。
很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj@alibaba-inc.com 。
相關文章
相關標籤/搜索