Z

依赖注入与贡献点架构

控制反转 (Inversion of Control, IoC) 是面向对象中的一个设计原则,用来降低代码耦合度。其中最常见的方式是 依賴注入 (Dependency Injection, DI)。

TL;DR

随着前端的不断发展,一个工程的规模、复杂度不断提升,对于工程的可扩展性、可维护性都有了更高要求。如果以常规开发思路考虑工程中各个部分的组合,随着工程的不断演进,复杂度不断提升,带来的主要问题就是可维护性和可扩展性的极大下降。此时可以使用依赖注入与贡献点架构组织前端工程,提升可维护性与可扩展性。

依賴注入

假设不使用依赖注入,通常我们有一个单例 Service 的需求时,可能是这样的:

1class _Service {
2    foo() {
3        return "foo"
4    }
5}
6
7export const Service = new _Service();

通过 export/import,我们可以确保使用的 Service 始终是同一个实例,目前看似乎没什么问题。

但随着业务发展,我们需要另一个服务,他的部分方法重载了 Service 上的部分方法,仍是一个单例,那么写法可能是这样的:

1class _Service2 extends _Service{
2    foo() {
3        return "bar"
4    }
5}
6
7export const Service2 = new _Service2();

咋看似乎没有问题,但随着项目不断演进,工程代码可能充斥这下面这样的判断:

1const targetService = cond ? Service : Service2;
2targetService.foo();

长久发展下去这样的代码是无法维护的。

出现这种问题的原因是:我们依赖了 实现,而非 接口

如前面代码描述的,我们在业务中使用 new 出来的 Service 实体,这个单例一旦创建,就是不可变实体,我们只能通过不同的逻辑去消费不同的它。

那么如何解决这个问题呢?方法是依赖 接口

不同的语言有不同的用于定义 接口 这个抽象概念的方式,例如 TypeScript、Go、Kotlin 中的 Interface,Rust 中的 Trait 等。

对于一个 接口,我们可以有不同实现,根据不同条件,使用不同的实现,对于上面的例子,我们就可以改成:

 1interface IService {}
 2
 3class ServiceA implements IService {}
 4class ServiceB implements IService {}
 5
 6const SA = new ServiceA();
 7const SB = new ServiceB();
 8
 9export function getServiceInstance():IService {
10    return cond ? SA : SB;
11}

此时我们的代码就变成了依赖 接口,而非 实现

除了上面的例子,另一个典型场景是 依赖。 例如一个基类,它可能有别的数据以依赖,需要在 new 时传进去:

 1class BaseService {
 2    constructor(private fooService, private barService) {
 3    }
 4}
 5
 6class ServiceA extends BaseService {}
 7class ServiceB extends BaseService {}
 8
 9const SA = new ServiceA(fooService, barService);
10const SB = new ServiceB(fooService, barService);

它的任何子类都需要 new 时手动传进去基类上的数据依赖,随着时间的推移,工程维护成本太高了。

该如何解决上面这类问题?答案是使用 依赖注入

通常的依赖注入写法类似下面的代码:

1@injectable()
2class fooService implements IFooService {
3    foo() {}
4}
5
6class Service{
7    @inject()
8    private fooService: IFooService
9}

可以看到,我们需要标记一个 class可注入的,在需要使用它时,标记某个属性 注入 某个目标。这篇文章不注重 依赖注入 如何实现,这是因为 依赖注入 在各个语言上都有着成熟的实现,例如 Koin、 TypeDI、TSyringe 等,这里就不再赘述了,大部分场景下直接使用开源库即可。

简单来说,通过 依赖注入,我们可以找到 接口 的一个或多个 实现,让依赖看上去是自动获得的,而不是手动传递。

贡献点

一个可扩展的软件,必然要在架构层面支持功能的扩展,例如各种 IDE 的 扩展、调色软件的插件等。这里我们不讨论这么庞大的扩展机制如何实现,仅从代码角度思考:如何让一个功能是可扩展的?

假设我们的工程既可以跑在浏览器中,又能跑在 Electron 中。有一个功能在浏览器上和 Electron 采用不同的实现,但他们对外暴露的 API 是一致的,例如下面的例子:

 1interface ILoad {
 2    load(): Promise<ArrayBuffer>
 3}
 4
 5class BrowserImpl implements ILoad {
 6    load(): Promise<ArrayBuffer> {
 7        // ...
 8    }
 9}
10
11class ElectronImpl implements ILoad{
12    load(): Promise<ArrayBuffer> {
13        // ...
14    }    
15}

那么如何如何根据不同的环境执行不同的 load 方法?或者更进一步说,如果未来又新增了一个平台,怎么新增一个 load 实现,让其方便的融入现有系统?

理解了依賴注入,你就应该知道你应该知道,我们要做的是通过 接口 找到 实现

所以再回看上面的代码,可以理解为:多个 实现 向一个 接口 贡献内容,剩下的就是找到一种方法,能让我们快速找到 接口 对应的所有 实现,这是我们可以继续利用依赖注入实现这个功能。

前面提到过,主流的依赖注入库都可以实现根据 接口 找到多个其对应的 实现。在这里我们直接视使用 typedi 这个依赖注入库。

 1// 标记一个贡献点定义
 2export function contribution() {
 3  return function (target: any) {
 4    Reflect.defineMetadata(target.name, '', target);
 5  };
 6}
 7
 8// 标记一个贡献点实现
 9export function contributionImplement() {
10  return function<T> (target: T) {
11    const keys: string[] = Reflect.getMetadataKeys(target) ?? [];
12    keys.forEach((key) => {
13      Container.set({
14        id: key,
15        type: target as unknown as Constructable<T>,
16        factory: undefined,
17        multiple: true,
18      });
19    });
20  };
21}
22
23// 根据类型捞取贡献点
24export function getContributions(obj: any) {
25  return function (target: any, propertyKey: string) {
26    Object.defineProperty(target, propertyKey, {
27      get() {
28        return Container.getMany(obj.name);
29      },
30    });
31  };
32}

通过这个三个装饰器,我们可以实现贡献点机制的三步:

  1. 定义贡献点
  2. 实现贡献点
  3. 根据定义捞取所有实现

以下面的例子为例简单看一下怎么使用:

 1import { Component, ComponentClass } from "react";
 2
 3// 定义一个贡献点,内容是一个 React Component Class
 4@contribution()
 5export abstract class IPanelContribution {
 6    abstract component: ComponentClass;
 7}
 8
 9
10// 实现一个贡献点,向贡献点贡献 panel a
11class PanelItemA extends Component {
12    public render(){
13        return <div>panel a</div>  
14    }
15}
16
17@contributionImplement()
18export class PanelItemAImpl extends IPanelContribution {
19    public component = PanelItemA;
20}
21
22// 根据贡献点定义捞取所有实现
23export class Panel extends Component {
24    @getContributions(IPanelContribution)
25    private panels: IPanelContribution[];
26
27    public render() {
28        return (
29            <div>
30                {this.panels.map((C, i) => <C.component key={i} />)}
31            </div>
32        );
33    }
34}

如此,我们实现了 PanelItemPanel 的解耦,Panel 不关心具体内容,有多少 IPanelContribution 实现,就会有多少 PanelItem

通过 依赖注入贡献点 架构,我们可以方便的进行逻辑解耦,试写出来的代码更具可维护性与可扩展性。