[譯]Go 中的循環依賴:如何解決這個問題

作爲一個 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 解決循環依賴

這樣包 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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/e3pIxN1qBbHfsrr_h4L2-g