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

阿里巴巴雲原生發表於2020-01-10

點選這裡,檢視Orthogonal,Modules,GOPATH & Vendor等剩餘重要內容

Go 開發指南

https://i.iter01.com/images/b79622057790a983a97edbb55a12d895ef8e77c216517f0c30ea1d6978acbe85.png

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 

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 

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 

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,詳細參考正交性

關鍵字:XML  儲存  Shell  Go  開發工具  Android開發  iOS開發  資料格式  git  C++

相關文章