[Go编程模式入门] 到底什么叫IoC控制反转

132 阅读10分钟

设计哲学的迷思

我们常说的设计哲学,如通过数据模型驱动界面、高内聚松耦合、职责分离、关注点分离、控制反转、单一职责、依赖倒置、接口隔离、领域驱动设计,听起来玄之又玄,但核心目标其实很现实:让代码生命周期更长、易于修改、能承受规模扩展和需求变化

实现这一目标的关键之一是开闭原则,即在不改动已有代码的基础上,通过扩展来应对新需求。本质是通过标准接口解耦,让组件可以各自演化、互不干扰。就像电源开关厂不关心它控制的设备类型,而灯泡厂也不关心开关的控制方式(手动、声控、遥控)。双方只需约定好接口。这种接口隔离带来的低耦合、高扩展性,就是开闭原则的现实映射。

就像现实中造开关的工厂不需要知道它控制的是灯、风扇还是电饭煲,它只要负责通断电;而灯泡厂也不关心你用的是手动开关、声控、还是遥控器,它只要定义好标准电源接口。这种通过接口实现的解耦关系,让双方都能独立演进,互不影响,这正是开闭原则和接口设计在现实世界中的直观体现。

Screenshot 2025-07-19 at 12.02.05.png

有些编程设计模式起名字特别直白,字面就能看出意图。比如建造者模式,作为一种创建型设计模式,其核心目的把“创建”和“使用”解耦。这就好像这其实就像你去餐馆只管点菜吃饭,根本不用关心后厨用了什么锅、什么火、多少油温来烹饪。

srv, err := new(ServerBuilder).
		New("127.0.0.1", -1).
		WithProtocol("xxx").
		WithMaxConn(1024).
		WithTimeout(30 * time.Second).
		Build()

复杂繁琐的步骤交由建造者处理,不仅实现了构建流程的模块化和安全校验,很好地实现了关注点分离,代码还显得尤为简洁优雅:

  • 通常使用指针接收器和返回指针值的方式,实现链式(Fluent)调用
  • 为了避免大量 if err != nil 导致的可读性下降,还可以将错误延到最后统一处理

控制反转:思想与例子

但是,控制反转这个名字就显得没那么通俗直白了。控制怎么还反转了?我代码里 iffor 不都是控制语句吗?实际上,这里的“控制”指的是对象创建、依赖注入、生命周期、调度策略等本应全由开发者显式控制的行为;然而,这些并不是核心业务的关注点,因此可以把这个职责委托给其他类(框架/容器),这就是IoC的核心思想:

控制维度技术机制 / 表现形式控制权转移说明示例
对象创建控制工厂模式、DI 容器、构造器注入对象由容器创建,避免显式 newSpring @Component、Guice、Uber/dig、Go 构造注入
依赖关系控制构造注入、字段注入、配置绑定依赖注入由容器解析并装配@Autowired, @Inject, @Value, @ConfigurationProperties
生命周期控制生命周期钩子、回调接口、容器感知接口初始化、销毁逻辑由容器管理@PostConstruct, @PreDestroy, InitializingBean
执行流程控制AOP、拦截器、Filter、事件机制容器控制横切逻辑插入点,统一流程控制@Transactional, Spring AOP, Servlet Filter, Go Gin Middleware
事件驱动控制发布订阅、观察者、事件总线、Reactive调用时机/顺序/线程由框架调度@EventListener, RxJava, LiveData, EventBus
配置/环境控制外部配置注入、条件装配、配置中心配置来源与加载方式由容器统一管理@Profile, Spring Cloud Config, EnvironmentAware
访问权限控制安全框架、权限注解、限流器资源访问规则交由安全组件统一管理Spring Security, OAuth2, Shiro
任务/流程控制状态机、编排器、流程引擎业务流转逻辑交由流程组件驱动Spring Batch, Flowable, AWS Step Functions
扩展点控制插件机制、SPI、策略模式、回调接口运行时扩展行为由容器发现与调度Java SPI, Spring BeanPostProcessor, ApplicationContextAware
并发/调度控制线程池、调度器、异步框架、消息中间件并发策略与执行时机交由框架控制@Async, Scheduled, ExecutorService, Kafka, Looper

从广义来说,笔者认为,甚至 Apollo、etcd 这类配置中心的使用也可以算作一种 Inversion of Control,它算然不是典型的控制反转容器(如 Spring IOC);但是比起硬编码配置,由外部系统控制配置的注入与更新是一种体现职责分离的实践做法。

例一:Spring IoC容器

IoC(控制反转)是一种设计思想,而 DI(依赖注入)是一种具体的实现手段。

// 传统写法:手动 new
Repo repo = new Repo(cfg);
Service svc = new Service(repo, logger);
Controller ctl = new Controller(svc, metrics, cache);

// 使用 Spring IoC
@Service
public class Service {
    @Autowired
    private Repo repo;
}

@RestController
public class Controller {
    @Autowired
    private Service service;
}

Spring IoC 容器支持:自动创建对象(通过扫描标注了 @Service@Component 等注解的类,生成相应的 BeanDefinition 并实例化);自动注入依赖(使用 @Autowired 注解自动将所需的 Service 注入到 Controller 等组件中);管理 Bean 生命周期(通过调用如 @PostConstructafterPropertiesSet() 等方法完成初始化与销毁过程);以及通过三级缓存机制解决循环依赖问题。

例二:Android LifecycleObserver/LiveData

Android 虽没有显式的 IoC 容器,但 Jetpack 架构组件中大量采用了 IoC 思想,主要体现在对象创建控制、生命周期感知和事件驱动:

viewModel = new ViewModelProvider(this).get(MyViewModel.class);

例如,在 Jetpack 架构组件中,ViewModel 并不是开发者手动 new 出来的,而是由系统根据 Activity/Fragment 生命周期自动创建/缓存/复用;支持配置变更时保留数据,避免手动管理生命周期。

liveData.observe(lifecycleOwner) { data -> updateUI(data) }

此外,LiveData 与 LifecycleObserver 的配合,也体现出执行控制的“反转”:通过注册观察者,让框架在合适时机回调相应方法,相比起开发者显式地定时检测更新和通知,LiveData中存放的数据发生变化时,观察者可以自动执行操作。又比如,我们希望某一个APP在处于前台可见状态时(onStart),统计时长、播放音乐、调用手机摄像头;当 APP 处于后台不可见状态时(onStop),它便停止计时、停止播放,不再获取摄像头数据。在 2017 年,谷歌I/O 开发者大会首次提出Lifecycle 类、LifecycleOwner 和 LifecycleObserver 接口前,一般都需要开发者在 Activity 的回调函数中控制其他对象生命周期。

传统高耦合模式生命周期感知模式
Screenshot 2025-07-19 at 20.55.11.pngScreenshot 2025-07-19 at 20.55.20.png

显然,这种依赖 Activity 类回调函数中控制其他对象生命周期的传统高耦合模式方式,会增加代码出错风险和维护成本;而生命周期感知型组件(LifecycleObserver)可以观察遵循 Activity 或 Fragment 状态,这与传统“命令式”的思维模式正好相反,让资源管理(如摄像头、计时器等)更加自动化,可维护性也更强。

实战:给集合增加撤回功能

下面通过一个通用集合类,演示如何从直接实现功能逐步演进到通过重构实现职责分离,以及控制逻辑的反转(IoC)。

完整代码:GitHub/DURUII

基础版本:泛型集合

这是最基础的集合实现,数据结构简单,增删查逻辑清晰直接。

type Set[T comparable] struct {
	data map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
	return &Set[T]{data: make(map[T]struct{})}
}

func (s *Set[T]) Add(x T) { s.data[x] = struct{}{} }

func (s *Set[T]) Delete(x T) { delete(s.data, x) }

func (s *Set[T]) Contains(x T) bool {
	_, ok := s.data[x]
	return ok
}
func TestSet(t *testing.T) {
	s := NewSet[string]()
	s.Add("武汉科技大学")
	s.Add("武汉大学")
	s.Add("华中科技大学")
	assert.True(t, s.Contains("武汉科技大学"))
	assert.False(t, s.Contains("武汉理工大学"))
	s.Delete("武汉科技大学")
	assert.False(t, s.Contains("武汉科技大学"))
}

但当我们加入撤销(Undo)功能,就会开始侵入和污染核心逻辑。

演进版本:耦合 Undo 实现

我们通过维护一个函数栈 []func() 来记录每步操作的撤销函数。

var (
	ErrNoUndo = errors.New("no functions to undo")
)

type Set[T comparable] struct {
	data map[T]struct{}
	undo []func()
}

func NewSet[T comparable]() *Set[T] {
	return &Set[T]{
		data: make(map[T]struct{}),
		undo: make([]func(), 0, 10),
	}
}

func (s *Set[T]) Add(x T) {
	if s.Contains(x) {
		s.undo = append(s.undo, nil)
		return
	}

	s.undo = append(s.undo, func() { s.Delete(x) })
	s.add(x)
}

func (s *Set[T]) Delete(x T) {
	if !s.Contains(x) {
		s.undo = append(s.undo, nil)
		return
	}

	s.undo = append(s.undo, func() { s.Add(x) })
	s.delete(x)
}

func (s *Set[T]) add(x T) {
	s.data[x] = struct{}{}
}

func (s *Set[T]) delete(x T) {
	delete(s.data, x)
}

func (s *Set[T]) Contains(x T) bool {
	_, ok := s.data[x]
	return ok
}

func (s *Set[T]) Undo() error {
	if len(s.undo) == 0 {
		return ErrNoUndo
	}

	index := len(s.undo) - 1
	if f := s.undo[index]; f != nil {
		f()
		s.undo[index] = nil
	}
	s.undo = s.undo[:index]
	return nil
}
func TestSetUndo(t *testing.T) {
	s := NewSet[string]()
	s.Add("武汉科技大学")
	assert.Equal(t, 1, len(s.undo))
	s.Undo()
	assert.Equal(t, 0, len(s.undo))
	s.Undo()
	assert.Equal(t, ErrNoUndo, s.Undo())
	s.Add("武汉大学")
	assert.Equal(t, 1, len(s.undo))
	s.Add("华中科技大学")
	assert.Equal(t, 2, len(s.undo))
	assert.True(t, s.Contains("武汉大学"))
	assert.False(t, s.Contains("武汉科技大学"))
	s.Undo()
	s.Undo()
	assert.False(t, s.Contains("华中科技大学"))
	s.Add("武汉科技大学")
	assert.True(t, s.Contains("武汉科技大学"))
	s.Delete("武汉科技大学")
	assert.False(t, s.Contains("武汉科技大学"))
	s.Undo()
	assert.True(t, s.Contains("武汉科技大学"))
}

虽然我们用非导出方法(小写的 deleteadd)进行了一定的封装,但还是显得耦合过于严重:集合的核心操作是增删查,怎么回滚撤销是按LIFO还是按FIFO顺序回滚不应是它的关注点,而且假如扩展新行为(如日志)又需要修改Set的核心结构,不符合开闭原则。

再演进版本:命令解耦和控制反转

为解决耦合问题我们采用命令模式(Command Pattern):每个操作生成一个可回滚的命令对象,撤销逻辑集中由一个 UndoManager 管理。这样可以把控制逻辑与业务逻辑分离,集合类不再负责撤销细节,而是将“撤销”的职责反转到外部组件中管理。

我们定义 Command 接口和 UndoManager

var ErrNoUndo = errors.New("no functions to undo")

type Command interface {
	Undo()
}

// 命令管理器
type UndoManager struct {
	stack []Command
}

func NewUndoManager(cap int) *UndoManager {
	return &UndoManager{
		stack: make([]Command, 0, cap),
	}
}

func (m *UndoManager) Add(cmd Command) {
	m.stack = append(m.stack, cmd)
}

func (m *UndoManager) Push(cmd Command) {
	m.stack = append(m.stack, cmd)
}

func (m *UndoManager) Undo() error {
	if len(m.stack) == 0 {
		return ErrNoUndo
	}
	idx := len(m.stack) - 1
	cmd := m.stack[idx]
	if cmd != nil {
		cmd.Undo()
	}
	m.stack = m.stack[:idx]
	return nil
}

UndoManager 仅负责按 LIFO 顺序管理和回退操作,不关心命令的具体内容,Command 是通用接口,任何实现该接口的类型都能接入使用。它将控制逻辑与业务逻辑解耦,只负责调度命令的执行和撤销,因此可以作为独立的通用组件复用到任何需要“撤销”功能的场景中,无需依赖具体的数据结构或业务语义。

每个操作实现自己的撤销逻辑:

type AddCommand[T comparable] struct {
	set *Set[T]
	x   T
}

func (c *AddCommand[T]) Undo() {
	c.set.Delete(c.x)
}

type DeleteCommand[T comparable] struct {
	set *Set[T]
	x   T
}

func (c *DeleteCommand[T]) Undo() {
	c.set.Add(c.x)
}

集合现在只需调用 Push() 注入命令对象,无需关心撤销逻辑细节,控制逻辑已被反转到 UndoManager。

type Set[T comparable] struct {
	data map[T]struct{}
	mgr  *undo.UndoManager
	err  error
}

func NewSet[T comparable]() *Set[T] {
	return &Set[T]{
		data: make(map[T]struct{}),
		mgr:  undo.NewUndoManager(10),
	}
}

func (s *Set[T]) Add(x T) {
	if s.Contains(x) {
		s.mgr.Push(nil)
		return
	}

	s.mgr.Push(&AddCommand[T]{set: s, x: x})
	s.add(x)
}

func (s *Set[T]) Delete(x T) {
	if !s.Contains(x) {
		s.mgr.Push(nil)
		return
	}

	s.mgr.Push(&DeleteCommand[T]{set: s, x: x})
	s.delete(x)
}

func (s *Set[T]) add(x T) {
	s.data[x] = struct{}{}
}

func (s *Set[T]) delete(x T) {
	delete(s.data, x)
}

func (s *Set[T]) Contains(x T) bool {
	_, ok := s.data[x]
	return ok
}

func (s *Set[T]) Undo() {
	if err := s.mgr.Undo(); err != nil {
		s.err = err
	}
	return
}

func (s *Set[T]) Size() int {
	return len(s.data)
}

func (s *Set[T]) Err() error {
	return s.err
}

至此,集合类只关注增删查核心行为,撤销等流程控制交由外部组件管理。这种设计不仅减少耦合,提高可读性,方便扩展如日志等功能,无需修改已有代码,也便于复用控制逻辑到其他场景。

ChatGPT Image Jul 19, 2025, 10_18_32 PM.png

参考资料