Go 開發關鍵技術指南 | 帶着服務器編程金剛經走進 2020 年(內含超全知識大圖)

做者 | 楊成立(忘籬) 阿里巴巴高級技術專家html

關注「阿里巴巴雲原生」公衆號,回覆 Go 便可查看清晰知識大圖!java

導讀:從問題自己出發,不侷限於 Go 語言,探討服務器中經常遇到的問題,最後回到 Go 如何解決這些問題,爲你們提供 Go 開發的關鍵技術指南。咱們將以系列文章的形式推出《Go 開發的關鍵技術指南》,共有 4 篇文章,本文爲第 3 篇。linux

Go 開發指南

Interfaces

Go 在類型和接口上的思考是:android

  • Go 類型系統並非通常意義的 OO,並不支持虛函數;
  • Go 的接口是隱含實現,更靈活,更便於適配和替換;
  • Go 支持的是組合、小接口、組合+小接口;
  • 接口設計應該考慮正交性,組合更利於正交性。

Type System

Go 的類型系統是比較容易和 C++/Java 混淆的,特別是習慣於類體系和虛函數的思路後,很容易想在 Go 走這個路子,惋惜是走不通的。而 interface 由於太過於簡單,並且和 C++/Java 中的概念差別不是特別明顯,因此本章節專門分析 Go 的類型系統。nginx

先看一個典型的問題 Is it possible to call overridden method from parent struct in golang? 代碼以下所示:git

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

咱們借用模板方法模式 (TemplateMethodPattern) 中的例子,考慮實現一個跨平臺編譯器,提供給用戶使用的函數是 crossCompile,而這個函數調用了兩個模板方法 collectSource 和 compileToTargetgolang

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,代碼以下所示:web

// 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 (正交性)則不擇不扣的是個數學術語,是線性代數(就是矩陣那個玩意兒)中用來描述兩個向量相關性的,在平面中就是兩個線條的垂直。好比下圖:

2.png

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:

3.png

若是公共庫 package(這裏是 OAuth)的導入路徑同樣(好比是 github.com/google/oauth),可是作了非兼容性變動,發佈了 OAuth-r1 和 OAuth-r2,其中一個雲服務商更新了本身的依賴,另一個沒有更新,就會形成衝突,他們依賴的版本不一樣:

4.png

在 Go 中不管怎麼修改都沒法支持這種狀況,除非在 package 的路徑中加入版本語義進去,也就是在路徑上帶上版本信息(這就是 Go Modules了),這和優雅沒有關係,這其實是最好的使用體驗:

5.png

另外作法就是改變包路徑,這要求包提供者要每一個版本都要使用一個特殊的名字,但使用者也不能分辨這些名字表明的含義,天然也不知道如何選擇哪一個版本。

先看看 Go Modules 創造的三大就業崗位,index 負責索引、proxy 負責代理緩存和 sum 負責簽名校驗,它們之間的關係在 Big Picture 中有描述。可見 go-get 會先從 index 獲取指定 package 的索引,而後從 proxy 下載數據,最後從 sum 來獲取校驗信息:

6.png

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/mod_major_releases/tree/v2;
  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.一、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 只是一個網絡庫。咱們一塊兒看看併發的本質目標,一塊兒看圖說話吧,先從併發相關的性能和伸縮性問題提及:

7.png

  • 橫軸是客戶端的數目,縱軸是吞吐率也就是正常提供服務須要能吐出的數據,好比 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<br />State Machine Great Great Good Very<br />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,<br />distributing units of work,<br /> communicating async results caches,<br />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 等。

Go 開發技術指南系列文章

雲原生技術公開課

8.png

本課程是由 CNCF 官方與阿里巴巴強強聯合,共同推出的以「雲原生技術體系」爲核心、以「技術解讀」和「實踐落地」並重的系列技術公開課

阿里巴巴雲原生關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,作最懂雲原生開發者的技術圈。」

相關文章
相關標籤/搜索