2619 字
13 分钟
Golang:依赖倒置不是循环依赖的万能解药

这篇文章本来是两个月前的草稿。不过好像后来忘掉了,现在重新写了一遍

用 Typeless 辅助写的,受朋友推荐了下,还确实挺好用的,可惜还找不到足够好用的开源平替,以后写作都用这个了

众所周知,Golang 项目的代码结构都比较趋向按模块划分,所以也会不可避免地遇到循环依赖的问题,当然,解决循环依赖的手段实际上有很多,其中一种被认为比较优雅的处理方式就是使用依赖倒置,也就是抽出来一个接口,以及很多文章都会推荐你这么去做

但实际上,这种方式并不适用于所有场景,甚至在某些更偏向传统业务后端的项目里,大多数出现循环依赖的场景都不太适合用依赖倒置来解决。而循环依赖时到底什么场景应该用依赖倒置,什么场景不应该用呢

简单来说,不同层级的模块间的循环依赖,可以按照依赖倒置来解决,而同层级模块之间的循环依赖,并不推荐靠依赖倒置来解决。什么是同层什么是不同层呢,诸如两个业务模块user和order,这就是同层级(至少通常来说是这样的),而一个业务模块user和一个基础设施模块比如消息队列,那就是不同层级(当然通常来说消息队列可能被划分为生产者与消费者两个模块,这里只是为了方便理解就归同一个模块了)。当然层级其实要结合实际业务需求来理解并划分,只不过通常在传统业务后端里默认都是这么划分,已经成常识了,但要是放到中间件或者一些轮子项目里可能就不太一样了。以下讨论所举的例子均基于传统web业务后端来理解

同层级模块产生循环依赖用依赖倒置会发生些什么#

首先,需要提的一点是,通常来说,模块内部划分的 handler、service 和 repo 层之间,使用了依赖注入方法

从代码层面来讲,比如,我们有一个模块 user 和一个模块 order,User 里面有一个方法 U 调用了 Order 模块,然后 Order 模块里有一个方法 O 调用了 User 模块,然后产生了循环依赖。那么这个时候,用依赖倒置是在 user 模块里面定义一个接口,然后 order 模块实现这个接口,然后,代码层面的循环依赖好像就被解决了。然而哪怕仅从代码层面讲,真的解决了吗?

// user 模块
package user
type Service struct {
order *order.Service // 依赖 order 模块
}
func (s *Service) U() {
s.order.O()
}
// order 模块
package order
type Service struct {
user *user.Service // 依赖 user 模块
}
func (s *Service) O() {
s.user.U()
}

两个包互相 import,Go 直接编译不过:

import cycle not allowed
import "example/user"
import "example/order"

如果改用依赖倒置,各自定义对方需要的能力抽象:

// user 模块:定义 OrderAPI 接口
package user
type OrderAPI interface {
O()
}
type Service struct {
order OrderAPI // 只依赖抽象,不再依赖 order 包
}
func NewService(o OrderAPI) *Service {
return &Service{order: o}
}
func (s *Service) U() {
s.order.O()
}
// order 模块:定义 UserAPI 接口
package order
type UserAPI interface {
U()
}
type Service struct {
user UserAPI // 只依赖抽象,不再依赖 user 包
}
func NewService(u UserAPI) *Service {
return &Service{user: u}
}
func (s *Service) O() {
s.user.U()
}

通常来说,模块里面会采用依赖注入的方法,这也是比较普遍的。好,如果你真的是这么去做了,你就会遇到一个问题,在最上层依赖注入的时候,产生了死锁,然后就根本没法实现依赖倒置。

// 组合根 main.go:无论先创建哪个,另一个都还没就绪
func main() {
userSvc := user.NewService(orderSvc) // orderSvc 还没创建
orderSvc := order.NewService(userSvc) // userSvc 还没创建
}

这个时候你可能想去解决这个问题,于是去翻文档或者问 AI,你可能会得到一些解决方法,其中一个叫懒加载,另一个叫属性注入。确实,这两个方法确实能解决这个问题,但是实际上只是在打补丁。而且我从个人观点出发,并不推荐在 Golang 的项目里面这么写,这其实是一种非常非常 Java 的写法。

// 属性注入:不通过构造函数注入,先把对象建出来再补上依赖
func main() {
userSvc := &user.Service{} // Order 依赖先空着
orderSvc := &order.Service{User: userSvc}
userSvc.Order = orderSvc // 事后补上,绕开注入死锁
}
// 懒加载:用到的时候才去获取依赖
package user
type Service struct {
orderOnce sync.Once
order OrderAPI
}
func (s *Service) getOrder() OrderAPI {
s.orderOnce.Do(func() {
s.order = order.GetInstance()
})
return s.order
}

而且最终你发现这么做,只不过是把代码层面的显式循环依赖改成了隐式循环依赖罢了哈哈。。。

那么好,所以问题出在哪 为什么这么一个优雅的方法,用的时候竟遭如此挫折

那么,首先我们要弄明白,依赖倒置它本身到底是拿来干什么的

依赖倒置(又名依赖反转)#

以下是维基百科的解释:

在面向对象编程领域中,依赖反转原则(Dependency inversion principle, DIP)是指一种特定的解耦(传统的依赖关系建立在高层次上,而具体的策略设置则应用在低层次的模块上)形式,使得高层次的模块不依赖于低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象。

该原则规定:

1.高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。 2.抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。 该原则颠倒了一部分人对于面向对象设计的认识方式。如高层次和低层次对象都应该依赖于相同的抽象接口。

也就是说,依赖倒置本来就应该被用在不同层次的模块之间用来解耦,依赖倒置不应该被当成解决pkg之间的循环依赖的一种solution

比如说我们有一个任务队列模块(这里为方便解释仍然当做一个模块)和业务里的task模块,Task 模块调用了任务队列模块的 Enqueue方法,任务作业模块调用了 Task 模块的更新任务状态方法,然后循环依赖了。那么,这个时候我们就应该用依赖倒置,因为 Task 模块本来不应该依赖于任务队列模块的具体实现。而且基础设施模块不会像那些业务模块一样采用几乎相同的依赖注入方式,所以不太会有前面的死锁问题

// task 模块
package task
type TaskQueue interface {
Enqueue(taskID string) error
}
type TaskService struct {
queue TaskQueue
}
func NewTaskService(q TaskQueue) *TaskService {
return &TaskService{queue: q}
}
func (s *TaskService) Submit(taskID string) error {
return s.queue.Enqueue(taskID)
}
func (s *TaskService) UpdateStatus(taskID string, status string) error {
// 更新任务状态
return nil
}
// taskqueue 模块
package taskqueue
type TaskUpdater interface {
UpdateStatus(taskID string, status string) error
}
type TaskQueue struct {
updater TaskUpdater
}
func NewTaskQueue(u TaskUpdater) *TaskQueue {
return &TaskQueue{updater: u}
}
func (q *TaskQueue) Enqueue(taskID string) error {
// ...投递到消息队列
return q.updater.UpdateStatus(taskID, "running")
}
// 组合根负责装配:双方的具体实现各自满足对方的抽象
func main() {
taskSvc := task.NewTaskService(nil) // 先创建,便于后续注入
queue := taskqueue.NewTaskQueue(taskSvc) // task.TaskService 实现了 TaskUpdater
taskSvc = task.NewTaskService(queue) // taskqueue.TaskQueue 实现了 TaskQueue
}

于是我们就实现了高层次模块对低层次模块的解耦,只是在 Golang 的语法与代码里表现出来,循环依赖的错误被解决了

所以循环依赖意味着什么呢?

循环依赖#

产生循环依赖,意味着你的模块设计本来就有问题,只是对于不同层次的模块出现的设计问题,恰好可以用依赖倒置来解决,而对于同层次模块出现的模块设计问题,依赖倒置则无法较好地解决。

Golang 的循环依赖错误应当被视为一种信号,而不是错误。只不过,它在 Golang 的语法里确实是错误的。

所以,最终应当归因于模块设计的问题,去更好地划分模块,而不是寻找一种解决方法去解决循环依赖的语法问题。要解决的不是循环依赖,而是要解决模块设计问题。

当然,解决模块设计问题就有很多方法了,诸如合并、抽公共层,上面讲到的依赖倒置,再到后面可以用事件驱动等等层层递进地解决

总结#

循环依赖本身并不是一个单纯的代码层面问题,它更多反映的是模块之间职责划分和依赖关系是否合理

依赖倒置确实是解决循环依赖的一种优秀手段,但它解决的前提是:两个模块之间存在明确的层级关系,高层模块不应该依赖低层模块的具体实现,而应该依赖抽象能力。在这种场景下,依赖倒置不仅解决了循环依赖,也让模块之间的边界更加清晰

但是,如果两个模块本身处于同一业务层级,例如 userorder 这种业务模块之间产生循环调用,那么强行引入接口、懒加载或者属性注入,往往只是绕过了语言层面的限制,并没有真正解决模块设计上的问题,甚至可能让原本简单的业务关系变得更加复杂

换句话说,循环依赖不是一个需要被消灭的语法错误,而是架构设计中的一个反馈信号

好的设计不是找到一种技巧让循环依赖消失,而是在出现循环依赖时,重新审视模块之间的关系,让依赖方向符合业务本身的逻辑