Go 開發關鍵技術指南 | 帶著伺服器程式設計金剛經走進 2020 年 (內含超全知識大圖)

alicloudnative發表於2020-02-17

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 解決問題的思路。

我們借用模板方法模式 (TemplateMethodPattern) 中的例子,考慮實現一個跨平臺編譯器,提供給使用者使用的函式是 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,但實際上可能只是依賴於 io.Reader 就可以方便做 UTest,那麼可以直接修改成 foo(r io.Reader) 所有地方都不用修改,特別是這個介面是新增的自定義介面時就更明顯;
  • In Go, interfaces are usually small: one or two or even zero methods. 在 Go 中介面都比較小,非常小,只有一兩個函式;但是物件卻會比較大,會使用很多的介面。這種方式能夠以最靈活的方式重用程式碼,而且保持介面的有效性和最小化,也就是介面隔離。

隱式實現介面有個很好的作用,就是兩個類似的模組實現同樣的服務時,可以無縫的提供服務,甚至可以同時提供服務。比如改進現有模組時,比如兩個不同的演算法。更厲害的時,兩個模組建立的私有介面,如果它們簽名一樣,也是可以互通的,其實簽名一樣就是一樣的介面,無所謂是不是私有的了。這個非常強大,可以允許不同的模組在不同的時刻升級,這對於提供服務的伺服器太重要了。

比較被嚴重誤認為是繼承的,莫過於是 Go 的內嵌 Embeding,因為 Embeding 本質上還是組合不是繼承,參考 Embeding is still composition

Embeding 在 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 相關的描述,可能還不止這些地方:

Composition not inheritance 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 並沒有提供上面的方法,而是提供了幾種不同的機制來管理執行緒:

  • go 關鍵鍵字啟動 goroutine;
  • sync.WaitGroup 等待執行緒退出;
  • chan 也可以用來同步,比如等 goroutine 啟動或退出,或者傳遞退出資訊給 goroutine;
  • context 也可以用來管理 goroutine,參考 Context
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.

注意上面只是例子,實際中推薦用 Context 管理 goroutine。

如果把 goroutine 看成一個向量,把 sync 看成一個向量,把 chan 看成一個向量,這些向量都不相關,也就是它們是正交的。

再舉個 Orthogonal Software 的例子,將物件儲存到 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

這個的壞處包括:

  1. 邏輯程式碼和序列化程式碼混合在一起,隨處可見序列化程式碼,非常難以維護;
  2. 如果要新增序列化的機制比如將物件序列化儲存到網路就很費勁了;
  3. 假設 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 格式看成一個向量,他們實際上是可以不相關的。

再看一個例子,考慮上面 JSON-RPC: a tale of interfaces 的修改,實際上是將序列化的部分,從 *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 掛掉了。使用時如果出現自己不用的函式也是一種壞味道,比如 Copy(src, dst io.ReadWriter) 就有問題,因為 src 明顯不會用到 Write 而 dst 不會用到 Read,所以改成 Copy(src io.Reader, dst io.Writer) 才是合理的。

由此可見,Orthogonal 是介面設計中非常關鍵的要素,我們需要從概念上考慮介面,儘量提供正交的介面和函式。比如 io.Readerio.Writer 和 io.Closer 是正交的,因為有時候我們需要的新向量是讀寫那麼可以使用 io.ReadWriter,這實際上是兩個介面的組合。

我們如何才能實現 Orthogonal 的介面呢?特別對於公共庫,這個非常關鍵,直接決定了我們是否能提供好用的庫,還是很爛的不知道怎麼用的庫。有幾個建議:

  1. 好用的公共庫,使用者可以通過 IDE 的提示就知道怎麼用,不應該提供多個不同的路徑實現一個功能,會造成很大的困擾。比如 Android 的通訊錄,超級多的完全不同的類可以用,實際上就是非常難用;
  2. 必須要有完善的文件。完全通過程式碼就能表達 Why 和 How,是不可能的。就算是 Go 的標準庫,也是大量的註釋,如果一個公共庫沒有文件和註釋,會非常的難用和維護;
  3. 一定要先寫 Example,一定要提供 UTest 完全覆蓋。沒有 Example 的公共庫是不知道介面設計是否合理的,沒有人有能力直接設計一個合理的庫,只有從使用者角度分析才能知道什麼是合理,Example 就是使用者角度;標準庫有大量的 Example。UTest 也是一種使用,不過是內部使用,也很必要。

如果上面數學上有不嚴謹的請原諒我,我數學很渣。

Modules

先把最重要的說了,關於 modules 的最新詳細資訊可以執行命令 go help modules 或者查這個長長的手冊 Go Modules,另外 modules 弄清楚後很好用遷移成本低。

Go Module 的好處,可以參考 Demo

  1. 程式碼不用必須放 GOPATH,可以放在任何目錄,終於不用做軟鏈了;
  2. Module 依然可以用 vendor,如果不需要更新依賴,可以不必從遠端下載依賴程式碼,同樣不必放 GOPATH;
  3. 如果在一個倉庫可以直接引用,會自動識別模組內部的 package,同樣不用連結到 GOPATH。

Go 最初是使用 GOPATH 存放依賴的包(專案和程式碼),這個 GOPATH 是公共的目錄,如果依賴的庫的版本不同就杯具了。2016 年也就是 7 年後才支援 vendor 規範,就是將依賴本地化了,每個專案都使用自己的 vendor 資料夾,但這樣也解決不了衝突的問題(具體看下面的分析),相反導致各種包管理專案天下混戰,參考 pkg management tools

2017 年也就是 8 年後,官方的 vendor 包管理器 dep 才確定方案,看起來命中註定的 TheOne 終於塵埃落定。不料 2018 年也就是 9 年後,又提出比較完整的方案 versioning 和 vgo,這年 Go1.11 支援了 Modules,2019 年 Go1.12 和 Go1.13 改進了不少 Modules 內容,Go 官方文件推出一系列的 Part 1 — Using Go ModulesPart 2 — Migrating To Go Modules 和 Part 3 — Publishing Go Modules,終於應該大概齊能明白,這次真的確定和肯定了,Go Modules 是最終方案。

為什麼要搞出 GOPATH、Vendor 和 GoModules 這麼多技術方案?本質上是為了創造就業崗位,一次創造了 indexproxy 和 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 年才確定最終方案就是這個原因了,下面舉例子詳細分析這個問題。

備註:標準庫也有遇到介面變更的風險,比如 Context 是 Go1.7 才引入標準庫的,控制程式生命週期,後續有很多介面的第一個引數都是 ctx context.Context,比如 net.DialContext 就是後面加的一個函式,而 net.Dial 也是呼叫它。再比如 http.Request.WithContext 則提供了一個函式,將 context 放在結構體中傳遞,這是因為要再為每個 Request 的函式新增一個引數不太合適。從 context 對於標準庫的介面的變更,可以看得到這裡有些不一致性,有很多批評的聲音比如 Context should go away for Go 2,就是覺得在標準庫中加 context 作為第一個引數不能理解,比如 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 的,可以想到,這會造成幾個問題:

  1. 每次都要從網路下載依賴,可能對於美國這個問題不存在,但是對於中國,要從 GITHUB 上下載很大的專案,是個很麻煩的問題,還沒有斷點續傳;
  2. 如果兩個專案,依賴了 GOPATH 了專案,如果一個更新會導致另外一個專案出現問題。比如新的專案下載了最新的依賴庫,可能會導致其他專案出問題;
  3. 無法獨立管理版本號和升級,獨立依賴不同的包的版本。比如 A 專案依賴 1.0 的庫,而 B 專案依賴 2.0 的庫。注意:如果 A 和 B 都是庫的話,這個問題還是無解的,它們可能會同時被一個專案引用,如果 A 和 B 是最終的應用是沒有問題,應用可以用不同的版本,它們在自己的目錄。

為了解決這些問題,引入了 vendor,在 src 下面有個 vendor 目錄,將依賴的庫都下載到這個目錄,同時會有描述檔案說明依賴的版本,這樣可以實現升級不同庫的升級。參考 vendor,以及官方的包管理器 dep。但是 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 創造的三大就業崗位,index 負責索引、proxy 負責代理快取和 sum 負責簽名校驗,它們之間的關係在 Big Picture 中有描述。可見 go-get 會先從 index 獲取指定 package 的索引,然後從 proxy 下載資料,最後從 sum 來獲取校驗資訊:

Go 開發關鍵技術指南 | 帶著伺服器程式設計金剛經走進 2020 年 (內含超全知識大圖)

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
│   │   │           │           └── go-oryx-lib@v0.0.7
│   └── github.com
│       └── ossrs
│           └── go-oryx-lib@v0.0.7
│               ├── 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/go-oryx-lib@v0.0.8。當然也可以降級,比如現在是 v0.0.8,可以 go get github.com/ossrs/go-oryx-lib@v0.0.7 降到 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/v2@v2.0.3

而庫提供大版本也是一樣的,參考 mod_major_releases/v2,主要做的事情:

  1. 新建 v2 的分支,git checkout -b v2,比如 https://github.com/winlinvip/...
  2. 修改 go.mod 的描述,路徑必須帶 v2,比如 module github.com/winlinvip/mod_major_releases/v2
  3. 提交後打 v2 的 tag,比如 git tag v2.0.0,分支和 tag 都要提交到 git。

其中 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,而釋出到 github.com/winlinvip/app,當然其他專案 import 這個包時會出現錯誤。對於庫,也就是希望別人依賴的包,go.mod 描述的和釋出的路徑,以及 package 名字都應該保持一致;

  • 如果一個包沒有釋出任何版本,則會取最新的 commit 和日期,格式為 v0.0.0 - 日期 - commit 號,比如 v0.0.0-20191028070444-45532e158b41,參考 Pseudo Versions。版本號可以從 v0.0.x 開始,比如 v0.0.1 或者 v0.0.3 或者 v0.1.0 或者 v1.0.1 之類,沒有強制要求必須要是 1.0 開始的釋出版本;

  • mod replace 在子 module 無效,只在編譯的那個 top level 有效,也就是在最終生成 binary 的 go.mod 中定義才有效,官方的說明是為了讓最終生成時控制依賴。例如想要把 github.com/pkg/errors 重寫為 github.com/winlinvip/errors 這個包,正確做法參考分支 replace_errors;若不在主模組 (top level) 中 replace 參考 replace_in_submodule,只在 子模組中定義了 replace 但會被忽略;如果在主模組 replace 會生效  replace_errors,而且在主模組依賴掉子模快依賴的模組也生效 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;

  • go get 並非將每個庫都更新後取最新的版本,比如庫 github.com/winlinvip/mod_minor_versions 有 v1.0.1、v1.1.2 兩個版本,目前依賴的是 v1.1.2 版本,如果庫更新到了 v1.2.3 版本,立刻使用 go get -u 並不會更新到 v1.2.3,執行 go get -u github.com/winlinvip/mod_minor_versions 也一樣不會更新,除非顯式更新 go get github.com/winlinvip/mod_minor_versions@v1.2.3 才會使用這個版本,需要等一定時間後才會更新;

  • 對於大版本比如 v2,必須用 go.mod 描述,直接引用也可以比如 go get github.com/winlinvip/mod_major_error@v2.0.0,會提示 v2.0.0+incompatible,意思就是預設都是 v0 和 v1,而直接打了 v2.0.0 的 tag,雖然版本上匹配到了,但實際上是把 v2 當做 v1 在用,有可能會有不相容的問題。或者說,一般來說 v2.0.0 的這個 tag,一定會有介面的變更(否則就不能叫 v2 了),如果沒有用 go.mod 會把這個認為是 v1,自然可能會有相容問題了;

  • 更新大版本時必須帶版本號比如 go get github.com/winlinvip/mod_major_releases/v2@v2.0.1,如果路徑中沒有這個 v2 則會報錯無法更新,比如 go get github.com/winlinvip/mod_major_releases@v2.0.1,錯誤訊息是 invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1,這個就是說 mod_major_releases 這個下面有 go.mod 描述的版本是 v0 或 v1,但後面指定的版本是 @v2 所以不匹配無法更新;

  • 和上面的問題一樣,如果在 go.mod 中,大版本路徑中沒有帶版本,比如 require github.com/winlinvip/mod_major_releases v2.0.3,一樣會報錯 module contains a go.mod file, so major version must be compatible: should be v0 or v1,這個有點含糊因為包定義的 go.mod 是 v2 的,這個錯誤的意思是,require 的那個地方,要求的是 v0 或 v1,而實際上版本是 v2.0.3,這個和手動要求更新 go get github.com/winlinvip/mod_major_releases@v2.0.1 是一回事;

  • 注意三大崗位有 cache,比如 mod_major_error@v5.0.0 的 go.mod 描述有錯誤,應該是 v5,而不是 v3。如果在打完 tag 後,獲取了這個版本 go get github.com/winlinvip/mod_major_error/v5,會提示錯誤 but does not contain package github.com/winlinvip/mod_major_error/v5 等錯誤,如果刪除這個 tag 後再推 v5.0.0,還是一樣的錯誤,因為 index 和 goproxy 有快取這個版本的資訊。解決版本就是升一個版本 v5.0.1,直接獲取這個版本就可以,比如 go get github.com/winlinvip/mod_major_error/v5@v5.0.1,這樣才沒有問題。詳細參考 Semantic versions and modules

  • 和上面一樣的問題,如果在版本沒有釋出時,就有 go get 的請求,會造成版本釋出後也無法獲取這個版本。比如 github.com/winlinvip/mod_major_error 沒有打版本 v3.0.1,就請求 go get github.com/winlinvip/mod_major_error/v3@v3.0.1,會提示沒有這個版本。如果後面再打這個 tag,就算有這個 tag 後,也會提示 401 找不到 reading https://sum.golang.org/lookup/github.com/winlinvip/mod_major_error/v3@v3.0.1: 410 Gone。只能再升級個版本,打個新的 tag 比如 v3.0.2 才能獲取到。

總結來說:

  • GOPATH,自從預設為 $HOME/go 後,很好用,依賴的包都快取在這個公共的地方,只要專案不大,完全是很直接很好用的方案。一般情況下也夠用了,估計 GOPATH 可能會被長期使用,畢竟習慣才是最可怕的,習慣是活的最久的,習慣就成為了一種生活方式,用餘老師的話說 “文化是一種精神價值和生活方式,最終體現了集體人格”;
  • 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,可能和倉庫的路徑並不相同,比如倉庫在 https://go.googlesource.com/lint,而包路徑是 golang.org/x/lint

Note: 特別注意如果是庫支援了 v2 及以上的版本,那麼路徑中一定需要包含 v2,比如 github.com/russross/blackfriday/v2。而且需要更新引用了這個包的 v2 的庫,比較蛋疼,不過這種情況還好是不多的。

我們們先看一個使用 GOPATH 的例子,我們新建一個測試包,先以 GOPATH 方式提供,參考 github.com/winlinvip/mod_gopath,依賴於 github.com/pkg/errorsrsc.io/quote 和 github.com/gorilla/websocket

再看一個 vendor 的例子,將這個 GOPATH 的專案,轉成 vendor 專案,參考 github.com/winlinvip/mod_vendor,安裝完 dep 後執行 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 程式碼(這裡為了演示差別所以拷貝了一份,直接轉換也是可以的),變成 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 成 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 有幾個步驟:

  1. 先轉成 modules,參考前面的步驟,也可以新建一個 modules 例如 go mod init xxx,然後把程式碼寫好,就是一個標準的 module,不過檔案是存在 $GOPATH/pkg 的,參考 github.com/winlinvip/mod_vgo_with_vendor@v1.0.0
  2. go mod vendor,這一步做的事情,就是將 modules 中的檔案都放到 vendor 中來。當然由於 go.mod 也存在,當然也知道這些檔案的版本資訊,也不會造成什麼問題,只是新建了一個 vendor 目錄而已。在別人看起來這就是這正常的 modules,和 vendor 一點影響都沒有。參考 github.com/winlinvip/mod_vgo_with_vendor@v1.0.1
  3. go build -mod=vendor,修改 mod 這個引數,預設是會忽略這個 vendor 目錄了,加上這個引數後就會從 vendor 目錄載入程式碼(可以把 $GOPATH/pkg 刪掉髮現也不會下載程式碼)。當然其他也可以加這個 flag,比如 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 的設定裡面,有個 Preferences /Go /Go Modules(vgo) /Vendoring mode,這樣會從專案的 vendor 目錄解析,而不是從全域性的 cache。如果不需要匯入新的包,可以預設開啟 vendor 方式,執行命令 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 的併發機制是至關重要的。

借用《人月神話》中關於固有複雜度 (Essential Complexity) 的概念,能比較清晰的說明併發問題。就算沒有讀過這本書,也肯定聽過軟體開發 “ 沒有銀彈”,要保持軟體的 “概念完整性”,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 的一種 固有複雜度 (Essential Complexity)

我翻譯了 ST (StateThreads) 總結的併發處理模型高效能、高併發、高擴充套件性和可讀性的網路伺服器架構:State Threads for Internet Applications,這篇文章也是理解 Go 併發處理的關鍵,本質上 ST 就是 C 語言的協程庫(騰訊微信也開源過一個 libco 協程庫),而 goroutine 是 Go 語言級別的實現,本質上他們解決的領域問題是一樣的,當然 goroutine 會更廣泛一些,ST 只是一個網路庫。我們一起看看併發的本質目標,一起看圖說話吧,先從併發相關的 效能和伸縮性問題說起:

Go 開發關鍵技術指南 | 帶著伺服器程式設計金剛經走進 2020 年 (內含超全知識大圖)

  • 橫軸是客戶端的數目,縱軸是吞吐率也就是正常提供服務需要能吐出的資料,比如 1000 個客戶端在觀看 500Kbps 位元速率的視訊時,意味著每個客戶端每秒需要 500Kb 的資料,那麼伺服器需要每秒吐出 500*1000Kb=500Mb 的資料才能正常提供服務,如果伺服器因為效能問題 CPU 跑滿了都無法達到 500Mbps 的吞吐率,客戶端必定就會開始卡頓;
  • 圖中黑色的線是客戶端要求的最低吞吐率,假設每個客戶端都是一樣的,那麼黑色的線就是一條斜率固定的直線,也就是客戶端越多吞吐率就越多,基本上和客戶端數目成正比。比如 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 Good Poor Poor Complex Tomcat, FMS/AMS
Event-Driven State Machine Great Great Good Very Complex Nginx, CRTMPD
StateThreads Great Great Good Simple 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 的併發的撒手鐗。

由於 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 跑不了多快抓老鼠卻比千里馬強。

Context

實際上 goroutine 的管理,在真正高可用的程式中是非常必要的,我們一般會需要支援幾種 gorotine 的控制方式:

  1. 錯誤處理:比如底層函式發生錯誤後,我們是忽略並告警(比如只是某個連線受到影響),還是選擇中斷整個服務(比如 LICENSE 到期);
  2. 使用者取消:比如升級時,我們需要主動的遷移新的請求到新的服務,或者取消一些長時間執行的 goroutine,這就叫熱升級;
  3. 超時關閉:比如請求的最大請求時長是 30 秒,那麼超過這個時間,我們就應該取消請求。一般客戶端的服務響應是有時間限制的;
  4. 關聯取消:比如客戶端請求伺服器,伺服器還要請求後端很多服務,如果中間客戶端關閉了連線,伺服器應該中止,而不是繼續請求完所有的後端服務。

而 goroutine 的管理,最開始只有 chan 和 sync,需要自己手動實現 goroutine 的生命週期管理,參考 Go Concurrency Patterns: Timing out, moving on 和 Go Concurrency Patterns: Context,這些都是 goroutine 的併發正規化。

直接使用原始的元件管理 goroutine 太繁瑣了,後來在一些大型專案中出現了 context 這些庫,並且 Go1.7 之後變成了標準庫的一部分。具體參考 GOLANG 使用 Context 管理關聯 goroutine 以及 GOLANG 使用 Context 實現傳值、超時和取消

Context 也有問題:

  1. 支援 Cancel、Timeout 和 Value,這些都是擴張 Context 樹的節點。Cancel 和 Timeout 在子樹取消時會刪除子樹,不會一直膨脹;Value 沒有提供刪除的函式,如果他們有公共的根節點,會導致這個 Context 樹越來越龐大;所以 Value 型別的 Context 應該掛在 Cancel 的 Context 樹下面,這樣在取消時 GC 會回收;
  2. 會導致介面不一致或者奇怪,比如 io.Reader 其實第一個引數應該是 context,比如 Read(Context, []byte) 函式。或者提供兩套介面,一種帶 Contex,一種不帶 Context。這個問題還蠻困擾人的,一般在應用程式中,推薦第一個引數是 Context;
  3. 注意 Context 樹,如果因為 Closure 導致樹越來越深,會有呼叫棧的效能問題。比如十萬個長鏈,會導致 CPU 佔用 500% 左右。

備註:關於對 Context 的批評,可以參考 Context should go away for Go 2,作者覺得在標準庫中加 context 作為第一個引數不能理解,比如 Read(ctx context.Context 等。

本文來源於 https://segmentfault.com/a/119000002150463... ,僅刪除了一些無意義內容並修改了一些格式

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章