恰逢 Go+ 進入兩週年的時間,七牛雲創始人兼 CEO、Go+ 語言創造者許式偉 (@xushiwei) 釋出了一篇名為《》的文章,具體內容如下:
在開發過程中,可能會因為各種原因導致重構的發生。有的是因為歷史包袱(程式碼經過太多輪次的演進已經不堪重負),有的是因為業務理解(接觸這個領域時間比較短,對需求的理解不深刻),有的是因為技術選型(以前用了 A 方案,現在想改成 B)。但是讓你印象最深,重構次數最多的模組(或功能)是什麼?
今天就 Go+ 開發進入 2 週年的時間(Go+ 史前時代:Go+ 程式碼倉庫是 2015 年 12 月建立,當時它並不是 Go+,而是作為 qlang 這個指令碼語言的倉庫。就像 Go 的程式碼倉庫提交記錄特意追溯到 C 語言的誕生史一樣,Go+ 倉庫以 qlang 作為起點是因為兩者有一些發展上的淵源,但是從語言角度來說,兩者並無任何關係。Go+ 語言誕生日:第一行程式碼是 2020 年 3 月 30 日提交。第一個體驗版:v0.6.01,該版本程式碼最後提交的時間是 5 月 23 日),我想借此機會聊聊我們在這兩年中遇到的坑。
Go+ 第一次大規模重構是發生在 2021 年 6 月 29 日開始第一行重構程式碼,7 月 21 日釋出 v0.9.0 重構預覽版本(github.com/goplus/gop/tree/v0.9.0),9 月 17 日釋出 v1.0 版本,它代表了第一次的大重構基本告一段落(github.com/goplus/gop/tree/v1.0.1)。
這個重構可以說是在預期中的。Go+ v0.6 和 v0.7 這兩個大版本都是以功能預覽為目的,儘可能快讓大家對 Go+ 到底是個什麼模樣有一個認知。但是有以下幾個根本性的問題,導致了它最終不得不走向一次大型的重構。
其一,型別系統的侷限性。Go+ v0.6 延續了 qlang 時期以 reflect.Type 作為 Go+ 的型別表示。這個做法是有好處的,做指令碼比較方便。而 Go+ 從一開始就提倡這個語言是雙引擎的,也就是它同時支援靜態編譯和指令碼解釋執行。從指令碼執行角度,reflect.Type 表示型別是一個非常天然的選擇。但是不幸的是 reflect.Type 並不能完全表達 Go 型別系統的所有能力,它只是代表了 Go 語言的動態特性。
慢慢地,這種機制下表達一些高階的 Go+ 語言特性越來越力不從心,其中以介面(interface)功能最為典型。Go 語言的動態特性本來就建立在介面的基礎上。所以 reflect.Type 可以表達各種型別,但是唯獨沒有介面。另一個例子是結構體(struct)。結構體雖然支援但也有一些侷限性,比如你可以建立小寫字母開頭的欄位(Field),但是正常手段下無法存取它(這是 Go 的安全機制,小寫字母開頭代表私有變數,動態特性不能存取私有變數)。
當然這些最終都有辦法突破。後面我們會有專門的文章來介紹怎麼突破這些限制,以實現指令碼版本的 Go+。
其二,import 包的障礙。如果說介面和結構體實現起來是有困難,那麼 import 包就是大障礙了。從指令碼版的 Go+ 來說,import 包最簡單的做法是對包的所有匯出函數進行一次包裝,然後提前註冊到指令碼解釋引擎中去。這種方式實現的 import 其實是偽 import,功能已經連結進來了,其實不 import 也能用,但是還是得 import 裝裝樣子。
基本上你見到的主流 Go 的解釋引擎都是這麼幹的。但是它的缺陷很明顯。首先是直譯器很臃腫,連結了一堆的包,僅僅只是以備不時之需。其次是擴充套件難,無法真正做到自由 import 任意的包。
難一點的做法是真去將包用 Go+ 直譯器引擎去編譯成位元組碼,然後去 import 位元組碼來執行。這樣通用性夠了,但是對 Go 的語法相容必須非常完備,甚至包括 cgo。另一方面來說,這種做法的效能肯定是遠不如前面那種對所有匯出函數做一次包裝的。
還有一種可能性,是將第一種方法改造一下,把所有的包(嚴謹來說是模組)都編譯成動態庫,然後讓 Go+ 直譯器引擎載入動態庫來實現 import。這種做法消除了前面兩種做法各自的缺陷,理論上來說是最完美的方案,但實現上也最為複雜。
其三,雙引擎帶來的迭代效率。我們越來越發現,讓兩大引擎同時支援所有功能是困難的,這讓 Go+ 的功能迭代變得越來越慢。而 Go+ 在起步階段最需要的並不是雙引擎,而是快速迭代它的使用正規化,讓人看到它的樣子,體驗它,吐槽它,改進它。
所以,無論是出於長治久安的目的(解決 reflect.Type 缺陷),還是出於版本迭代效率,我們最終選擇了第一次大的重構。這次重構主要有以下變化:
其一,以 go/types 作為型別系統的支撐(最英明神武的決策,但並不是唯一的)。這個決策讓 Go+ 的功能迭代一下子飛了起來。Go+ 1.0 釋出的時候,我們跑通了 Go 官方几乎所有語言語法上的測試用例(除了我們暫時不考慮實現的一些功能)。
其二,放棄了雙引擎同步迭代模式,以靜態編譯引擎迭代為優先,動態指令碼引擎獨立迭代發展(也就是 github.com/goplus/gossa 這個庫了)。
其三,以 Go AST 作為標準協定,而並非 Go+ v0.6 引入的 exec.spec 執行器規範。而這個決策其實也是動態指令碼引擎獨立發展的重要基礎,雙方的共同作業邊界非常明確而清晰。
其四,引入了 gox 包(github.com/goplus/gox)作為 Go AST 的生成輔助模組(最英明神武的決策,沒有之一)。這其中最重要的也最根本的變化,是把型別推導能力從 Go+ 編譯器中獨立出來,由 gox 來完成。這一下子大大給 Go+ 編譯器做了減負。編譯器的程式碼可讀性巨幅提升(大家可以回去看 Go+ v0.6 和 v0.7 版本的編譯器即 gop/cl 模組的程式碼),程式碼量大幅減少。
這也是後面 Go+ c2go 模組的 C 編譯器為什麼 10 天就能夠做出來的根本原因。詳細請見《》。
其五,引入 golang.org/x/tools/go/packages 來實現 Go+ import 包的能力(最糟糕的決策,沒有之一)。
總結來說,這次的重構獲得了非常巨大的成功,直接加速了 Go+ v1.0 版本的到來。但,它也留了一個大坑,那就是 golang.org/x/tools/go/packages。
先讓我們看看 golang.org/x/tools/go/packages 有什麼問題:
其一,慢,非常慢。有多慢?載入一個常規工程(不是那麼大型的工程)的所有包,可以到幾十秒之久。為此 gox 專門為包載入過程實現了一個快取模組。從最早的純記憶體快取,到後來也實現了磁碟快取(以實現跨不同的包、跨多次編譯的快取共用)。
其二,介面不合理,不能在多次的 packages.Load 之間共用載入過的包。這除了慢(如果共用了,可以檢測以前載入過就不用再載入,我們的快取模組的原理就是這樣),還帶來一個新問題:同一個包 A 可能被載入過兩次,得到了兩個不同的範例 A1、A2。我們人肉眼可能可以知道 A1 和 A2 是等同的,但是對於 go/types 型別系統來說,A1.Foo 和 A2.Foo 兩個就屬於完全不同的型別了,這樣就會導致型別匹配失敗的誤報。
怎麼辦?
我們在 gox 中實現了一個 dedup(去重)模組,專門用於解決同一個包被載入兩次的問題。假如我們先後載入了 B 和 C 兩個包。B 載入時依賴了 A1,C 載入時依賴了 A2。那麼 dedup 模組做什麼呢,dedup C 的時候,會發現它依賴的 A2 和 之前載入過的 A1 是同一個模組,進而將其修正為 A1。
但是這些都不太完美。快取,會有快取更新的問題。怎麼正確更新快取,需要一點點去改進,把各種需要更新快取的場景加上。dedup(去重),會有型別遍歷成環的問題。怎麼確保所有函數原型、變數、常數、型別等公開的符號被遍歷(這其實是複雜的,型別裡面還可以套型別),要想不會出現死迴圈,就要做標記,哪些已經被存取過了別再存取。
總之,為了一個坑,我們給自己又挖了兩個不小的坑。
於是準備再三,我又開始了一次不小的重構。這次重構從 2021 年 12 月 26 日開始,我建立了 v1.1 分支。這是為了能夠讓大部分的 Go+ 貢獻者仍然基於 v1.0 主版本進行迭代,不受我的重構干擾。到 2022 年 1 月 5 日,我們釋出了 v1.1.0-alpha1,有了第一個可用的版本(github.com/goplus/gop/tree/v1.1.0-alpha1)。
當然這還是因為 import 包實際上是一個非常全域性性的事情,因為它涉及的是模組這樣一個關鍵概念。如果說型別系統是語言設計的核心的話,那麼模組是工程能力的核心,所以它天然跟很多功能交織在一起。這一點大家看看 go 命令列那條命令和模組無關(go build/install/test/...)就知道了,它基本和所有工程能力相關的功能都有關。
如果大家留意過的話,可能也知道,最新一個 Go+ 的釋出是 v1.1.0-beta2,還沒有轉正。所以大家如果要體驗 Go+ 的話,還是鼓勵下載 Go+ v1.0.x 系列,最新是 v1.0.39 版本。
這次重構還並沒有結束,但是我給自己按了暫停鍵。
我們先來說說重構的方案,然後再一起看它有什麼問題。
實際上,在第一次大重構時,我並非不知道 golang.org/x/tools/go/packages 不夠好,它遠非我的首選。在重構前,我對與 Go 語法能力相關的,除了標準庫的 go/... 這些庫之外,我還系統性地學習了 golang.org/x/tools/go/... 這些包。以下是部分學習筆記的截圖。
這是總綱:
這是 go/gcexportdata(支援 .a 檔案讀取的包):
這是 go/ssa(Go 編譯器引入的 ir 文法):
整個學習筆記我在重構前分享給了 Go+ 貢獻者群的小夥伴們。它對整個重構的影響是比較深的,比如為什麼我們有 gossa 這個 Go 直譯器,看了上面的學習筆記大家可能就比較清楚了。
回到正題。剛才我說 golang.org/x/tools/go/packages 並非我的首選,我心目中的首選實際上是 golang.org/x/tools/go/gcexportdata,也就是直接去讀 .a 檔案裡面的內容。但是這裡面有一個問題是我無法克服的:就是這些 .a 檔案在哪裡,不知道。
在 Go 的早期版本里面,這些 .a 在哪裡是明確的,我們只需要知道要 import 的包叫什麼名字,就可以找到對應的 .a 檔案。但是隨著 Go 的版本迭代,除了 Go 標準庫編譯後的 .a 檔案還在老地方外,其他所有的包編譯後的 .a 檔案,被某種未知的 hash 演演算法分散到各處。
找不到了。
不得已我們選擇了 golang.org/x/tools/go/packages,掉入了坑。
那麼我們想怎麼改掉呢?
這次 import 包的區域性重構,我們的起點是想 fork 一個 golang.org/x/tools/go/packages 然後改掉它的毛病。在研究它的實現時,發現它用的是 go list 命令列。於是我開始研究起 go 命令列來。
在一次和七葉溝通中,七葉給我看了 go 目錄的 -x 引數,可以列印 go 的執行細節,這讓我大有啟發。
最終我發現,找不到 .a 檔案的問題,通過 -x 引數就可以解決了,go 命令列會向 stderr 列印 packagefile 語句,用於輸出 package import path 與 .a 檔案的對應關係。
這太好了。
於是,gox 就有了大版本升級,從 v1.8.8 升級到 v1.9.1,這裡面最主要的變化,就是去掉了 golang.org/x/tools/go/packages,而直接改用 gox 自己的 packags 包。它工作的機理也很簡單,通過 go install -x 命令獲得 .a 檔案位置,然後通過 gcexportdata 包去讀取它。
這個方法解決了 golang.org/x/tools/go/packages 的所有缺陷。
其一,它很快,非常快。不需要我們自己做快取,直接複用 go 自身的 「快取」(如果我們把 .a 看做一種快取的話)。
其二,它不會重複載入包,所以也就不需要 dedup 操作。
看起來完美?但是它帶來了一些新問題。
其一,醜陋的使用介面。我們首先需要提前告訴 github.com/goplus/gox/packages 我們要載入哪些包。為了大家有直觀感受,我們列出程式碼大家體感一下:
package main import ( "github.com/goplus/gox" "github.com/goplus/gox/packages" ) func main() { imp, _, _ := packages.NewImporter( nil, "fmt", "strings", "strconv") pkg := gox.NewPackage( "", "main", &gox.Config{Importer: imp}) fmt := pkg.Import("fmt") ... }
這還是是忽略了錯誤處理後的程式碼。
以前我們是怎麼用的?如下:
package main import ( "github.com/goplus/gox" ) func main() { pkg := gox.NewPackage("", "main", nil) fmt := pkg.Import("fmt") ... }
基於 golang.org/x/tools/go/packages 雖然問題多多,但是用起來還是很清爽的。
其二,internal 包的載入問題。github.com/goplus/gox/packages 包背後的機理是通過構造一個臨時的包(比如叫 dummy),把所有要載入的包統統 import 進去。比如我們要載入 A,就在這個臨時包裡面加上 import _ "A" 這樣的語句,然後用 go install -x 顯示出各個包對應的 .a 檔案在哪裡,然後去載入。但是這個做法有一個問題,那就是 internal 包是有載入規則限制的。臨時的 dummy 包放在哪個目錄,決定了它是否有許可權載入某些 internal 包。
這個細節不是不能解決,只是很噁心。簡單說,我們多搞幾個 dummy 包去規避 internal 包的載入限制就好了。
這兩個問題都只是不夠優雅,但他們不是我停下來的原因。
停下來的原因,是我在考慮 Go+ 幾個新功能的時候,發現無論是 golang.org/x/tools/go/packages,還是我們重構後的 github.com/goplus/gox/packages,都不能解決。
卡殼了。
是什麼樣的新功能讓我卡殼了?是 Go 語言進一步的相容。我在考慮如何相容 cgo,以及 Go 的泛型。
在 Go+ 語法的選擇上,我是很篤定的:Go+ 不會支援 cgo(我們選擇了 c2go,詳細參閱《》這篇文章),短期也不考慮完整支援泛型(Go+ 只考慮支援呼叫泛型,但是不允許定義泛型,無論是定義泛型函數還是泛型的類),完整支援等 Go 的泛型迭代個若干大版本後再說。
你可能說:既然都不支援,那有什麼好卡殼的啊?
好吧,我們只是說 Go+ 自身不支援 cgo,暫不支援泛型定義。但是我們選擇了一個很重要的特性:支援 Go 和 Go+ 混合工程。
簡單說,你拿一個手頭現有的 Go 專案,在裡面加幾個 Go+ 原始碼檔案,然後用 Go+ 編譯器編譯,它總能夠正常執行。並且,Go 和 Go+ 程式碼相互可以自由參照對方的程式碼,就如同用一個語言寫的一樣。
我們以此來變相支援 cgo 和泛型定義。
這意味著我們突然打破了一個固有的邊界:我們之前會認為 Go 包是 Go 包,Go+ 包是 Go+ 包,但是現在 Go 程式碼單獨看是不完整的,無法編譯的,Go+ 程式碼單獨看也是不完整的,無法編譯的,他們只有合併起來才能夠編譯。
我們進入了死迴圈。Go+ 編譯需要依賴 Go 程式碼中的類資訊資訊,而 Go 程式碼如果給 go install -x 來處理也會因為程式碼不完整而報錯。
這意味著我們需要新的解決問題的方法。
我找到了。
在上一篇《》中我提到 go/types 的重要性時說到,types.Checker 類也很重要,我們 gox 後面也需要用到它。其實我是為這一篇準備了一個伏筆。
是的,問題的答案是:我們用它來解決。
我們先來看一下相關的函數原型:
func (conf *Config) Check( path string, fset *token.FileSet, files []*ast.File, info *Info) (*Package, error)
這個 Check 函數的意思是,我們輸入 Go 程式碼的抽象語法樹(Go AST),也就是這裡面的 files 引數,就可以得到 types.Package 範例。
還有一個重要的細節:Config 有 IgnoreFuncBodies 成員,指示 Check 函數忽略函數的程式碼,只關注函數原型。
所以對於混合 Go、Go+ 程式碼的工程,我們只需要用 go/parser 處理 Go 程式碼,用 gop/parser 處理 Go+ 程式碼,然後把 Go+ AST 轉成 Go AST(可以忽略函數的程式碼,只需要轉換原型,詳細見 gop/ast/togo 這個包),然後把它們放到一起交給上面的 Check 方法即可。
Go/Go+ 混合編碼的問題這就解決了。
我們還有意外收穫。在研究 types.Checker 類的時候,我留意到 importer 包也有使用它,接下來是讓人大跌眼鏡的一幕。
我們再次做了重構,改寫了 gox 的 packages 包:
去除防禦性的 if 語句,只有一行程式碼!然後我們用這個新的 packages 包去實現 gox 的 import 包能力。
沒有 golang.org/x/tools/go/packages 需要的快取和 dedup,也沒有 github.com/goplus/gox/packages.v1(是的我們把基於 go install -x 版本的留了下來,只是改名為 packages.v1 了)的介面醜陋(最新使用介面見下,和最早的版本一致)和 internal 包限制問題。
package main import ( "github.com/goplus/gox" ) func main() { pkg := gox.NewPackage("", "main", nil) fmt := pkg.Import("fmt") ... }
我們前面兩次重構用盡渾身各種解數,只幹了一行程式碼乾的事情?
總結一下:importer.ForCompiler 將編譯器型別設為 "source" 絕對是隱藏最深的暗門,如果不是研究 types.Checker 絕對發現不了。之前 Go+ 第一次大重構的時候,我就測試過 importer 包各種行為,竟沒發現此等暗門。
藏得真夠深的啊。