邪恶的字段注入
继《给 Java gradle 工程添加 git hooks》之后,再次延续《后端工程圣殿形象的崩塌以及重建》一文,用 JavaScript 工程师的视角,来改造理论上本该高大上但是现实中却大面积倒塌的 java 后端工程。
今天面试了一个号称有着 8 年 java 开发经验的工程师(据说所谓的 java 工程师,事实上就是 Spring 工程师而已)。我问她这两种写法有什么区别?你更倾向哪一种?
class ServiceX {...}
// 写法 1
class A1 {
private ServiceX service;
...
}
// 写法 2
class A2 {
private final ServiceX service;
public A2 ( ServiceX service) {
this.service = service;
}
...
}
她看了大惊失色:啊?还有 A2 这种写法?没见过啊,都是像第一种那样去写啊!
我不惊讶她的回答,因为我们公司的实际项目中也是这样的,所有人都是像第一种那样去写的,从来没有人觉得任何不适。
但是我很不适,首先 IDE 会给第一种写法画上波浪线,给一个黄色警告。我不明白那些看不起 JavaScript 工程师的 java 工程师们,为什么从来不去注意这种警告?
其次是我在写测试时,觉得更加不爽。原来没有人觉得不适,因为项目中根本没有测试。通过写测试,我切身体会到了两种写法的区别和各自的优劣,结论是强烈建议使用第二种写法,应该没有不得不使用第一种写法的场景。
以下是个人粗浅的理解的总结
以上的代码就是要写一个类,该类依赖一个 Service,使用 Spring 框架实现这个依赖 Service 的类,当然要使用依赖注入,这个 @Autowired 注解就是用来注入依赖的。但是注入的方式有两种,以上第一种写法是字段注入,而第二种写法是构造器注入。
为什么不建议采用字段注入?
一、测试不仅难写,而且难以运行
测试时需要控制依赖项,所以往往需要将真正的依赖项模拟掉。如果使用字段注入,测试就很难写。因为要模拟这个依赖项,就要写更多的代码。测试也很难运行,因为要花更长时间运行,更耗机器资源,还让反馈变慢。
比如对于字段注入的代码进行测试,你需要先在测试类上注解上 Spring 相关的环境,而且在运行测试时会真的启动 Spring 容器,所以运行起来很慢:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class A1Test {
@MockBean
private Service mockService;
@Autowired
private A1 sut;
@Test
void testIt() {
...
}
}
事实上,除非是写集成测试,根本没有必要启动 Spring 环境。所以对于构造器注入,测试代码不仅更加简洁且运行更快:
class A2Test {
private final A2 sut;
private final Service mockService;
{
mockService = Mockito.mock(Service.class);
// 直接 new,避开了 Spring 容器的开销
sut = new A2(mockService);
}
void testIt() {
when(mockService.method(any(Object.class)).thenReturn(0);
...
}
}
二、字段注入方式违反了不可变性原则
对比 A1 的实现,注意在 A2 的实现中(构造器注入),使用了 final 关键字。这带来了很大的好处,因为这个字段内容在应用的整个生命周期中不能再被改变,从而可以避免编程错误(比如忘掉初始化这个字段会导致编译报错)。
三、字段注入实现的代码不够安全
当构造器执行完毕,对象就准备好被使用了。采用构造器注入方式,对象只有准备好或者没有准备好的状态,不存在中间态。但是采用字段注入,导致对象存在一个中间状态,这个对象会比较脆弱。
四、字段注入方式对依赖的表述不够清晰
采用构造器注入,使得类的必要依赖一目了然。
五、逼迫开发者思考类的设计
如果你的构造器里出现了很多参数,就是非常明显的一个坏的设计,实际上是一种上帝对象这样的反模式。不管类通过构造器还是字段的方式依赖多个其他服务,这都是错的,但是通过构造器注入更能让人在依赖变多时停下来思考代码结构的设计。
总结
综上所述,如果非要说构造器注入有什么不好的地方,那就是增加了实现上代码量,因为字段注入只需要写一个字段,而构造器注入既要写构造器,还免不了要写字段。但作为 JavaScript 工程师,不得不说这是 java 语言本身的问题,在 JavaScript 或者 TypeScript 的世界里,采用构造器注入,连这个缺点都没有。
举个例子
如果你使用 NestJs(https://docs.nestjs.com/fundamentals/injection-scopes),那么可以这样写:
class Service {}
class A2 {
constructor( private service) {}
}
注意到在构造器里可以直接写上 private,这样 A2 类就自动有了 service 这个私有字段。
如果你不用 NestJs,那么使用 InversifyJs(https://doc.inversify.cloud/zh_cn/classes_as_id.html)也类似:
class Service {...}
class A2 {
constructor(private readonly service: Service) {}
}
最后再次强调,如果你发现自己的项目中还在用字段注入,赶紧改成构造器注入的方式吧!