依赖注入:解耦,自动填充代码,方便单元测试的优质轮子!
前言
熟悉Java语言的同学一定不陌生,依赖注入(Dependency Injection)是Spring框架中的设计基石,有开发经验的同学一定会熟知它的概念(当然也是面试常考问题)。
然而在Golang中,我发现很多项目的代码缺少了这一部分,这也是由于Golang并不是严格意义上的面向对象的编程语言。本文将从Java与Go常用框架的DI实现方式分析,详细介绍他们之间的区别。
Dependency Injection(DI) Wikipedia官方解释 In software engineering,
dependency injection is a technique in which an object receives other
objects that it depends on, called dependencies. Typically, the
receiving object is called a client and the passed-in (‘injected’)
object is called a service. The code that passes the service to the
client is called the injector. Instead of the client specifying which
service it will use, the injector tells the client what service to
use. The ‘injection’ refers to the passing of a dependency (a service)
into the client that uses it.
依赖注入形象解释
先抛结论:
- 控制反转(IOC)是目的,指让生成类的方式由传统方式(new)反过来,不需要通过new新建类;
- 依赖注入(DI)是手段,是指通过自动化的手段由框架帮助生成代码;
从作用与意义的角度,依赖注入就像是程序员觉得自己的工作太繁琐了,需要简化自己工作的部分,把一部分工作丢给了框架,从而让自己有更多的时间专注业务(mo)逻辑(yu)。
然而,它更重要的一部分原因还在于,它可以有助于上下层的解耦,避免修改一处代码时还需要改动多个位置,适用于业务发展较快的场景,并且更有利于撰写单元测试。
怎么理解上下层解耦与方便单元测试呢?下面给一个简单的例子:
借鉴官方文档,迎候顾客的服务员Greeter需要发送Message招呼客人,事件Event表示Greeter开始招呼客人的事件,很明显,Event依赖Greeter
type Greeter struct {
Message string
}
type Event struct {
Greeter Greeter
}
func NewGreeter(msg string) Greeter {
return Greeter{Message: msg}
}
// 非依赖注入
func NewEventNDI() Event {
gtr := NewGreeter("11")
return Event{Greeter: gtr}
}
// 依赖注入
func NewEvent(gtr Greeter) Event {
return Event{Greeter: gtr}
}
可以看到,如果采用非依赖注入方式(line13-17),在NewEventNDI()时,需要在构造方法内部输入Greeter参数并且初始化,假设Greeter结构改变或方法改变,却需要在NewEventNDI()中大量修改代码,代码耦合程度较大,因此引入了依赖注入的方式进行Event初始化,即将Greeter通过参数的方式“注入到”Event中,两方面的耦合度降低,在测试的时候,可以mock一个Greeter,从而继续进行测试。
Java依赖注入
Java中有以下几种方式:
- 基于构造函数的注入:实现特定参数的构造函数,在新建对象时传入所依赖类型的对象。
- setter方法注入:实现特定属性的public set方法,来让外部容器调用传入所依赖类型的对象。
- 基于接口的注入:实现特定接口以供外部容器注入所依赖类型的对象。
- 基于框架的注入:通过注解或配置文件完成注入
Spring
作为最常用的Java项目框架,其核心的思想便是通过依赖注入解决业务逻辑耦合的问题
使用方法:
手动配置方法:
首先需要添加Spring相关的配置依赖pom.xml
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>4.3.2.RELEASE</version>
</dependency>
结合前文的例子,在java中构造相关的Greeter与Event类
public class Greeter{
private String Message;
public Greeter(String msg){
this.Message = msg;
}
}
public class Event {
private Greeter Greeter;
public Event(Greeter gtr) {
this.Greeter = gtr;
}
public void Start(){
String msg = Greeter.Message;
System.out.println(msg);
}
}
修改Spring的配置文件application.xml文件,配置了Event与Greeter两个类之间的依赖关系
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="event" class="com.cmower.java_demo.ioc.Event">
<constructor-arg ref="greeter" />
</bean>
<bean id="greeter" class="com.cmower.java_demo.ioc.Greeter">
<property name="Message" value="11"/>
</bean>
</beans>
上述配置工作完成后,测试代码如下,即使用了依赖注入的方式,将控制权赋予了框架(xml配置文件),可以较好地解决代码耦合度较紧的问题。
public class Test {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
Event event = (Event) context.getBean("event");
event.Start(); // output: 11
}
}
自动配置方法:
在Spring2.5以后,框架推出了更加方便的@Autowired注解的自动装配。
只需要将注解添加到成员变量(或构造方法)之前,即可根据名称或指定名根据自动进行装配。
public class Event {
@Autowired
private Greeter Greeter;
public void Start(){
String msg = Greeter.Message;
System.out.println(msg);
}
}
核心原理:
手动配置方法:
首先基于 xml(Configration) 的配置向 ApplicationContext 注册合适的类,并从 ApplicationContext 请求创建 bean 对象。 然后 ApplicationContext 构建一个依赖关系树并遍历它以创建所需的 bean对象。ApplicationContext作为Spring容器,可以被配置适应各种需要作为实现方式。
自动配置方法:
启动spring IoC时,容器自动装载了一个AutowiredAnnotationBeanPostProcessor后置处理器,当容器扫描到@Autowied就会在IoC容器自动查找需要的bean,并装配给该对象的属性
<bean class="org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor"/>
Go依赖注入
Google–Wire
因为Google出品(和Golang语言一个地方诞生),所以也被认为是当前官方的依赖注入实现方式。
官网:https://github.com/google/wire
官网说明: Wire is a code generation tool that automates connecting
components using dependency injection. Dependencies between components
are represented in Wire as function parameters, encouraging explicit
initialization instead of global variables. Because Wire operates
without runtime state or reflection, code written to be used with Wire
is useful even for hand-written initialization.
使用方法:
- 安装
go get github.com/google/wire/cmd/wire
- 主函数:
type Greeter struct {
Message string
}
type Event struct {
Greeter Greeter
}
func NewGreeter(msg string) Greeter {
return Greeter{Message: msg}
}
func NewEvent(gtr Greeter) Event {
return Event{Greeter: gtr}
}
func (e Event) Start() {
msg := e.Greeter.Message
fmt.Println(msg)
}
func main() {
e := InitializeEvent("11")
e.Start()
}
- 新建wire.go 文件,定义初始化
注意:在文件顶部需要注明//+build wireinject
//+build wireinject
func InitializeEvent(msg string) Event {
wire.Build(NewEvent, NewGreeter)
return Event{}
}
- 命令行生成文件wire_gen.go
在第一步中安装的wire没问题的情况下,在wire.go 文件夹下执行 wire即可生成wire_gen.go文件
func InitializeEvent(msg string) Event {
greeter := NewGreeter(msg)
event := NewEvent(greeter)
return event
}
通过如上方法,即可通过wire自动推导出他们之间的依赖关系,减少手动代码撰写量。
核心原理:
wire有两个最基本的概念:provider和injector
- provider就是接收各个构造函数,通过构造函数告诉wire该对象的依赖情况(在wire.go 文件中说明);
- injector就是wire解析生成的函数,将获取到依赖情况进行排序,按照顺序调用(生成的wire_gen.go 文件);
阅读源码分析,核心逻辑有两点:
- 不通过反射,通过代码生成的方式,在编译阶段完成不同模块依赖关系的构建
代码生成参考了 Java’s 中的 Dagger 2 - 通过dfs(深度优先搜索)进行源码依赖的搜索:
// github.com/google/wire/internal/wire/analyze.go
func solve(fset *token.FileSet, out types.Type, given *types.Typle, set *ProviderSet)([]call, []error){
...
stk := []frame{{t: out}}
dfs:
for len(stk) > 0{
curr := stk[len(stk)-1]
stk = stk[:len(stk)-1]
...
pv := set.For(curr.t) // 判定其类型
...
switch pv := set.For(curr.t);{
case pv.IsArg(): // 属于参数,直接continue
case pv.IsProvider(): // 属于Provider元素
visitedArgs := true
for i := len(p.Args) - 1; i >= 0; i-- {
a := p.Args[i]
if index.At(a.Type) == nil {
if visitedArgs {
stk = append(stk, curr)
visitedArgs = false
}
stk = append(stk, frame{t: a.Type, from: curr.t, up: &curr})
}
}
if !visitedArgs {
continue
}
...
continue dfs
...
calls = append(calls, call{...})
case pv.IsValue(): // 初始值
...
calls = append(calls, call{...})
case pv.IsField(): // 单独结构体struct
...
stk = append(stk, frame{t: a.Type, from: curr.t, up: &curr})
...
continue dfs
...
calls = append(calls, call{...})
default:
panic("unknown return value from ProviderSet.For")
}
}
...
return calls, nil
}
- 通过判定输入输出struct来匹配前后依赖关系
// github.com/google/wire/internal/wire/parse.go
type Provider struct {
...
Args []ProviderInput // 入参
Out []types.Type // 出参
...
}
目前的项目中,往往依赖关系非常复杂,这就需要用到一些进阶的功能。
可参考:https://github.com/google/wire/blob/main/docs/guide.md
- 集合/组:将同一批次需要打包的放到一个set中,然后再将所有set进行注入;
- 接口绑定:注入一个接口,让provider返回具体类,但在injector声明环节将类绑定成接口;
- 构造结构体:Struct可以帮助构造出所需要的结构体的相关信息;
- 绑定值:对于一些初始化时需要赋值的部分,通过Value初始化;
- 部分依赖注入:标记结构体中需要依赖注入的部分/在Provider时进行FieldsOf声明;
- 清理函数:在执行错误时保证所有资源最终都能得到回收;
优缺点:
- 优点:
- 代码生成式依赖注入,运行时没有额外的开销
- 简单易用,扩展功能丰富,基本能满足大部分项目开发需要
- 方便debug,如果有问题,在编译阶段就会报错
- 避免依赖膨胀。 生成的代码只包含被依赖的代码,而运行时依赖注入则无法做到这一点
- 缺点:
- 默认不提供单例模式,需要手动实现单例的provider
Facebook–inject
官网:https://github.com/facebookarchive/inject
使用方法:
- 安装
go get github.com/facebookgo/inject
- 定义结构体,标注inject(默认 inject:“”; 私有注入 inject:“private”; 内联注入 inject:“inline”; 命名注入: inject:“object_name”)
type Greeter struct {
Message string `inject:"message"`
}
type Event struct {
Greeter Greeter `inject:"inline"`
}
func (e Event) Start() {
msg := e.Greeter.Message
fmt.Println(msg)
}
- 定义主函数,初始化Graph,Provide,Populate
func main(){
// step1: 初始化Graph
var g inject.Graph
var event Event
msg := "22"
// step2: 提供需要建立关系的元素
if err := g.Provide(
&inject.Object{Value: &event},
&inject.Object{
Value: msg,
Name: "message",
},
); err != nil {
fmt.Printf("g.Provide err = %v \n", err)
return
}
// step3: 注入与解析
if err := g.Populate(); err != nil {
fmt.Printf("g.Populate err = %v \n", err)
return
}
event.Start() // output: 22
}
注意:如果struct中的元素不为指针,需要加入inject:"inline"标识;如果需要定义初始化的参数,需要在inject中指定名字(当然不能是inline和private这两个关键字)
核心原理:
与wire的原理相似,首先新建inject.Graph,即为关系推导中图谱,然后Provide接收相关对象,首先必要地提供顶层对象(依赖其他对象的),本项目中即为打招呼的事件event,然后提供底层对象(被依赖对象)必要参数,本案例中即为打招呼的信息Message(通过Name来指定确定对应关系。最后通过Populate()进行注入,类比wire中的injector。
不同之处在于,Facebook 的inject方法采用了反射的方法在运行时进行解析,所以执行效率上会有一定下降。
优缺点:
- 优点: 只需要声明inject:""的tag,即可完全自动注入所有依赖关系,与Spring配置思想相同;
- 缺点:
- 所有需要注入的字段都需要是 public 的。
- 只能进行属性赋值,不能执行初始化函数。 facebookgo/inject只会帮你注入好对象,把各个属性赋值好。但很多时候,我们往往需要在对象赋值完成后,再进行其他一些动作。但对于这个需求场景,facebookgo/inject并不能很好的支持。
- 文档相对较少,普及率不高;
- 采用核心为反射注入,运行时有一定开销
对比分析
Java-Spring | Go-Wire | Go-inject | |
---|---|---|---|
便捷程度 | 🌟🌟🌟🌟🌟 | 🌟🌟🌟🌟 | 🌟🌟🌟 |
核心原理 | Bean反射与解析 | 配置文件,文件生成 | Tag标识,反射解析 |
额外性能开销 | 反射 | 无 | 反射 |
推广程度 | 高 | 高(7.9k star) | 中(1.4k star) |
除了上述介绍的三种方法,在golang中还有uber的dig工具,仿照java Spring的go-spring工具,其核心思想大致相同,即:标识/注册,提供元素,注入与解析。感兴趣的同学可以深入了解一下。
结论
Go不是OOP的语言,但是又允许有OOP的编程风格
Go项目推荐使用Wire包生成依赖
原因: 额外性能开销很小,普及率较高,文档丰富,功能强大。
虽然但是,其他框架的设计思想还是比较值得进一步借鉴学习的~~🙌