[译]Go中的循环依赖:如何解决这个问题

语言: CN / TW / HK

作为一个 Golang 开发,你可能在项目中遇到过包的循环依赖问题。Golang 不允许循环依赖,如果检测到代码中存在这种情况,在编译时就会抛出异常。本文会讨论循环依赖是如何发生的以及如何处理。

循环依赖

假设我们有两个包:p1和p2。当包p1依赖包p2,包p2依赖包p1时,就会产生循环依赖。真实情况可能会更复杂一些。例如,包p2不直接依赖包p1而是依赖于包p3,而p3又依赖包p1,这就构成了循环依赖。

import cycle golang

下面来看两个包互相依赖的示例:

Package p1:

package p1

import (
"fmt"
"import-cycle-example/p2"
)

type PP1 struct{}

func New() *PP1 {
return &PP1{}
}

func (p *PP1) HelloFromP1() {
fmt.Println("Hello from package p1")
}

func (p *PP1) HelloFromP2Side() {
pp2 := p2.New()
pp2.HelloFromP2()
}

Package p2:

package p2

import (
"fmt"
"import-cycle-example/p1"
)

type PP2 struct{}

func New() *PP2 {
return &PP2{}
}

func (p *PP2) HelloFromP2() {
fmt.Println("Hello from package p2")
}

func (p *PP2) HelloFromP1Side() {
pp1 := p1.New()
pp1.HelloFromP1()
}

执行go build, 编译器会返回这样的错误:

imports import-cycle-example/p1
imports import-cycle-example/p2
imports import-cycle-example/p1: import cycle not allowed

循环依赖是糟糕的设计

比起代码执行速度,Go语言更关注如何快速编译(甚至愿意牺牲一些运行时性能来换取更快的构建速度)。Go编译器不会花很多时间去生成最高效的机器码,它更关心的是快速编译大量源码。

支持循环依赖功能会大大增加代码的编译时长,因为每当其中一个依赖发生变化时,整个依赖关系就需要重新编译。其还会增加链接(link)时间,并让独立测试、包重用变得更加困难 (由于包之间不能保证隔离性,单元测试会变得更困难)。循环依赖有时还会导致无限递归。

循环依赖还有可能导致内存泄露,因为一个对象会引用另一个对象,它们的引用计数永远不会变成0,因此永远不会成为收集和清理的对象。

Robe Pike 在:Golang是否会支持循环依赖的提案中答复道:这是一个需要前置简化的领域,循环依赖虽然能带来一定便捷,但其成本是灾难性的。应该被继续禁止。

调试循环依赖

比较尴尬的是Go语言并不会告诉你循环依赖导致错误的源文件或者源码信息。因此当你的代码库很大时,定位这个问题就有点困难。你可能会在多个不同的文件或包里徘徊,检查问题出在哪里。为什么Go中不显示导致错误的原因呢?原因是在循环依赖中并不是只有一个源文件。

但Go语言会在报错信息中告诉你导致问题的package名,因此可以通过包名来解决问题。

也可以使用godepgraph工具, 把项目中包之间的依赖关系可视化,可以通过这个指令进行安装:

go get github.com/kisielk/godepgraph

它会以 Graphviz 点格式展示依赖图。如果你安装了graphviz工具(没有的话可以通过这个链接下载),你可以通过管道命令输出dot格式来渲染依赖图。

godepgraph -s import-cycle-example | dot -Tpng -o godepgraph.png

可以在输出的png图中查看到依赖关系:

import cycle golang

除了godepgraph,你还可以使用 go list 命令得到一些启发(运行 go help list 命令来获取额外的信息)。

go list -f '{\{join .DepsErrors "\n"\}}' <import-path>

你可以提供引用路径,也可以对当前目录留空。

解决循环依赖问题

当你遇到循环依赖问题时,先思考项目的组织关系是否合理。处理循环依赖最常见的方法是interface,但有时你可能并不需要它。检查一下产生循环依赖关系的包,如果他们之间强耦合,需要通过互相引用对方来工作,那它们可能需要合并成一个包。在Go中,包是一个编译单元,如果两个包需要一起编译,他们应该处于相同的包下。

用interface解决循环依赖

  • 包p1通过导入p2来使用p2的函数/变量。

  • 包p2不想导入p1包,但是要使用p1包的函数/变量,可以在p2中声明p1的接口,然后通过对象实例来调用接口,这些对象会被视为包p2的对象。

这样包p2不用导入包p1,循环依赖被打破。p2包的代码如下:

package p2

import (
"fmt"
)

type pp1 interface {
HelloFromP1()
}

type PP2 struct {
PP1 pp1
}

func New(pp1 pp1) *PP2 {
return &PP2{
PP1: pp1,
}
}

func (p *PP2) HelloFromP2() {
fmt.Println("Hello from package p2")
}

func (p *PP2) HelloFromP1Side() {
p.PP1.HelloFromP1()
}

p1包的代码如下:

package p1

import (
"fmt"
"import-cycle-example/p2"
)

type PP1 struct{}

func New() *PP1 {
return &PP1{}
}

func (p *PP1) HelloFromP1() {
fmt.Println("Hello from package p1")
}

func (p *PP1) HelloFromP2Side() {
pp2 := p2.New(p)
pp2.HelloFromP2()
}

main包的调用关系如下:

package main

import (
"import-cycle-example/p1"
)

func main() {
pp1 := p1.PP1{}
pp1.HelloFromP2Side() // Prints: "Hello from package p2"
}

你可以在GitHub中找到源文件: jogendra/import-cycle-example-go

另一种使用接口解决循环依赖的方法是将接口代码作为独立桥梁放到独立的第三方包中。但很多时候它增加了代码的重复性,要使用这种方法的话需要牢记你的代码结构(原文没有提供三个包的例子,可以在这个库中查看三个包的例子:https://github.com/yigenshutiao/Go-design-codes/tree/main/cycle-import/how-to-deal-cycle-import)。

"三包"调用链:包p1 -> 包m2 & 包p2 -> 包m1.

丑陋的解决方式

有趣的是,你可以通 go:linkname 注释来避免导入包。 go:linkname 是一个编译器指令(格式://go:linkname localname [importpath.name] ) 。这个特殊指令的作用域不是紧跟的下一行代码,而是在同一个包下生效。 //go:linkname 告诉Go的编译器本本地的变量或方法 localname 链接到指令的变量或方法 importpath.name 上go:linkname定义 。听起来可能有点难以理解,可以参考后面的源码,来试着用它来解决循环引用问题。

Go的很多标准包都依赖 go:linktime 运行时的私有调用。你可以使用它来解决你代码中的循环引用问题,但应该避免使用,因为这是Go官方的黑科技,他们自己也不建议使用。

需要注意的是,Go的标准包使用 go:linkname 不是为了避免循环依赖,而是用它避免导出不应该公开的API。

下面是使用 go:linkname 方案解决循环依赖的源码:jogendra/import-cycle-example-go -> golinkname。

结语

当你的代码库很大时,循环依赖问题肯定非常痛苦。所以需要尝试分层构建应用程序,高层应该导入低层,而低层不应导入高层(会导致循环依赖)。需要记住:强耦合的包可以合并成一个,这样比通过interface解决依赖循环更好,但对于一般情况,一般需要通过interface来解决循环依赖。

原文链接:

https://jogendra.dev/import-cycles-in-golang-and-how-to-deal-with-them