乐趣区

关于前端:从-Eggjs-到-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。

退出移动版