この記事では、Node バックエンド フレームワーク Nest.js について理解し、Nestjs モジュール メカニズムの概念と実装原理を紹介します。
#Nest はモジュール機構を提供しており、依存関係の注入はモジュール デコレータでプロバイダ、インポート、エクスポート、プロバイダ コンストラクタを定義することで完了し、モジュール ツリー全体が編成されたアプリケーション開発になります。フレームワーク自体の規約に従ってアプリケーションを直接起動しても全く問題ありません。しかし、私にとっては、フレームワークによって宣言される依存関係の挿入、制御の反転、モジュール、プロバイダー、メタデータ、関連するデコレーターなどについて、より明確かつ体系的に理解できていないように感じます。私はそれを理解して理解できるようですが、最初からはっきりと説明させてください、私はそれをはっきりと説明することはできません。そこで、いくつか調べてみてこの記事を思いつきました。ここからはゼロから本文に入っていきます。 1 2 つの段階なぜ制御の反転が必要なのでしょうか?
- 依存性注入とは何ですか?
- デコレータは何をしますか?
- モジュール (@Module) でのプロバイダー、インポート、エクスポートの実装原則は何ですか?
1.1 Express、Koa
言語とその技術コミュニティの開発プロセスは、下から上へ機能が段階的に発達するのは、木の根がゆっくりと枝に成長し、葉が茂る過程に似ています。以前は、Express や Koa などの基本的な Web サービス フレームワークが 開発が進むにつれて、より効率的な制作とより統一されたルールを備えたいくつかのフレームワークが誕生し、新たな段階に入りました。1.2 EggJ と Nestjs
迅速な実稼働アプリケーションへの適応性を高めるために、仕様を統一し、すぐに使用できるようにします。 EggJs と NestJs は、Midway およびその他のフレームワークが開発されました。このタイプのフレームワークは、基礎となるライフサイクルを実装することによって、アプリケーションの実装を普遍的で拡張可能なプロセスに抽象化します。アプリケーションをより簡単に実装するには、フレームワークが提供する構成メソッドに従うだけです。フレームワークはプログラムの工程管理を実現しており、部品を適切な場所に組み立てるだけで、流れ作業に近く、各工程が明確に分割され、実装コストが大幅に節約されます。1.3 まとめ
上記の 2 つの段階は単なる伏線であり、フレームワークのアップグレードによって生産効率が向上することが大まかに理解できます。フレームワークをアップグレードすると、いくつかの設計アイデアとパターンが導入されます。制御反転、依存関係注入、メタプログラミングの概念が Nest に登場します。以下で説明します。2.1 依存関係の注入
実際に存在するアプリケーションアプリケーションのすべての機能を相互に呼び出して実現する多数の抽象クラスです。アプリケーションのコードと関数が複雑になるにつれて、クラスの数が増え、クラス間の関係がますます複雑になるため、プロジェクトの保守は確実にますます困難になります。 たとえば、Koa を使用してアプリケーションを開発する場合、Koa 自体は主に基本的な Web サービス機能のセットを実装します。アプリケーションの実装プロセスでは、多くのクラスとこれらのクラスのインスタンス化を定義します。メソッドと相互依存関係は、コード ロジック内で自由に編成および制御されます。各クラスのインスタンス化は手動で新しく行われ、クラスを 1 回だけインスタンス化して共有するか、毎回インスタンス化するかを制御できます。次のクラス B は A に依存します。B がインスタンス化されるたびに、A も 1 回インスタンス化されるため、各インスタンス B に対して、A は共有されないインスタンスになります。class A{} // B class B{ contructor(){ this.a = new A(); } }
class A{} // C const app = {}; app.a = new A(); class C{ contructor(){ this.a = app.a; } }
class A{} class X{} // D const app = {}; app.a = new A(); class D{ contructor(a){ this.a = a; } } class F{ contructor(a){ this.a = a; } } new D(app.a) new F(app.a) new D(new X())
依存性注入 で、B が依存する A を値を渡すことで B に注入します。コンストラクターを介したインジェクション (値による受け渡し) は実装方法の 1 つにすぎませんが、外部依存関係を内部依存関係に渡すことができる限り、set メソッド呼び出しまたはその他のメソッドを実装することによって渡すこともできます。それは本当に簡単です。
class A{} // D class D{ setDep(a){ this.a = a; } } const d = new D() d.setDep(new A())
2.2 すべて依存関係の注入ですか?
随着迭代进行,出现了 B 根据不同的前置条件依赖会发生变化。比如,前置条件一 this.a
需要传入 A 的实例,前置条件二this.a
需要传入 X 的实例。这个时候,我们就会开始做实际的抽象了。我们就会改造成上面 D 这样依赖注入的方式。
初期,我们在实现应用的时候,在满足当时需求的情况下,就会实现出 B 和 C 类的写法,这本身也没有什么问题,项目迭代了几年之后,都不一定会动这部分代码。我们要是去考虑后期扩展什么的,是会影响开发效率的,而且不一定派的上用场。所以大部分时候,我们都是遇到需要抽象的场景,再对部分代码做抽象改造。
// 改造前 class B{ contructor(){ this.a = new A(); } } new B() // 改造后 class D{ contructor(a){ this.a = a; } } new D(new A()) new D(new X())
按照目前的开发模式,CBD三种类都会存在,B 和 C有一定的几率发展成为 D,每次升级 D 的抽象过程,我们会需要重构代码,这是一种实现成本。
这里举这个例子是想说明,在一个没有任何约束或者规定的开发模式下。我们是可以自由的写代码来达到各种类与类之间依赖控制。在一个完全开放的环境里,是非常自由的,这是一个刀耕火种的原始时代。由于没有一个固定的代码开发模式,没有一个最高行动纲领,随着不同开发人员的介入或者说同一个开发者不同时间段写代码的差别,代码在增长的过程中,依赖关系会变得非常不清晰,该共享的实例可能被多次实例化,浪费内存。从代码中,很难看清楚一个完整的依赖关系结构,代码可能会变得非常难以维护。
那我们每定义一个类,都按照依赖注入的方式来写,都写成 D 这样的,那 C 和 B 的抽象过程就被提前了,这样后期扩展也比较方便,减少了改造成本。所以把这叫All in 依赖注入
,也就是我们所有依赖都通过依赖注入的方式实现。
可这样前期的实现成本又变高了,很难在团队协作中达到统一并且坚持下去,最终可能会落地失败,这也可以被定义为是一种过度设计,因为额外的实现成本,不一定能带来收益。
2.3 控制反转
既然已经约定好了统一使用依赖注入的方式,那是否可以通过框架的底层封装,实现一个底层控制器,约定一个依赖配置规则,控制器根据我们定义的依赖配置来控制实例化过程和依赖共享,帮助我们实现类管理。这样的设计模式就叫控制反转。
控制反转可能第一次听说的时候会很难理解,控制指的什么?反转了啥?
猜测是由于开发者一开始就用此类框架,并没有体验过上个“Express、Koa时代”,缺乏旧社会毒打。加上这反转的用词,在程序中显得非常的抽象,难以望文生义。
前文我们说的实现 Koa 应用,所有的类完全由我们自由控制的,所以可以看作是一个常规的程序控制方式,那就叫它:控制正转。而我们使用 Nest,它底层实现一套控制器,我们只需要在实际开发过程中,按照约定写配置代码,框架程序就会帮我们管理类的依赖注入,所以就把它叫作:控制反转。
本质就是把程序的实现过程交给框架程序去统一管理,控制权从开发者,交给了框架程序。
控制正转:开发者纯手动控制程序
控制反转:框架程序控制
举个现实的例子,一个人本来是自己开车去上班的,他的目的就是到达公司。它自己开车,自己控制路线。而如果交出开车的控制权,就是去赶公交,他只需要选择一个对应的班车就可以到达公司了。单从控制来说,人就是被解放出来了,只需要记住坐那趟公交就行了,犯错的几率也小了,人也轻松了不少。公交系统就是控制器,公交线路就是约定配置。
通过如上的实际对比,我想应该有点能理解控制反转了。
2.4 小结
从 Koa 到 Nest,从前端的 JQuery 到 Vue React。其实都是一步步通过框架封装,去解决上个时代低效率的问题。
上面的 Koa 应用开发,通过非常原始的方式去控制依赖和实例化,就类似于前端中的 JQuery 操作 dom ,这种很原始的方式就把它叫控制正转,而 Vue React 就好似 Nest 提供了一层程序控制器,他们可以都叫控制反转。这也是个人理解,如果有问题期望大神指出。
下面再来说说 Nest 中的模块 @Module,依赖注入、控制反转需要它作为媒介。
Nestjs实现了控制反转,约定配置模块(@module)的 imports、exports、providers 管理提供者也就是类的依赖注入。
providers 可以理解是在当前模块注册和实例化类,下面的 A 和 B 就在当前模块被实例化,如果B在构造函数中引用 A,就是引用的当前 ModuleD 的 A 实例。
import { Module } from '@nestjs/common'; import { ModuleX } from './moduleX'; import { A } from './A'; import { B } from './B'; @Module({ imports: [ModuleX], providers: [A,B], exports: [A] }) export class ModuleD {} // B class B{ constructor(a:A){ this.a = a; } }
exports
就是把当前模块中的 providers
中实例化的类,作为可被外部模块共享的类。比如现在 ModuleF 的 C 类实例化的时候,想直接注入 ModuleD 的 A 类实例。就在 ModuleD 中设置导出(exports)A,在 ModuleF 中通过 imports
导入 ModuleD。
按照下面的写法,控制反转程序会自动扫描依赖,首先看自己模块的 providers 中,有没有提供者 A,如果没有就去寻找导入的 ModuleD 中是否有 A 实例,发现存在,就取得 ModuleD 的 A 实例注入到 C 实例之中。
import { Module } from '@nestjs/common'; import { ModuleD} from './moduleD'; import { C } from './C'; @Module({ imports: [ModuleD], providers: [C], }) export class ModuleF {} // C class C { constructor(a:A){ this.a = a; } }
因此想要让外部模块使用当前模块的类实例,必须先在当前模块的providers
里定义实例化类,再定义导出这个类,否则就会报错。
//正确 @Module({ providers: [A], exports: [A] }) //错误 @Module({ providers: [], exports: [A] })
后期补充
模块查找实例的过程回看了一下,确实有点不清晰。核心点就是providers里的类会被实例化,实例化后就是提供者,模块里只有providers里的类会被实例化,而导出和导入只是一个组织关系配置。模块会优先使用自己的提供者,如果没有,再去找导入的模块是否有对应提供者
这里还是提一嘴ts的知识点
export class C { constructor(private a: A) { } }
由于 TypeScript 支持 constructor 参数(private、protected、public、readonly)隐式自动定义为 class 属性 (Parameter Property),因此无需使用 this.a = a
。Nest 中都是这样的写法。
元编程的概念在 Nest 框架中得到了体现,它其中的控制反转、装饰器,就是元编程的实现。大概可以理解为,元编程本质还是编程,只是中间多了一些抽象的程序,这个抽象程序能够识别元数据(如@Module中的对象数据),其实就是一种扩展能力,能够将其他程序作为数据来处理。我们在编写这样的抽象程序,就是在元编程了。
4.1 元数据
Nest 文档中也常提到了元数据,元数据这个概念第一次看到的话,也会比较费解,需要随着接触时间增长习惯成理解,可以不用太过纠结。
元数据的定义是:描述数据的数据,主要是描述数据属性的信息,也可以理解为描述程序的数据。
Nest 中 @Module 配置的exports、providers、imports、controllers
都是元数据,因为它是用来描述程序关系的数据,这个数据信息不是展示给终端用户的实际数据,而是给框架程序读取识别的。
4.2 Nest 装饰器
如果看看 Nest 中的装饰器源码,会发现,几乎每一个装饰器本身只是通过 reflect-metadata 定义了一个元数据。
@Injectable装饰器
export function Injectable(options?: InjectableOptions): ClassDecorator { return (target: object) => { Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target); Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target); }; }
这里存在反射的概念,反射也比较好理解,拿 @Module 装饰器举例,定义元数据 providers
,只是往providers
数组里传入了类,在程序实际运行时providers
里的类,会被框架程序自动实例化变为提供者,不需要开发者显示的去执行实例化和依赖注入。类只有在模块中实例化了之后才变成了提供者。providers
中的类被反射了成了提供者,控制反转就是利用的反射技术。
换个例子的话,就是数据库中的 ORM(对象关系映射),使用 ORM 只需要定义表字段,ORM 库会自动把对象数据转换为 SQL 语句。
const data = TableModel.build(); data.time = 1; data.browser = 'chrome'; data.save(); // SQL: INSERT INTO tableName (time,browser) [{"time":1,"browser":"chrome"}]
ORM 库就是利用了反射技术,让使用者只需要关注字段数据本身,对象被 ORM 库反射成为了 SQL 执行语句,开发者只需要关注数据字段,而不需要去写 SQL 了。
4.3 reflect-metadata
reflect-metadata 是一个反射库,Nest 用它来管理元数据。reflect-metadata 使用 WeakMap,创建一个全局单实例,通过 set 和 get 方法设置和获取被装饰对象(类、方法等)的元数据。
// 随便看看即可 var _WeakMap = !usePolyfill && typeof WeakMap === "function" ? WeakMap : CreateWeakMapPolyfill(); var Metadata = new _WeakMap(); function defineMetadata(){ OrdinaryDefineOwnMetadata(){ GetOrCreateMetadataMap(){ var targetMetadata = Metadata.get(O); if (IsUndefined(targetMetadata)) { if (!Create) return undefined; targetMetadata = new _Map(); Metadata.set(O, targetMetadata); } var metadataMap = targetMetadata.get(P); if (IsUndefined(metadataMap)) { if (!Create) return undefined; metadataMap = new _Map(); targetMetadata.set(P, metadataMap); } return metadataMap; } } }
reflect-metadata 把被装饰者的元数据存在了全局单例对象中,进行统一管理。reflect-metadata 并不是实现具体的反射,而是提供了一个辅助反射实现的工具库。
现在再来看看前面的几个疑问。
为什么需要控制反转?
什么是依赖注入?
装饰器做了啥?
模块 (@Module) 中的提供者(providers),导入(imports)、导出(exports)是什么实现原理?
1 和 2 我想前面已经说清楚了,如果还有点模糊,建议再回去看一遍并查阅一些其它文章资料,通过不同作者的思维来帮助理解知识。
5.1 问题 [3 4] 总述:
Nest 利用反射技术、实现了控制反转,提供了元编程能力,开发者使用 @Module 装饰器修饰类并定义元数据(providers\imports\exports),元数据被存储在全局对象中(使用 reflect-metadata 库)。程序运行后,Nest 框架内部的控制程序读取和注册模块树,扫描元数据并实例化类,使其成为提供者,并根据模块元数据中的 providers\imports\exports 定义,在所有模块的提供者中寻找当前类的其它依赖类的实例(提供者),找到后通过构造函数注入。
本文概念较多,也并没有做太详细的解析,概念需要时间慢慢理解,如果一时理解不透彻,也不必太过着急。好吧,就到这里,这篇文章还是花费不少精力,喜欢的朋友期望你能一键三连~
更多node相关知识,请访问:nodejs 教程!
以上がNode.js Nestjs フレームワークのモジュールの仕組みを理解し、実装原理について話すの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。