作者 | 楊成立(忘籬) 阿裡巴巴進階技術專家
關注“阿裡巴巴雲原生”公衆号,回複 Go 即可檢視清晰知識大圖!
導讀:從問題本身出發,不局限于 Go 語言,探讨伺服器中常常遇到的問題,最後回到 Go 如何解決這些問題,為大家提供 Go 開發的關鍵技術指南。我們将以系列文章的形式推出 《Go 開發的關鍵技術指南》 ,共有 4 篇文章,本文為第 3 篇。
Go 開發指南
Interfaces
Go 在類型和接口上的思考是:
- Go 類型系統并不是一般意義的 OO,并不支援虛函數;
- Go 的接口是隐含實作,更靈活,更便于适配和替換;
- Go 支援的是組合、小接口、組合+小接口;
- 接口設計應該考慮正交性,組合更利于正交性。
Type System
Go 的類型系統是比較容易和 C++/Java 混淆的,特别是習慣于類體系和虛函數的思路後,很容易想在 Go 走這個路子,可惜是走不通的。而 interface 因為太過于簡單,而且和 C++/Java 中的概念差異不是特别明顯,是以本章節專門分析 Go 的類型系統。
先看一個典型的問題
Is it possible to call overridden method from parent struct in golang?代碼如下所示:
package main
import (
"fmt"
)
type A struct {
}
func (a *A) Foo() {
fmt.Println("A.Foo()")
}
func (a *A) Bar() {
a.Foo()
}
type B struct {
A
}
func (b *B) Foo() {
fmt.Println("B.Foo()")
}
func main() {
b := B{A: A{}}
b.Bar()
}
本質上它是一個
模闆方法模式 (TemplateMethodPattern),A 的 Bar 調用了虛函數 Foo,期待子類重寫虛函數 Foo,這是典型的 C++/Java 解決問題的思路。
我們借用
中的例子,考慮實作一個跨平台編譯器,提供給使用者使用的函數是
crossCompile
,而這個函數調用了兩個模闆方法
collectSource
和
compileToTarget
:
public abstract class CrossCompiler {
public final void crossCompile() {
collectSource();
compileToTarget();
}
//Template methods
protected abstract void collectSource();
protected abstract void compileToTarget();
}
C 版,不用 OOAD 思維參考 C: CrossCompiler use StateMachine,代碼如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
void collectSource(bool isIPhone) {
if (isIPhone) {
printf("IPhone: Collect source\n");
} else {
printf("Android: Collect source\n");
}
}
void compileToTarget(bool isIPhone) {
if (isIPhone) {
printf("IPhone: Compile to target\n");
} else {
printf("Android: Compile to target\n");
}
}
void IDEBuild(bool isIPhone) {
beforeCompile();
collectSource(isIPhone);
compileToTarget(isIPhone);
afterCompile();
}
int main(int argc, char** argv) {
IDEBuild(true);
//IDEBuild(false);
return 0;
}
C 版本使用 OOAD 思維,可以參考 C: CrossCompiler,代碼如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
class CrossCompiler {
public:
void crossCompile() {
beforeCompile();
collectSource();
compileToTarget();
afterCompile();
}
private:
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
// Template methods.
public:
virtual void collectSource() = 0;
virtual void compileToTarget() = 0;
};
class IPhoneCompiler : public CrossCompiler {
public:
void collectSource() {
printf("IPhone: Collect source\n");
}
void compileToTarget() {
printf("IPhone: Compile to target\n");
}
};
class AndroidCompiler : public CrossCompiler {
public:
void collectSource() {
printf("Android: Collect source\n");
}
void compileToTarget() {
printf("Android: Compile to target\n");
}
};
void IDEBuild(CrossCompiler* compiler) {
compiler->crossCompile();
}
int main(int argc, char** argv) {
IDEBuild(new IPhoneCompiler());
//IDEBuild(new AndroidCompiler());
return 0;
}
我們可以針對不同的平台實作這個編譯器,比如 Android 和 iPhone:
public class IPhoneCompiler extends CrossCompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//iphone specific compilation
}
}
public class AndroidCompiler extends CrossCompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//android specific compilation
}
}
在 C++/Java 中能夠完美的工作,但是在 Go 中,使用結構體嵌套隻能這麼實作,讓 IPhoneCompiler 和 AndroidCompiler 内嵌 CrossCompiler,參考 Go: TemplateMethod,代碼如下所示:
package main
import (
"fmt"
)
type CrossCompiler struct {
}
func (v CrossCompiler) crossCompile() {
v.collectSource()
v.compileToTarget()
}
func (v CrossCompiler) collectSource() {
fmt.Println("CrossCompiler.collectSource")
}
func (v CrossCompiler) compileToTarget() {
fmt.Println("CrossCompiler.compileToTarget")
}
type IPhoneCompiler struct {
CrossCompiler
}
func (v IPhoneCompiler) collectSource() {
fmt.Println("IPhoneCompiler.collectSource")
}
func (v IPhoneCompiler) compileToTarget() {
fmt.Println("IPhoneCompiler.compileToTarget")
}
type AndroidCompiler struct {
CrossCompiler
}
func (v AndroidCompiler) collectSource() {
fmt.Println("AndroidCompiler.collectSource")
}
func (v AndroidCompiler) compileToTarget() {
fmt.Println("AndroidCompiler.compileToTarget")
}
func main() {
iPhone := IPhoneCompiler{}
iPhone.crossCompile()
}
執行結果卻讓人手足無措:
# Expect
IPhoneCompiler.collectSource
IPhoneCompiler.compileToTarget
# Output
CrossCompiler.collectSource
CrossCompiler.compileToTarget
Go 并沒有支援類繼承體系和多态,Go 是面向對象卻不是一般所了解的那種面向對象,用老子的話說“道可道,非常道”。
實際上在 OOAD 中,除了類繼承之外,還有另外一個解決問題的思路就是組合 Composition,面向對象設計原則中有個很重要的就是
The Composite Reuse Principle (CRP),
Favor delegation over inheritance as a reuse mechanism
,重用機制應該優先使用組合(代理)而不是類繼承。類繼承會喪失靈活性,而且通路的範圍比組合要大;組合有很高的靈活性,另外組合使用另外對象的接口,是以能獲得最小的資訊。
C++ 如何使用組合代替繼承實作模闆方法?可以考慮讓 CrossCompiler 使用其他的類提供的服務,或者說使用接口,比如
CrossCompiler
依賴于
ICompiler
public interface ICompiler {
//Template methods
protected abstract void collectSource();
protected abstract void compileToTarget();
}
public abstract class CrossCompiler {
public ICompiler compiler;
public final void crossCompile() {
compiler.collectSource();
compiler.compileToTarget();
}
}
C 版本可以參考 C: CrossCompiler use Composition,代碼如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
class ICompiler {
// Template methods.
public:
virtual void collectSource() = 0;
virtual void compileToTarget() = 0;
};
class CrossCompiler {
public:
CrossCompiler(ICompiler* compiler) : c(compiler) {
}
void crossCompile() {
beforeCompile();
c->collectSource();
c->compileToTarget();
afterCompile();
}
private:
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
ICompiler* c;
};
class IPhoneCompiler : public ICompiler {
public:
void collectSource() {
printf("IPhone: Collect source\n");
}
void compileToTarget() {
printf("IPhone: Compile to target\n");
}
};
class AndroidCompiler : public ICompiler {
public:
void collectSource() {
printf("Android: Collect source\n");
}
void compileToTarget() {
printf("Android: Compile to target\n");
}
};
void IDEBuild(CrossCompiler* compiler) {
compiler->crossCompile();
}
int main(int argc, char** argv) {
IDEBuild(new CrossCompiler(new IPhoneCompiler()));
//IDEBuild(new CrossCompiler(new AndroidCompiler()));
return 0;
}
我們可以針對不同的平台實作這個
ICompiler
,比如 Android 和 iPhone。這樣從繼承的類體系,變成了更靈活的接口的組合,以及對象直接服務的調用:
public class IPhoneCompiler implements ICompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//iphone specific compilation
}
}
public class AndroidCompiler implements ICompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//android specific compilation
}
}
在 Go 中,推薦用組合和接口,小的接口,大的對象。這樣有利于隻獲得自己應該擷取的資訊,或者不會獲得太多自己不需要的資訊和函數,參考
Clients should not be forced to depend on methods they do not use. –Robert C. Martin,以及
The bigger the interface, the weaker the abstraction, Rob Pike。關于面向對象的原則在 Go 中的展現,參考
Go: SOLID或
中文版 Go: SOLID。
先看如何使用 Go 的思路實作前面的例子,跨平台編譯器,Go Composition: Compiler,代碼如下所示:
package main
import (
"fmt"
)
type SourceCollector interface {
collectSource()
}
type TargetCompiler interface {
compileToTarget()
}
type CrossCompiler struct {
collector SourceCollector
compiler TargetCompiler
}
func (v CrossCompiler) crossCompile() {
v.collector.collectSource()
v.compiler.compileToTarget()
}
type IPhoneCompiler struct {
}
func (v IPhoneCompiler) collectSource() {
fmt.Println("IPhoneCompiler.collectSource")
}
func (v IPhoneCompiler) compileToTarget() {
fmt.Println("IPhoneCompiler.compileToTarget")
}
type AndroidCompiler struct {
}
func (v AndroidCompiler) collectSource() {
fmt.Println("AndroidCompiler.collectSource")
}
func (v AndroidCompiler) compileToTarget() {
fmt.Println("AndroidCompiler.compileToTarget")
}
func main() {
iPhone := IPhoneCompiler{}
compiler := CrossCompiler{iPhone, iPhone}
compiler.crossCompile()
}
這個方案中,将兩個模闆方法定義成了兩個接口,
CrossCompiler
使用了這兩個接口,因為本質上 C++/Java 将它的函數定義為抽象函數,意思也是不知道這個函數如何實作。而
IPhoneCompiler
AndroidCompiler
并沒有繼承關系,而它們兩個實作了這兩個接口,供
CrossCompiler
使用;也就是它們之間的關系,從之前的強制綁定,變成了組合。
type SourceCollector interface {
collectSource()
}
type TargetCompiler interface {
compileToTarget()
}
type CrossCompiler struct {
collector SourceCollector
compiler TargetCompiler
}
func (v CrossCompiler) crossCompile() {
v.collector.collectSource()
v.compiler.compileToTarget()
}
Rob Pike 在
Go Language: Small and implicit中描述 Go 的類型和接口,第 29 頁說:
- Objects implicitly satisfy interfaces. A type satisfies an interface simply by implementing its methods. There is no "implements" declaration; interfaces are satisfied implicitly. 這種隐式的實作接口,實際中還是很靈活的,我們在 Refector 時可以将對象改成接口,縮小所依賴的接口時,能夠不改變其他地方的代碼。比如如果一個函數
,最初依賴于foo(f *os.File)
,但實際上可能隻是依賴于os.File
就可以友善做 UTest,那麼可以直接修改成io.Reader
所有地方都不用修改,特别是這個接口是新增的自定義接口時就更明顯;foo(r io.Reader)
- In Go, interfaces are usually small: one or two or even zero methods. 在 Go 中接口都比較小,非常小,隻有一兩個函數;但是對象卻會比較大,會使用很多的接口。這種方式能夠以最靈活的方式重用代碼,而且保持接口的有效性和最小化,也就是接口隔離。
隐式實作接口有個很好的作用,就是兩個類似的子產品實作同樣的服務時,可以無縫的提供服務,甚至可以同時提供服務。比如改進現有子產品時,比如兩個不同的算法。更厲害的時,兩個子產品建立的私有接口,如果它們簽名一樣,也是可以互通的,其實簽名一樣就是一樣的接口,無所謂是不是私有的了。這個非常強大,可以允許不同的子產品在不同的時刻更新,這對于提供服務的伺服器太重要了。
比較被嚴重誤認為是繼承的,莫過于是 Go 的内嵌
Embeding,因為 Embeding 本質上還是組合不是繼承,參考
Embeding is still compositionEmbeding 在 UTest 的 Mocking 中可以顯著減少需要 Mock 的函數,比如
Mocking net.Conn,如果隻需要 mock Read 和 Write 兩個函數,就可以通過内嵌 net.Conn 來實作,這樣 loopBack 也實作了整個 net.Conn 接口,不必每個接口全部寫一遍:
type loopBack struct {
net.Conn
buf bytes.Buffer
}
func (c *loopBack) Read(b []byte) (int, error) {
return c.buf.Read(b)
}
func (c *loopBack) Write(b []byte) (int, error) {
return c.buf.Write(b)
}
Embeding 隻是将内嵌的資料和函數自動全部代理了一遍而已,本質上還是使用這個内嵌對象的服務。Outer 内嵌了Inner,和 Outer 繼承 Inner 的差別在于:内嵌 Inner 是不知道自己被内嵌,調用 Inner 的函數,并不會對 Outer 有任何影響,Outer 内嵌 Inner 隻是自動将 Inner 的資料和方法代理了一遍,但是本質上 Inner 的東西還不是 Outer 的東西;對于繼承,調用 Inner 的函數有可能會改變 Outer 的資料,因為 Outer 繼承 Inner,那麼 Outer 就是 Inner,二者的依賴是更緊密的。
如果很難了解為何 Embeding 不是繼承,本質上是沒有區分繼承群組合的差別,可以參考
Composition not inheritance,Go 選擇組合不選擇繼承是深思熟慮的決定,面向對象的繼承、虛函數、多态和類樹被過度使用了。類繼承樹需要前期就設計好,而往往系統在演化時發現類繼承樹需要變更,我們無法在前期就精确設計出完美的類繼承樹;Go 的接口群組合,在接口變更時,隻需要變更最直接的調用層,而沒有類子樹需要變更。
The designs are nothing like hierarchical, subtype-inherited methods. They are looser (even ad hoc), organic, decoupled, independent, and therefore scalable.
組合比繼承有個很關鍵的優勢是正交性
orthogonal
,詳細參考
正交性Orthogonal
真水無香,真的牛逼不用裝。——來自網絡
軟體是一門科學也是藝術,換句話說軟體是工程。科學的意思是邏輯、數學、二進制,比較偏基礎的理論都是需要數學的,比如 C 的結構化程式設計是有論證的,那些關鍵字和邏輯是夠用的。實際上 Go 的 GC 也是有數學證明的,還有一些網絡傳輸算法,又比如奠定一個新領域的論文比如 Google 的論文。藝術的意思是,大部分時候都用不到嚴密的論證,有很多種不同的路,還需要看自己的品味或者叫偏見,特别容易引起口水仗和争論,從好的方面說,好的軟體或代碼,是能被感覺到很好的。
由于大部分時候軟體開發是要靠經驗的,特别是國内填鴨式教育培養了對于數學的莫名的仇恨(“莫名”主要是早就把該忘的不該忘記的都忘記了),是以在代碼中強調數學,會激發起大家心中一種特别的鄙視和懷疑,而這種鄙視和懷疑應該是以蔥白和畏懼為基礎——大部分時候在代碼中吹數學都會被認為是裝逼。而 Orthogonal (正交性)則不擇不扣的是個數學術語,是線性代數(就是矩陣那個玩意兒)中用來描述兩個向量相關性的,在平面中就是兩個線條的垂直。比如下圖:
Vectors A and B are orthogonal to each other.
旁白:妮瑪,兩個線條垂直能和代碼有個毛線關系,八竿子打不着關系吧,請繼續吹。
先請看 Go 關于 Orthogonal 相關的描述,可能還不止這些地方:
Object-oriented programming provides a powerful insight: that the behavior of data can be generalized independently of the representation of that data. The model works best when the behavior (method set) is fixed, but once you subclass a type and add a method, the behaviors are no longer identical. If instead the set of behaviors is fixed, such as in Go's statically defined interfaces, the uniformity of behavior enables data and programs to be composed uniformly, orthogonally, and safely.
JSON-RPC: a tale of interfaces In an inheritance-oriented language like Java or C++, the obvious path would be to generalize the RPC class, and create JsonRPC and GobRPC subclasses. However, this approach becomes tricky if you want to make a further generalization orthogonal to that hierarchy.
實際上 Orthogonal 并不是隻有 Go 才提,參考
Orthogonal Software。實際上很多軟體設計都會提正交性,比如 OOAD 裡面也有不少地方用這個描述。我們先從實際的例子出發吧,關于線程一般 Java、Python、C# 等語言,會定義個線程的類 Thread,可能包含以下的方法管理線程:
var thread = new Thread(thread_main_function);
thread.Start();
thread.Interrupt();
thread.Join();
thread.Stop();
如果把 goroutine 也看成是 Go 的線程,那麼實際上 Go 并沒有提供上面的方法,而是提供了幾種不同的機制來管理線程:
-
關鍵鍵字啟動 goroutine;go
-
等待線程退出;sync.WaitGroup
-
也可以用來同步,比如等 goroutine 啟動或退出,或者傳遞退出資訊給 goroutine;chan
-
也可以用來管理 goroutine,參考 Contextcontext
s := make(chan bool, 0)
q := make(chan bool, 0)
go func() {
s <- true // goroutine started.
for {
select {
case <-q:
return
default:
// do something.
}
}
} ()
<- s // wait for goroutine started.
time.Sleep(10)
q <- true // notify goroutine quit.
注意上面隻是例子,實際中推薦用 管理 goroutine。
如果把 goroutine 看成一個向量,把 sync 看成一個向量,把 chan 看成一個向量,這些向量都不相關,也就是它們是正交的。
再舉個
的例子,将對象存儲到 TEXT 或 XML 檔案,可以直接寫對象的序列化函數:
def read_dictionary(file)
if File.extname(file) == ".xml"
# read and return definitions in XML from file
else
# read and return definitions in text from file
end
end
這個的壞處包括:
- 邏輯代碼和序列化代碼混合在一起,随處可見序列化代碼,非常難以維護;
- 如果要新增序列化的機制比如将對象序列化存儲到網絡就很費勁了;
- 假設 TEXT 要支援 JSON 格式,或者 INI 格式呢?
如果改進下這個例子,将存儲分離:
class Dictionary
def self.instance(file)
if File.extname(file) == ".xml"
XMLDictionary.new(file)
else
TextDictionary.new(file)
end
end
end
class TextDictionary < Dictionary
def write
# write text to @file using the @definitions hash
end
def read
# read text from @file and populate the @definitions hash
end
end
如果把 Dictionay 看成一個向量,把存儲方式看成一個向量,再把 JSON 或 INI 格式看成一個向量,他們實際上是可以不相關的。
再看一個例子,考慮上面
的修改,實際上是将序列化的部分,從
*gob.Encoder
變成了接口
ServerCodec
,然後實作了 jsonCodec 和 gobCodec 兩種 Codec,是以 RPC 和 ServerCodec 是正交的。非正交的做法,就是從 RPC 繼承兩個類 jsonRPC 和 gobRPC,這樣 RPC 和 Codec 是耦合的并不是不相關的。
Orthogonal 不相關到底有什麼好說的?
- 數學中不相關的兩個向量,可以作為空間的基,比如平面上就是 x 和 y 軸,從向量看就是兩個向量,這兩個不相關的向量 x 和 y 可以組合出平面的任意向量,平面任一點都可以用 x 和 y 表示;如果向量不正交,有些區域就不能用這兩個向量表達,有些點就不能表達。這個在接口設計上就是:正交的接口,能讓使用者靈活組合出能解決各種問題的調用方式, 不相關的向量可以張成整個向量空間 ;同樣的如果不正交,有時候就發現自己想要的功能無法通過現有接口實作,必須修改接口的定義;
- 比如 goroutine 的例子,我們可以用 sync 或 chan 達到自己想要的控制 goroutine 的方式。比如 context 也是組合了 chan、timeout、value 等接口提供的一個比較明确的功能庫。這些語言級别的正交的元素,可以組合成非常多樣和豐富的庫。比如有時候我們需要等 goroutine 啟動,有時候不用;有時候甚至不需要管理 goroutine,有時候需要主動通知 goroutine 退出;有時候我們需要等 goroutine 出錯後處理;
- 比如序列化 TEXT 或 XML 的例子,可以将對象的邏輯完全和存儲分離,避免對象的邏輯中随處可見存儲對象的代碼,維護性可以極大的提升。另外,兩個向量的耦合還可以了解,如果是多個向量的耦合就難以實作,比如要将對象序列化為支援注釋的 JSON 先存儲到網絡有問題再存儲為 TEXT 檔案,同時如果是程式更新則存儲為 XML 檔案,這種複雜的邏輯實際上需要很靈活的組合,本質上就是空間的多個向量的組合表達出空間的新向量(新功能);
- 當對象出現了自己不該有的特性和方法,會造成巨大的維護成本。比如如果 TEXT 和 XML 機制耦合在一起,那麼維護 TEXT 協定時,要了解 XML 的協定,改動 TEXT 時竟然造成 XML 挂掉了。使用時如果出現自己不用的函數也是一種壞味道,比如
就有問題,因為 src 明顯不會用到Copy(src, dst io.ReadWriter)
而 dst不會用到Write
,是以改成Read
才是合理的。Copy(src io.Reader, dst io.Writer)
由此可見,Orthogonal 是接口設計中非常關鍵的要素,我們需要從概念上考慮接口,盡量提供正交的接口和函數。比如
io.Reader
、
io.Writer
io.Closer
是正交的,因為有時候我們需要的新向量是讀寫那麼可以使用
io.ReadWriter
,這實際上是兩個接口的組合。
我們如何才能實作 Orthogonal 的接口呢?特别對于公共庫,這個非常關鍵,直接決定了我們是否能提供好用的庫,還是很爛的不知道怎麼用的庫。有幾個建議:
- 好用的公共庫,使用者可以通過 IDE 的提示就知道怎麼用,不應該提供多個不同的路徑實作一個功能,會造成很大的困擾。比如 Android 的通訊錄,超級多的完全不同的類可以用,實際上就是非常難用;
- 必須要有完善的文檔。完全通過代碼就能表達 Why 和 How,是不可能的。就算是 Go 的标準庫,也是大量的注釋,如果一個公共庫沒有文檔和注釋,會非常的難用和維護;
- 一定要先寫 Example,一定要提供 UTest 完全覆寫。沒有 Example 的公共庫是不知道接口設計是否合理的,沒有人有能力直接設計一個合理的庫,隻有從使用者角度分析才能知道什麼是合理,Example 就是使用者角度;标準庫有大量的 Example。UTest 也是一種使用,不過是内部使用,也很必要。
如果上面數學上有不嚴謹的請原諒我,我數學很渣。
Modules
先把最重要的說了,關于 modules 的最新詳細資訊可以執行指令
go help modules
或者查這個長長的手冊
Go Modules,另外 modules 弄清楚後很好用遷移成本低。
Go Module 的好處,可以參考
Demo- 代碼不用必須放 GOPATH,可以放在任何目錄,終于不用做軟鍊了;
- Module 依然可以用 vendor,如果不需要更新依賴,可以不必從遠端下載下傳依賴代碼,同樣不必放 GOPATH;
- 如果在一個倉庫可以直接引用,會自動識别子產品内部的 package,同樣不用連結到 GOPATH。
Go 最初是使用 GOPATH 存放依賴的包(項目和代碼),這個 GOPATH 是公共的目錄,如果依賴的庫的版本不同就杯具了。2016 年也就是 7 年後才支援
vendor規範,就是将依賴本地化了,每個項目都使用自己的 vendor 檔案夾,但這樣也解決不了沖突的問題(具體看下面的分析),相反導緻各種包管理項目天下混戰,參考
pkg management tools2017 年也就是 8 年後,官方的 vendor 包管理器
dep才确定方案,看起來命中注定的 TheOne 終于塵埃落定。不料 2018 年也就是 9 年後,又提出比較完整的方案
versioning vgo,這年 Go1.11 支援了 Modules,2019 年 Go1.12 和 Go1.13 改進了不少 Modules 内容,Go 官方文檔推出一系列的
Part 1 — Using Go Modules Part 2 — Migrating To Go Modules Part 3 — Publishing Go Modules,終于應該大概齊能明白,這次真的确定和肯定了,Go Modules 是最終方案。
為什麼要搞出 GOPATH、Vendor 和 GoModules 這麼多技術方案?本質上是為了創造就業崗位,一次創造了
index proxy sum三個官網,哈哈哈。當然技術上也是必須要這麼做的,簡單來說是為了解決古老的
DLL Hell
問題,也就是依賴管理和版本管理的問題。版本說起來就是幾個數字,比如
1.2.3
,實際上是非常複雜的問題,推薦閱讀
Semantic Versioning,假設定義了良好和清晰的 API,我們用版本号來管理 API 的相容性;版本号一般定義為
MAJOR.MINOR.PATCH
,Major 變更時意味着不相容的API變更,Minor 是功能變更但是是相容的,Patch 是 BugFix 也是相容的,Major 為 0 時表示 API 還不穩定。由于 Go 的包是 URL 的,沒有版本号資訊,最初對于包的版本管理原則是必須一直保持接口相容:
If an old package and a new package have the same import path, the new package must be backwards compatible with the old package.
試想下如果所有我們依賴的包,一直都是接口相容的,那就沒有啥問題,也沒有
DLL Hell
。可惜現實卻不是這樣,如果我們提供過包就知道,對于持續維護和更新的包,在最初不可能提供一個永遠不變的接口,變化的接口就是不相容的了。就算某個接口可以不變,還有依賴的包,還有依賴的依賴的包,還有依賴的依賴的依賴的包,以此往複,要求世界上所有接口都不變,才不會有版本問題,這麼說起來,包管理是個極其難以解決的問題,Go 花了 10 年才确定最終方案就是這個原因了,下面舉例子詳細分析這個問題。
備注:标準庫也有遇到接口變更的風險,比如 是 Go1.7 才引入标準庫的,控制程式生命周期,後續有很多接口的第一個參數都是,比如
ctx context.Context
就是後面加的一個函數,而
net.DialContext
也是調用它。再比如
net.Dial
則提供了一個函數,将 context 放在結構體中傳遞,這是因為要再為每個 Request 的函數新增一個參數不太合适。從 context 對于标準庫的接口的變更,可以看得到這裡有些不一緻性,有很多批評的聲音比如 Context should go away for Go 2 ,就是覺得在标準庫中加 context 作為第一個參數不能了解,比如
http.Request.WithContext
等。
Read(ctx context.Context
GOPATH & Vendor
咱們先看 GOPATH 的方式。Go 引入外部的包,是 URL 方式的,先在環境變量
$GOROOT
中搜尋,然後在
$GOPATH
中搜尋,比如我們使用 Errors,依賴包
github.com/ossrs/go-oryx-lib/errors
,代碼如下所示:
package main
import (
"fmt"
"github.com/ossrs/go-oryx-lib/errors"
)
func main() {
fmt.Println(errors.New("Hello, playground"))
}
如果我們直接運作會報錯,錯誤資訊如下:
prog.go:5:2: cannot find package "github.com/ossrs/go-oryx-lib/errors" in any of:
/usr/local/go/src/github.com/ossrs/go-oryx-lib/errors (from $GOROOT)
/go/src/github.com/ossrs/go-oryx-lib/errors (from $GOPATH)
需要先下載下傳這個依賴包
go get -d github.com/ossrs/go-oryx-lib/errors
,然後運作就可以了。下載下傳後放在 GOPATH 中:
Mac $ ls -lh $GOPATH/src/github.com/ossrs/go-oryx-lib/errors
total 72
-rw-r--r-- 1 chengli.ycl staff 1.3K Sep 8 15:35 LICENSE
-rw-r--r-- 1 chengli.ycl staff 2.2K Sep 8 15:35 README.md
-rw-r--r-- 1 chengli.ycl staff 1.0K Sep 8 15:35 bench_test.go
-rw-r--r-- 1 chengli.ycl staff 6.7K Sep 8 15:35 errors.go
-rw-r--r-- 1 chengli.ycl staff 5.4K Sep 8 15:35 example_test.go
-rw-r--r-- 1 chengli.ycl staff 4.7K Sep 8 15:35 stack.go
如果我們依賴的包還依賴于其他的包,那麼
go get
會下載下傳所有依賴的包到 GOPATH。這樣是下載下傳到公共的 GOPATH 的,可以想到,這會造成幾個問題:
- 每次都要從網絡下載下傳依賴,可能對于美國這個問題不存在,但是對于中國,要從 GITHUB 上下載下傳很大的項目,是個很麻煩的問題,還沒有斷點續傳;
- 如果兩個項目,依賴了 GOPATH 了項目,如果一個更新會導緻另外一個項目出現問題。比如新的項目下載下傳了最新的依賴庫,可能會導緻其他項目出問題;
- 無法獨立管理版本号和更新,獨立依賴不同的包的版本。比如 A 項目依賴 1.0 的庫,而 B 項目依賴 2.0 的庫。注意:如果 A 和 B 都是庫的話,這個問題還是無解的,它們可能會同時被一個項目引用,如果 A 和 B 是最終的應用是沒有問題,應用可以用不同的版本,它們在自己的目錄。
為了解決這些問題,引入了 vendor,在 src 下面有個 vendor 目錄,将依賴的庫都下載下傳到這個目錄,同時會有描述檔案說明依賴的版本,這樣可以實作更新不同庫的更新。參考
,以及官方的包管理器
。但是 vendor 并沒有解決所有的問題,特别是包的不相容版本的問題,隻解決了項目或應用,也就是會編譯出二進制的項目所依賴庫的問題。
咱們把上面的例子用 vendor 實作,先要把項目軟鍊或者挪到 GOPATH 裡面去,若沒有 dep 工具可以參考
Installation安裝,然後執行下面的指令來将依賴導入到 vendor 目錄:
dep init && dep ensure
這樣依賴的檔案就會放在 vendor 下面,編譯時也不再需要從遠端下載下傳了:
├── Gopkg.lock
├── Gopkg.toml
├── t.go
└── vendor
└── github.com
└── ossrs
└── go-oryx-lib
└── errors
├── errors.go
└── stack.go
Remark: Vendor 也會選擇版本,也有版本管理,但每個包它隻會選擇一個版本,也就是本質上是本地化的 GOPATH,如果出現鑽石依賴和沖突還是無解,下面會詳細說明。
何為版本沖突?
我們來看 GOPATH 和 Vencor 無法解決的一個問題,版本依賴問題的一個例子
Semantic Import Versioning,考慮鑽石依賴的情況,使用者依賴于兩個雲服務商的 SDK,而它們可能都依賴于公共的庫,形成一個鑽石形狀的依賴,使用者依賴 AWS 和 Azure 而它們都依賴 OAuth:
如果公共庫 package(這裡是 OAuth)的導入路徑一樣(比如是 github.com/google/oauth),但是做了非相容性變更,釋出了 OAuth-r1 和 OAuth-r2,其中一個雲服務商更新了自己的依賴,另外一個沒有更新,就會造成沖突,他們依賴的版本不同:
在 Go 中無論怎麼修改都無法支援這種情況,除非在 package 的路徑中加入版本語義進去,也就是在路徑上帶上版本資訊(這就是 Go Modules了),這和優雅沒有關系,這實際上是最好的使用體驗:
另外做法就是改變包路徑,這要求包提供者要每個版本都要使用一個特殊的名字,但使用者也不能分辨這些名字代表的含義,自然也不知道如何選擇哪個版本。
先看看 Go Modules 創造的三大就業崗位,
負責索引、
負責代理緩存和
負責簽名校驗,它們之間的關系在
Big Picture中有描述。可見 go-get 會先從 index 擷取指定 package 的索引,然後從 proxy 下載下傳資料,最後從 sum 來擷取校驗資訊:
vgo 全面實踐
還是先跟着官網的三部曲,先了解下 modules 的基本用法,後面補充下特别要注意的問題就差不多齊了。首先是
Using Go Modules,如何使用 modules,還是用上面的例子,代碼不用改變,隻需要執行指令:
go mod init private.me/app && go run t.go
Remark:和vendor并不相同,modules并不需要在GOPATH下面才能建立,是以這是非常好的。
執行的結果如下,可以看到 vgo 查詢依賴的庫,下載下傳後解壓到了 cache,并生成了 go.mod 和 go.sum,緩存的檔案在
$GOPATH/pkg
下面:
Mac:gogogo chengli.ycl$ go mod init private.me/app && go run t.go
go: creating new go.mod: module private.me/app
go: finding github.com/ossrs/go-oryx-lib v0.0.7
go: downloading github.com/ossrs/go-oryx-lib v0.0.7
go: extracting github.com/ossrs/go-oryx-lib v0.0.7
Hello, playground
Mac:gogogo chengli.ycl$ cat go.mod
module private.me/app
go 1.13
require github.com/ossrs/go-oryx-lib v0.0.7 // indirect
Mac:gogogo chengli.ycl$ cat go.sum
github.com/ossrs/go-oryx-lib v0.0.7 h1:k8ml3ZLsjIMoQEdZdWuy8zkU0w/fbJSyHvT/s9NyeCc=
github.com/ossrs/go-oryx-lib v0.0.7/go.mod h1:i2tH4TZBzAw5h+HwGrNOKvP/nmZgSQz0OEnLLdzcT/8=
Mac:gogogo chengli.ycl$ tree $GOPATH/pkg
/Users/winlin/go/pkg
├── mod
│ ├── cache
│ │ ├── download
│ │ │ ├── github.com
│ │ │ │ └── ossrs
│ │ │ │ └── go-oryx-lib
│ │ │ │ └── @v
│ │ │ │ ├── list
│ │ │ │ ├── v0.0.7.info
│ │ │ │ ├── v0.0.7.zip
│ │ │ └── sumdb
│ │ │ └── sum.golang.org
│ │ │ ├── lookup
│ │ │ │ └── github.com
│ │ │ │ └── ossrs
│ │ │ │ └── [email protected]
│ └── github.com
│ └── ossrs
│ └── [email protected]
│ ├── errors
│ │ ├── errors.go
│ │ └── stack.go
└── sumdb
└── sum.golang.org
└── latest
可以手動更新某個庫,即 go get 這個庫:
Mac:gogogo chengli.ycl$ go get github.com/ossrs/go-oryx-lib
go: finding github.com/ossrs/go-oryx-lib v0.0.8
go: downloading github.com/ossrs/go-oryx-lib v0.0.8
go: extracting github.com/ossrs/go-oryx-lib v0.0.8
Mac:gogogo chengli.ycl$ cat go.mod
module private.me/app
go 1.13
require github.com/ossrs/go-oryx-lib v0.0.8
更新某個包到指定版本,可以帶上版本号,例如
go get github.com/ossrs/[email protected]
。當然也可以降級,比如現在是 v0.0.8,可以
go get github.com/ossrs/[email protected]
降到 v0.0.7 版本。也可以更新所有依賴的包,執行
go get -u
指令就可以。檢視依賴的包和版本,以及依賴的依賴的包和版本,可以執行
go list -m all
指令。檢視指定的包有哪些版本,可以用
go list -m -versions github.com/ossrs/go-oryx-lib
指令。
Note: 關于 vgo 如何選擇版本,可以參考 Minimal Version Selection
如果依賴了某個包大版本的多個版本,那麼會選擇這個大版本最高的那個,比如:
- 若 a 依賴 v1.0.1,b 依賴 v1.2.3,程式依賴 a 和 b 時,最終使用 v1.2.3;
- 若 a 依賴 v1.0.1,d 依賴 v0.0.7,程式依賴 a 和 d 時,最終使用 v1.0.1,也就是認為 v1 是相容 v0 的。
比如下面代碼,依賴了四個包,而這四個包依賴了某個包的不同版本,分别選擇不同的包,執行
rm -f go.mod && go mod init private.me/app && go run t.go
,可以看到選擇了不同的版本,始終選擇的是大版本最高的那個(也就是滿足要求的最小版本):
package main
import (
"fmt"
"github.com/winlinvip/mod_ref_a" // 1.0.1
"github.com/winlinvip/mod_ref_b" // 1.2.3
"github.com/winlinvip/mod_ref_c" // 1.0.3
"github.com/winlinvip/mod_ref_d" // 0.0.7
)
func main() {
fmt.Println("Hello",
mod_ref_a.Version(),
mod_ref_b.Version(),
mod_ref_c.Version(),
mod_ref_d.Version(),
)
}
若包需要更新大版本,則需要在路徑上加上版本,包括本身的 go.mod 中的路徑,依賴這個包的 go.mod,依賴它的代碼,比如下面的例子,同時使用了 v1 和 v2 兩個版本(隻用一個也可以):
package main
import (
"fmt"
"github.com/winlinvip/mod_major_releases"
v2 "github.com/winlinvip/mod_major_releases/v2"
)
func main() {
fmt.Println("Hello",
mod_major_releases.Version(),
v2.Version2(),
)
}
運作這個程式後,可以看到 go.mod 中導入了兩個包:
module private.me/app
go 1.13
require (
github.com/winlinvip/mod_major_releases v1.0.1
github.com/winlinvip/mod_major_releases/v2 v2.0.3
)
Remark: 如果需要更新 v2 的指定版本,那麼路徑中也必須帶 v2,也就是所有 v2 的路徑必須帶 v2,比如 go get github.com/winlinvip/mod_major_releases/[email protected]
而庫提供大版本也是一樣的,參考
mod_major_releases/v2,主要做的事情:
- 建立 v2 的分支,
,比如 https://github.com/winlinvip/mod_major_releases/tree/v2 ;git checkout -b v2
- 修改 go.mod 的描述,路徑必須帶 v2,比如
module github.com/winlinvip/mod_major_releases/v2
- 送出後打 v2 的 tag,比如
,分支和 tag 都要送出到 git。git tag v2.0.0
其中 go.mod 更新如下:
module github.com/winlinvip/mod_major_releases/v2
go 1.13
代碼更新如下,由于是大版本,是以就變更了函數名稱:
package mod_major_releases
func Version2() string {
return "mmv/2.0.3"
}
Note: 更多資訊可以參考 Modules: v2 ,還有 Russ Cox: From Repository to Modules 介紹了兩種方式,常見的就是上面的分支方式的例子,還有一種檔案夾方式。
Go Modules 特别需要注意的問題:
- 對于公開的 package,如果 go.mod 中描述的 package,和公開的路徑不相同,比如 go.mod 是
,而釋出到private.me/app
,當然其他項目 import 這個包時會出現錯誤。對于庫,也就是希望别人依賴的包,go.mod 描述的和釋出的路徑,以及 package 名字都應該保持一緻;github.com/winlinvip/app
- 如果一個包沒有釋出任何版本,則會取最新的 commit 和日期,格式為 v0.0.0-日期-commit 号,比如
,參考 Pseudo Versions 。版本号可以從v0.0.0-20191028070444-45532e158b41
開始,比如v0.0.x
或者v0.0.1
v0.0.3
v0.1.0
之類,沒有強制要求必須要是 1.0 開始的釋出版本;v1.0.1
- mod replace 在子 module 無效,隻在編譯的那個 top level 有效,也就是在最終生成 binary 的 go.mod 中定義才有效,官方的說明是為了讓最終生成時控制依賴。例如想要把
重寫為github.com/pkg/errors
這個包,正确做法參考分支 replace_errors ;若不在主子產品 (top level) 中 replace 參考 replace_in_submodule ,隻在 子子產品 中定義了 replace 但會被忽略;如果在主子產品 replace 會生效 ,而且在主子產品依賴掉子模快依賴的子產品也生效 replace_deps_of_submodule 。不過在子模快中也能 replace,這個預感到會是個混淆的地方。有一個例子就是 fork 倉庫後修改後自己使用,這時候 go.mod 的 package 當然也變了,參考 Migrating Go1.13 Errors ,Go1.13 的 errors 支援了 Unwrap 接口,這樣可以拿到 root error,而 pkg/errors 使用的則是 Cause(err) 函數來擷取 root error,而提的 PR 沒有支援,pkg/errors 不打算支援 Go1.13 的方式,作者建議 fork 來解決,是以就可以使用 go mod replace 來将 fork 的 url 替換 pkg/errors;github.com/winlinvip/errors
-
并非将每個庫都更新後取最新的版本,比如庫go get
有 v1.0.1、v1.1.2 兩個版本,目前依賴的是 v1.1.2 版本,如果庫更新到了 v1.2.3 版本,立刻使用github.com/winlinvip/mod_minor_versions
并不會更新到 v1.2.3,執行go get -u
也一樣不會更新,除非顯式更新go get -u github.com/winlinvip/mod_minor_versions
才會使用這個版本,需要等一定時間後才會更新;go get github.com/winlinvip/[email protected]
- 對于大版本比如 v2,必須用 go.mod 描述,直接引用也可以比如
,會提示go get github.com/winlinvip/[email protected]
,意思就是預設都是 v0 和 v1,而直接打了 v2.0.0 的 tag,雖然版本上比對到了,但實際上是把 v2 當做 v1 在用,有可能會有不相容的問題。或者說,一般來說 v2.0.0 的這個 tag,一定會有接口的變更(否則就不能叫 v2 了),如果沒有用 go.mod 會把這個認為是 v1,自然可能會有相容問題了;v2.0.0+incompatible
- 更新大版本時必須帶版本号比如
,如果路徑中沒有這個 v2 則會報錯無法更新,比如go get github.com/winlinvip/mod_major_releases/[email protected]
,錯誤消息是go get github.com/winlinvip/[email protected]
,這個就是說 mod_major_releases 這個下面有 go.mod 描述的版本是 v0 或 v1,但後面指定的版本是 @v2 是以不比對無法更新;invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1
- 和上面的問題一樣,如果在 go.mod 中,大版本路徑中沒有帶版本,比如
,一樣會報錯require github.com/winlinvip/mod_major_releases v2.0.3
,這個有點含糊因為包定義的 go.mod 是 v2 的,這個錯誤的意思是,require 的那個地方,要求的是 v0 或 v1,而實際上版本是 v2.0.3,這個和手動要求更新module contains a go.mod file, so major version must be compatible: should be v0 or v1
是一回事;go get github.com/winlinvip/[email protected]
- 注意三大崗位有 cache,比如 [email protected] 的 go.mod 描述有錯誤,應該是 v5,而不是 v3。如果在打完 tag 後,擷取了這個版本
,會提示錯誤go get github.com/winlinvip/mod_major_error/v5
等錯誤,如果删除這個 tag 後再推 v5.0.0,還是一樣的錯誤,因為 index 和 goproxy 有緩存這個版本的資訊。解決版本就是升一個版本 v5.0.1,直接擷取這個版本就可以,比如but does not contain package github.com/winlinvip/mod_major_error/v5
,這樣才沒有問題。詳細參考 Semantic versions and modulesgo get github.com/winlinvip/mod_major_error/[email protected]
- 和上面一樣的問題,如果在版本沒有釋出時,就有 go get 的請求,會造成版本釋出後也無法擷取這個版本。比如
沒有打版本 v3.0.1,就請求github.com/winlinvip/mod_major_error
,會提示沒有這個版本。如果後面再打這個 tag,就算有這個 tag 後,也會提示 401 找不到go get github.com/winlinvip/mod_major_error/[email protected]
。隻能再更新個版本,打個新的 tag 比如 v3.0.2 才能擷取到。reading https://sum.golang.org/lookup/github.com/winlinvip/mod_major_error/[email protected]: 410 Gone
總結來說:
- GOPATH,自從預設為
後,很好用,依賴的包都緩存在這個公共的地方,隻要項目不大,完全是很直接很好用的方案。一般情況下也夠用了,估計 GOPATH 可能會被長期使用,畢竟習慣才是最可怕的,習慣是活的最久的,習慣就成為了一種生活方式,用餘老師的話說“文化是一種精神價值和生活方式,最終展現了集體人格”;$HOME/go
- vendor,vendor 緩存依賴在項目本地,能解決很多問題了,比 GOPATH 更好的是對于依賴可以定期更新,一般的項目中,對于依賴都是有需要了去更新,而不是每次編譯都去取最新的代碼。是以 vendor 還是非常實用的,如果能保持比較克制,不要因為要用一個函數就要依賴一個包,結果這個包依賴了十個,這十個又依賴了百個;
- vgo/modules,代碼使用上沒有差異;在版本更新時比如明确需要導入 v2 的包,才會在導入 url 上有差異;代碼緩存上使用 proxy 來下載下傳,緩存在 GOPATH 的 pkg 中,由于有版本資訊是以不會有沖突;會更安全,因為有 sum 在;會更靈活,因為有 index 和 proxy 在。
如何無縫遷移?
現有 GOPATH 和 vendor 的項目,如何遷移到 modules 呢?官方的遷移指南
Migrating to Go Modules,說明了項目會有三種狀态:
- 完全新的還沒開始的項目。那麼就按照上面的方式,用 modules 就好了;
- 現有的項目,使用了其他依賴管理,也就是 vendor,比如 dep 或 glide 等。go mod 會将現有的格式轉換成 modules,支援的格式參考 這裡 。其實 modules 還是會繼續支援 vendor,參考下面的較長的描述;
- 現有的項目,沒有使用任何依賴管理,也就是 GOPATH。注意 go mod init 的包路徑,需要和之前導出的一樣,特别是 Go1.4 支援的 import comment ,可能和倉庫的路徑并不相同,比如倉庫在
,而包路徑是 golang.org/x/linthttps://go.googlesource.com/lint
Note: 特别注意如果是庫支援了 v2 及以上的版本,那麼路徑中一定需要包含 v2,比如 github.com/russross/blackfriday/v2
。而且需要更新引用了這個包的 v2 的庫,比較蛋疼,不過這種情況還好是不多的。
咱們先看一個使用 GOPATH 的例子,我們建立一個測試包,先以 GOPATH 方式提供,參考
github.com/winlinvip/mod_gopath,依賴于
github.com/pkg/errors rsc.io/quote github.com/gorilla/websocket再看一個 vendor 的例子,将這個 GOPATH 的項目,轉成 vendor 項目,參考
github.com/winlinvip/mod_vendor,安裝完
後執行
dep init
就可以了,可以檢視依賴:
chengli.ycl$ dep status
PROJECT CONSTRAINT VERSION REVISION LATEST PKGS USED
github.com/gorilla/websocket ^1.4.1 v1.4.1 c3e18be v1.4.1 1
github.com/pkg/errors ^0.8.1 v0.8.1 ba968bf v0.8.1 1
golang.org/x/text v0.3.2 v0.3.2 342b2e1 v0.3.2 6
rsc.io/quote ^3.1.0 v3.1.0 0406d72 v3.1.0 1
rsc.io/sampler v1.99.99 v1.99.99 732a3c4 v1.99.99 1
接下來轉成 modules 包,先拷貝一份
代碼(這裡為了示範差别是以拷貝了一份,直接轉換也是可以的),變成
github.com/winlinvip/mod_gopath_vgo,然後執行指令
go mod init github.com/winlinvip/mod_gopath_vgo && go test ./... && go mod tidy
,接着釋出版本比如
git add . && git commit -am "Migrate to vgo" && git tag v1.0.1 && git push origin v1.0.1
:
Mac:mod_gopath_vgo chengli.ycl$ cat go.mod
module github.com/winlinvip/mod_gopath_vgo
go 1.13
require (
github.com/gorilla/websocket v1.4.1
github.com/pkg/errors v0.8.1
rsc.io/quote v1.5.2
)
depd 的 vendor 的項目也是一樣的,先拷貝一份
成
github.com/winlinvip/mod_vendor_vgo,執行指令
go mod init github.com/winlinvip/mod_vendor_vgo && go test ./... && go mod tidy
git add . && git commit -am "Migrate to vgo" && git tag v1.0.3 && git push origin v1.0.3
module github.com/winlinvip/mod_vendor_vgo
go 1.13
require (
github.com/gorilla/websocket v1.4.1
github.com/pkg/errors v0.8.1
golang.org/x/text v0.3.2 // indirect
rsc.io/quote v1.5.2
rsc.io/sampler v1.99.99 // indirect
)
這樣就可以在其他項目中引用它了:
package main
import (
"fmt"
"github.com/winlinvip/mod_gopath"
"github.com/winlinvip/mod_gopath/core"
"github.com/winlinvip/mod_vendor"
vcore "github.com/winlinvip/mod_vendor/core"
"github.com/winlinvip/mod_gopath_vgo"
core_vgo "github.com/winlinvip/mod_gopath_vgo/core"
"github.com/winlinvip/mod_vendor_vgo"
vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
)
func main() {
fmt.Println("mod_gopath is", mod_gopath.Version(), core.Hello(), core.New("gopath"))
fmt.Println("mod_vendor is", mod_vendor.Version(), vcore.Hello(), vcore.New("vendor"))
fmt.Println("mod_gopath_vgo is", mod_gopath_vgo.Version(), core_vgo.Hello(), core_vgo.New("vgo(gopath)"))
fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
}
Note: 對于私有項目,可能無法使用三大件來索引校驗,那麼可以設定 GOPRIVATE 來禁用校驗,參考 Module configuration for non public modules
vgo with vendor
Vendor 并非不能用,可以用 modules 同時用 vendor,參考
How do I use vendoring with modules? Is vendoring going away?,其實 vendor 并不會消亡,Go 社群有過詳細的讨論
vgo & vendoring決定在 modules 中支援 vendor,有人覺得,把 vendor 作為 modules 的存儲目錄挺好的啊。在 modules 中開啟 vendor 有幾個步驟:
- 先轉成 modules,參考前面的步驟,也可以建立一個 modules 例如
,然後把代碼寫好,就是一個标準的 module,不過檔案是存在go mod init xxx
的,參考 github.com/winlinvip/[email protected]$GOPATH/pkg
-
,這一步做的事情,就是将 modules 中的檔案都放到 vendor 中來。當然由于 go.mod 也存在,當然也知道這些檔案的版本資訊,也不會造成什麼問題,隻是建立了一個 vendor 目錄而已。在别人看起來這就是這正常的 modules,和 vendor 一點影響都沒有。參考 github.com/winlinvip/[email protected]go mod vendor
-
,修改 mod 這個參數,預設是會忽略這個 vendor 目錄了,加上這個參數後就會從 vendor 目錄加載代碼(可以把go build -mod=vendor
删掉發現也不會下載下傳代碼)。當然其他也可以加這個 flag,比如$GOPATH/pkg
go test -mod=vendor ./...
go run -mod=vendor .
調用這個包時,先使用 modules 把依賴下載下傳下來,比如
go mod init private.me/app && go run t.go
package main
import (
"fmt"
"github.com/winlinvip/mod_vendor_vgo"
vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
"github.com/winlinvip/mod_vgo_with_vendor"
vvgo_core "github.com/winlinvip/mod_vgo_with_vendor/core"
)
func main() {
fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
fmt.Println("mod_vgo_with_vendor is", mod_vgo_with_vendor.Version(), vvgo_core.Hello(), vvgo_core.New("vgo with vendor"))
}
然後一樣的也要轉成 vendor,執行指令
go mod vendor && go run -mod=vendor t.go
。如果有新的依賴的包需要導入,則需要先使用 modules 方式導入一次,然後
go mod vendor
拷貝到 vendor。其實一句話來說,modules with vendor 就是最後送出代碼時,把依賴全部放到 vendor 下面的一種方式。
Note: IDE 比如 goland 的設定裡面,有個,這樣會從項目的 vendor 目錄解析,而不是從全局的 cache。如果不需要導入新的包,可以預設開啟 vendor 方式,執行指令
Preferences /Go /Go Modules(vgo) /Vendoring mode
go env -w GOFLAGS='-mod=vendor'
Concurrency&Control
并發是伺服器的基本問題,
并發控制當然也是基本問題,Go 并不能避免這個問題,隻是将這個問題更簡化。
Concurrency
早在十八年前的 1999 年,千兆網卡還是一個新玩意兒,想當年有吉比特帶寬卻隻能支援 10K 用戶端,還是個值得研究的問題,畢竟
Nginx在 2009 年才出來,在這之前大家還在核心折騰過 HTTP 伺服器,伺服器領域還在讨論如何解決
C10K問題,
C10K 中文翻譯在這裡。讀這個文章,感覺進入了繁忙伺服器工廠的工廠中的房間,成千上萬錯綜複雜的電纜交織在一起,甚至還有古老的
驚群 (thundering herd)問題,驚群像遠古狼人一樣就算是在 21 世紀還是偶然能聽到它的傳說。現在大家讨論的都是如何支援
C10M,也就是
千萬級并發的問題。
并發,無疑是伺服器領域永遠無法逃避的話題,是伺服器軟體工程師的基本能力。Go 的撒手锏之一無疑就是并發處理,如果要從 Go 衆多優秀的特性中挑一個,那就是并發和工程化,如果隻能選一個的話,那就是并發的支援。大規模軟體,或者雲計算,很大一部分都是伺服器程式設計,伺服器要處理的幾個基本問題:并發、叢集、容災、相容、運維,這些問題都可以因為 Go 的并發特性得到改善,按照
《人月神話》的觀點,并發無疑是伺服器領域的
固有複雜度 (Essential Complexity)之一。Go 之是以能迅速占領雲計算的市場,Go 的并發機制是至關重要的。
借用 中關于 的概念,能比較清晰的說明并發問題。就算沒有讀過這本書,也肯定聽過軟體開發“ 沒有銀彈 ”,要保持軟體的“ 概念完整性 ”,Brooks 作為硬體和軟體的雙重專家和出色的教育家始終活躍在計算機舞台上,在計算機技術的諸多領域中都作出了巨大的貢獻,在 1964 年 (33 歲) 上司了 IBM System/360 IBM OS/360 的研發,于 p1993 年 (62 歲) 獲得馮諾依曼獎,并于 1999 年 (68 歲) 獲得圖靈獎,在 2010 年 (79 歲) 獲得虛拟現實 (VR) 的獎項 IEEE Virtual Reality Career Award (2010)
在軟體領域,很少能有像 一樣具有深遠影響力和暢銷不衰的著作。Brooks 博士為人們管理複雜項目提供了具有洞察力的見解,既有很多發人深省的觀點,又有大量軟體工程的實踐。本書内容來自 Brooks 博士在 IBM 公司 System/360 家族和 OS/360 中的項目管理經驗,該項目堪稱軟體開發項目管理的典範。該書英文原版一經面世,即引起業内人士的強烈反響,後又譯為德、法、日、俄、中、韓等多種文字,全球銷售數百萬冊。确立了其在行業内的經典地位。
Brooks 是我最崇拜的人,有理論有實踐,懂硬體懂軟體,緻力于大規模軟體(當初還沒有雲計算)系統,足夠(長達十年甚至二十年)的預見性,孜孜不倦奮鬥不止,強烈推薦軟體工程師讀
短暫的廣告回來,繼續讨論并發 (Concurrency) 的問題,要了解并發的問題就必須從了解并發問題本身,以及并發處理模型開始。2012 年我在當時中國最大的 CDN 公司
藍汛設計和開發流媒體伺服器時,學習了以高并發聞名的
NGINX的并發處理機制
EDSM(Event-Driven State Machine Architecture),自己也照着這套機制實作了一個流媒體伺服器,和 HTTP 的 Request-Response 模型不同,流媒體的協定比如 RTMP 非常複雜中間狀态非常多,特别是在做到叢集
Edge時和上遊伺服器的互動會導緻系統的狀态機翻倍,當時請教了公司的北美研發中心的架構師 Michael,Michael 推薦我用一個叫做 ST(StateThreads) 的技術解決這個問題,ST 實際上使用 setjmp 和 longjmp 實作了使用者态線程或者叫協程,協程和 goroutine 是類似的都是在使用者空間的輕量級線程,當時我本沒有懂為什麼要用一個完全不懂的協程的東西,後來我花時間了解了 ST 後豁然開朗,原來伺服器的并發處理有幾種典型的并發模型,流媒體伺服器中超級複雜的狀态機,也廣泛存在于各種伺服器領域中,屬于這個複雜協定伺服器領域不可 Remove 的一種
我翻譯了 ST(StateThreads) 總結的并發處理模型
高性能、高并發、高擴充性和可讀性的網絡伺服器架構:State Threads for Internet Applications,這篇文章也是了解 Go 并發處理的關鍵,本質上 ST 就是 C 語言的協程庫(騰訊微信也開源過一個
libco協程庫),而 goroutine 是 Go 語言級别的實作,本質上他們解決的領域問題是一樣的,當然 goroutine 會更廣泛一些,ST 隻是一個網絡庫。我們一起看看并發的本質目标,一起看圖說話吧,先從并發相關的
性能和伸縮性問題說起:
- 橫軸是用戶端的數目,縱軸是吞吐率也就是正常提供服務需要能吐出的資料,比如
個用戶端在觀看1000
碼率的視訊時,意味着每個用戶端每秒需要 500Kb 的資料,那麼伺服器需要每秒吐出500Kbps
的資料才能正常提供服務,如果伺服器因為性能問題 CPU 跑滿了都無法達到 500Mbps 的吞吐率,用戶端必定就會開始卡頓;500*1000Kb=500Mb
- 圖中黑色的線是用戶端要求的最低吞吐率,假設每個用戶端都是一樣的,那麼黑色的線就是一條斜率固定的直線,也就是用戶端越多吞吐率就越多,基本上和用戶端數目成正比。比如 1 個用戶端需要 500Kbps 的吞吐率, 1000 個就是 500Mbps 吞吐率;
- 圖中藍色的實線,是伺服器實際能達到的吞吐率。在用戶端比較少時,由于 CPU 空閑,伺服器(如果有需要)能夠以超過用戶端要求的最低吞吐率給資料,比如點播伺服器的場景,用戶端看 500Kbps 碼率的點播視訊,每秒最少需要 500Kb 的資料,那麼伺服器可以以 800Kbps 的吞吐率給用戶端資料,這樣用戶端自然不會卡頓,用戶端會将資料儲存在自己的緩沖區,隻是如果使用者放棄播放這個視訊時會導緻緩存的資料浪費;
- 圖中藍色實線會有個天花闆,也就是伺服器在給定的 CPU 資源下的最高吞吐率,比如某個版本的伺服器在 4CPU 下由于性能問題隻能達到 1Gbps 的吞吐率,那麼黑線和藍線的交叉點,就是這個伺服器能正常服務的最多用戶端比如 2000 個。理論上如果超過這個最大值比如 10K 個,伺服器吞吐率還是保持在最大吞吐率比如 1Gbps,但是由于用戶端的數目持續增加需要繼續消耗系統資源,比如 10K 個 FD 和線程的切換會搶占用于網絡收發的 CPU 時間,那麼就會出現藍色虛線,也就是超負載運作的伺服器,吞吐率會降低,導緻伺服器無法正常服務已經連接配接的用戶端;
- 負載伸縮性 (Load Scalability) 就是指黑線和藍線的交叉點,系統的負載能力如何,或者說是否并發模型能否盡可能的将 CPU 用在網絡吞吐上,而不是程式切換上,比如多程序的伺服器,負載伸縮性就非常差,有些空閑的用戶端也會 Fork 一個程序服務,這無疑是浪費了 CPU 資源的。同時多程序的系統伸縮性會很好,增加 CPU 資源時吞吐率基本上都是線性的;
- 系統伸縮性 (System Scalability) 是指吞吐率是否随系統資源線性增加,比如新增一倍的 CPU,是否吞吐率能翻倍。圖中綠線,就是增加了一倍的 CPU,那麼好的系統伸縮性應該系統的吞吐率也要增加一倍。比如多線程程式中,由于要對競争資源加鎖或者多線程同步,增加的 CPU 并不能完全用于吞吐率,多線程模型的系統伸縮性就不如多程序模型。
并發的模型包括幾種,總結
Existing Architectures如下表:
Arch | Load Scalability | System Scalability | Robust | Complexity | Example |
---|---|---|---|---|---|
Multi-Process | Poor | Good | Great | Simple | Apache1.x |
Multi-Threaded | Complex | Tomcat, FMS/AMS | |||
Event-Driven State Machine | Very | Nginx, CRTMPD | |||
StateThreads | SRS , Go |
- MP(Multi-Process)多程序模型:每個連接配接 Fork 一個程序服務。系統的魯棒性非常好,連接配接彼此隔離互不影響,就算有程序挂掉也不會影響其他連接配接。負載伸縮性 (Load Scalability) 非常差 (Poor),系統在大量程序之間切換的開銷太大,無法将盡可能多的 CPU 時間使用在網絡吞吐上,比如 4CPU 的伺服器啟動 1000 個繁忙的程序基本上無法正常服務。系統伸縮性 (System Scalability) 非常好,增加 CPU 時一般系統吞吐率是線性增長的。目前比較少見純粹的多程序伺服器了,特别是一個連接配接一個程序這種。雖然性能很低,但是系統複雜度低 (Simple),程序很獨立,不需要處理鎖或者狀态;
- MT(Multi-Threaded) 多線程模型:有的是每個連接配接一個線程,改進型的是按照職責分連接配接,比如讀寫分離的線程,幾個線程讀,幾個線程寫。系統的魯棒性不好 (Poor),一個連接配接或線程出現問題,影響其他的線程,彼此互相影響。負載伸縮性 (Load Scalability) 比較好 (Good),線程比程序輕量一些,多個使用者線程對應一個核心線程,但出現被阻塞時性能會顯著降低,變成和多程序一樣的情況。系統伸縮性 (System Scalability) 比較差 (Poor),主要是因為線程同步,就算使用者空間避免鎖,在核心層一樣也避免不了;增加 CPU 時,一般在多線程上會有損耗,并不能獲得多程序那種幾乎線性的吞吐率增加。多線程的複雜度 (Complex) 也比較高,主要是并發和鎖引入的問題;
- EDSM(Event-Driven State Machine) 事件驅動的狀态機。比如 select/poll/epoll,一般是單程序單線程,這樣可以避免多程序的鎖問題,為了避免單程的系統伸縮問題可以使用多程序單線程,比如 NGINX 就是這種方式。系統魯棒性比較好 (Good),一個程序服務一部分的用戶端,有一定的隔離。負載伸縮性 (Load Scalability) 非常好 (Great),沒有程序或線程的切換,使用者空間的開銷也非常少,CPU 幾乎都可以用在網絡吞吐上。系統伸縮性 (System Scalability) 很好,多程序擴充時幾乎是線性增加吞吐率。雖然效率很高,但是複雜度也非常高 (Very Complex),需要維護複雜的狀态機,特别是兩個耦合的狀态機,比如用戶端服務的狀态機和回源的狀态機。
- ST(StateThreads)協程模型。在 EDSM 的基礎上,解決了複雜狀态機的問題,從堆開辟協程的棧,将狀态儲存在棧中,在異步 IO 等待 (EAGAIN) 時,主動切換 (setjmp/longjmp) 到其他的協程完成 IO。也就是 ST 是綜合了 EDSM 和 MT 的優勢,不過 ST 的線程是使用者空間線程而不是系統線程,使用者空間線程也會有排程的開銷,不過比系統的開銷要小很多。協程的排程開銷,和 EDSM 的大循環的開銷差不多,需要循環每個激活的用戶端,逐個處理。而 ST 的主要問題,在于平台的适配,由于 glibc 的 setjmp/longjmp 是加密的無法修改 SP 棧指針,是以 ST 自己實作了這個邏輯,對于不同的平台就需要自己适配,目前 Linux 支援比較好,Windows 不支援,另外這個庫也不在維護有些坑隻能繞過去,比較偏僻使用和維護者都很少,比如 ST Patch 修複了一些問題。
我将 Go 也放在了 ST 這種模型中,雖然它是,和 SRS 不同是
多線程+協程
(SRS 本身是
多程序+協程
可以擴充為
單程序+協程
)。
多程序+協程
從并發模型看 Go 的 goroutine,Go 有 ST 的優勢,沒有 ST 的劣勢,這就是 Go 的并發模型厲害的地方了。當然 Go 的多線程是有一定開銷的,并沒有純粹多程序單線程那麼高的負載伸縮性,在活躍的連接配接過多時,可能會激活多個實體線程,導緻性能降低。也就是 Go 的性能會比 ST 或 EDSM 要差,而這些性能用來交換了系統的維護性,個人認為很值得。除了 goroutine,另外非常關鍵的就是 chan。Go 的并發實際上并非隻有 goroutine,而是 goroutine+chan,chan 用來在多個 goroutine 之間同步。實際上在這兩個機制上,還有标準庫中的 context,這三闆斧是 Go 的并發的撒手锏。
- goroutine: Go 對于協程的語言級别原生支援,一個 go 就可以啟動一個協程,ST 是通過函數來實作;
- chan 和 select: goroutine 之間通信的機制,ST 如果要實作兩個協程的消息傳遞和等待,隻能自己實作 queue 和 cond。如果要同步多個呢?比如一個協程要處理多種消息,包括使用者取消,逾時,其他線程的事件,Go 提供了 select 關鍵字。參考
Share Memory By Communicating
- context: 管理 goroutine 的元件,參考 GOLANG 使用 Context 管理關聯 goroutine 以及 GOLANG 使用 Context 實作傳值、逾時和取消 。參考
Go Concurrency Patterns: Timing out, moving on
Go Concurrency Patterns: Context
由于 Go 是多線程的,關于多線程或協程同步,除了 chan 也提供了 Mutex,其實這兩個都是可以用的,而且有時候比較适合用 chan 而不是用 Mutex,有時候适合用 Mutex 不适合用 chan,參考
Mutex or Channel
Channel | Mutex |
---|---|
passing ownership of data, distributing units of work, communicating async results | caches, state |
特别提醒:不要懼怕使用 Mutex,不要什麼都用 chan,千裡馬可以一日千裡卻不能抓老鼠,HelloKitty 跑不了多快抓老鼠卻比千裡馬強。
實際上 goroutine 的管理,在真正高可用的程式中是非常必要的,我們一般會需要支援幾種gorotine的控制方式:
- 錯誤處理:比如底層函數發生錯誤後,我們是忽略并告警(比如隻是某個連接配接受到影響),還是選擇中斷整個服務(比如 LICENSE 到期);
- 使用者取消:比如更新時,我們需要主動的遷移新的請求到新的服務,或者取消一些長時間運作的 goroutine,這就叫熱更新;
- 逾時關閉:比如請求的最大請求時長是 30 秒,那麼超過這個時間,我們就應該取消請求。一般用戶端的服務響應是有時間限制的;
- 關聯取消:比如用戶端請求伺服器,伺服器還要請求後端很多服務,如果中間用戶端關閉了連接配接,伺服器應該中止,而不是繼續請求完所有的後端服務。
而 goroutine 的管理,最開始隻有 chan 和 sync,需要自己手動實作 goroutine 的生命周期管理,參考
Go Concurrency Patterns: Timing out, moving on
Go Concurrency Patterns: Context
,這些都是 goroutine 的并發範式。
直接使用原始的元件管理 goroutine 太繁瑣了,後來在一些大型項目中出現了 context 這些庫,并且 Go1.7 之後變成了标準庫的一部分。具體參考
Context 也有問題:
- 支援 Cancel、Timeout 和 Value,這些都是擴張 Context 樹的節點。Cancel 和 Timeout 在子樹取消時會删除子樹,不會一直膨脹;Value 沒有提供删除的函數,如果他們有公共的根節點,會導緻這個 Context 樹越來越龐大;是以 Value 類型的 Context 應該挂在 Cancel 的 Context 樹下面,這樣在取消時 GC 會回收;
- 會導緻接口不一緻或者奇怪,比如 io.Reader 其實第一個參數應該是 context,比如
函數。或者提供兩套接口,一種帶 Contex,一種不帶 Context。這個問題還蠻困擾人的,一般在應用程式中,推薦第一個參數是 Context;Read(Context, []byte)
- 注意 Context 樹,如果因為 Closure 導緻樹越來越深,會有調用棧的性能問題。比如十萬個長鍊,會導緻 CPU 占用 500% 左右。
備注:關于對 Context 的批評,可以參考 ,作者覺得在标準庫中加 context 作為第一個參數不能了解,比如 Read(ctx context.Context
Go 開發技術指南系列文章
雲原生技術公開課
本課程是由 CNCF 官方與阿裡巴巴強強聯合,共同推出的以“雲原生技術體系”為核心、以“技術解讀”和“實踐落地”并重的系列
技術公開課“ 阿裡巴巴雲原生 關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,做最懂雲原生開發者的技術圈。”