引言
从 Java 转到 Go 的开发同学,大概都会踩到第一个“坑”:Go 的包循环引用。
Go 的包循环引用是什么意思呢?有一定经验的开发者都知道循环依赖,比如 A 依赖了 B, B 依赖了 C ,C 又依赖了 A。这就构成了一个循环依赖(有环图)。
在 Java 里面,循环依赖是类级别的;但 Go 里要更严格一些:Go 的循环引用判定是 包级别的。举个例子,包 A 下的类 A 依赖了包 B 下的类 B,类 B 又依赖了包 C 下的类 C, 类 C 又依赖了包 A 下的 D。在 Java 里面,这里并没有构成循环依赖。但在 Go 里面,这导致了包循环引用:包 A => 包 B = > 包 C => 包 A。Go 会编译不通过:报 import circle not allowed。
对包依赖不太重视的人,初期会感到不适应。本文举几个自己踩过的坑,作一说明。
包循环引用释例
对象导致的包循环引用
哎呀呀,初来乍到,一下子给我来了八个包循环引用,打击得我有点不知所措了。这是怎么回事呢 ?
图片
第一个循环引用,是因为上报的包 agent 下对象 DetectionBase 依赖了包 denoise 下的降噪模型结果对象 HitDenoiseModel,而在某一处,HitDenoiseModel 又引用了包 agent 下的另一个对象。
为什么会发生这个事情?这是因为,图省事,我直接把输出对象放到了输入对象的包里,不想复制一份。正确的做法是,输入的对象只能放输入对象,不能放输出对象,否则依赖就会扩大出去。
如何解决?有两种方案:
- 把 HitDenoiseModel 放在 agent 包下。然后定义另一个对象 denoise/DenoiseResultModel,将 FillHitDenoiseModel 方法移到包 denoise 下,HitDenoiseModel 赋值给 DenoiseResultModel。这样形成了包 denoise => agent 的单向依赖,保证“输出 =>输入” 的单向依赖。不过,这种方法还是存在“篡改”原始数据的小罪行。
- 不对 DetectionBase 做任何变更,新创建一个包和对象,把 HitDenoiseModel 放在新创建对象里,同时将 FillHitDenoiseModel 方法移到新创建对象的包下。3
启示:避免篡改输入对象。应用中的对象往往是很多的,不太重视包依赖,很容易造成对象的循环引用。即使你自己能够小心确保不出问题,也会和别人添加的类造成循环引用。
发送消息与接收消息循环引用
梅开二度。又来了一个包循环引用。
图片
这又是怎么回事?有了第一次经验,第二次就不那么慌了。先提个 MR ,看看引入了哪些类。尤其是循环引用的那条链路。我们看到 cdc/msg 这个地方作为起点开始循环。为什么会有循环呢?因为我本来打算把 msg 相关的消息对象和消息处理都放在一起。但这种方式很容易导致 循环引用。为什么呢?因为 引入消息对象的时候, 就会把包下的所有类引入的所有包都引进来,这样就会把 /cdc/msg/XXXReceiver 引入,进而引入 /cdc/handler/XXXhandler, 而 msg/handler/XXXhandler 又会引入 msg/XXXSender。就导致了包循环引用依赖。
看来之前还是随意惯了。
图片
怎么解决?把 XXXReceiver 放在包 receiver 下,把 XXXSender 放在包 sender 下即可。
启示:
- 引入类的包要小,这样就很类似 Java 的全限定性包,减少循环引用的可能性。
- 简单的消息对象与复杂的消息处理不要放在一起,因为引入简单的就会把复杂的引入,复杂的又会递归引入更多的依赖。
internal 引用了 share
一键三连。
下图又是怎么回事?借鉴业界最佳实践,咱们把一个工程下的代码分为了 internal 和 share。其中 internal 的代码不能被其它模块访问,只有 share 下的代码作为桥梁,为其它模块提供服务。
图片
这个是因为 internal/detect_config/AService 引用了 share/detect_config/BService ,然后 share/detect_config/CService 又引用了 internal/detect_config/DService。internal 包怎么能够引用 share 下的包呢 ?内部模块怎么能够依赖外部模块 ?世界似乎不那么美好了。
与同事讨论,他们认为,helper 不应该依赖 service ,而应该依赖 repository, 而我一直认为 helper 是对 service 提供服务的一种高层封装, helper 依赖 service 是很正常。helper 依赖 repository, 看上去说得也很有道理。不过 internal 模块依赖 share 模块,看上去总是感觉有点违反单向依赖的设计原则。
经过讨论后,我和同事各做了修改。我的修改是让 helper 依赖 repository, 同事的修改是,把原来 service 拆分成公共的 service 和内部的 service,保证 share 对 internal 的单向依赖。
运行时循环依赖
即使你幸运地逃过了包循环引用的检测,但存在运行时循环依赖,Go 会直接卡住。
一个例子如下图所示:有若干个流程组件 A, B, C,加载到一个工厂 F 下,由一个 执行器 E 依次从 F 中取出执行。但是呢,在流程组件C 中,又依赖了 E 来执行任务。这样会导致循环依赖。在 Java 中,Spring 会忽略组件 C, 初始化成功,但运行时找不到 C 而导致流程出错(可以用懒加载机制解决);但是 Go 就不那么幸运了(也许是幸运的,因为它让你早点发现问题)。
对于这种场景,可以用消息队列来解耦。
图片
对策汇总
遇到包循环引用,有哪些经验可循呢 ?
(1) 提倡小而独立的包。不要把大量的有关联的类都放在一个包下。这样,很容易因为一个类的引入,而引入更多依赖,导致依赖不可控。
(2)单向依赖原则。internal 下的类不应当依赖 share 下的类。因为 share 下的类一定会依赖 internal。如果 internal 又依赖 share ,就破坏了“单向依赖”原则。当工程越来越大时,一定会有一个点会爆发。不是不报,时候未到。细分包可以缓解这种问题,但不能从根本上避免单向依赖的破坏。
(3) 依赖倒置原则。尽量依赖接口,而不是具体实现类。
(4)使用消息队列解耦依赖。
(5)相对合理的依赖方向:model => (constants, 无依赖的 model) ; dto => types => constants ;util => (dto, types, constants, models);service => repository => model ;helper => (repository, util, cache) ; controller => (helper, service) ; receiver => handler => (service, helper)
最后问一句:Go 为什么不允许包循环引用呢 ?听听 Go 语言作者怎么说:
图片
再听听 AI 怎么说 :
小结
本文讨论了几个包循环引用的例子,并给出了相应的对策。
个人觉得,这种禁止循环引用的做法还是可取的,能培养良好的设计习惯。软件开发,本质是应对结构复杂性的技艺。而设计思维,则是应对结构复杂性的重要法宝。开发人员应多多学习设计思考,保持系统和架构的优雅。
参考资料
- go循环依赖最佳解决方案[1]
Reference
[1]go循环依赖最佳解决方案:https://juejin.cn/post/7290389972406501432