首页/文章/nestjs

nestjs的核心:利用装饰器实现依赖注入,实现类与类的解耦

2024-07-10
3379 字约 12 分钟
...

在上一篇文章《nestjs系列三:nestjs如何通过Module组织代码结构?》(juejin.cn/post/738847…%E4%B8%AD%EF%BC%8C%E6%88%91%E4%BB%AC%E7%9F%A5%E9%81%93%E4%BA%86%60@Module%60%EF%BC%8C%60@Controller%60%EF%BC%8C "https://juejin.cn/post/7388470580464844852)%E4%B8%AD%EF%BC%8C%E6%88%91%E4%BB%AC%E7%9F%A5%E9%81%93%E4%BA%86%60@Module%60%EF%BC%8C%60@Controller%60%EF%BC%8C") @Injectable等装饰器,那它们是干什么用的呢?

其实,它们的主要作用是为了解决类与类之间的耦合,那什么是耦合呢?

耦合

现在有 A、B 两个类,其中 A 依赖 B,这种依赖关系在日常开发中很容易遇到,如果用传统的编码方式,我们一般会这么实现:


class B {
    constructor() {}
}


class A {
    b:B;
    constructor() {
        this.b = new B();
    }
}


const a = new A();

这看上去似乎没有什么问题,然而,当需要对 B类进行改造,需要在初始化时传递一个参数 c。


class B {
    c: number;
    constructor(c: number) {
        this.c = c;
    }
}

那问题来了,由于 B 是在 A 的构造函数中进行实例化的,我们不得不在 A 的构造函数里传入这个 c 参数。

但是, A 里面的 c 怎么来呢?

我们当然不能写死它,否则设定这个参数就没有意义了,因此我们只能将 c 也设定为 A 构造函数中的一个参数,如下:


class A {
    b:B;
    constructor(c: number) {
        this.b = new B(c);
    }
}


class C {
    constructor() {}
}


const a = new A(100);
console.log(a); 

你发现问题没,当我修改了B类,我需要同时修改A类,本来只是B类变化了,但是因为A和B耦合在一起了,所以要修改两处地方。

如果当我们改完了 A 后,发现 B 所需要的 c 不能是一个 number,需要变更为 string,于是我们又不得不重新修改 A 中对参数 c 的类型修饰。

假设还有上层类D依赖 A,用的也是同样的方式,那上层类D也要经历同样的修改。

这就是耦合所带来的问题,明明是修改底层类的一项参数,却需要修改其依赖链路上的所有文件,当应用程序的依赖关系复杂到一定程度时,很容易形成牵一发而动全身的现象,为应用程序的维护带来极大的困难。

这就是耦合。那如何解耦呢?

控制反转IOC容器

事实上,在上述例子中,真正需要参数 c 的仅仅只有 B,而 A 完全只是因为内部依赖的对象在实例化时需要 c,才不得不定义这个参数,实际上它对 c ��什么根本不关心。

于是,可以将类所依赖对象的实例化从类本身剥离出来,比如上面的例子我们可以这样改写:


class B {
    c: number;
    constructor(c: number) {
        this.c = c;
    }
}


class A {
    private b:B;
    constructor(b: B) {
        this.b = b;
    }
}


const b = new B(100);

const a = new A(b);
console.log(a); 

此时,A 不再接收参数 c ,而是选择直接接收其内部所依赖的对象,至于这个对象在哪里进行实例化则并不关心,这样有效解决了我们在上面遇到的问题,当我们需要修改参数 c 时,我们仅仅只要修改 B 即可,而不需要修改 A ,这个过程中我们就实现了类与类之间的解耦。

虽然我们实现了解耦,但我们仍需要自己初始化所有的类,并以构造函数参数的形式进行传递。

那有没有一个容器能帮我们实现如下功能呢?

  • 它预先注册好了我们所需对象的类定义以及初始化参数,每个对象有一个唯一的 key。
  • 当需要用到某个对象时,只需要告诉容器这个对象所对应的 key,就可以直接从容器中取出实例化好的对象。

这样开发者就不用再关心对象的实例化过程,也不需要将依赖对象作为构造函数的参数在依赖链路上传递。

也就是说,我们的容器必须具备两个功能:类的注册和类的实例的获取,我们利用Map来简单实现一个容器:


export class Container {
    bindMap = new Map();

    
    bind(identifier: string, clazz: any, constructorArgs: Array<any>) {
        this.bindMap.set(identifier, {
            clazz,
            constructorArgs
        });
    }

    
    get<T>(identifier: string): T {
        const target = this.bindMap.get(identifier);
        const { clazz, constructorArgs } = target;
        
        const inst = Reflect.construct(clazz, constructorArgs);
    }
}

有了容器之后,我们就可以彻底抛弃传参实现解耦,如下所示:


class B {
    constructor(c: number) {
        this.c = c;
    }
}


class A {
    b:B;
    constructor() {
        this.b = container.get('b');
    }
}


const container = new Container();
container.bind('a', A);
container.bind('b', B, [100]);


const a = container.get('a');
console.log(a); 

从上面代码中,你能看出什么?

本来A依赖了B,但是在a.ts文件中根本就没有看到B的身影,只有容器 container.get('b')。

这就是控制反转,本来是A控制B,但是现在是由容器来控制了。也就是A来实例化B,但是现在由容器来实例化了。

到这里为止,其实已经基本实现了控制反转IOC容器,基于容器完成了类与类的解耦。但从代码量上看似乎并没有简洁多少,关键问题在于容器的初始化以及类的注册仍然让我们觉得繁琐。

如果这部分代码能被封装到框架里面,所有类的注册都能够自动进行,同时,所有类在实例化的时候可以直接拿到依赖对象的实例,而不用在构造函数中手动指定,这样就可以彻底解放开发者的双手,专注编写类内部的逻辑,而这也就是所谓的依赖注入 DI(Dependency Injection)。

依赖注入 DI

需要搞清楚的是,控制反转是一种设计模式,目的是解耦,而依赖注入是控制反转的一种技术手段。

依赖注入简��来说就是可以将依赖注入给调用方,而不需要调用方来主动获取依赖。

为了实现 DI,主要要解决以下两个问题:

  • 需要注册到 IOC 容器中的类能够在程序启动时自动进行注册;
  • 在 IOC 容器中的类实例化时可以直接拿到依赖对象的实例,而不用在构造函数中手动指定;

对于前端开发来说,利用 TypeScript 具备的装饰器特性,通过Reflect Metadata元数据的修饰来识别出需要进行注册以及注入的依赖,从而完成依赖的注入。

下面的代码就是对类User添加了一个元数据data,它的值为dataValue。

function TestUser() {
  return function (target: any): void {
    Reflect.defineMetadata('data', 'dataValue', target);
  };
}
@TestUser()
class User {
  name = 'lee';
}

console.log(Reflect.getMetadata('data', User)); 

对于第一个问题,我们需要在应用启动的时候自动对所有类进行定义和参数的注册,问题是并不是所有的类都需要注册到容器中,我们并不清楚哪些类需要注册的,同时也不清楚需要注册的类,它的初始化参数是什么样的。

这里就可以引入元数据来解决这个问题,只要在定义的时候为这个类的元数据添加特殊的标记,就可以在扫描的时候识别出来。

按照这个思路,我们先来实现一个装饰器标记需要注册的类,这个装饰器可以命名 Provider,代表它将会作为提供者给其他类进行消费。


import 'reflect-metadata'

export const CLASS_KEY = 'ioc:tagged_class';

export function Provider(identifier: string, args?: Array<any>) {
    return function (target: any) {
        Reflect.defineMetadata(CLASS_KEY, {
            id: identifier,
            args: args || []
        }, target);
        return target;
    };
}

可以看到,这里的标记包含了 id 和 args,其中 id 是我们准备用来注册 IOC 容器的 key,而 args 则是实例初始化时需要的参数。

Provider 可以以装饰器的形式直接进行使用,使用方式如下:


import { Provider } from 'provider';

@Provider('b', [100])
export class B {
    constructor(c: number) {
        this.c = c;
    }
}

标记完成后,问题又来了,如果在应用启动的时候拿到这些类的定义呢?

比较容易想到的思路是在启动的时候对所有文件进行扫描,获取每个文件导出的类,然后根据元数据进行绑定。

简单起见,我们假设项目目录只有一级文件,实现如下:


import * as fs from 'fs';
import { CLASS_KEY } from './provider';

export function load(container) { 
  const list = fs.readdirSync('./');

  for (const file of list) {
    if (/\.ts$/.test(file)) { 
      const exports = require(`./${file}`);
      for (const m in exports) {
        const module = exports[m];
        if (typeof module === 'function') {
          const metadata = Reflect.getMetadata(CLASS_KEY, module);
          
          if (metadata) {
            container.bind(metadata.id, module, metadata.args)
          }
        }
      }
    }
  }
}

那么现在,我们只要在 main.ts 中运行 load.ts 即可完成项目目录下所有被修饰的类的绑定工作,值得注意的是,load 和 Container 的逻辑是完全通用的,它们完全可以被封装成包,一个简化的 IOC 框架就成型了。


import { Container } from './container';
import { load } from './load';


const container = new Container();

load(container);

console.log(container.get('a')); 

解决注册的问题后,现在来看上文中提到的第二个问题:如何在类初始化的时候能直接拿到它所依赖的对象的实例,而不需要手动通过构造函数进行传参。

其实也很简单,我们已经将所有需要注册的类都放入了 IOC 容器,那么,当我们需要用到某个类时,在获取这个类的实例时可以递归遍历类上的属性,并从 IOC 容器中取出相应的对象并进行赋值,即可完成依赖的注入。

那么,又是类似的问题,如何区分哪些属性需要注入?

同样,我们可以使用元数据来解决。

只要定义一个装饰器,以此来标记哪些属性需要注入即可,这个装饰器命名为 Inject,代表该属性需要注入依赖。


import 'reflect-metadata';

export const PROPS_KEY = 'ioc:inject_props';

export function Inject() {
    return function (target: any, targetKey: string) {
        const annotationTarget = target.constructor;
        let props = {};
        if (Reflect.hasOwnMetadata(PROPS_KEY, annotationTarget)) {
            props = Reflect.getMetadata(PROPS_KEY, annotationTarget);
        }
        
        props[targetKey] = {
            value: targetKey
        };

        Reflect.defineMetadata(PROPS_KEY, props, annotationTarget);
    };
}

需要注意的是,这里我们虽然是对属性进行修饰,但实际元数据是要定义在类上,而不是类的原型上,以维护该类需要注入的属性列表,因此我们必须取 target.constructor 作为要操作的 target。

另外,为了方便起见,这里直接用了属性名(targetKey)作为从 IOC 容器中实例对应的 key。

使用的时候,用 Inject 对需要的属性进行修饰即可:


import { Provider } from 'provider';

@Provider('a')
export class A {
    @Inject()
    b: B;
}

然后,我们需要修改 IOC 容器的 get 方法,递归注入所有属性:


import { PROPS_KEY } from './inject';

export class Container {
    bindMap = new Map();

    bind(identifier: string, clazz: any, constructorArgs?: Array<any>) {
        this.bindMap.set(identifier, {
            clazz,
            constructorArgs: constructorArgs || []
        });
    }

    get<T>(identifier: string): T {
        const target = this.bindMap.get(identifier);

        const { clazz, constructorArgs } = target;
        
        const props = Reflect.getMetadata(PROPS_KEY, clazz);
        
        const inst = Reflect.construct(clazz, constructorArgs);
        
        
        for (let prop in props) {
            
            const identifier = props[prop].value;
            
            inst[prop] = this.get(identifier);
        }
        return inst;
    }
}

经过上述调整后,最终我们的业务代码成了这样:



@Proivder('b', [100]) 
class B {
    constructor(c: number) {
        this.c = c;
    }
}



@Proivder('a')
class A {
    
    @Inject() 
    private b:B;
}


const container = new Container();
load(container);

console.log(container.get('a'));  

可以看到,代码中不会再有手动进行实例化的情况,无论要注册多少个类,框架层都可以自动处理好一切,并在这些类实例化的时候注入需要的属性。所有类可提供的实例都由类自身来维护,即使存在修改也不需要改动其他文件。

nestjs 就是一个 IOC 容器

我们现在再看一下nestjs中的代码:


@Controller('son')
export class SonController {
  
  
  
  @Inject(SonService)
  private readonly sonService: SonService;

  @Post()
  create(@Body() createSonDto: CreateSonDto) {
    return this.sonService.create(createSonDto);
  }
}


@Injectable()
export class SonService {
  create(createSonDto: CreateSonDto) {
    return 'This action adds a new son';
  }
}

首先,通过装饰器Controller, Injectable把类注册到 IOC 容器中。然后,类SonController依赖了类SonService,但是我们并没有在SonController中去new SonService(),而是使用装饰器@Inject(SonService)进行了标注,在实例化SonController类时,框架nestjs就会自动帮我们依赖的类进行实例化,并注入到对应的属性中,所以,我们就可以使用this.sonService(SonService类的实例)。

这就是nestjs框架的作用。

声明:本文内容来源于网易技术团队写的文章《如何基于 TypeScript 实现控制反转》,只是加入了自己的一些理解,之所以写这篇文章,主要是为了加深对依赖注入的理解。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
nestjs2024-09-10
NestJS + Prisma 增删改查实践
官网链接: , 当前版本 node: 20.11.0 @nestjs/cli: 10.3.2 prisma: 5.18.0 初始化项目 安装NestJS 这里需要选择 package manager,本文是用 作为包管理器的 安装Prisma 执行完上面两条命令之后,会发现多了一个 的文件夹和 文件 : 指定数据库连接,以及数据库 \ .env MySQL .env prisma/prisma.schema prisma.schema...
学习
http-网络2026-10-15
OAuth 2.0是什么,以及如何正确接入
一、先从一个需求理解 OAuth 2.0 假设你开发了一个相册整理工具,用户希望它读取某个云盘里的照片。 把云盘密码交给工具,会带来几个问题:工具拿到的权限太大,密码可能泄漏,用户也难以只撤销这个工具的访问。 OAuth 2.0 提供一套 委托授权机制 :用户在云盘自己的页面确认授权,工具取得一个有权限范围和有效期的访问令牌,再用它调用云盘 API。用户的云盘密码不交给工具。 可以把它理解为:给工具一张“限定用途的通行证”。但令牌泄漏同...
学习
前端2026-10-15
前端动画全景指南
一、前端动画到底是什么 前端动画,就是让画面随时间、用户输入或页面状态连续变化。变化的对象可以是按钮的位置、弹窗的透明度、SVG 的线条、Canvas 里的粒子,也可以是三维场景中的镜头。 先把三个问题分开: | 问题 | 常见答案 | | | | | 画什么? | DOM/CSS、SVG、Canvas 2D、GPU 渲染的场景、预制动画素材 | | 怎样变化? | CSS 过渡/关键帧、Web Animations API、逐帧计算、...
学习
flutter2026-10-14
Flutter Android 正式签名实战:生成密钥、配置构建与验证 APK
Flutter Android 正式签名实战:生成密钥、配置构建与验证 APK Android 正式签名不需要购买证书。开发者可以自己生成密钥,用它给 APK 签名;密钥的用途是建立稳定的应用身份。签名有效并不等于应用已经通过商店审核,也不等于应用功能已完成真机验收。 本文面向 Flutter Android 项目,介绍从开发签名切换到独立正式签名的完整流程。应用名称、目录、包名和证书信息均使用通用示例,不包含真实项目密码、私钥、签名指...
学习
flutter2026-10-14
Flutter 新版本发布实战:完整安装包与 Shorebird 热更新
一、先判断该发完整包还是补丁 | 这次改动 | 发布方式 | | | | | Dart 文案、Flutter 页面布局、业务逻辑修复 | 通常可以发布补丁 | | 纯 Dart 依赖变化 | 可以尝试补丁构建,仍需验证兼容性 | | Kotlin、Java、Swift、Objective C 或原生构建配置变化 | 发布完整包 | | 插件的原生实现变化、新增原生插件、权限变化 | 发布完整包 | | 新增或修改打包图片、字体、应用图标...
学习
react2026-09-15
React 19.3 新特性总结
React 19.3 已发布到 npm,核心是把去年以实验 API 形式公布的 View Transitions 和 Fragment Refs 正式转正,同时新增了几个 DOM 与 RSC 相关的 API。下面按特性整理。 一、View Transitions(视图过渡动画,正式发布) 是什么 是基于浏览器原生 封装的组件。把你想要加动画的元素包起来,当元素 进入、退出、移动或尺寸变化 时,React 自动播放过渡动画。 基本用法 只...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录