Go 部落格
實驗、簡化、釋出
引言
這是我在上週的 GopherCon 2019 大會上演講的部落格文章版本。
我們都在通往 Go 2 的道路上,攜手同行,但我們誰也不知道這條路具體通往哪裡,甚至有時連方向都不確定。本文討論了我們如何真正找到並遵循通往 Go 2 的道路。過程如下所示。

我們對現有的 Go 進行實驗,以便更好地理解它,學習哪些地方做得好,哪些地方做得不好。然後,我們對可能的變化進行實驗,以便更好地理解它們,再次學習哪些地方做得好,哪些地方做得不好。根據從這些實驗中學到的東西,我們進行簡化。然後再次實驗。然後再次簡化。如此迴圈,週而復始。
簡化的四個 R
在這個過程中,我們可以透過四種主要方式簡化編寫 Go 程式的整體體驗:重塑 (reshaping)、重新定義 (redefining)、移除 (removing) 和限制 (restricting)。
透過重塑進行簡化
我們簡化的第一種方式是將現有事物重塑成一種新的形式,從而最終達到整體更簡單的效果。
我們編寫的每一個 Go 程式都 serve as 一個測試 Go 本身的實驗。在 Go 的早期,我們很快就發現編寫像這樣的 addToList
函式是很常見的
func addToList(list []int, x int) []int {
n := len(list)
if n+1 > cap(list) {
big := make([]int, n, (n+5)*2)
copy(big, list)
list = big
}
list = list[:n+1]
list[n] = x
return list
}
我們會為位元組切片、字串切片等編寫相同的程式碼。我們的程式太複雜了,因為 Go 太簡單了。
因此,我們將程式中許多像 addToList
這樣的函式重塑為 Go 本身提供的一個函式。新增 append
使 Go 語言本身稍微複雜了一些,但總體而言,它簡化了編寫 Go 程式的整體體驗,即使考慮了學習 append
的成本。
這是另一個例子。對於 Go 1,我們研究了 Go 發行版中眾多的開發工具,並將它們重塑成一個新命令。
5a 8g
5g 8l
5l cgo
6a gobuild
6cov gofix → go
6g goinstall
6l gomake
6nm gopack
8a govet
go
命令現在如此核心,以至於很容易忘記我們曾經很久沒有它,以及這額外增加了多少工作量。
我們向 Go 發行版添加了程式碼和複雜性,但總體而言,我們簡化了編寫 Go 程式的體驗。新結構還為其他有趣的實驗創造了空間,我們稍後會看到。
透過重新定義進行簡化
我們簡化的第二種方式是重新定義現有功能,讓它能夠做更多的事情。與透過重塑進行簡化類似,透過重新定義進行簡化使程式編寫起來更簡單,而且現在無需學習任何新東西。
例如,append
最初定義為只能從切片讀取。當向位元組切片追加時,可以追加另一個位元組切片中的位元組,但不能追加字串中的位元組。我們重新定義了 append,使其允許從字串追加,而無需向語言新增任何新東西。
var b []byte
var more []byte
b = append(b, more...) // ok
var b []byte
var more string
b = append(b, more...) // ok later
透過移除進行簡化
我們簡化的第三種方式是在某些功能被證明不如我們預期那樣有用或重要時將其移除。移除功能意味著少學一樣東西,少修復一個 bug,少被分心或誤用一樣東西。當然,移除也會迫使使用者更新現有程式,可能使其變得更復雜,以彌補移除帶來的影響。但總體結果仍然可以是編寫 Go 程式的過程變得更簡單。
一個例子是我們從語言中移除了非阻塞通道操作的布林形式
ok := c <- x // before Go 1, was non-blocking send
x, ok := <-c // before Go 1, was non-blocking receive
這些操作也可以使用 select
來完成,這使得需要決定使用哪種形式變得令人困惑。移除它們簡化了語言,同時沒有削弱其能力。
透過限制進行簡化
我們還可以透過限制允許的內容進行簡化。從第一天起,Go 就限制了 Go 原始檔的編碼:它們必須是 UTF-8。這一限制使得任何試圖讀取 Go 原始檔的程式都變得更簡單。這些程式無需擔心 Go 原始檔使用 Latin-1、UTF-16、UTF-7 或其他任何編碼。
另一個重要的限制是用於程式格式化的 gofmt
。沒有什麼會拒絕不使用 gofmt
格式化的 Go 程式碼,但我們建立了一個約定:重寫 Go 程式的工具會將它們保留在 gofmt
格式。如果您也保持程式使用 gofmt
格式,那麼這些重寫工具就不會進行任何格式化更改。當您比較前後版本時,唯一看到的差異是實際的改動。這一限制簡化了程式重寫工具,並帶來了像 goimports
、gorename
等許多成功的實驗。
Go 開發過程
這個實驗和簡化的迴圈是我們過去十年一直在做的事情的良好模型。但它有一個問題:它太簡單了。我們不能只進行實驗和簡化。
我們必須釋出結果。我們必須使其可用。當然,使用它會帶來更多實驗,並可能帶來更多簡化,這個過程就這樣迴圈往復。

我們於 2009 年 11 月 10 日首次向大家釋出了 Go。然後,在你們的幫助下,我們於 2012 年 3 月共同釋出了 Go 1。從那以後,我們又釋出了十二個 Go 版本。所有這些都是重要的里程碑,它們使得更多的實驗成為可能,幫助我們更多地瞭解 Go,當然也使得 Go 可用於生產環境。
當我們釋出 Go 1 時,我們明確地將重點轉移到使用 Go 上,以便在嘗試任何涉及語言變化的進一步簡化之前,更好地理解這個版本的語言。我們需要花時間進行實驗,真正理解哪些地方有效,哪些地方無效。
當然,自從 Go 1 以來,我們已經發布了十二個版本,所以我們仍然一直在實驗、簡化和釋出。但我們專注於在不進行重大語言更改且不破壞現有 Go 程式的情況下簡化 Go 開發的方法。例如,Go 1.5 釋出了第一個併發垃圾回收器,隨後的版本又對其進行了改進,透過消除暫停時間這一持續關注點來簡化 Go 開發。
在 2017 年的 Gophercon 大會上,我們宣佈經過五年的實驗,現在是時候再次考慮能夠簡化 Go 開發的重大變化了。我們通往 Go 2 的道路實際上與通往 Go 1 的道路相同:實驗、簡化和釋出,朝著簡化 Go 開發的總體目標前進。
對於 Go 2,我們認為最需要解決的具體主題是錯誤處理、泛型和依賴項。從那時起,我們意識到另一個重要主題是開發者工具。
本文的其餘部分將討論我們在這些領域的工作如何遵循這條道路。在此過程中,我們將進行一次繞道,停下來仔細研究 Go 1.13 中即將釋出的錯誤處理的技術細節。
錯誤
當所有輸入都有效且正確,並且程式依賴的一切都沒有問題時,編寫一個在所有情況下都能正常工作的程式已經夠難了。當你將錯誤也考慮進去時,無論出現什麼問題都能正常工作的程式就更難編寫了。
作為考慮 Go 2 的一部分,我們想更好地瞭解 Go 是否能幫助簡化這項工作。
有兩個不同的方面可能可以簡化:錯誤值和錯誤語法。我們將依次檢視每個方面,其中我承諾的技術繞道將重點介紹 Go 1.13 的錯誤值變化。
錯誤值
錯誤值總得有個開端。這是 os
包的第一個版本中的 Read
函式
export func Read(fd int64, b *[]byte) (ret int64, errno int64) {
r, e := syscall.read(fd, &b[0], int64(len(b)));
return r, e
}
當時還沒有 File
型別,也沒有錯誤型別。Read
和包中的其他函式直接返回底層 Unix 系統呼叫返回的 errno int64
。
這段程式碼於 2008 年 9 月 10 日下午 12:14 提交。就像當時的一切一樣,這是一個實驗,程式碼變化很快。兩小時五分鐘後,API 發生了變化
export type Error struct { s string }
func (e *Error) Print() { … } // to standard error!
func (e *Error) String() string { … }
export func Read(fd int64, b *[]byte) (ret int64, err *Error) {
r, e := syscall.read(fd, &b[0], int64(len(b)));
return r, ErrnoToError(e)
}
這個新的 API 引入了第一個 Error
型別。一個錯誤包含一個字串,可以返回該字串,也可以將其列印到標準錯誤輸出。
這裡的目的是要超越整數程式碼進行泛化。我們從過去的經驗中知道,作業系統錯誤號的表示能力太有限了,將所有錯誤細節塞進 64 位會使程式變得複雜。使用錯誤字串在我們過去做得相當好,所以我們在這裡也做了同樣的事情。這個新的 API 持續了七個月。
來年四月,在使用介面方面有了更多經驗後,我們決定進一步泛化,並允許使用者自定義錯誤實現,方法是將 os.Error
型別本身變成一個介面。我們透過移除 Print
方法進行了簡化。
兩年後釋出的 Go 1 中,根據 Roger Peppe 的建議,os.Error
變成了內建的 error
型別,並且 String
方法重新命名為 Error
。從那時起,沒有任何變化。但我們編寫了許多 Go 程式,因此在如何最好地實現和使用錯誤方面進行了大量實驗。
錯誤是值
將 error
設定為一個簡單的介面並允許許多不同的實現,意味著我們擁有整個 Go 語言來定義和檢查錯誤。我們喜歡說錯誤是值,就像其他任何 Go 值一樣。
舉個例子。在 Unix 上,嘗試撥號建立網路連線最終會使用 connect
系統呼叫。該系統呼叫返回一個 syscall.Errno
,這是一個表示系統呼叫錯誤號並實現了 error
介面的命名整數型別
package syscall
type Errno int64
func (e Errno) Error() string { ... }
const ECONNREFUSED = Errno(61)
... err == ECONNREFUSED ...
syscall
包還為宿主作業系統定義的錯誤號定義了命名常量。在這種情況下,在這個系統上,ECONNREFUSED
是數字 61。從函式獲取錯誤的程式可以使用普通的值相等性來測試錯誤是否是 ECONNREFUSED
。
再往上一層,在 os
包中,任何系統呼叫失敗都會使用一個更大的錯誤結構來報告,該結構除了錯誤本身外,還記錄了嘗試進行的操作。有少數幾個這樣的結構。這個結構 SyscallError
描述了呼叫特定系統時發生的錯誤,但沒有記錄其他額外資訊
package os
type SyscallError struct {
Syscall string
Err error
}
func (e *SyscallError) Error() string {
return e.Syscall + ": " + e.Err.Error()
}
再往上一層,在 net
包中,任何網路失敗都會使用一個更大的錯誤結構來報告,該結構記錄了周圍網路操作的細節,例如撥號或監聽,以及涉及的網路和地址
package net
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}
func (e *OpError) Error() string { ... }
將這些放在一起,像 net.Dial
這樣的操作返回的錯誤可以格式化為字串,但它們也是結構化的 Go 資料值。在這種情況下,錯誤是 net.OpError
,它為 os.SyscallError
添加了上下文,而 os.SyscallError
又為 syscall.Errno
添加了上下文
c, err := net.Dial("tcp", "localhost:50001")
// "dial tcp [::1]:50001: connect: connection refused"
err is &net.OpError{
Op: "dial",
Net: "tcp",
Addr: &net.TCPAddr{IP: ParseIP("::1"), Port: 50001},
Err: &os.SyscallError{
Syscall: "connect",
Err: syscall.Errno(61), // == ECONNREFUSED
},
}
當我們說錯誤是值時,我們的意思是整個 Go 語言都可以用來定義它們,也可以用來檢查它們。
這裡有一個來自 net 包的例子。事實證明,當你嘗試進行套接字連線時,大多數時候你會連線成功或連線被拒絕,但有時你可能會莫名其妙地收到一個假的 EADDRNOTAVAIL
錯誤。Go 透過重試來保護使用者程式免受這種失敗模式的影響。要做到這一點,它必須檢查錯誤結構,以找出深層的 syscall.Errno
是否是 EADDRNOTAVAIL
。
程式碼如下
func spuriousENOTAVAIL(err error) bool {
if op, ok := err.(*OpError); ok {
err = op.Err
}
if sys, ok := err.(*os.SyscallError); ok {
err = sys.Err
}
return err == syscall.EADDRNOTAVAIL
}
一個型別斷言剝離了任何 net.OpError
包裝。然後第二個型別斷言剝離了任何 os.SyscallError
包裝。然後該函式檢查未包裝的錯誤是否與 EADDRNOTAVAIL
相等。
我們從多年的經驗,從對 Go 錯誤的實驗中瞭解到,能夠定義 error
介面的任意實現,擁有整個 Go 語言來構建和解構錯誤,並且不要求使用任何單一實現,這是非常強大的。
這些屬性——錯誤是值,並且沒有一個強制要求的錯誤實現——是需要保留的重要特性。
不強制要求使用一種錯誤實現,使得每個人都可以實驗錯誤可能提供的額外功能,從而產生了許多包,例如 github.com/pkg/errors、gopkg.in/errgo.v2、github.com/hashicorp/errwrap、upspin.io/errors、github.com/spacemonkeygo/errors 等等。
然而,不受限制的實驗有一個問題,那就是作為客戶端,您必須針對可能遇到的所有可能實現的聯合進行程式設計。對於 Go 2 而言,一個值得探索的簡化是定義常用附加功能的標準版本,以商定的可選介面形式,這樣不同的實現就可以互操作。
解包 (Unwrap)
這些包中最常用的附加功能是某種方法,可以呼叫該方法來從錯誤中移除上下文,返回內部的錯誤。不同的包對這個操作使用不同的名稱和含義,有時它移除一層上下文,而有時它會移除儘可能多的層次。
對於 Go 1.13,我們引入了一個約定,即為內部錯誤新增可移除上下文的錯誤實現應該實現一個 Unwrap
方法,該方法返回內部錯誤,從而解包上下文。如果沒有適合暴露給呼叫者的內部錯誤,那麼該錯誤就不應該有 Unwrap
方法,或者 Unwrap
方法應該返回 nil。
// Go 1.13 optional method for error implementations.
interface {
// Unwrap removes one layer of context,
// returning the inner error if any, or else nil.
Unwrap() error
}
呼叫這個可選方法的方式是呼叫輔助函式 errors.Unwrap
,它可以處理諸如錯誤本身為 nil 或根本沒有 Unwrap
方法的情況。
package errors
// Unwrap returns the result of calling
// the Unwrap method on err,
// if err’s type defines an Unwrap method.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error
我們可以使用 Unwrap
方法編寫一個更簡單、更通用的 spuriousENOTAVAIL
版本。通用版本不再尋找像 net.OpError
或 os.SyscallError
這樣特定的錯誤包裝實現,而是可以迴圈呼叫 Unwrap
來移除上下文,直到達到 EADDRNOTAVAIL
或者沒有錯誤剩餘為止
func spuriousENOTAVAIL(err error) bool {
for err != nil {
if err == syscall.EADDRNOTAVAIL {
return true
}
err = errors.Unwrap(err)
}
return false
}
然而,這種迴圈非常常見,因此 Go 1.13 定義了第二個函式 errors.Is
,它會重複解包錯誤以尋找特定目標。所以我們可以用一次對 errors.Is
的呼叫來替換整個迴圈
func spuriousENOTAVAIL(err error) bool {
return errors.Is(err, syscall.EADDRNOTAVAIL)
}
到這個階段,我們甚至可能不會定義這個函式;在呼叫處直接呼叫 errors.Is
將同樣清晰,而且更簡單。
Go 1.13 還引入了一個函式 errors.As
,它會解包錯誤直到找到特定的實現型別。
如果您想編寫能處理任意包裝錯誤的程式,errors.Is
是錯誤相等性檢查的包裝感知版本
err == target
→
errors.Is(err, target)
而 errors.As
是錯誤型別斷言的包裝感知版本
target, ok := err.(*Type)
if ok {
...
}
→
var target *Type
if errors.As(err, &target) {
...
}
解包還是不解包?
是否允許解包錯誤是一個 API 決策,就像是否匯出結構體欄位是一個 API 決策一樣。有時適合將該細節暴露給呼叫程式碼,有時則不適合。如果適合,就實現 Unwrap。如果不適合,就不實現 Unwrap。
到目前為止,使用 %v
格式化的 fmt.Errorf
返回的結果,其底層錯誤並未暴露給呼叫者檢查。也就是說,fmt.Errorf
的結果一直無法解包。考慮這個例子
// errors.Unwrap(err2) == nil
// err1 is not available (same as earlier Go versions)
err2 := fmt.Errorf("connect: %v", err1)
如果將 err2
返回給呼叫者,該呼叫者之前沒有任何方法來開啟 err2
並訪問 err1
。我們在 Go 1.13 中保留了這一特性。
對於您確實希望允許解包 fmt.Errorf
結果的情況,我們還添加了一個新的列印動詞 %w
,它像 %v
一樣進行格式化,需要一個錯誤值引數,並使生成的錯誤的 Unwrap
方法返回該引數。在我們的例子中,假設我們將 %v
替換為 %w
// errors.Unwrap(err4) == err3
// (%w is new in Go 1.13)
err4 := fmt.Errorf("connect: %w", err3)
現在,如果將 err4
返回給呼叫者,呼叫者可以使用 Unwrap
來檢索 err3
。
重要的是要注意,像“總是使用 %v
(或從不實現 Unwrap
)”或“總是使用 %w
(或總是實現 Unwrap
)”這樣的絕對規則,就像“從不匯出結構體欄位”或“總是匯出結構體欄位”這樣的絕對規則一樣是錯誤的。相反,正確的決定取決於呼叫者是否應該能夠檢查並依賴使用 %w
或實現 Unwrap
暴露的額外資訊。
為了說明這一點,標準庫中所有已經有匯出 Err
欄位的錯誤包裝型別現在也都有一個返回該欄位的 Unwrap
方法,但帶有未匯出錯誤欄位的實現則沒有,並且現有使用 %v
的 fmt.Errorf
仍然使用 %v
,而不是 %w
。
錯誤值列印(已放棄)
除了 Unwrap 的設計草案,我們還發布了一份關於更豐富錯誤列印可選方法的設計草案,包括堆疊幀資訊以及對本地化、翻譯錯誤的支援。
// Optional method for error implementations
type Formatter interface {
Format(p Printer) (next error)
}
// Interface passed to Format
type Printer interface {
Print(args ...interface{})
Printf(format string, args ...interface{})
Detail() bool
}
這個設計不如 Unwrap
簡單,我在這裡不會深入細節。在冬季與 Go 社群討論設計時,我們瞭解到這個設計不夠簡單。對於單個錯誤型別來說,實現它太難了,而且它對現有程式的幫助不夠。總的來說,它並沒有簡化 Go 開發。
由於這次社群討論,我們放棄了這個列印設計。
錯誤語法
上面是錯誤值。讓我們簡要看看錯誤語法,這是另一個被放棄的實驗。
這裡是標準庫中compress/lzw/writer.go
中的一些程式碼
// Write the savedCode if valid.
if e.savedCode != invalidCode {
if err := e.write(e, e.savedCode); err != nil {
return err
}
if err := e.incHi(); err != nil && err != errOutOfCodes {
return err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
if err := e.write(e, eof); err != nil {
return err
}
乍一看,這段程式碼大約有一半是錯誤檢查。我讀它的時候眼睛都花了。我們知道,寫起來枯燥、讀起來乏味的程式碼很容易被誤讀,這為難以發現的 bug 提供了溫床。例如,這三個錯誤檢查中有一個與眾不同,快速瀏覽時很容易錯過這個事實。如果你正在除錯這段程式碼,需要多久才能注意到這一點?
去年的 Gophercon 大會上,我們提出了一個設計草案,用於一種由關鍵字 check
標記的新控制流結構。Check
消費函式呼叫或表示式返回的錯誤結果。如果錯誤不是 nil,check
就返回該錯誤。否則,check
評估為呼叫的其他結果。我們可以使用 check
來簡化 lzw 程式碼
// Write the savedCode if valid.
if e.savedCode != invalidCode {
check e.write(e, e.savedCode)
if err := e.incHi(); err != errOutOfCodes {
check err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)
這個版本的相同程式碼使用了 check
,這減少了四行程式碼,更重要的是突出了對 e.incHi
的呼叫被允許返回 errOutOfCodes
。
也許最重要的是,該設計還允許定義錯誤處理塊,以便在後續檢查失敗時執行。這將允許您只編寫一次共享的上下文新增程式碼,就像這個片段中一樣
handle err {
err = fmt.Errorf("closing writer: %w", err)
}
// Write the savedCode if valid.
if e.savedCode != invalidCode {
check e.write(e, e.savedCode)
if err := e.incHi(); err != errOutOfCodes {
check err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)
本質上,check
是編寫 if
語句的一種簡寫方式,而 handle
則類似於defer
,但僅用於錯誤返回路徑。與其它語言的異常不同,這個設計保留了 Go 的重要特性,即每一個潛在的失敗呼叫都在程式碼中明確標記,現在使用 check
關鍵字而不是 if err != nil
。
這個設計的最大問題在於 handle
與 defer
的重疊太多,並且是以令人困惑的方式重疊。
五月份,我們釋出了一個包含三項簡化的新設計:為了避免與 defer
混淆,該設計放棄了 handle
,轉而只使用 defer
;為了與 Rust 和 Swift 中類似的想法保持一致,該設計將 check
重新命名為 try
;為了以現有解析器(如 gofmt
)能夠識別的方式進行實驗,它將 check
(現在是 try
)從關鍵字改為了內建函式。
現在相同的程式碼會看起來像這樣
defer errd.Wrapf(&err, "closing writer")
// Write the savedCode if valid.
if e.savedCode != invalidCode {
try(e.write(e, e.savedCode))
if err := e.incHi(); err != errOutOfCodes {
try(err)
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
try(e.write(e, eof))
我們大部分六月的時間都在 GitHub 上公開討論這個提案。
check
或 try
的基本思想是縮短每個錯誤檢查處重複的語法量,特別是將 return
語句從視線中移除,同時保持錯誤檢查的明確性,並更好地突出有趣的變體。然而,在公眾反饋討論中提出的一個有趣的觀點是,如果沒有明確的 if
語句和 return
,就無法放置除錯列印語句,無法設定斷點,程式碼覆蓋率結果中也沒有程式碼可以顯示為未執行。我們追求的益處是以使這些情況更復雜為代價的。綜合考慮這一點以及其他因素,總體結果是否能簡化 Go 開發根本不清楚,因此我們放棄了這個實驗。
這就是關於錯誤處理的所有內容,這是今年主要的關注點之一。
泛型
現在談談一個爭議小一點的話題:泛型。
我們為 Go 2 確定的第二個重要議題是某種使用型別引數編寫程式碼的方式。這將使得編寫泛型資料結構以及編寫可處理任何型別的切片、任何型別的通道或任何型別的 map 的泛型函式成為可能。例如,這是一個泛型通道過濾器
// Filter copies values from c to the returned channel,
// passing along only those values satisfying f.
func Filter(type value)(f func(value) bool, c <-chan value) <-chan value {
out := make(chan value)
go func() {
for v := range c {
if f(v) {
out <- v
}
}
close(out)
}()
return out
}
自從 Go 的工作開始以來,我們一直在思考泛型,並在 2010 年編寫並否定了我們的第一個具體設計。到 2013 年底,我們又編寫並否定了三個設計。四次被放棄的實驗,但並非失敗的實驗。我們從中學習到了,就像我們從 check
和 try
中學習到的一樣。每一次,我們都學到了通往 Go 2 的道路並非確切是那個方向,並且我們注意到了其他可能值得探索的方向。但到了 2013 年,我們決定需要專注於其他問題,所以我們將整個話題擱置了幾年。
去年我們再次開始探索和實驗,並在去年夏天的 GopherCon 大會上,基於契約(contract)的概念,提出了一個新設計。我們一直在繼續實驗和簡化,並且與程式語言理論專家合作,以便更好地理解這個設計。
總的來說,我希望我們正朝著一個好的方向前進,朝向一個能夠簡化 Go 開發的設計。即便如此,我們可能發現這個設計也行不通。我們可能不得不放棄這個實驗,並根據我們學到的東西調整我們的道路。我們拭目以待。
在 Gophercon 2019 大會上,Ian Lance Taylor 談到了我們為何可能希望向 Go 中新增泛型,並簡要預覽了最新的設計草案。詳情請參閱他的部落格文章“為何需要泛型?”
依賴項
我們為 Go 2 確定的第三個重要議題是依賴管理。
2010 年,我們釋出了一個名為 goinstall
的工具,我們稱之為“包安裝實驗”。它下載依賴項並將其儲存在您的 Go 發行版目錄樹中,即 GOROOT。
在對 goinstall
進行實驗時,我們瞭解到 Go 發行版和安裝的包應該分開存放,這樣在更換新的 Go 發行版時就不會丟失所有 Go 包。因此,在 2011 年,我們引入了 GOPATH
,這是一個環境變數,用於指定在主 Go 發行版中找不到的包的查詢位置。
新增 GOPATH 為 Go 包建立了更多位置,但透過將您的 Go 發行版與您的 Go 庫分開,總體上簡化了 Go 開發。
相容性
goinstall
實驗故意省略了包版本控制的明確概念。相反,goinstall
總是下載最新的副本。我們這樣做是為了能夠專注於包安裝的其他設計問題。
Goinstall
在 Go 1 中成為了 go get
。當人們詢問版本時,我們鼓勵他們透過建立額外的工具進行實驗,他們也確實這樣做了。我們還鼓勵包作者為其使用者提供與我們為 Go 1 庫提供的相同的向後相容性。引用Go 常見問題解答 (FAQ) 的一段話
“用於公共用途的包在演進時應努力保持向後相容性。
如果需要不同的功能,請新增新名稱,而不是更改舊名稱。
如果需要徹底改變,請建立一個具有新匯入路徑的新包。”
這個約定透過限制作者可以做的事情來簡化使用包的整體體驗:避免對 API 進行破壞性更改;給新功能一個新名稱;給全新的包設計一個新匯入路徑。
當然,人們一直在實驗。其中一個最有趣的實驗是由 Gustavo Niemeyer 發起的。他建立了一個名為 gopkg.in
的 Git 重定向服務,它為不同的 API 版本提供了不同的匯入路徑,以幫助包作者遵循為新包設計提供新匯入路徑的約定。
例如,GitHub 倉庫 go-yaml/yaml 中的 Go 原始碼在 v1 和 v2 語義版本標籤中提供了不同的 API。gopkg.in
伺服器使用不同的匯入路徑 gopkg.in/yaml.v1 和 gopkg.in/yaml.v2 來提供這些版本。
保持向後相容性的約定,即可以使用新版本的包來替代舊版本,這使得 go get
非常簡單的規則——“始終下載最新副本”——即使在今天也能很好地工作。
版本控制與供應商機制(Vendoring)
但在生產環境中,您需要更精確地指定依賴版本,以使構建可重現。
許多人嘗試了這應該是什麼樣子,構建了滿足他們需求的工具,包括 Keith Rarick 的 goven
(2012) 和 godep
(2013),Matt Butcher 的 glide
(2014),以及 Dave Cheney 的 gb
(2015)。所有這些工具都採用將依賴包複製到您自己的原始碼控制倉庫中的模型。用於使這些包可匯入的精確機制各不相同,但都比看起來應有的更加複雜。
經過社群範圍的討論,我們採納了 Keith Rarick 的一項提案,以增加對引用複製的依賴項的顯式支援,而無需使用 GOPATH 技巧。這是透過重塑來簡化的:就像 addToList
和 append
一樣,這些工具已經實現了這個概念,但比原本需要的更笨拙。新增對供應商目錄的顯式支援使這些用法整體上更加簡單。
在 go
命令中支援供應商目錄導致了更多關於供應商機制本身的實驗,我們意識到我們引入了一些問題。最嚴重的問題是我們失去了包唯一性(package uniqueness)。以前,在任何給定的構建期間,一個匯入路徑可能出現在許多不同的包中,並且所有的匯入都指向同一個目標。現在有了供應商機制,不同包中的同一個匯入路徑可能指向包的不同供應商副本,所有這些都會出現在最終生成的二進位制檔案中。
當時,我們沒有給這個屬性命名:包唯一性。它只是 GOPATH 模型的工作方式。直到它消失,我們才完全理解它的價值。
這與 check
和 try
錯誤語法提案有相似之處。在那種情況下,我們依賴於可見的 return
語句的工作方式,直到我們考慮移除它時才理解其價值。
當我們新增供應商目錄支援時,有許多不同的依賴管理工具。我們認為,如果對供應商目錄的格式和供應商元資料達成明確協議,將使各種工具能夠互操作,就像對 Go 程式如何儲存在文字檔案中的協議使得 Go 編譯器、文字編輯器以及 goimports
和 gorename
等工具能夠互操作一樣。
事實證明,這過於天真樂觀了。這些供應商工具在細微的語義方面都有所不同。互操作需要改變所有工具以在語義上達成一致,這很可能會破壞它們各自的使用者。趨同並未發生。
Dep
在 2016 年的 Gophercon 大會上,我們開始努力定義一個單一的工具來管理依賴項。作為這項工作的一部分,我們對許多不同型別的使用者進行了調查,以瞭解他們在依賴管理方面的需求,並且一個團隊開始著手開發一個新工具,最終成為了 dep
。
Dep
的目標是能夠取代所有現有的依賴管理工具。目標是透過將現有不同的工具重塑為一個單一工具來實現簡化。它部分地實現了這一點。Dep
還透過在專案樹頂部僅設一個供應商目錄,為其使用者恢復了包唯一性。
但 dep
也引入了一個嚴重的問題,我們花了一段時間才完全理解。問題在於 dep
採納了 glide
的一個設計選擇,即支援和鼓勵對給定包進行不相容的更改而無需更改匯入路徑。
這是一個例子。假設您正在構建自己的程式,需要一個配置檔案,因此您使用了流行的 Go YAML 包的第 2 版。

現在假設您的程式匯入了 Kubernetes 客戶端。結果發現 Kubernetes 廣泛使用了 YAML,並且它使用了同一個流行包的第 1 版。

第 1 版和第 2 版的 API 是不相容的,但它們的匯入路徑也不同,因此對於給定匯入究竟指代哪個版本沒有歧義。Kubernetes 使用第 1 版,您的配置解析器使用第 2 版,一切正常工作。
Dep
放棄了這個模型。yaml 包的第 1 版和第 2 版現在將擁有相同的匯入路徑,從而產生衝突。對兩個不相容的版本使用相同的匯入路徑,再加上包唯一性,使得無法構建之前可以構建的這個程式。

我們花了一段時間才理解這個問題,因為我們應用“新 API 意味著新匯入路徑”的約定已經太久了,以至於我們將其視為理所當然。dep
的實驗幫助我們更好地理解了這個約定,並給它起了個名字:匯入相容性規則(import compatibility rule)。
“如果舊包和新包具有相同的匯入路徑,則新包必須向後相容舊包。”
Go Modules
我們吸取了 dep
實驗中執行良好的部分以及我們瞭解到的不足之處,並試驗了一個新的設計,稱為 vgo
。在 vgo
中,包遵循匯入相容性規則,這樣我們就可以提供包唯一性,同時仍然不會像我們剛才看到的例子那樣破壞構建。這也使我們能夠簡化設計的其他部分。
除了恢復匯入相容性規則之外,vgo
設計的另一個重要部分是給一組包的概念一個名稱,並允許將這個分組與原始碼倉庫邊界分開。一組 Go 包的名稱稱為模組(module),因此我們現在將該系統稱為 Go modules。
Go modules 現已整合到 go
命令中,這樣就完全避免了需要複製供應商目錄。
取代 GOPATH
隨著 Go modules 的到來,GOPATH 作為全域性名稱空間的作用即將終結。將現有 Go 用法和工具轉換為 modules 的幾乎所有艱苦工作都是由這一變化引起的,即擺脫 GOPATH。
GOPATH 的基本思想是,GOPATH 目錄樹是正在使用的版本的全域性真實來源,並且無論您在目錄之間如何移動,正在使用的版本都不會改變。但是全域性 GOPATH 模式與生產環境中每專案可重現構建的需求直接衝突,而可重現構建本身在許多重要方面簡化了 Go 的開發和部署體驗。
每專案可重現構建意味著當您在專案 A 的檢出副本中工作時,您會獲得與專案 A 的其他開發人員在那個提交(commit)上相同的依賴版本集合,這由 go.mod
檔案定義。當您切換到專案 B 的檢出副本中工作時,您將獲得該專案所選的依賴版本,與專案 B 的其他開發人員獲得的相同。但這些版本很可能與專案 A 的不同。當您從專案 A 切換到專案 B 時,依賴版本集合發生變化對於使您的開發與 A 和 B 專案的其他開發人員保持同步是必要的。不再可能存在一個單一的全域性 GOPATH。
採用 modules 的大部分複雜性直接源於失去一個全域性 GOPATH。包的原始碼在哪裡?以前,答案僅取決於您的 GOPATH 環境變數,大多數人很少改變它。現在,答案取決於您正在處理哪個專案,這可能會經常改變。為了適應這個新約定,一切都需要更新。
大多數開發工具使用 go/build
包來查詢和載入 Go 原始碼。我們已使該包繼續工作,但其 API 未預期到 modules,並且我們為避免 API 更改而新增的權宜之計比我們期望的要慢。我們釋出了一個替代品:golang.org/x/tools/go/packages
。開發工具現在應該使用它來代替。它同時支援 GOPATH 和 Go modules,並且更快、更容易使用。在一兩個版本之後,我們可能會將其移入標準庫,但目前 golang.org/x/tools/go/packages
是穩定的,可以立即使用。
Go Module Proxy
Modules 簡化 Go 開發的一種方式是,它將一組包的概念與儲存它們的底層原始碼控制倉庫分開。
當我們與 Go 使用者討論依賴項時,幾乎所有在其公司使用 Go 的人都會問如何透過自己的伺服器路由 go get
包獲取請求,以便更好地控制可以使用哪些程式碼。即使是開源開發者也擔心依賴項意外消失或更改,從而破壞他們的構建。在 modules 之前,使用者嘗試了複雜的解決方案來解決這些問題,包括攔截 go
命令執行的版本控制命令。
Go modules 的設計使得引入一個可以查詢特定 module 版本的 module proxy 變得容易。
公司現在可以輕鬆執行自己的 module proxy,並設定關於允許哪些內容以及快取副本儲存位置的自定義規則。開源的 Athens 專案 就構建了這樣一個 proxy,Aaron Schlesinger 在 2019 年的 Gophercon 大會上就此發表了演講。(影片可用時,我們將在此新增連結。)
對於個人開發者和開源團隊,Google 的 Go 團隊啟動了一個 proxy,作為所有開源 Go 包的公共映象,並且 Go 1.13 在 module 模式下將預設使用該 proxy。Katie Hockman 在 2019 年的 Gophercon 大會上就這個系統發表了演講。
Go Modules 狀態
Go 1.11 引入了 modules 作為實驗性的、可選預覽。我們不斷實驗和簡化。Go 1.12 提供了改進,Go 1.13 將帶來更多改進。
Modules 現在已經達到了我們相信它將服務於大多數使用者的程度,但我們還沒有準備好立即關閉 GOPATH。我們將繼續實驗、簡化和修訂。
我們充分認識到,Go 使用者社群圍繞 GOPATH 積累了近十年的經驗、工具和工作流程,將所有這些轉換到 Go modules 需要一段時間。
但是再次強調,我們認為 modules 現在對大多數使用者來說將非常有用,我鼓勵您在 Go 1.13 釋出時試一試。
作為一個數據點,Kubernetes 專案有很多依賴項,他們已經遷移到使用 Go modules 來管理它們。您很可能也可以。如果您不能,請透過提交 bug 報告告訴我們哪些地方不起作用或哪些地方過於複雜,我們將進行實驗和簡化。
工具
錯誤處理、泛型和依賴管理至少還需要幾年時間,我們目前將專注於它們。錯誤處理已接近完成,接下來是 modules,之後可能是泛型。
但是假設我們放眼幾年後,當我們完成實驗和簡化,併發布了錯誤處理、modules 和泛型。那時呢?預測未來非常困難,但我認為一旦這三者都發布,可能標誌著主要變化的“平靜期”的開始。那時我們的重點可能會轉向透過改進工具來簡化 Go 開發。
一些工具方面的工作已經在進行中,所以本文最後將展望這部分。
在我們幫助更新所有 Go 社群的現有工具以理解 Go modules 時,我們注意到擁有一堆各自只做一小部分工作的開發輔助工具並不能很好地服務使用者。這些工具太難組合,呼叫太慢,使用起來也太不一致。
我們開始努力將最常用的開發輔助工具統一到一個單一工具中,現在稱為 gopls
(發音為“go, please”)。Gopls
遵循 語言伺服器協議(Language Server Protocol, LSP),並與任何支援 LSP 的整合開發環境或文字編輯器配合使用,基本上現在所有主流的編輯器都支援 LSP。
Gopls
標誌著 Go 專案焦點的擴充套件,從提供像 go vet 或 gorename 這樣的獨立編譯器式命令列工具,擴充套件到提供一個完整的 IDE 服務。Rebecca Stambler 在 2019 年的 Gophercon 大會上就 gopls
和 IDEs 發表了一次演講,提供了更多細節。(影片可用時,我們將在此新增連結。)
在 gopls
之後,我們還有關於以可擴充套件方式重振 go fix
和使 go vet
更加有用的想法。
尾聲

這就是通往 Go 2 的道路。我們將實驗並簡化。然後實驗並簡化。然後釋出。然後實驗並簡化。如此迴圈往復。這條路可能看起來甚至感覺像是在繞圈子。但每一次實驗和簡化,我們都會對 Go 2 應該是什麼樣子學到更多,並向它邁進一步。即使是像 try
或我們最初的四個泛型設計或 dep
這樣被放棄的實驗,時間也沒有浪費。它們幫助我們瞭解在釋出之前需要簡化什麼,在某些情況下,它們幫助我們更好地理解我們曾視為理所當然的事物。
在某個時刻,我們將意識到我們已經進行了足夠的實驗,足夠的簡化,併發布了足夠多的東西,然後我們就將擁有 Go 2。
感謝 Go 社群中的各位幫助我們實驗、簡化、釋出,並在這條道路上找到方向。
下一篇文章:2019 貢獻者峰會
上一篇文章:為什麼需要泛型?
部落格索引