从 Egg.js 到 NestJS,爱码客后端选型之路

爱码客3.0 开始开发到现在已经过去快整整一年了,虽然我投入其中的时间只有短短4个月,但是在最初后端几乎只有我一个人投入的情况下,可以说也是研究了一些东西,蹚了二三次浑水,来来回回改过五六次结构,心里七上八下的时间也不少,当然最后折腾出来的东西肯定到不了九十分。但,这些都不重要了,事了拂衣去,深藏功(辛)与名(酸)。如今回头,只是把当时一些探索的历程简单记录一下,权当给这段经历画下一个省略号。。。

青梅竹马

爱码客是一个 Node 应用,在当时的阿里经济体里,提到 Node 应用的框架,Egg.js 可谓无人不知,无人不晓。作为阿里声名在外的一个重要开源产品,这几年它在集团内也是独占鳌头的一个态势。故而,Egg.js 当然是我们第一眼的选择。并且之前在 图灵计划 和 UTT 中我都与它并肩作战,现在再次相遇,那必然是驾轻就熟,三下五除二便能把一整个框架给建立起来。于是说干就干,立马根据 Egg.js 的规范,整理了一个代码框架进行了第一次汇报。

主管之命,媒妁之言

第一次汇报,主管自然是欲扬先抑,于是在主管的耳提面命之下,我总结出了两个需要改进的点,并且知道了主管最终想要的是什么:一个标准化,但是高度可扩展的服务框架。最终的想法且先不提,让我们先看看这两个痛点是什么。

第一点,Egg.js 是一个约定大于配置的框架

Egg 奉行『约定优于配置』,按照一套统一的约定进行应用开发,团队内部采用这种方式可以减少开发人员的学习成本,开发人员不再是『钉子』,可以流动起来。

正因为如此,Egg.js 中对于目录的规范是有一个约束的,一个基础的 Egg.js 项目的目录结构如下:

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 支持像下面一样按模块来归类的目录结构呢?

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 建立一个新的框架,然后再基于这个框架进行开发。

Egg 基于 Loader 实现了 AppWorkerLoader 和 AgentWorkerLoader,上层框架基于这两个类来扩展,Loader 的扩展只能在框架进行

因此,我们需要做的事情大概是:

  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 的方法。具体的步骤在链接里已经很详细了,其实主要就是两点:

  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 。
阅读 557

推荐阅读
ES2049 Studio
用户专栏

阿里巴巴 - CRO 技术部 - 体验技术

1942 人关注
31 篇文章
专栏主页