再谈依赖注入和控制反转

上次提到了Quarkus和Spring里面的依赖注入是通过注解来实现的,这次也就再讨论一下依赖注入的事情吧。

从字面角度理解的话,依赖注入(DI),也就是在上下文外面构建一个变量,然后把变量「注入」进上下文里面。

用Java来举例的话,假设有A和B类型,A类型里面需要转发一些操作到B类型上,一种办法是在A类型里面直接创建B的变量然后操作,例如这样:

1
2
3
4
5
6
7
8
class A {
@NotNull B b;
A () {
b = new B();
}
}

var A = new A(); // B变量被自动初始化

这样的问题在于,B变量的生命周期依附在A上面,和A是一种强耦合的关系。也许这样说比较抽象,那么有个例子,假如说有不同的A变量被初始化,那么每个A变量都有自己独有的B变量,一个B变量里面发生的事情,在别的A里面的B里面是不知情的。有时候这种特性会很有用,比如构建actor模型的时候,但是在许多时候,这并不是程序员所想要的。

一个思路就是把B作为A的构造参数传进来。

1
2
3
4
5
6
7
8
9
class A {
@NotNull B b;
A (B _b) {
this.b = _b;
}
}

var b = new B();
var a = new A(b);

然而,手动把变量传进new A的参数列,在复杂的程序里面会形成很复杂的调用树,甚至可能会写出环来,这种情况下手动初始化自然也会非常复杂。所以就需要依赖注入框架了。

这是大学软工课程里面会告诉大家的内容。不过,有一些东西可能课堂上就不会说了。

比如说,依赖注入框架的实现方法,一般是,先构造一个白板对象,然后再通过反射把字段逐个加进去。这样的构造固然有很多优点,比如说不用再被复杂的constructor之间组成的树或者环所困扰,一次性就可以把变量都搭建起来。

但也有缺点:

  • 受限于反射机制,所有的方法和字段都必须被标为public甚至open的,某种程度上说,算是把编程语言提供的模块化和可视性的机制给破坏了
  • 因为构建的是白板对象,每个类都必须添加一个无参constructor,或者使用no-args插件自动化这一过程
  • 更进一步的,编程里面常用的带参数的constructor往往是和依赖注入框架不兼容的,用户不得不用命令式风味的代码来环绕依赖注入框架

简单归结一下就是依赖注入并不是银弹。

另一个经常和依赖注入一起被讨论的概念是控制反转(IOC),不难看出前面例子是怎么表现出这个思路的。另一个例子:

1
2
3
var dict = new HashMap<String, String>();

var process = new Process(dict);

这样的话,这个字典的控制权就被反转到了Process的外面,这也是依赖注入的思路。

不过,是不是也可以换个思路想呢?

1
2
3
4
5
6
7
class Foo {
@NotNull Map dict = ...;

public static void main() {
var process = new Process(dict);
}
}

或者再进一步说,

1
2
3
4
5
6
7
8
9
10
11
public static void process(Map dict) {
...
}

Map dict = ...

public static void main() {
...
process(dict);
...
}

这里的process当然也可以被改写为lambda的形式,例如() → Unit或者Runnable,但是可以观察到的一点大概是,函数之间的调用和函数的参数列,也可以起到类似于对象的参数列那样的控制反转的作用。

实际上函数式的依赖注入/控制反转也就是用lambda的方式来实现的,Reader Monad就是一个很好的例子。

也像这里提到的,「让底层代码也能操作上层代码」。

回调函数本质上提供了一种与常规的上层调用下层代码相反的模式,使得底层代码也有机会反调高层的代码,这大大提升了代码的能力,也同时给工程化项目带来了新的问题和挑战。

回调函数也是事件驱动式编程的基础,使得程序不必像传统的流程驱动式编程那样亦步亦趋的向下进行,而是可以被动性的由外来事件来触发进行,这几乎是所有图形化编程最基础和标准的实现方式。

不过函数式的写法,相比基于注解的框架,虽说可以通过闭包引入外面的变量,总归还是缺少一些灵活性。这也就再一次说明没有银弹了。

总的来说,大概还是觉得不同的技术各有使用的场景吧,对于大型前后端Web应用来说,使用注解的依赖注入框架比较省事,对于一些应用层的用户态代码,函数式的依赖注入可以提供简洁但在类型上安全的写法。至于一些只在局部使用的变量,我觉得如果能确保变量的生命周期的前提下,new也不是什么坏事。如果只是写个() → ()的小函数都要用注解,显得多少有点叠床架屋,而且也会把代码拆得乱七八糟。

说到底,抛开层层叠加的抽象概念和某些语言里喜欢玄乎其玄地堆叠概念的方法,依赖注入和控制反转本身其实也是一些很简单的思路,也有一些很简单的实现。完全没必要一提到DI和IOC就沿着某些Java框架的道路越跑越远搞出极度复杂的一些东西出来。

大概就是这样了。


再谈依赖注入和控制反转
http://inori.moe/2023/10/23/another-talk-of-di-and-ioc/
作者
inori
发布于
2023年10月23日
许可协议