依赖注入

依赖注入(DI)是一种设计模式,其中类和函数_receive_ their dependencies。它遵循反转控制(IoC)的原则,有助于更好地分离复杂的代码,以提高可测试性、模块化和清晰度。尽管还有其他设计模式,如服务定位器模式,来应用IoC的原则,但DI已经确立了自己的主导模式,特别是在企业软件中。

为了说明IoC的原则,这里有一个例子:

import { HttpClient } from 'http-library';

class UserRepository {
    async getUsers(): Promise<Users> {
        const client = new HttpClient();
        return await client.get('/users');
    }
}

UserRepository类有一个HttpClient作为依赖。这个依赖关系本身并不显眼,但UserRepository自己创建了HttpClient,这就有问题了。这一点乍一看很明显,但它有其缺点。如果我们想替换掉HttpClient怎么办?如果我们想在单元测试中测试UserRepository,而不允许真正的HTTP请求出去呢?我们怎么知道这个类甚至使用了HttpClient?

控制反转

在控制反转(IoC)的思想中,有以下的替代变体,它将HttpClient设置为构造函数中的显式依赖(也称为构造函数注入)。

class UserRepository {
    constructor(
        private http: HttpClient
    ) {}

    async getUsers(): Promise<Users> {
        return await this.http.get('/users');
    }
}

现在UserRepository不再负责创建HttpClient,但UserRepository的用户负责。这就是反转控制(IoC)。控制权已被逆转或颠倒。具体来说,这段代码使用了依赖注入,因为依赖被接收(注入),不再创建或请求。依赖注入只是应用IoC的一种方式。

服务定位器

除了DI,服务定位器(SL)也是应用IoC原则的一种方式。这通常被认为是与依赖性注入相对应的,因为它请求依赖性而不是接受它们。如果HttpClient在上述代码中被请求如下,它将被称为服务定位模式。

class UserRepository {
    async getUsers(): Promise<Users> {
        const client = locator.getHttpClient();
        return await client.get('/users');
    }
}

函数`locator.getHttpClient`可以有一个完全任意的名字。替代品是函数调用,如`useContext(HttpClient)、`getHttpClient()await import("client"),或容器调用,如`container.get(HttpClient)`。全局的导入是服务定位器的一个稍微不同的变体,使用模块系统本身作为定位器:

import { httpClient } from 'clients'

class UserRepository {
    async getUsers(): Promise<Users> {
        return await httpClient.get('/users');
    }
}

所有这些变体的共同点是它们明确请求依赖HttpClient。这种要求不仅可能发生在作为默认值的属性上,也可能发生在代码中间的某个地方。因为在代码的中间意味着它不是类型接口的一部分,所以HttpClient的使用是隐藏的。根据HttpClient被请求的方式的不同,有时候用另一种实现来替换它是非常困难的,或者是完全不可能的。特别是在单元测试领域,为了清晰起见,这里会出现困难,所以现在服务定位器在某些情况下被归类为反模式。

依赖注入

在依赖注入中,没有任何东西被请求,但它是由用户明确提供或由代码接收。从控制反转的例子中可以看出,依赖注入模式已经在那里得到了应用。具体来说,在这里可以看到构造函数注入,因为依赖关系是在构造函数中声明的。所以UserRepository现在必须如下使用。

const users = new UserRepository(new HttpClient());

想要使用UserRepository的代码还必须提供(注入)它的所有依赖。是每次都要创建HttpClient还是每次都要使用同一个HttpClient,现在由类的用户决定,而不再由类本身决定。它不再像服务定位器那样被要求(从类的角度),或者在最初的例子中,完全由类本身创建。这种反转的流程有各种好处:

  • 代码更容易理解,因为所有的依赖关系都是明确可见的。

  • 这个代码更容易测试,因为所有的依赖关系都是唯一的,如果有必要,可以很容易地进行修改。

  • 代码更加模块化,因为依赖关系可以很容易地交换。

  • 它促进了关注点分离原则,因为UserRepository在有疑问时不再负责自己创建非常复杂的依赖。

但也可以直接认识到一个明显的缺点。我真的要自己创建或管理像HttpClient这样的所有依赖性吗?是的,有很多情况下,自己管理依赖关系是完全合法的。一个好的API的特点是,依赖性不会失控,即使如此,它们仍然是令人愉快的使用。对于许多应用程序或复杂的库,这很可能是一种情况。

依赖注入容器

另一方面,对于更复杂的应用程序,没有必要自己管理所有的依赖,因为这正是所谓的依赖注入容器的作用。这不仅自动创建了所有的对象,而且还自动 "注入 "了依赖关系,这样就不再需要手动调用 "新建 "了。有各种类型的注入,如构造函数注入、方法注入或属性注入。

依赖注入容器(也称为DI容器或IoC容器)在`@deepkit/injector`中随Deepkit一起提供,或者已经通过Deepkit框架中的应用模块集成。使用`@deepkit/injector`包中的低级API,上面的代码会是这样的。

import { InjectorContext } from '@deepkit/injector';

const injector = InjectorContext.forProviders(
    [UserRepository, HttpClient]
);

const userRepo = injector.get(UserRepository);

const users = await userRepo.getUsers();

本例中的`injector`对象是依赖注入容器。容器没有使用`new UserRepository`,而是使用`get(UserRepository)返回UserRepository的实例。为了静态地初始化容器,一个提供者的列表被传递给函数`InjectorContext.forProviders(在这种情况下只是简单的类)。 由于DI是关于提供依赖关系的,容器被提供了依赖关系,因此技术术语为 "提供者"。有各种类型的提供者:ClassProvider、ValueProvider、ExistingProvider、FactoryProvider。

提供者之间的所有依赖关系都被自动解决,一旦发生`injector.get()`调用,对象和依赖关系就会被创建、缓存,并正确地作为构造函数参数传递(构造函数注入)、设置为属性(属性注入)或传递给方法调用(方法注入)。

为了与另一个HttpClient交换,可以为HttpClient定义另一个提供者(这里是ValueProvider):

const injector = InjectorContext.forProviders([
    UserRepository,
    {provide: HttpClient, useValue: new AnotherHttpClient()},
]);

一旦UserRepository通过`injector.get(UserRepository)`被请求,它就会收到另一个HttpClient对象。

const injector = InjectorContext.forProviders([
    UserRepository,
    {provide: HttpClient, useClass: AnotherHttpClient},
]);

所有类型的提供者都在依赖注入提供者一节中列出并解释。

这里需要提到的是,Deepkit的DI容器只适用于Deepkit的运行时类型。这意味着任何包含类、类型、接口和函数的代码都必须经过Deepkit类型编译器的编译,以便在运行时获得类型信息。参见Runtime Types一章。

依赖反转

控制反转下的UserRepository的例子显示,UserRepository依赖于一个较低的层次,即一个HTTP库。此外,一个具体的实现(类)而不是抽象的(接口)被声明为依赖关系。乍一看,这似乎符合面向对象的范式,但它可能会导致问题,特别是在复杂和大型的架构中。

另一个变体是将HttpClient依赖关系转移到一个抽象(接口)中,从而不将HTTP库的代码导入UserRepository

interface HttpClientInterface {
   get(path: string): Promise<any>;
}

class UserRepository {
    concstructor(
        private http: HttpClientInterface
    ) {}

    async getUsers(): Promise<Users> {
        return await this.http.get('/users');
    }
}

这被称为依赖性反转原则。UserRepository不再直接依赖一个HTTP库,而是基于一个抽象(接口)。因此,它解决了这个原则中的两个基本目标:

  • 高层模块不应该从低层模块中导入任何东西。

  • 实现应该基于抽象(接口)。

两个实现(UserRepository与HTTP库)的合并现在可以通过DI容器完成。

import { HttpClient } from 'http-library';
import { UserRepository } from './user-repository';

const injector = InjectorContext.forProviders([
    UserRepository,
    HttpClient,
]);

由于Deepkit的DI容器能够解决抽象的依赖关系(接口),例如在这种情况下,UserRepository自动获得HttpClient的实现,因为HttpClient已经实现了接口HttpClientInterface。这可以通过HttpClient专门实现HttpClientInterface(class HttpClient implements HttpClientInterface),或者通过HttpClient的API仅仅与HttpClientInterface兼容来实现。 一旦HttpClient改变了它的API(例如,删除了`get’方法),从而不再与HttpClientInterface兼容,DI容器就会抛出一个错误("没有提供HttpClientInterface的依赖性")。 在这里,想要将两种实现结合起来的用户不得不找到一种解决方案。作为一个例子,可以在这里注册一个适配器类,它实现了HttpClientInterface并正确地将方法调用转发给HttpClient。

这里应该指出,尽管在理论上,依赖反转原则有其优点,但在实践中,它也有很大的缺点。这不仅导致了更多的代码(因为必须编写更多的接口),而且还导致了更多的复杂性(因为现在每个实现都有一个接口用于每个依赖关系)。只有当应用达到一定规模,并且还需要这种灵活性时,这种代价才是值得的。像任何设计模式和原则一样,这个也有它的成本使用因素,在使用之前应该考虑清楚。 设计模式不应该盲目地用于每一个代码,无论多么简单。然而,如果有复杂的架构、大型应用程序或扩展团队等先决条件,依赖反转和其他设计模式才会展开其真正的力量。

Installation

由于Deepkit中的依赖注入是基于运行时类型的,因此有必要正确安装`@deepkit/type`。参见运行时类型安装

如果已经成功完成,`@deepkit/injector`可以自行安装,或者安装已经使用该库的Deepkit框架的引擎。

npm install @deepkit/injector

一旦库被安装,可以直接使用其中的API。

使用

现在要使用依赖注入,有三种可能性。

  • Injector API (Low Level)

  • 模块API

  • App API (Deepkit Framework)

如果`@deepkit/injector`要在没有Deepkit框架的情况下使用,推荐使用前两种变体。

Injector API

在依赖注入的介绍中已经介绍了Injector API。它的特点是使用非常简单,通过单一的`InjectorContext`类来创建一个DI容器,特别适合于没有模块的简单应用。

import { InjectorContext } from '@deepkit/injector';

const injector = InjectorContext.forProviders([
    UserRepository,
    HttpClient,
]);

const repository = injector.get(UserRepository);

在这种情况下,`injector`对象是依赖注入容器。函数`InjectorContext.forProviders`接收一个提供者的数组。参见依赖注入提供者一节,了解哪些值可以被传递。

模块API

一个稍微复杂的API是`InjectorModule`类,它允许将提供者换成不同的模块,以便在每个模块中创建多个封装的DI容器。这也允许每个模块使用配置类,这使得自动向提供者提供经过验证的配置值更加容易。模块之间可以相互导入,导出提供者,以建立一个层次结构和良好的分离架构。

如果应用程序比较复杂,并且没有使用Deepkit框架,就应该使用这个API。

import { InjectorModule, InjectorContext } from '@deepkit/injector';

const lowLevelModule = new InjectorModule([HttpClient])
     .addExport(HttpClient);

const rootModule = new InjectorModule([UserRepository])
     .addImport(lowLevelModule);

const injector = new InjectorContext(rootModule);

这种情况下的`injector`对象是依赖注入容器。提供者可以被分割成不同的模块,然后可以用模块导入的方式将它们重新导入到不同的地方。这创建了一个自然的层次结构,映射了应用程序或架构的层次结构。 InjectorContext应该总是被赋予层次结构中的顶级模块,也称为根模块或应用模块。然后InjectorContext只有一个中间任务:对`injector.get()`的调用被简单地转发给根模块。然而,非根模块的提供者也可以通过传递模块作为第二个参数来获得。

const repository = injector.get(UserRepository);

const httpClient = injector.get(HttpClient, lowLevelModule);

所有非根模块都被默认封装,因此这个模块中的所有提供者只对它自己有效。如果一个提供者要对其他模块可用,这个提供者必须被导出。

为了默认将所有提供者导出到顶层,即根模块,可以使用选项`forRoot`。

const lowLevelModule = new InjectorModule([HttpClient])
     .forRoot(); //export all Providers to the root

App API

一旦使用了Deepkit框架,就可以使用`@deepkit/app` API来定义模块。这是基于模块API的,所以那里的能力也是可用的。此外,还可以使用强大的钩子和定义配置加载器来映射更多的动态架构。

Framework Modules一章对此有更详细的描述。

提供者

在依赖注入容器中,有几种方法来提供依赖。最简单的变体是简单地指定一个类。这也被称为短的ClassProvider。

InjectorContext.forProviders([
    UserRepository
]);

这代表一个特殊的提供者,因为只指定了类。

默认情况下,所有提供者都被标记为单子,因此在任何时候都只存在一个实例。要在每次提供的时候创建一个新的实例,可以使用`transient`选项。这导致每次都要重新创建类,或者每次都要执行工厂。

InjectorContext.forProviders([
    {provide: UserRepository, transient: true}
]);

ClassProvider

除了简短的ClassProvider,还有普通的ClassProvider,它是一个对象字面,而不是一个类。

InjectorContext.forProviders([
    {provide: UserRepository, useClass: UserRepository}
]);

这相当于这两个:

InjectorContext.forProviders([
    {provide: UserRepository}
]);

InjectorContext.forProviders([
    UserRepository
]);

它可以用来将一个提供者与另一个类交换。

InjectorContext.forProviders([
    {provide: UserRepository, useClass: OtherUserRepository}
]);

在这个例子中,`OtherUserRepository’类现在也在DI容器中被管理,它的所有依赖关系都被自动解决。

ValueProvider

静态值可以用这个提供者提供。

InjectorContext.forProviders([
    {provide: OtherUserRepository, useValue: new OtherUserRepository()},
]);

因为不仅是类实例可以作为依赖关系提供,任何值都可以被指定为`useValue'。符号或基元(字符串、数字、布尔)也可以作为提供者标记。

InjectorContext.forProviders([
    {provide: 'domain', useValue: 'localhost'},
]);

基元提供者标记必须作为依赖关系与注入类型一起声明。

import { Inject } from '@deepkit/injector';

class EmailService {
    constructor(public domain: Inject<string, 'domain'>) {}
}

通过注入别名和原始提供者令牌的组合,也可以从不包含运行时类型信息的包中提供依赖性。

import { Inject } from '@deepkit/injector';
import { Stripe } from 'stripe';

export type StripeService = Inject<Stripe, '_stripe'>;

InjectorContext.forProviders([
    {provide: '_stripe', useValue: new Stripe},
]);

然后在用户端声明如下:

class PaymentService {
    constructor(public stripe: StripeService) {}
}

ExistingProvider

可以定义一个已经定义的提供者的重定向。

InjectorContext.forProviders([
    {provide: OtherUserRepository, useValue: new OtherUserRepository()},
    {provide: UserRepository, useExisting: OtherUserRepository}
]);

FactoryProvider

一个函数可以用来为提供者提供一个值。这个函数也可以包含参数,而这些参数又是由DI容器提供的。通过这种方式,可以访问其他依赖性或配置选项。

InjectorContext.forProviders([
    {provide: OtherUserRepository, useFactory: () => {
        return new OtherUserRepository()
    }},
]);

InjectorContext.forProviders([
    {
        provide: OtherUserRepository,
        useFactory: (domain: RootConfiguration['domain']) => {
            return new OtherUserRepository(domain);
        }
    },
]);

InjectorContext.forProviders([
    Database,
    {
        provide: OtherUserRepository,
        useFactory: (database: Database) => {
            return new OtherUserRepository(database);
        }
    },
]);

InterfaceProvider

除了类和基元之外,还可以提供抽象(接口)。这是用`provide`函数完成的,当要提供的值不包含任何类型信息时特别有用。

import { provide } from '@deepkit/injector';

interface Connection {
    write(data: Uint16Array): void;
}

class Server {
   constructor (public connection: Connection) {}
}

class MyConnection {
    write(data: Uint16Array): void {}
}

InjectorContext.forProviders([
    Server,
    provide<Connection>(MyConnection)
]);

异步提供者

由于设计原因,异步提供者是不可能的,因为异步的 依赖性注入容器将意味着对提供者的请求也是异步的。 因此,整个应用程序在最高级别上已经被迫进入异步状态。

要异步初始化一些东西,这个初始化应该被移到应用服务器的启动阶段。 因为那里的事件可以是异步的。另外,也可以手动触发初始化。

TODO: 更好地解释它,也许是例子

如果几个提供者实现了接口连接,则使用最后一个提供者。

所有其他提供者都可以作为provide()的参数。

const myConnection = {write: (data: any) => undefined};

InjectorContext.forProviders([
    provide<Connection>({useValue: myConnection})
]);

InjectorContext.forProviders([
    provide<Connection>({useFactory: () => myConnection})
]);

构造器/属性注入

在大多数情况下,使用构造器注入。所有的依赖被指定为构造函数参数,并由DI容器自动注入。

class MyService {
    constructor(protected database: Database) {
    }
}

可选的依赖应该被标记为这样,否则如果找不到提供者,可能会引发错误。

class MyService {
    constructor(protected database?: Database) {
    }
}

构造函数注入的一个替代方法是属性注入。这通常是在依赖关系是可选的或者构造函数太满的情况下使用。一旦实例被创建(也就是构造函数被执行),这些属性就会被自动分配。

import { Inject } from '@deepkit/injector';

class MyService {
    //required
    protected database!: Inject<Database>;

    //or optional
    protected database?: Inject<Database>;
}

配置

依赖注入容器也允许注入配置选项。这种配置注入可以通过构造函数注入或属性注入来接收。

模块API支持定义配置定义,它是一个普通的类。通过为这样的类提供属性,每个属性都作为一个配置选项。

class RootConfiguration {
    domain: string = 'localhost';
    debug: boolean = false;
}

const rootModule = new InjectorModule([UserRepository])
     .setConfigDefinition(RootConfiguration)
     .addImport(lowLevelModule);

配置选项`domain`和`debug`现在可以方便地以类型安全的方式在提供者中使用。

class UserRepository {
    constructor(private debug: RootConfiguration['debug']) {}

    getUsers() {
        if (this.debug) console.debug('fetching users ...');
    }
}

选项本身的值可以通过`configure()`来设置。

	rootModule.configure({debug: true});

没有默认值,但仍然是必要的选项,可以用`!`来提供。这迫使模块的用户提供该值,否则将导致错误。

class RootConfiguration {
    domain!: string;
}

验证

另外,前几章的所有序列化和验证类型验证序列化都可以用来非常详细地指定一个选项必须有哪些类型和内容限制。

class RootConfiguration {
    domain!: string & MinLength<4>;
}

注入

配置选项可以像其他依赖关系一样通过DI容器安全而容易地注入,如前所示。最简单的方法是使用索引访问操作符来引用单个选项:

class WebsiteController {
    constructor(private debug: RootConfiguration['debug']) {}

    home() {
        if (this.debug) console.debug('visit home page');
    }
}

配置选项不仅可以单独引用,还可以作为一个组来引用。为此,使用了TypeScript实用类型`Partial`:

class WebsiteController {
    constructor(private options: Partial<RootConfiguration, 'debug' | 'domain'>) {}

    home() {
        if (this.options.debug) console.debug('visit home page');
    }
}

为了获得所有的配置选项,也可以直接引用配置类:

class WebsiteController {
    constructor(private options: RootConfiguration) {}

    home() {
        if (this.options.debug) console.debug('visit home page');
    }
}

然而,建议只引用实际使用的配置选项。这不仅简化了单元测试,也使我们更容易从代码中看到实际需要的东西。

Scopes

默认情况下,DI容器的所有提供者是一个单子,因此只被实例化一次。这意味着在UserRepository的例子中,在整个运行时间内,始终只有一个UserRepository的实例。在任何时候都不会创建第二个实例,除非用户用 "new "关键字手动创建。

然而,在各种使用情况下,一个提供者应该只在短时间内或只在某一事件中被实例化。这样的事件可以是,例如,一个HTTP请求或一个RPC调用。这就意味着每次事件发生时都会创建一个新的实例,在这个实例不再被使用后,它会被自动删除(由垃圾收集器)。

一个HTTP请求是一个典型的作用域的例子。例如,诸如会话、用户对象或其他与请求有关的提供者可以被注册到这个范围。要创建一个作用域,只需选择一个任意的作用域名称,然后用提供者来指定它。

import { InjectorContext } from '@deepkit/injector';

class UserSession {}

const injector = InjectorContext.forProviders([
    {provide: UserSession, scope: 'http'}
]);

一旦指定了一个作用域,就不能再直接通过DI容器获得该提供者,所以下面的调用失败了:

const session = injector.get(UserSession); //throws

相反,必须创建一个有范围的DI容器。每次有HTTP请求进来时都会发生这种情况:

const httpScope = injector.createChildScope('http');

在这个范围内的DI容器上,现在也可以请求也在这个范围内注册的提供者,以及所有没有定义范围的提供者。

const session = httpScope.get(UserSession); //works

由于所有的提供者默认是单子,每次调用`get(UserSession)`将总是返回每个范围内容器的相同实例。如果你创建了多个作用域的容器,也将创建多个UserSessions。

范围内的DI容器有能力从外部动态地设置值。例如,在一个HTTP作用域中,很容易设置HttpRequest和HttpResponse对象。

const injector = InjectorContext.forProviders([
    {provide: HttpResponse, scope: 'http'},
    {provide: HttpRequest, scope: 'http'},
]);

httpServer.on('request', (req, res) => {
    const httpScope = injector.createChildScope('http');
    httpScope.set(HttpRequest, req);
    httpScope.set(HttpResponse, res);
});

与Deepkit框架一起工作的应用程序默认有一个`http`、一个`rpc`和一个`cli`范围。分别参见CLI, HTTP, 或 RPC

设置调用

设置调用允许你操纵一个提供者的结果。这很有用,例如,使用另一种依赖注入的变体,方法注入。

设置调用只能分别用于模块API或应用API,并在模块之上注册。

class UserRepository  {
    private db?: Database;
    setDatabase(db: Database) {
       this.db = db;
    }
}

const rootModule = new InjectorModule([UserRepository])
     .addImport(lowLevelModule);

rootModule.setupProvider(UserRepository).setDatabase(db);

`setupProvider`方法返回一个UserRepository的代理对象,其方法可以被调用。应该注意的是,这些方法调用只是被放在一个队列中,此时并没有被执行。相应地,也没有返回值。

除了方法调用,还可以设置属性。

class UserRepository  {
    db?: Database;
}

const rootModule = new InjectorModule([UserRepository])
     .addImport(lowLevelModule);

rootModule.setupProvider(UserRepository).db = db;

这种赋值也只放在队列中。

队列中的调用或赋值,一旦被创建,就在提供者的实际结果上执行。也就是说,在ClassProvider的情况下,一旦实例被创建,它们就被应用到类实例上,在FactoryProvider的情况下,它们被应用到工厂的结果上,而在ValueProvider的情况下,它们被应用到提供者上。

为了不仅引用静态值,也引用其他提供者,可以使用函数`injectorReference`。这将返回一个对提供者的引用,这也是DI容器在执行设置调用时的请求。

class Database {}

class UserRepository  {
    db?: Database;
}

const rootModule = new InjectorModule([UserRepository, Database])
rootModule.setupProvider(UserRepository).db = injectorReference(Database);

Abstractions/Interfaces

Interfaces也可以被分配设置调用。

rootModule.setupProvider<DatabaseInterface>().logging = logger;