通过控制反转降低代码耦合

· Technology

或许你在学习 Spring 的时候曾听过,Spring 提供的容器叫做 IoC 容器。 IoC 是 Inversion of Control 的缩写,中文翻译为控制反转。控制反转是一种设计原则,早在 2004 年 Martin Fowler 便提出依赖反转,即依赖对象的获得被反转了。

Contents

非控制反转的问题

大多数应用程序都是由两个或是更多的类或组件通过彼此的合作来实现业务逻辑,这使得每个对象都需要获取与其合作的对象(也就是它所依赖的对象)的引用。 如果这个获取过程要靠自身实现,那么这将导致代码高度耦合并且难以维护和调试。

直接实例化与紧耦合

比如 A 组件需要调用 B,一般情况下你可能会在 A 的 constructorinit 块中显式建立 B 组件然后调用。

class A {
    private val b = new B();
    
    public fun doSomething() {
        b.doSomething();
    }
}

这种方式意味着 Class A 对 Class B 有一个直接的、编译时刻的依赖。由于 Class A 直接创建了 Class B 的实例,这就导致了 A 和 B 之间的紧密耦合。Class A 不仅需要知道 Class B 的存在,还需要知道如何创建 B 的实例,这可能包括知道 B 的构造函数参数等。这种耦合使得修改、测试和重用 A 和 B 变得更加困难。

如果存在一个基础类 B 需要加入一个参数(Class C)作为依赖,那会出现两种情况:

  1. 加入一个设置默认值的依赖,此时就产生了新的由 B 到 C 的依赖,项目的耦合度增加。
  2. 不加入默认值,此时就需要修改所有调用 B 的地方,这是一种破坏性的修改。

不幸的是,这种耦合是非常常见的。

单元测试困难

package example

func newDB() *DB {
    return &DB{}
}

func DoSomething() {
    db := newDB()
    db.Write("Hello, World!")
}

// test
package example

import "testing"

func TestDoSomething(t *testing.T) {
    db := &MockDB{}
    DoSomething() // 无法注入 MockDB
}

大多数生产环境项目要求编写详细的单元测试,这种耦合会使得单元测试变得困难。因为在测试 A 的时候,你不得不同时测试 B,这就不是一个单元测试了。且如果我们需要注入一个 mock 对象,那么我们就需要修改 A 的构造函数,这种操作违反了开闭原则。

管理责任问题

在非 IoC 架构中,类必须自行负责管理它们的依赖关系。

这包括创建依赖对象、管理它们的生命周期以及处理任何依赖相关的配置。这种方式要求开发者对应用程序的结构和依赖有深入的了解。在团队开发中,这种方式会导致团队成员之间的沟通成本增加,且对于开发人员个人能力的要求也会增加。

控制反转

控制反转通过将对象的创建和绑定的控制权从程序代码转移到外部容器或框架中,从而达到解耦的目的。在传统的程序设计中,程序的流程是由程序本身控制的,而在采用了 IoC 之后,这种控制被反转:对象的创建和生命周期不再由调用者控制,而是由 IoC 容器来管理。

例如使用了 Spring 后,上述的代码可以改为这样:

@Component
class B {
    public fun doSomething() {
        println("Hello, World!")
    }
}

@Component
class A {
    @Autowired
    private val b: B;
    
    public fun doSomething() {
        b.doSomething();
    }
}

这种方式意味着 Class A 不再负责创建 Class B 的实例,而是由 IoC 容器负责创建和管理 B 的实例。这种方式使得 A 和 B 之间的耦合度大大降低,A 不再需要知道 B 的构造函数参数,也不需要知道如何创建 B 的实例。这种方式使得 A 和 B 变得更加容易修改、测试和重用。

依赖注入

依赖注入是控制反转的一种实现方式。在依赖注入中,对象的依赖关系不再由对象自己创建和管理,而是由外部容器来创建和管理。常见的依赖注入方式有构造函数注入、属性注入和方法注入。

Spring 的 @Autowired 注解就是一种属性注入的方式。在 Spring 中,你可以使用 @Autowired 注解来标记一个属性,Spring 容器会自动为这个属性注入一个实例。(如上)

Go 中常常提到的依赖注入方法是 Wire1 和 Fx2。Fx 是我常用的依赖注入框架,其通过 fx.Provide 提供构建方法,对 fx.Invoke提供依赖,实现在全局初始化阶段的依赖注入。Wire 不同于 Fx 的是,它是一个代码生成工具,通过 wire 命令在编译阶段生成依赖注入代码,而 Fx 依赖 reflector,实现了动态注入。Fx 属于上述的构造函数注入方式。

package example

import (
    "go.uber.org/fx"
)

// db.go

type DB struct {}

func NewDB() *DB {
    return &DB{}
}

// service.go

type Service interface {
    Write(msg string)
}

func NewService(db *DB) Service {
    return &service{db: db}
}

type service struct {
    db *DB
}

func (s *Service) Write(msg string) {
    s.db.Write(msg)
}

// main.go

func main() {
    app := fx.New(
        fx.Provide(NewDB),
        fx.Provide(NewService),
        fx.Invoke(func(s Service) {
            s.Write("Hello, World!")
        }),
    )
    
    app.Run()
}

此外,Kotlin 中还可以使用 Koin 框架3进行依赖注入,其使用 DSL 语法,对于轻量级的项目非常适用。

val appModule = module {
    single { MyRepository() }
    factory { MyViewModel(get()) }
}

优势与劣势

控制反转基本能够解决上述的问题,它通过框架或容器来管理对象的创建和生命周期,从而达到解耦的目的。

但其也有显著的缺陷:

  1. 控制反转高度依赖框架,一个项目很难脱离其 IoC 容器,这使得项目的迁移和升级变得困难。
  2. 对于未使用过 IoC 的开发者来说,控制反转的概念可能会比较难以理解。
  3. 控制反转会增加项目的复杂度,使得项目的启动时间变长。(对于 reflection 实现的 IoC 容器来说)

结语

尽管 IoC 带来了许多好处,但它也引入了一定的学习曲线和项目复杂度。选择合适的 IoC 框架或容器,以及合理地设计和实现依赖注入,对于成功利用 IoC 原则至关重要。随着技术的发展,IoC 框架和工具也在不断进化,提供了更多灵活性和更好的性能,使得控制反转成为现代软件开发不可或缺的一部分。

无论是在 Java 的 Spring 框架、Go 的 Fx 框架,还是 Kotlin 的 Koin 框架中,IoC 已经成为实现高质量、可维护和可扩展软件系统的关键技术。通过合理利用这些工具和原则,开发者可以构建出具有更高的可维护性和可扩展性的软件系统。

理解和应用控制反转不仅仅是学习一项新技术,更是一种思维方式的转变。它要求开发者从整体上思考应用程序的架构,关注组件之间的解耦,从而提高软件项目的质量和开发效率。随着时间的推移,掌握控制反转将会成为每个软件工程师技能库中的一个重要组成部分。

参考

Footnotes

  1. Wire: https://github.com/google/wire

  2. Go Fx: https://pkg.go.dev/go.uber.org/fx https://uber-go.github.io/fx/

  3. Koin: https://insert-koin.io

Comments

Send Comments

Markdown supported. Please keep comments clean.