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,好比 github.com/winlinvip/m…
  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/m…,依赖于 github.com/pkg/errorsrsc.io/quote 和 github.com/gorilla/web…

再看一个 vendor 的例子,将这个 GOPATH 的项目,转成 vendor 项目,参考 github.com/winlinvip/m…,安装完 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/m… 代码(这里为了演示差异因此拷贝了一份,直接转换也是能够的),变成 github.com/winlinvip/m…,而后执行命令 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/m… 成 github.com/winlinvip/m…,执行命令 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/m…

  2. go mod vendor,这一步作的事情,就是将 modules 中的文件都放到 vendor 中来。固然因为 go.mod 也存在,固然也知道这些文件的版本信息,也不会形成什么问题,只是新建了一个 vendor 目录而已。在别人看起来这就是这正常的 modules,和 vendor 一点影响都没有。参考 github.com/winlinvip/m…

  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
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 等。

Go 开发技术指南系列文章

云原生技术公开课

8.png

本课程是由 CNCF 官方与阿里巴巴强强联合,共同推出的以“云原生技术体系”为核心、以“技术解读”和“实践落地”并重的系列技术公开课

阿里巴巴云原生关注微服务、Serverless、容器、Service Mesh 等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,作最懂云原生开发者的技术圈。”

相关文章
相关标签/搜索