Nestjs 初探 - 理解 IoC 和 DI
导读
《Nodejs 服务端框架调研》里挖的坑终于要到填的时候了,前面稀稀拉拉的写了 Nextjs 初探 和 Eggjs 初探,还附带产出了一篇 如何获取 cnpm 的 packages 下载量,总算是有了一些积累,可以研究下 Nestjs(以下简称 Nest)了。
有了前面几篇文章的铺垫,目前能够得到的结论有:
Nextjs
的 Star 和 Download 数据虽然非常高,但是它服务端的功能,更像是为了实现 SSR 顺带赠送的,就连在教程里面也只是占了很少的篇幅。整个框架的重点还是在前端工程上,所以可以 Pass 调了,Nuxtjs 也是同理。对了,Nextjs 还强绑定了 React,所以也是不太符合要求;Express
和Koa
都相对太底层,太基础了。灵活性很强,但是易用性和规范性就不太够了,不太适合直接上来做工程;Eggjs
在国内非常流行,甚至在 cnpm 上的下载量是 Nest 的 1.57 倍。但是在 npm 上,后者下载量是前者的 28 倍。不过 Eggjs 的继承者Midway
采用了与 Nest 极其相似的设计,所以理性来说,Nest 肯定还是有它先进性在的;- Nest 本身就是 TS 写的,个人认为,TS 是 Nodejs 写后端必备的条件。貌似 Eggjs 的 TS 支持总会被人诟病。又貌似 Midway 已经解决 TS 的问题了,但它的下载量和使用量实在是太低了,不敢用啊。
综上,目前的结论,大概率会选择 Nestjs 作为新项目 BFF 的选型,本文就来手撕 Nest,看看 Nest 到底是怎么个设计思路。
前言
Nest 的语法和用法其实非常简单。举个例子:
// cats.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('cats')
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats';
}
}
// app.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
@Module({
controllers: [CatsController],
})
export class AppModule {}
在用 @nestjs/cli
初始化好项目之后,只需要新建一个 cats.controller.ts
文件,然后写入上面的代码,再修改一下 app.module.ts
文件,就可以实现 localhost:3000/cats
接口。用浏览器访问该接口,可以看到「This action returns all cats」 这句话。
Nest 通过标志性的 Decorator 的写法让开发人员可以节省很多的模板代码,从而只需要关注核心逻辑代码即可。
当然,它背后还是有 MVC 设计思想在里面,所以理解 Controller、Module、Service 等基本概念是必须的。如果在这块了解不是很多的同学,可以先试试能不能读懂 Eggjs 初探 这篇,里面对 MVC 有一些粗浅的解释,应该够用了。
另外,由于其背后的 IoC 思想,让一些不太了解这个概念的同学,尤其是前端同学特别的「懵」。怎么就写个 Decorator 就完事了?发什么了什么?很不踏实啊有木有~
所以,正如前文所说,Nest 的语法和用法其实非常简单,难的还是理解其背后的原理。理解了原理和设计思想之后,才能真的会用,要不真不敢写啊,那么多的 Decorator 也不会用啊。
所以本文会用大量篇幅来尝试解读 Nest 的设计思想和原理,讲解代码的部分反而不会有很多。
正文
Decorator 扫盲
我们先来看一段 Nest 的代码:
import { Controller, Get, Post } from '@nestjs/common';
@Controller('cats')
export class CatsController {
@Post()
@Header('Cache-Control', 'none')
@HttpCode(204)
create(): string {
return 'This action adds a new cat';
}
@Get()
findAll(): string {
return 'This action returns all cats';
}
}
对于一些前端同学来说 @Controller
这种 Decorator(装饰器)的写法可能比较陌生。它实际上就是一种语法糖,用过 Angular 或者 Vue Class Component,或者干脆用过 Java 的 Spring 的同学可能会比较熟悉。至于前端同学,可以把它理解成 HOC(高阶组件)。或者我们直接把上文的例子改成「普通」一点的样子,可能就好懂一些了,其实它们是等价的:
import { Controller, Get, Post } from '@nestjs/common';
class CatsController {
// ...code
}
export default Controller('cats')(CatsController)
IoC 和 DI
这里就必须引出两个概念了,IoC(Inversion of Control 控制反转)和 DI(Dependency Injection 依赖注入)。这两个概念很难一两句话就讲清楚,不过不得不吐槽这两个名字取的挺烂的,非常不直观(DI 还好点)。所以大家可以先忘掉它们,或者把它们换成「狗剩」、「二蛋」之类的,不影响下面的理解。我们直接从一个实际场景出发,反推它们俩。
初始案例
这里引用 前端中的 IoC 理念 当中的例子。假设有个 App 类,需要有 Router 和 Track 的功能,代码可能如下:
// app.js
import Router from './modules/Router';
import Track from './modules/Track';
class App {
constructor(options) {
this.options = options;
this.router = new Router();
this.track = new Track();
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () => {
this.router.to('home');
this.track.tracking();
this.options.onReady();
});
}
}
// index.js
import App from 'path/to/App';
new App({
onReady() {
// do something here...
},
});
在 index.js
文件里调用 App 的时候,传入一些参数即可。好,下面需求有变动了。
第一次优化 - 解耦
假设我们要给 Router 模块实现 history 模式。修改完 ./modules/Router
的代码之后,需要在实例化 Router 的时候,这样使用 new Router({ mode: 'history' })
。
这就要修改 app.js
文件的内容了,这时候有个问题,怎么改呢?直接写死 history 模式,肯定不妥,要是有的采用的不是这个参数呢?把 Router 的参数在 App 实例化的时候传入似乎是个不错的主意。更进一步的,我们干脆把 Router 的实例作为参数传进去不就行了,这样 App 和 Router 解耦的更彻底了。
App 内部不需要进行 Router 的 new 操作了,你直接在外边 new 完了给我传进来,我直接用就行了。Track 也是一样的道理。于是,代码变成了这样:
// app.js
class App {
constructor(options) {
this.options = options;
// no more 'new'
this.router = options.router;
this.track = options.track;
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () => {
this.router.to('home');
this.track.tracking();
this.options.onReady();
});
}
}
// index.js
import App from 'path/to/App';
import Router from './modules/Router';
import Track from './modules/Track';
// 'new' here
new App({
router: new Router({ mode: 'history' }),
track: new Track(),
onReady() {
// do something here...
},
});
这样,起码我们不需要动 app.js
的代码了,这就算是解耦了。这时候已经有点 DI(依赖注入)的意思了。就是把 App 的依赖,在实例化的时候注入,大家意会一下。
好,这时候变动又来了。
第二次优化 - 约定
假设 Router、Track 已经不够了,我们需要再加一个 Share 的功能模块。而且 DOMContentLoaded
的时候,要运行一下 new Share().init()
方法。
看来还得改 app.js
啊。有没有办法不改呢?我们想办法让 App 可以「随意注入依赖」,并且挨个初始化一下不就好了。
这时候我们就需要一些「约定」了,这些约定要求都是 App 提的,比如:
- 你得先告诉我都有哪些类需要我操作,要用
App.use([Class1, Class2, Class3])
这样的方式告诉我; - 你自己得定一个
init
方法,我在DOMContentLoaded
之后会挨个调用。当然,在你调用的时候,可以访问到我(App)的实例,会作为第一个参数存在,即init(app)
;
那么 App 的代码将会变成这样:
class App {
static modules = []
constructor(options) {
this.options = options;
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () => {
this.initModules();
this.options.onReady(this);
});
}
static use(module) {
Array.isArray(module) ? module.map(item => App.use(item)) : App.modules.push(module);
}
initModules() {
App.modules.map(module => module.init && typeof module.init == 'function' && module.init(this));
}
}
这时候,所有想要注入 App 的模块,都必须自己实现 init
方法(此处就可以用 Decorator 优化),比如:
// modules/Router.js
import Router from 'path/to/Router';
export default {
init(app) {
app.router = new Router(app.options.router);
app.router.to('home');
}
};
// modules/Track.js
import Track from 'path/to/Track';
export default {
init(app) {
app.track = new Track(app.options.track);
app.track.tracking();
}
};
// modules/Share.js
import Share from 'path/to/Share';
export default {
init(app) {
app.share = new Share();
app.setShare = data => app.share.setShare(data);
}
};
这时候,index.js
的代码会是这样:
// index.js
import Router from './modules/Router';
import Track from './modules/Track';
import Share from './modules/Share';
App.use([ Router, Track, Share ]);
new App({
// ...
onReady(app) {
app.setShare({
title: 'Hello IoC.',
description: 'description here...',
// some other data here...
});
}
});
IoC 容器
敲黑板!划重点了!要说概念了!
我们看到,这时候 app.js
的代码已经很抽象了,没有任何业务逻辑在里面,所有业务逻辑都收敛在了各模块(Module)内的 init 方法或在 App 实例化的时候注入。
在 IoC
里面,这个 app.js
或者说这个 App
就被称为「IoC 容器」。它只负责制定约定规则,加载各种依赖。请仔细理解一下这个概念,虽然这个例子还很单薄,而且不够严谨,但是本质就是这个意思。
接下来,我们可以继续加强这个容器,让它的功能更加强大。
装饰器化
我们看会开头举的 Nest 的例子:
// cats.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('cats')
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats';
}
}
// app.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
@Module({
controllers: [CatsController],
})
export class AppModule {}
app.module.ts
的修改很好理解,就相当于调用 App.use
注入依赖,都是模板代码。
再看 cats.controller.ts
的实现,用到了 Controller 和 Get 两个 Decorator。毫无疑问,这两个 Decorator 都是 Nest 的 IoC 容器提供的能力,为了方便开发者使用而封装的。对应到前面的例子就是:容器有一个 Decorator 可以帮你实现 init 方法,你自己不用写了,直接声明个类,用这个 Decorator 包裹,就能在我的容器里直接用了。
这种用法可以说是非常方便了,相较于其它框架,比如 Eggjs,可以省掉很多模板代码,不过也增加了(很多的)理解和学习的成本。
理解 IoC 和 DI
我们趁热打铁,理解了「IoC 容器」的概念,再尝试理解一下什么是IoC,即控制反转。
我们从上面的代码衍化可以发现,最开始,「new 操作」、「业务逻辑」都是聚合在 App 内部的。这个时候 App 对于各模块,有着很强的「控制权」。但是最后,App 内部几乎没有任何业务逻辑代码,所有的控制权,都回归到了模块手中。整个过程相当于把控制权「反转」了,所以称之为控制反转。
好吧,我估计有的同学还没看明白,实在是这个名字起的太「抽象」了,不得不吐槽一下。即使不完全明白,也不耽误我们使用,随着对 Nest 的逐渐熟悉,自然会有更深一步的理解。我们尝试总结一下:
- 首先,IoC 是一种设计思想,而不是什么技术。它的目标是解决对象间高耦合的问题。;
- 然后,它一般只适用于 OOP(面向对象编程)的语言;
- 最后,Decorator 这种写法只是目前业内最主流的 IoC 实现方式,并非唯一。
至于 DI,也就是依赖注入,应该算是 IoC 思想的另一种称呼,反正可以把他俩理解成一个东西。总之 IoC、DI 什么的,名字起的实在是太糟糕了(我这是第几次吐槽了),除了拿出去唬人,也没什么大用,反正不耽误我们会写代码。
总结
这篇文章写的太难了,主要是花了太多时间去研究 IoC 和 DI(我内心已经无数次吐槽这帮老外起名字的能力了,真是怎么让人懵逼怎么来,就不能有点产品 Sense 吗?),然后还得想方设法把它们讲明白,真是要了老命了,不知道掉了多少头发。
不过吐槽归吐槽,归根结底还是自己对于这些概念的不熟悉,所以还是能力问题。希望在用熟练了 Nest 之后,能有更多的理解吧。
最后,关于如何学习 Nest。实际上就是用,先用起来,碰到问题查文档。说是查文档,实际就是查 Decorator。Nest 的文档很碎片化,如果没有上面说的 IoC、DI、MVC、Decorator 的概念理解,很难把把文档中的内容串起来,也就不太能学会使用 Nest,谁让它的模型这么抽象呢。
不过,从 如何获取 cnpm 的 packages 下载量 调研的数据,和阿里最新的 Nodejs 框架 Midway(号称接过了 Eggjs 的接力棒)来看。Nest 这种 IoC 的写法,会是未来的主流,毕竟已经在 Java 领域(Spring)充分的验证过了。
所以,如果你的团队比较有朝气,乐于接受挑战和新鲜事物,还是很推荐使用 Nest 的。但是,如果以求稳为主,或者团队对于学习新东西的热情不高,还是可以考虑 Eggjs 的。一定要结合自己团队的情况选择。
(如果有兴趣,可以在评论区多吐槽一下 IoC 这个名字,这两天真是被他折磨的够呛)
优秀的自驱力,就是在没有人管你的时候,自己在发光。
参考文献
转载自:https://juejin.cn/post/7165812796914532360