[译]Go语言最佳实战[二]

Posted GoCN

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[译]Go语言最佳实战[二]相关的知识,希望对你有一定的参考价值。

Go 语言实战: 编写可维护 Go 语言代码建议

目录

  • 1. 指导原则

    • 1.1 简单性

    • 1.2 可读性

    • 1.3 生产力

  • 2. 标识符

    • 2.1 选择标识是为了清晰, 而不是简洁

    • 2.2 标识符长度

    • 2.3 不要用变量类型命名变量

    • 2.4 使用一致的命名风格

    • 2.5 使用一致的声明样式

    • 2.6 成为团队的合作者

  • 3. 注释

    • 3.1 关于变量和常量的注释应描述其内容而非其目的

    • 3.2 公共符号始终要注释

  • 4. 包的设计

    • 4.1 一个好的包从它的名字开始

    • 4.2 避免使用类似 base、 common或 util的包名称

    • 4.3 尽早 return而不是深度嵌套

    • 4.4 让零值更有用

    • 4.5 避免包级别状态

  • 5. 项目结构

    • 5.1 考虑更少,更大的包

    • 5.2 保持 main包内容尽可能的少

  • 6. API 设计

    • 6.1 设计难以被误用的 API

    • 6.2 为其默认用例设计 API

    • 6.3 让函数定义它们所需的行为

  • 7. 错误处理

    • 7.1 通过消除错误来消除错误处理

    • 7.2 错误只处理一次

  • 8. 并发

    • 8.1 保持自己忙碌或做自己的工作

    • 8.2 将并发性留给调用者

    • 8.3 永远不要启动一个停止不了的 goroutine



3. 注释

在我们继续讨论更大的项目之前,我想花几分钟时间谈论一下注释。

Good code has lots of comments, bad code requires lots of comments.
(好的代码有很多注释,坏代码需要很多注释。)
— Dave Thomas and Andrew Hunt (The Pragmatic Programmer)

注释对 Go 语言程序的可读性非常重要。 注释应该做的三件事中的一件:

  1. 注释应该解释其作用。

  2. 注释应该解释其如何做的。

  3. 注释应该解释其原因。

第一种形式是公共符号注释的理想选择:

 
   
   
 
  1. // Open opens the named file for reading.

  2. // If successful, methods on the returned file can be used for reading.

第二种形式非常适合在方法中注释:

 
   
   
 
  1. // queue all dependant actions

  2. var results []chan error

  3. for _, dep := range a.Deps {

  4.        results = append(results, execute(seen, dep))

  5. }

第三种形式是独一无二的,因为它不会取代前两种形式,但与此同时它并不能代替前两种形式。 此形式的注解用以解释代码的外部因素。 这些因素脱离上下文后通常很难理解,此注释的为了提供这种上下文。

 
   
   
 
  1. return &v2.Cluster_CommonLbConfig{

  2.    // Disable HealthyPanicThreshold

  3.        HealthyPanicThreshold: &envoy_type.Percent{

  4.            Value: 0,

  5.        },

  6. }

在此示例中,无法清楚地明白 HealthyPanicThreshold设置为零百分比的效果。 需要注释 0值将禁用 panic阀值。

3.1. 关于变量和常量的注释应描述其内容而非其目的

我之前谈过,变量或常量的名称应描述其目的。 向变量或常量添加注释时,该注释应描述变量内容,而不是变量目的。

 
   
   
 
  1. const randomNumber = 6 // determined from an unbiased die

在此示例中,注释描述了为什么 randomNumber被赋值为 6,以及 6 来自哪里。 注释没有描述 randomNumber的使用位置。 还有更多的栗子:

 
   
   
 
  1. const (

  2.    StatusContinue           = 100 // RFC 7231, 6.2.1

  3.    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2

  4.    StatusProcessing         = 102 // RFC 2518, 10.1

  5.    StatusOK                 = 200 // RFC 7231, 6.3.1

在 HTTP 的上下文中,数字 100被称为 StatusContinue,如 RFC 7231 第 6.2.1 节中所定义。

贴士:
对于没有初始值的变量,注释应描述谁负责初始化此变量。

 
   
   
 
  1. // sizeCalculationDisabled indicates whether it is safe

  2. // to calculate Types' widths and alignments. See dowidth.

  3. var sizeCalculationDisabled bool

这里的注释让读者知道 dowidth函数负责维护 sizeCalculationDisabled的状态。

隐藏在众目睽睽下
这个提示来自 Kate Gregory[3]。有时你会发现一个更好的变量名称隐藏在注释中。

 
   
   
 
  1. // registry of SQL drivers

  2. var registry = make(map[string]*sql.Driver)

注释是由作者添加的,因为 registry没有充分解释其目的 - 它是一个注册表,但注册的是什么?

通过将变量重命名为 sqlDrivers,现在可以清楚地知道此变量的目的是保存 SQL 驱动程序。

 
   
   
 
  1. var sqlDrivers = make(map[string]*sql.Driver)

之前的注释就是多余的,可以删除。

3.2. 公共符号始终要注释

godoc是包的文档,所以应该始终为包中声明的每个公共符号 — 变量、常量、函数以及方法添加注释。

以下是 Google Style指南中的两条规则:

  • 任何既不明显也不简短的公共功能必须予以注释。

  • 无论长度或复杂程度如何,对库中的任何函数都必须进行注释

 
   
   
 
  1. package ioutil

  2. // ReadAll reads from r until an error or EOF and returns the data it read.

  3. // A successful call returns err == nil, not err == EOF. Because ReadAll is

  4. // defined to read from src until EOF, it does not treat an EOF from Read

  5. // as an error to be reported.

  6. func ReadAll(r io.Reader) ([]byte, error)

这条规则有一个例外; 您不需要注释实现接口的方法。 具体不要像下面这样做:

 
   
   
 
  1. // Read implements the io.Reader interface

  2. func (r *FileReader) Read(buf []byte) (int, error)

这个注释什么也没说。 它没有告诉你这个方法做了什么,更糟糕是它告诉你去看其他地方的文档。 在这种情况下,我建议完全删除该注释。

这是 io包中的一个例子

 
   
   
 
  1. // LimitReader returns a Reader that reads from r

  2. // but stops with EOF after n bytes.

  3. // The underlying implementation is a *LimitedReader.

  4. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

  5. // A LimitedReader reads from R but limits the amount of

  6. // data returned to just N bytes. Each call to Read

  7. // updates N to reflect the new amount remaining.

  8. // Read returns EOF when N <= 0 or when the underlying R returns EOF.

  9. type LimitedReader struct {

  10.    R Reader // underlying reader

  11.    N int64  // max bytes remaining

  12. }

  13. func (l *LimitedReader) Read(p []byte) (n int, err error) {

  14.    if l.N <= 0 {

  15.        return 0, EOF

  16.    }

  17.    if int64(len(p)) > l.N {

  18.        p = p[0:l.N]

  19.    }

  20.    n, err = l.R.Read(p)

  21.    l.N -= int64(n)

  22.    return

  23. }

请注意, LimitedReader的声明就在使用它的函数之前,而 LimitedReader.Read的声明遵循 LimitedReader本身的声明。 尽管 LimitedReader.Read本身没有文档,但它清楚地表明它是 io.Reader的一个实现。

贴士:
在编写函数之前,请编写描述函数的注释。 如果你发现很难写出注释,那么这就表明你将要编写的代码很难理解。

3.2.1. 不要注释不好的代码,将它重写

Don’t comment bad code — rewrite it
— Brian Kernighan

粗劣的代码的注释高亮显示是不够的。 如果你遇到其中一条注释,则应提出问题,以提醒您稍后重构。 只要技术债务数额已知,它是可以忍受的。

标准库中的惯例是注意到它的人用 TODO(username)的样式来注释。

 
   
   
 
  1. // TODO(dfc) this is O(N^2), find a faster way to do this.

注释 username不是该人承诺要解决该问题,但在解决问题时他们可能是最好的人选。 其他项目使用 TODO与日期或问题编号来注释。

3.2.2. 与其注释一段代码,不如重构它

Good code is its own best documentation. As you’re about to add a comment, ask yourself, 'How can I improve the code so that this comment isn’t needed?' Improve the code and then document it to make it even clearer.
好的代码是最好的文档。 在即将添加注释时,请问下自己,“如何改进代码以便不需要此注释?' 改进代码使其更清晰。
— Steve McConnell

函数应该只做一件事。 如果你发现自己在注释一段与函数的其余部分无关的代码,请考虑将其提取到它自己的函数中。

除了更容易理解之外,较小的函数更易于隔离测试,将代码隔离到函数中,其名称可能是所需的所有文档。

4. 包的设计

Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations.
编写谨慎的代码 - 不向其他模块透露任何不必要的模块,并且不依赖于其他模块的实现。
— Dave Thomas

每个 Go 语言的包实际上都是它一个小小的 Go 语言程序。 正如函数或方法的实现对调用者而言并不重要一样,包的公共 API - 其函数、方法以及类型的实现对于调用者来说也并不重要。

一个好的 Go 语言包应该具有低程度的源码级耦合,这样,随着项目的增长,对一个包的更改不会跨代码库级联。 这些世界末日的重构严格限制了代码库的变化率以及在该代码库中工作的成员的生产率。

在本节中,我们将讨论如何设计包,包括包的名称,命名类型以及编写方法和函数的技巧。

4.1. 一个好的包从它的名字开始

编写一个好的 Go 语言包从包的名称开始。将你的包名用一个词来描述它。

正如我在上一节中谈到变量的名称一样,包的名称也非常重要。我遵循的经验法则不是 “我应该在这个包中放入什么类型的?”。相反,我要问是“该包提供的服务是什么?” 通常这个问题的答案不是“这个包提供 X类型”,而是 “这个包提供 HTTP”。

贴士:
以包所提供的内容来命名,而不是它包含的内容。

4.1.1. 好的包名应该是唯一的。

在项目中,每个包名称应该是唯一的。包的名称应该描述其目的的建议很容易理解 - 如果你发现有两个包需要用相同名称,它可能是:

  1. 包的名称太通用了。

  2. 该包与另一个类似名称的包重叠了。在这种情况下,您应该检查你的设计,或考虑合并包。

4.2. 避免使用类似 base, common或 util的包名称

不好的包名的常见情况是 utility包。这些包通常是随着时间的推移一些帮助程序和工具类的包。由于这些包包含各种不相关的功能,因此很难根据包提供的内容来描述它们。这通常会导致包的名称来自包含的内容 - utilities

像 utils或 helper这样的包名称通常出现在较大的项目中,这些项目已经开发了深层次包的结构,并且希望在不遇到导入循环的情况下共享 helper函数。通过将 utility程序函数提取到新的包中,导入循环会被破坏,但由于该包源于项目中的设计问题,因此其包名称不反映其目的,仅反映其为了打破导入循环。

我建议改进 utils或 helpers包的名称是分析它们的调用位置,如果可能的话,将相关的函数移动到调用者的包中。即使这涉及复制一些 helper程序代码,这也比在两个程序包之间引入导入依赖项更好。

[A little] duplication is far cheaper than the wrong abstraction.
([一点点] 重复比错误的抽象的性价比高很多。)
— Sandy Metz

在使用 utility程序的情况下,最好选多个包,每个包专注于单个方面,而不是选单一的整体包。

贴士: 
使用复数形式命名 utility包。例如 strings来处理字符串。

当两个或多个实现共有的功能或客户端和服务器的常见类型被重构为单独的包时,通常会找到名称类似于 base或 common的包。我相信解决方案是减少包的数量,将客户端,服务器和公共代码组合到一个以包的功能命名的包中。

例如, net/http包没有 client和 server的分包,而是有一个 client.go和 server.go文件,每个文件都有各自的类型,还有一个 transport.go文件,用于公共消息传输代码。

贴士:
标识符的名称包括其包名称。
重要的是标识符的名称包括其包的名称。

  • 当由另一个包引用时, net/http包中的 Get 函数变为 http.Get

  • 当导入到其他包中时, strings包中的 Reader类型变为 strings.Reader

  • net包中的 Error接口显然与网络错误有关。

4.3. 尽早 reture而不是深度嵌套

由于 Go 语言的控制流不使用 exception,因此不需要为 try和 catch块提供顶级结构而深度缩进代码。 Go 语言代码不是成功的路径越来越深地嵌套到右边,而是以一种风格编写,其中随着函数的进行,成功路径继续沿着屏幕向下移动。 我的朋友 Mat Ryer 将这种做法称为 “视线” 编码。[4]

这是通过使用 guard clauses来实现的; 在进入函数时是具有断言前提条件的条件块。 这是一个来自 bytes包的例子:

 
   
   
 
  1. func (b *Buffer) UnreadRune() error {

  2.    if b.lastRead <= opInvalid {

  3.        return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")

  4.    }

  5.    if b.off >= int(b.lastRead) {

  6.        b.off -= int(b.lastRead)

  7.    }

  8.    b.lastRead = opInvalid

  9.    return nil

  10. }

进入 UnreadRune后,将检查 b.lastRead的状态,如果之前的操作不是 ReadRune,则会立即返回错误。 之后,函数的其余部分继续进行 b.lastRead大于 opInvalid的断言。

与没有 guard clause的相同函数进行比较,

 
   
   
 
  1. func (b *Buffer) UnreadRune() error {

  2.    if b.lastRead > opInvalid {

  3.        if b.off >= int(b.lastRead) {

  4.            b.off -= int(b.lastRead)

  5.        }

  6.        b.lastRead = opInvalid

  7.        return nil

  8.    }

  9.    return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")

  10. }

最常见的执行成功的情况是嵌套在第一个 if 条件内,成功的退出条件是 return nil,而且必须通过仔细匹配大括号来发现。 函数的最后一行是返回一个错误,并且被调用者必须追溯到匹配的左括号,以了解何时执行到此点。

对于读者和维护程序员来说,这更容易出错,因此 Go 语言更喜欢使用 guard clauses并尽早返回错误。

4.4. 让零值更有用

假设变量没有初始化,每个变量声明都会自动初始化为与零内存的内容相匹配的值。 这就是零值。 值的类型决定了其零值; 对于数字类型,它为 0,对于指针类型为 nil, slices、 map和 channel同样是 nil

始终设置变量为已知默认值的属性对于程序的安全性和正确性非常重要,并且可以使 Go 语言程序更简单、更紧凑。 这就是 Go 程序员所说的 “给你的结构一个有用的零值”。

对于 sync.Mutex类型。 sync.Mutex包含两个未公开的整数字段,它们用来表示互斥锁的内部状态。 每当声明 sync.Mutex时,其字段会被设置为 0初始值。 sync.Mutex利用此属性来编写,使该类型可直接使用而无需初始化。

 
   
   
 
  1. type MyInt struct {

  2.    mu  sync.Mutex

  3.    val int

  4. }

  5. func main() {

  6.    var i MyInt

  7.    // i.mu is usable without explicit initialisation.

  8.    i.mu.Lock()

  9.    i.val++

  10.    i.mu.Unlock()

  11. }

另一个利用零值的类型是 bytes.Buffer。 您可以声明 bytes.Buffer然后就直接写入而无需初始化。

 
   
   
 
  1. func main() {

  2.    var b bytes.Buffer

  3.    b.WriteString("Hello, world!\n")

  4.    io.Copy(os.Stdout, &b)

  5. }

切片的一个有用属性是它们的零值 nil。 如果我们看一下切片运行时 header的定义就不难理解:

 
   
   
 
  1. type slice struct {

  2.        array *[...]T // pointer to the underlying array

  3.        len   int

  4.        cap   int

  5. }

此结构的零值意味着 len和 cap的值为 0,而 array(指向保存切片的内容数组的指针)将为 nil。 这意味着你不需要 make切片,你只需声明它即可。

 
   
   
 
  1. func main() {

  2.    // s := make([]string, 0)

  3.    // s := []string{}

  4.    var s []string

  5.    s = append(s, "Hello")

  6.    s = append(s, "world")

  7.    fmt.Println(strings.Join(s, " "))

  8. }

注意:
var s []string类似于它上面的两条注释行,但并不完全相同。 值为 nil的切片与具有零长度的切片就可以来相互比较。 以下代码将输出 false。

 
   
   
 
  1. func main() {

  2.    var s1 = []string{}

  3.    var s2 []string

  4.    fmt.Println(reflect.DeepEqual(s1, s2))

  5. }

nil pointers -- 未初始化的指针变量的一个有用属性是你可以在具有 nil值的类型上调用方法。 它可以简单地用于提供默认值。

 
   
   
 
  1. type Config struct {

  2.    path string

  3. }

  4. func (c *Config) Path() string {

  5.    if c == nil {

  6.        return "/usr/home"

  7.    }

  8.    return c.path

  9. }

  10. func main() {

  11.    var c1 *Config

  12.    var c2 = &Config{

  13.        path: "/export",

  14.    }

  15.    fmt.Println(c1.Path(), c2.Path())

  16. }

4.5. 避免包级别状态

编写可维护程序的关键是它们应该是松散耦合的 - 对一个程序包的更改应该很少影响另一个不直接依赖于第一个程序包的程序包。

在 Go 语言中有两种很好的方法可以实现松散耦合

  1. 使用接口来描述函数或方法所需的行为。

  2. 避免使用全局状态。

在 Go 语言中,我们可以在函数或方法范围以及包范围内声明变量。当变量是公共的时,给定一个以大写字母开头的标识符,那么它的范围对于整个程序来说实际上是全局的 - 任何包都可以随时观察该变量的类型和内容。

可变全局状态引入程序的独立部分之间的紧密耦合,因为全局变量成为程序中每个函数的不可见参数!如果该变量的类型发生更改,则可以破坏依赖于全局变量的任何函数。如果程序的另一部分更改了该变量,则可以破坏依赖于全局变量状态的任何函数。

如果要减少全局变量所带来的耦合,

  1. 将相关变量作为字段移动到需要它们的结构上。

  2. 使用接口来减少行为与实现之间的耦合。

5. 项目结构

我们来谈谈如何将包组合到项目中。 通常一个项目是一个 git仓库,但在未来 Go 语言开发人员会交替地使用 module和 project

就像一个包,每个项目都应该有一个明确的目的。 如果你的项目是一个库,它应该提供一件事,比如 XML解析或记录。 您应该避免在一个包实现多个目的,这将有助于避免成为 common库。

贴士:
据我的经验, common库最终会与其最大的调用者紧密相连,在没有升级该库与最大调用者的情况下是很难修复的,还会带来了许多无关的更改以及 API 破坏。

如果你的项目是应用程序,如 Web应用程序, Kubernetes控制器等,那么项目中可能有一个或多个 main程序包。 例如,我编写的 Kubernetes控制器有一个 cmd / contour包,既可以作为部署到 Kubernetes集群的服务器,也可以作为调试目的的客户端。

5.1. 考虑更少,更大的包

对于从其他语言过渡到 Go 语言的程序员来说,我倾向于在代码审查中提到的一件事是他们会过度使用包。

Go 语言没有提供有关可见性的详细方法; Java 有 public、 protected、 private以及隐式 default的访问修饰符。 没有 C++的 friend类概念。

在 Go 语言中,我们只有两个访问修饰符, public和 private,由标识符的第一个字母的大小写表示。 如果标识符是公共的,则其名称以大写字母开头,该标识符可用于任何其他 Go 语言包的引用。

注意: 
你可能会听到人们说 exported与 not exported, 跟 public和 private是同义词。

鉴于包的符号的访问有限控件,Go 程序员应遵循哪些实践来避免创建过于复杂的包层次结构?

贴士:
除 cmd/和 internal/之外的每个包都应包含一些源代码。

我的建议是选择更少,更大的包。 你应该做的是不创建新的程序包。 这将导致太多类型被公开,为你的包创建一个宽而浅的 API。

以下部分将更为详细地探讨这一建议。

贴士:
来自 Java
如果您来自 Java或 C#,请考虑这一经验法则 -- Java包相当于单个 .go源文件。 - Go 语言包相当于整个 Maven模块或 .NET程序集。

5.1.1. 通过 import语句将代码排列到文件中

如果你按照包提供的内容来安排你的程序包,是否需要对 Go 包中的文件也执行相同的操作?什么时候应该将 .go文件拆分成多个文件?什么时候应该考虑整合 .go文件?

以下是我的经验法则:

  • 开始时使用一个 .go文件。为该文件指定与文件夹名称相同的名称。例如: package http应放在名为 http的目录中名为 http.go的文件中。

  • 随着包的增长,您可能决定将各种职责任务拆分为不同的文件。例如, messages.go包含 Request和 Response类型, client.go包含 Client类型, server.go包含 Server类型。

  • 如果你的文件中 import的声明类似,请考虑将它们组合起来。或者确定 import集之间的差异并移动它们。

  • 不同的文件应该负责包的不同区域。 messages.go可能负责网络的 HTTP请求和响应, http.go可能包含底层网络处理逻辑, client.go和 server.go实现 HTTP业务逻辑请求的实现或路由等等。

贴士: 首选名词为源文件命名。

注意: 
Go 编译器并行编译每个包。 在一个包中,编译器并行编译每个函数(方法只是 Go 语言中函数的另一种写法)。 更改包中代码的布局不会影响编译时间。

5.1.2. 优先内部测试再到外部测试

go tool支持在两个地方编写 testing包测试。假设你的包名为 http2,您可以编写 http2_test.go文件并使用包 http2声明。这样做会编译 http2_test.go中的代码,就像它是 http2包的一部分一样。这就是内部测试。

go tool还支持一个特殊的包声明,以 test为结尾,即 package http_test。这允许你的测试文件与代码一起存放在同一个包中,但是当编译时这些测试不是包的代码的一部分,它们存在于自己的包中。就像调用另一个包的代码一样来编写测试。这被称为外部测试。

我建议在编写单元测试时使用内部测试。这样你就可以直接测试每个函数或方法,避免外部测试干扰。

但是,你应该将 Example测试函数放在外部测试文件中。这确保了在 godoc中查看时,示例具有适当的包名前缀并且可以轻松地进行复制粘贴。

贴士:
避免复杂的包层次结构,抵制应用分类法
Go 语言包的层次结构对于 go tool没有任何意义除了下一节要说的。 例如, net/http包不是一个子包或者 net包的子包。

如果在项目中创建了不包含 .go文件的中间目录,则可能无法遵循此建议。

5.1.3. 使用 internal包来减少公共 API

如果项目包含多个包,可能有一些公共的函数,这些函数旨在供项目中的其他包使用,但不打算成为项目的公共 API 的一部分。 如果你发现是这种情况,那么 go tool会识别一个特殊的文件夹名称 - 而非包名称 - internal/可用于放置对项目公开的代码,但对其他项目是私有的。

要创建此类包,请将其放在名为 internal/的目录中,或者放在名为 internal/的目录的子目录中。 当 go命令在其路径中看到导入包含 internal的包时,它会验证执行导入的包是否位于 internal目录。

例如, .../a/b/c/internal/d/e/f的包只能通过以 .../a/b/c/为根目录的代码被导入。 它无法通过.../a/b/g` 或任何其他仓库中的代码导入。[5]

5.2. 确保 main包内容尽可能的少

main函数和 main包的内容应尽可能少。 这是因为 main.main充当单例; 程序中只能有一个 main函数,包括 tests

因为 main.main是一个单例,假设 main函数中需要执行很多事情, main.main只会在 main.main或 main.init中调用它们并且只调用一次。 这使得为 main.main编写代码测试变得很困难,因此你应该将所有业务逻辑从 main函数中移出,最好是从 main包中移出。

贴士: 
main应该做解析 flags,开启数据库连接、开启日志等,然后将执行交给更高一级的对象。

6. API 设计

我今天要给出的最后一条建议是设计, 我认为也是最重要的。

到目前为止我提出的所有建议都是建议。 这些是我尝试编写 Go 语言的方式,但我不打算在代码审查中拼命推广。

但是,在审查 API 时, 我就不会那么宽容了。 这是因为到目前为止我所谈论的所有内容都是可以修复而且不会破坏向后兼容性; 它们在很大程度上是实现的细节。

当涉及到软件包的公共 API 时,在初始设计中投入大量精力是值得的,因为稍后更改该设计对于已经使用 API 的人来说会是破坏性的。

6.1. 设计难以被误用的 API。

APIs should be easy to use and hard to misuse.
(API 应该易于使用且难以被误用)
— Josh Bloch [3]

如果你从这个演讲中带走任何东西,那应该是 Josh Bloch的建议。 如果一个 API 很难用于简单的事情,那么 API 的每次调用都会很复杂。 当 API 的实际调用很复杂时,它就会便得不那么明显,而且会更容易被忽视。

6.1.1. 警惕采用几个相同类型参数的函数

简单, 但难以正确使用的 API 是采用两个或更多相同类型参数的 API。 让我们比较两个函数签名:

 
   
   
 
  1. func Max(a, b int) int

  2. func CopyFile(to, from string) error

这两个函数有什么区别? 显然,一个返回两个数字最大的那个,另一个是复制文件,但这不重要。

 
   
   
 
  1. Max(8, 10) // 10

  2. Max(10, 8) // 10

Max是可交换的; 参数的顺序无关紧要。 无论是 8 比 10 还是 10 比 8,最大的都是 10。

但是,却不适用于 CopyFile

 
   
   
 
  1. CopyFile("/tmp/backup", "presentation.md")

  2. CopyFile("presentation.md", "/tmp/backup")

这些声明中哪一个备份了 presentation.md,哪一个用上周的版本覆盖了 presentation.md? 没有文档,你无法分辨。 如果没有查阅文档,代码审查员也无法知道你写对了顺序。

一种可能的解决方案是引入一个 helper类型,它会负责如何正确地调用 CopyFile

 
   
   
 
  1. type Source string

  2. func (src Source) CopyTo(dest string) error {

  3.    return CopyFile(dest, string(src))

  4. }

  5. func main() {

  6.    var from Source = "presentation.md"

  7.    from.CopyTo("/tmp/backup")

  8. }

通过这种方式, CopyFile总是能被正确调用 - 还可以通过单元测试 - 并且可以被设置为私有,进一步降低了误用的可能性。

贴士: 具有多个相同类型参数的 API 难以正确使用。

6.2. 为其默认用例设计 API

几年前,我就对 functional options[7] 进行过讨论 [6],使 API 更易用于默认用例。

本演讲的主旨是你应该为常见用例设计 API。 另一方面,API 不应要求调用者提供他们不在乎参数。

6.2.1. 不鼓励使用 nil作为参数

本章开始时我建议是不要强迫提供给 API 的调用者他们不在乎的参数。 这就是我要说的为默认用例设计 API。

这是 net/http包中的一个例子

 
   
   
 
  1. package http

  2. // ListenAndServe listens on the TCP network address addr and then calls

  3. // Serve with handler to handle requests on incoming connections.

  4. // Accepted connections are configured to enable TCP keep-alives.

  5. //

  6. // The handler is typically nil, in which case the DefaultServeMux is used.

  7. //

  8. // ListenAndServe always returns a non-nil error.

  9. func ListenAndServe(addr string, handler Handler) error {

现在, Serve的调用者有两种方式可以做同样的事情。

 
   
   
 
  1. http.ListenAndServe("0.0.0.0:8080", nil)

  2. http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)

两者完全相同。

这种 nil行为是病毒式的。 http包也有一个 http.Serve帮助类,你可以合理地想象一下 ListenAndServe是这样构建的

 
   
   
 
  1. func ListenAndServe(addr string, handler Handler) error {

  2.    l, err := net.Listen("tcp", addr)

  3.    if err != nil {

  4.        return err

  5.    }

  6.    defer l.Close()

  7.    return Serve(l, handler)

  8. }

因为 ListenAndServe允许调用者为第二个参数传递 nil,所以 http.Serve也支持这种行为。 事实上, http.Serve实现了如果 handler是 nil,使用 DefaultServeMux的逻辑。 参数可为 nil可能会导致调用者认为他们可以为两个参数都使用 nil。 像下面这样:

 
   
   
 
  1. http.Serve(nil, nil)

会导致 panic

贴士: 
不要在同一个函数签名中混合使用可为 nil和不能为 nil的参数。

http.ListenAndServe的作者试图在常见情况下让使用 API 的用户更轻松些,但很可能会让该程序包更难以被安全地使用。

使用 DefaultServeMux或使用 nil没有什么区别。

 
   
   
 
  1. const root = http.Dir("/htdocs")

  2. http.Handle("/", http.FileServer(root))

  3. http.ListenAndServe("0.0.0.0:8080", nil)

对比

 
   
   
 
  1. const root = http.Dir("/htdocs")

  2. http.Handle("/", http.FileServer(root))

  3. http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)

这种混乱值得拯救吗?

 
   
   
 
  1. const root = http.Dir("/htdocs")

  2. mux := http.NewServeMux()

  3. http.Handle("/", http.FileServer(root))

  4. http.ListenAndServe("0.0.0.0:8080", mux)

贴士: 认真考虑 helper函数会节省不少时间。 清晰要比简洁好。

贴士: 
避免公共API使用测试参数
避免在公开的 API 上使用仅在测试范围上不同的值。 相反,使用 Public wrappers隐藏这些参数,使用辅助方式来设置测试范围中的属性。

6.2.2. 首选可变参数函数而非 []T参数

编写一个带有切片参数的函数或方法是很常见的。

 
   
   
 
  1. func ShutdownVMs(ids []string) error

这只是我编的一个例子,但它与我所写的很多代码相同。 这里的问题是他们假设他们会被调用于多个条目。 但是很多时候这些类型的函数只用一个参数调用,为了满足函数参数的要求,它必须打包到一个切片内。

另外,因为 ids参数是切片,所以你可以将一个空切片或 nil传递给该函数,编译也没什么错误。 但是这会增加额外的测试负载,因为你应该涵盖这些情况在测试中。

举一个这类 API 的例子,最近我重构了一条逻辑,要求我设置一些额外的字段,如果一组参数中至少有一个非零。 逻辑看起来像这样:

 
   
   
 
  1. if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {

  2.    // apply the non zero parameters

  3. }

由于 if语句变得很长,我想将签出的逻辑拉入其自己的函数中。 这就是我提出的:

 
   
   
 
  1. // anyPostive indicates if any value is greater than zero.

  2. func anyPositive(values ...int) bool {

  3.    for _, v := range values {

  4.        if v > 0 {

  5.            return true

  6.        }

  7.    }

  8.    return false

  9. }

这就能够向读者明确内部块的执行条件:

 
   
   
 
  1. if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {

  2.        // apply the non zero parameters

  3. }

但是 anyPositive还存在一个问题,有人可能会这样调用它:

 
   
   
 
  1. if anyPositive() { ... }

在这种情况下, anyPositive将返回 false,因为它不会执行迭代而是立即返回 false。 对比起如果 anyPositive在没有传递参数时返回 true, 这还不算世界上最糟糕的事情。

然而,如果我们可以更改 anyPositive的签名以强制调用者应该传递至少一个参数,那会更好。 我们可以通过组合正常和可变参数来做到这一点,如下所示:

 
   
   
 
  1. // anyPostive indicates if any value is greater than zero.

  2. func anyPositive(first int, rest ...int) bool {

  3.    if first > 0 {

  4.        return true

  5.    }

  6.    for _, v := range rest {

  7.        if v > 0 {

  8.            return true

  9.        }

  10.    }

  11.    return false

  12. }

现在不能使用少于一个参数来调用 anyPositive

6.3. 让函数定义它们所需的行为

假设我需要编写一个将 Document结构保存到磁盘的函数的任务。

 
   
   
 
  1. // Save writes the contents of doc to the file f.

  2. func Save(f *os.File, doc *Document) error

我可以指定这个函数 Save,它将 *os.File作为写入 Document的目标。 但这样做会有一些问题

Save的签名排除了将数据写入网络位置的选项。 假设网络存储可能在以后成为需求,则此功能的签名必须改变,从而影响其所有调用者。

Save测试起来也很麻烦,因为它直接操作磁盘上的文件。 因此,为了验证其操作,测试时必须在写入文件后再读取该文件的内容。

而且我必须确保 f被写入临时位置并且随后要将其删除。

*os.File还定义了许多与 Save无关的方法,比如读取目录并检查路径是否是符号链接。 如果 Save函数的签名只用 *os.File的相关内容,那将会很有用。

我们能做什么 ?

 
   
   
 
  1. // Save writes the contents of doc to the supplied

  2. // ReadWriterCloser.

  3. func Save(rwc io.ReadWriteCloser, doc *Document) error

使用 io.ReadWriteCloser,我们可以应用接口隔离原则来重新定义 Save以获取更通用文件形式。

通过此更改,任何实现 io.ReadWriteCloser接口的类型都可以替换以前的 *os.File

这使 Save在其应用程序中更广泛,并向 Save的调用者阐明 *os.File类型的哪些方法与其操作有关。

而且, Save的作者也不可以在 *os.File上调用那些不相关的方法,因为它隐藏在 io.ReadWriteCloser接口后面。

但我们可以进一步采用接口隔离原则。

首先,如果 Save遵循单一功能原则,它不可能读取它刚刚写入的文件来验证其内容 - 这应该是另一段代码的功能。

 
   
   
 
  1. // Save writes the contents of doc to the supplied

  2. // WriteCloser.

  3. func Save(wc io.WriteCloser, doc *Document) error

因此,我们可以将我们传递给 Save的接口的规范缩小到只写和关闭。

其次,通过向 Save提供一个关闭其流的机制,使其看起来仍然像一个文件,这就提出了在什么情况下关闭 wc的问题。

可能 Save会无条件地调用 Close,或者在成功的情况下调用 Close

这给 Save的调用者带来了问题,因为它可能希望在写入文档后将其他数据写入流。

 
   
   
 
  1. // Save writes the contents of doc to the supplied

  2. // Writer.

  3. func Save(w io.Writer, doc *Document) error

一个更好的解决方案是重新定义 Save仅使用 io.Writer,它只负责将数据写入流。

将接口隔离原则应用于我们的 Save功能,同时, 就需求而言, 得出了最具体的一个函数 - 它只需要一个可写的东西 - 并且它的功能最通用,现在我们可以使用 Save将我们的数据保存到实现 io.Writer的任何事物中。

[译注: 不理解设计原则部分的同学可以阅读 Dave 大神的另一篇 ]

7. 错误处理

我已经给出了几个关于错误处理的演示文稿 [8],并在我的博客上写了很多关于错误处理的文章。我在昨天的会议上也讲了很多关于错误处理的内容,所以在这里不再赘述。

  • https://dave.cheney.net/2014/12/24/inspecting-errors

  • https://dave.cheney.net/2016/04/07/constant-errors

相反,我想介绍与错误处理相关的两个其他方面。

7.1. 通过消除错误来消除错误处理

如果你昨天在我的演讲中,我谈到了改进错误处理的提案。但是你知道有什么比改进错误处理的语法更好吗?那就是根本不需要处理错误。

注意:
我不是说 “删除你的错误处理”。我的建议是,修改你的代码,这样就不用处理错误了。

本节从 John Ousterhout最近的著作 “软件设计哲学”[9] 中汲取灵感。该书的其中一章是 “定义不存在的错误”。我们将尝试将此建议应用于 Go 语言。

7.1.1. 计算行数

让我们编写一个函数来计算文件中的行数。

 
   
   
 
  1. func CountLines(r io.Reader) (int, error) {

  2.    var (

  3.        br    = bufio.NewReader(r)

  4.        lines int

  5.        err   error

  6.    )

  7.    for {

  8.        _, err = br.ReadString('\n')

  9.        lines++

  10.        if err != nil {

  11.            break

  12.        }

  13.    }

  14.    if err != io.EOF {

  15.        return 0, err

  16.    }

  17.    return lines, nil

  18. }

由于我们遵循前面部分的建议, CountLines需要一个 io.Reader,而不是一个 *File; 它的任务是调用者为我们想要计算的内容提供 io.Reader

我们构造一个 bufio.Reader,然后在一个循环中调用 ReadString方法,递增计数器直到我们到达文件的末尾,然后我们返回读取的行数。

至少这是我们想要编写的代码,但是这个函数由于需要错误处理而变得更加复杂。 例如,有这样一个奇怪的结构:

 
   
   
 
  1. _, err = br.ReadString('\n')

  2. lines++

  3. if err != nil {

  4.    break

  5. }

我们在检查错误之前增加了行数,这样做看起来很奇怪。

我们必须以这种方式编写它的原因是,如果在遇到换行符之前就读到文件结束,则 ReadString将返回错误。如果文件中没有换行符,同样会出现这种情况。

为了解决这个问题,我们重新排列逻辑增来加行数,然后查看是否需要退出循环。

注意:
这个逻辑仍然不完美,你能发现错误吗?

但是我们还没有完成检查错误。当 ReadString到达文件末尾时,预期它会返回 io.EOF。 ReadString需要某种方式在没有什么可读时来停止。因此,在我们将错误返回给 CountLine的调用者之前,我们需要检查错误是否是 io.EOF,如果不是将其错误返回,否则我们返回 nil说一切正常。

我认为这是 Russ Cox观察到错误处理可能会模糊函数操作的一个很好的例子。我们来看一个改进的版本。

 
   
   
 
  1. func CountLines(r io.Reader) (int, error) {

  2.    sc := bufio.NewScanner(r)

  3.    lines := 0

  4.    for sc.Scan() {

  5.        lines++

  6.    }

  7.    return lines, sc.Err()

  8. }

这个改进的版本从 bufio.Reader切换到 bufio.Scanner

在 bufio.Scanner内部使用 bufio.Reader,但它添加了一个很好的抽象层,它有助于通过隐藏 CountLines的操作来消除错误处理。

注意:
bufio.Scanner可以扫描任何模式,但默认情况下它会查找换行符。

如果扫描程序匹配了一行文本并且没有遇到错误,则 sc.Scan()方法返回 true。因此,只有当扫描仪的缓冲区中有一行文本时,才会调用 for循环的主体。这意味着我们修改后的 CountLines正确处理没有换行符的情况,并且还处理文件为空的情况。

其次,当 sc.Scan在遇到错误时返回 false,我们的 for循环将在到达文件结尾或遇到错误时退出。 bufio.Scanner类型会记住遇到的第一个错误,一旦我们使用 sc.Err()方法退出循环,我们就可以获取该错误。

最后, sc.Err()负责处理 io.EOF并在达到文件末尾时将其转换为 nil,而不会遇到其他错误。

贴士:
当遇到难以忍受的错误处理时,请尝试将某些操作提取到辅助程序类型中。

7.1.2. WriteResponse

我的第二个例子受到了 Errors are values博客文章 [10] 的启发。

在本章前面我们已经看过处理打开、写入和关闭文件的示例。错误处理是存在的,但是接收范围内的,因为操作可以封装在诸如 ioutil.ReadFile和 ioutil.WriteFile之类的辅助程序中。但是,在处理底层网络协议时,有必要使用 I/O原始的错误处理来直接构建响应,这样就可能会变得重复。看一下构建 HTTP响应的 HTTP服务器的这个片段。

 
   
   
 
  1. type Header struct {

  2.    Key, Value string

  3. }

  4. type Status struct {

  5.    Code   int

  6.    Reason string

  7. }

  8. func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {

  9.    _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

  10.    if err != nil {

  11.        return err

  12.    }

  13.    for _, h := range headers {

  14.        _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)

  15.        if err != nil {

  16.            return err

  17.        }

  18.    }

  19.    if _, err := fmt.Fprint(w, "\r\n"); err != nil {

  20.        return err

  21.    }

  22.    _, err = io.Copy(w, body)

  23.    return err

  24. }

首先,我们使用 fmt.Fprintf构造状态码并检查错误。 然后对于每个标题,我们写入键值对,每次都检查错误。 最后,我们使用额外的 \r\n终止标题部分,检查错误之后将响应主体复制到客户端。 最后,虽然我们不需要检查 io.Copy中的错误,但我们需要将 io.Copy返回的两个返回值形式转换为 WriteResponse的单个返回值。

这里很多重复性的工作。 我们可以通过引入一个包装器类型 errWriter来使其更容易。

errWriter实现 io.Writer接口,因此可用于包装现有的 io.Writer。 errWriter写入传递给其底层 writer,直到检测到错误。 从此时起,它会丢弃任何写入并返回先前的错误。

 
   
   
 
  1. type errWriter struct {

  2.    io.Writer

  3.    err error

  4. }

  5. func (e *errWriter) Write(buf []byte) (int, error) {

  6.    if e.err != nil {

  7.        return 0, e.err

  8.    }

  9.    var n int

  10.    n, e.err = e.Writer.Write(buf)

  11.    return n, nil

  12. }

  13. func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {

  14.    ew := &errWriter{Writer: w}

  15.    fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

  16.    for _, h := range headers {

  17.        fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)

  18.    }

  19.    fmt.Fprint(ew, "\r\n")

  20.    io.Copy(ew, body)

  21.    return ew.err

  22. }

将 errWriter应用于 WriteResponse可以显着提高代码的清晰度。 每个操作不再需要自己做错误检查。 通过检查 ew.err字段,将错误报告移动到函数末尾,从而避免转换从 io.Copy的两个返回值。

7.2. 错误只处理一次

最后,我想提一下你应该只处理错误一次。 处理错误意味着检查错误值并做出单一决定。

 
   
   
 
  1. // WriteAll writes the contents of buf to the supplied writer.

  2. func WriteAll(w io.Writer, buf []byte) {

  3.        w.Write(buf)

  4. }

如果你做出的决定少于一个,则忽略该错误。 正如我们在这里看到的那样, w.WriteAll的错误被丢弃。

但是,针对单个错误做出多个决策也是有问题的。 以下是我经常遇到的代码。

 
   
   
 
  1. func WriteAll(w io.Writer, buf []byte) error {

  2.    _, err := w.Write(buf)

  3.    if err != nil {

  4.        log.Println("unable to write:", err) // annotated error goes to log file

  5.        return err                           // unannotated error returned to caller

  6.    }

  7.    return nil

  8. }

在此示例中,如果在 w.Write期间发生错误,则会写入日志文件,注明错误发生的文件与行数,并且错误也会返回给调用者,调用者可能会记录该错误并将其返回到上一级,一直回到程序的顶部。

调用者可能正在做同样的事情

 
   
   
 
  1. func WriteConfig(w io.Writer, conf *Config) error {

  2.    buf, err := json.Marshal(conf)

  3.    if err != nil {

  4.        log.Printf("could not marshal config: %v", err)

  5.        return err

  6.    }

  7.    if err := WriteAll(w, buf); err != nil {

  8.        log.Println("could not write config: %v", err)

  9.        return err

  10.    }

  11.    return nil

  12. }

因此你在日志文件中得到一堆重复的内容,

 
   
   
 
  1. unable to write: io.EOF

  2. could not write config: io.EOF

但在程序的顶部,虽然得到了原始错误,但没有相关内容。

 
   
   
 
  1. err := WriteConfig(f, &conf)

  2. fmt.Println(err) // io.EOF

我想深入研究这一点,因为作为个人偏好, 我并没有看到 logging和返回的问题。

 
   
   
 
  1. func WriteConfig(w io.Writer, conf *Config) error {

  2.    buf, err := json.Marshal(conf)

  3.    if err != nil {

  4.        log.Printf("could not marshal config: %v", err)

  5.        // oops, forgot to return

  6.    }

  7.    if err := WriteAll(w, buf); err != nil {

  8.        log.Println("could not write config: %v", err)

  9.        return err

  10.    }

  11.    return nil

  12. }

很多问题是程序员忘记从错误中返回。正如我们之前谈到的那样,Go 语言风格是使用 guard clauses以及检查前提条件作为函数进展并提前返回。

在这个例子中,作者检查了错误,记录了它,但忘了返回。这就引起了一个微妙的错误。

Go 语言中的错误处理规定,如果出现错误,你不能对其他返回值的内容做出任何假设。由于 JSON解析失败, buf的内容未知,可能它什么都没有,但更糟的是它可能包含解析的 JSON片段部分。

由于程序员在检查并记录错误后忘记返回,因此损坏的缓冲区将传递给 WriteAll,这可能会成功,因此配置文件将被错误地写入。但是,该函数会正常返回,并且发生问题的唯一日志行是有关 JSON解析错误,而与写入配置失败有关。

7.2.1. 为错误添加相关内容

发生错误的原因是作者试图在错误消息中添加 context。 他们试图给自己留下一些线索,指出错误的根源。

让我们看看使用 fmt.Errorf的另一种方式。

 
   
   
 
  1. func WriteConfig(w io.Writer, conf *Config) error {

  2.    buf, err := json.Marshal(conf)

  3.    if err != nil {

  4.        return fmt.Errorf("could not marshal config: %v", err)

  5.    }

  6.    if err := WriteAll(w, buf); err != nil {

  7.        return fmt.Errorf("could not write config: %v", err)

  8.    }

  9.    return nil

  10. }

  11. func WriteAll(w io.Writer, buf []byte) error {

  12.    _, err := w.Write(buf)

  13.    if err != nil {

  14.        return fmt.Errorf("write failed: %v", err)

  15.    }

  16.    return nil

  17. }

通过将注释与返回的错误组合起来,就更难以忘记错误的返回来避免意外继续。

如果写入文件时发生 I/O错误,则 error的 Error()方法会报告以下类似的内容;

 
   
   
 
  1. could not write config: write failed: input/output error

7.2.2. 使用 github.com/pkg/errors包装 errors

fmt.Errorf模式适用于注释错误 message,但这样做的代价是模糊了原始错误的类型。 我认为将错误视为不透明值对于松散耦合的软件非常重要,因此如果你使用错误值做的唯一事情是原始错误的类型应该无关紧要的面孔

  1. 检查它是否为 nil

  2. 输出或记录它。

但是在某些情况下,我认为它们并不常见,您需要恢复原始错误。 在这种情况下,使用类似我的 errors包来注释这样的错误, 如下

 
   
   
 
  1. func ReadFile(path string) ([]byte, error) {

  2.    f, err := os.Open(path)

  3.    if err != nil {

  4.        return nil, errors.Wrap(err, "open failed")

  5.    }

  6.    defer f.Close()

  7.    buf, err := ioutil.ReadAll(f)

  8.    if err != nil {

  9.        return nil, errors.Wrap(err, "read failed")

  10.    }

  11.    return buf, nil

  12. }

  13. func ReadConfig() ([]byte, error) {

  14.    home := os.Getenv("HOME")

  15.    config, err := ReadFile(filepath.Join(home, ".settings.xml"))

  16.    return config, errors.WithMessage(err, "could not read config")

  17. }

  18. func main() {

  19.    _, err := ReadConfig()

  20.    if err != nil {

  21.        fmt.Println(err)

  22.        os.Exit(1)

  23.    }

  24. }

现在报告的错误就是 KD [11] 样式错误,

 
   
   
 
  1. could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

并且错误值保留对原始原因的引用。

 
   
   
 
  1. func main() {

  2.    _, err := ReadConfig()

  3.    if err != nil {

  4.        fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))

  5.        fmt.Printf("stack trace:\n%+v\n", err)

  6.        os.Exit(1)

  7.    }

  8. }

因此,你可以恢复原始错误并打印堆栈跟踪;

 
   
   
 
  1. original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory

  2. stack trace:

  3. open /Users/dfc/.settings.xml: no such file or directory

  4. open failed

  5. main.ReadFile

  6.        /Users/dfc/devel/practical-go/src/errors/readfile2.go:16

  7. main.ReadConfig

  8.        /Users/dfc/devel/practical-go/src/errors/readfile2.go:29

  9. main.main

  10.        /Users/dfc/devel/practical-go/src/errors/readfile2.go:35

  11. runtime.main

  12.        /Users/dfc/go/src/runtime/proc.go:201

  13. runtime.goexit

  14.        /Users/dfc/go/src/runtime/asm_amd64.s:1333

  15. could not read config

使用 errors包,你可以以人和机器都可检查的方式向错误值添加上下文。 如果昨天你来听我的演讲,你会知道这个库在被移植到即将发布的 Go 语言版本的标准库中。

8. 并发

由于 Go 语言的并发功能,经常被选作项目编程语言。 Go 语言团队已经竭尽全力以廉价(在硬件资源方面)和高性能来实现并发,但是 Go 语言的并发功能也可以被用来编写性能不高同时也不太可靠的代码。在结尾,我想留下一些建议,以避免 Go 语言的并发功能带来的一些陷阱。

Go 语言以 channels以及 select和 go语句来支持并发。如果你已经从书籍或培训课程中正式学习了 Go 语言,你可能已经注意到并发部分始终是这些课程的最后一部分。这个研讨会也没有什么不同,我选择最后覆盖并发,好像它是 Go 程序员应该掌握的常规技能的额外补充。

这里有一个二分法; Go 语言的最大特点是简单、轻量级的并发模型。作为一种产品,我们的语言几乎只推广这个功能。另一方面,有一种说法认为并发使用起来实际上并不容易,否则作者不会把它作为他们书中的最后一章,我们也不会遗憾地来回顾其形成过程。

本节讨论了 Go 语言的并发功能的 “坑”。

8.1. 保持自己忙碌或做自己的工作

这个程序有什么问题?

 
   
   
 
  1. package main

  2. import (

  3.    "fmt"

  4.    "log"

  5.    "net/http"

  6. )

  7. func main() {

  8.    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

  9.        fmt.Fprintln(w, "Hello, GopherCon SG")

  10.    })

  11.    go func() {

  12.        if err := http.ListenAndServe(":8080", nil); err != nil {

  13.            log.Fatal(err)

  14.        }

  15.    }()

  16.    for {

  17.    }

  18. }

该程序实现了我们的预期,它提供简单的 Web服务。 然而,它同时也做了其他事情,它在无限循环中浪费 CPU资源。 这是因为 main的最后一行上的 for {}将阻塞 main goroutine,因为它不执行任何 IO、等待锁定、发送或接收通道数据或以其他方式与调度器通信。

由于 Go 语言运行时主要是协同调度,该程序将在单个 CPU上做无效地旋转,并可能最终实时锁定。

我们如何解决这个问题? 这是一个建议。

 
   
   
 
  1. package main

  2. import (

  3.    "fmt"

  4.    "log"

  5.    "net/http"

  6.    "runtime"

  7. )

  8. func main() {

  9.    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

  10.        fmt.Fprintln(w, "Hello, GopherCon SG")

  11.    })

  12.    go func() {

  13.        if err := http.ListenAndServe(":8080", nil); err != nil {

  14.            log.Fatal(err)

  15.        }

  16.    }()

  17.    for {

  18.        runtime.Gosched()

  19.    }

  20. }

这看起来很愚蠢,但这是我看过的一种常见解决方案。 这是不了解潜在问题的症状。

现在,如果你有更多的经验,你可能会写这样的东西。

 
   
   
 
  1. package main

  2. import (

  3.    "fmt"

  4.    "log"

  5.    "net/http"

  6. )

  7. func main() {

  8.    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

  9.        fmt.Fprintln(w, "Hello, GopherCon SG")

  10.    })

  11.    go func() {

  12.        if err := http.ListenAndServe(":8080", nil); err != nil {

  13.            log.Fatal(err)

  14.        }

  15.    }()

  16.    select {}

  17. }

空的 select语句将永远阻止。 这是一个有用的属性,因为现在我们不再调用 runtime.GoSched()而耗费整个 CPU。 但是这也只是治疗了症状,而不是病根。

我想向你提出另一种你可能在用的解决方案。 与其在 goroutine中运行 http.ListenAndServe,会给我们留下处理 main goroutine的问题,不如在 main goroutine本身上运行 http.ListenAndServe

贴士: 
如果 Go 语言程序的 main.main函数返回,无论程序在一段时间内启动的其他 goroutine在做什么, Go 语言程序会无条件地退出。

 
   
   
 
  1. package main

  2. import (

  3.    "fmt"

  4.    "log"

  5.    "net/http"

  6. )

  7. func main() {

  8.    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

  9.        fmt.Fprintln(w, "Hello, GopherCon SG")

  10.    })

  11.    if err := http.ListenAndServe(":8080", nil); err != nil {

  12.        log.Fatal(err)

  13.    }

  14. }

所以这是我的第一条建议:如果你的 goroutine在得到另一个结果之前无法取得进展,那么让自己完成此工作而不是委托给其他 goroutine会更简单。

这通常会消除将结果从 goroutine返回到其启动程序所需的大量状态跟踪和通道操作。

贴士:
许多 Go 程序员过度使用 goroutine,特别是刚开始时。 与生活中的所有事情一样,适度是成功的关键。

8.2. 将并发性留给调用者

以下两个 API 有什么区别?

 
   
   
 
  1. // ListDirectory returns the contents of dir.

  2. func ListDirectory(dir string) ([]string, error)

 
   
   
 
  1. // ListDirectory returns a channel over which

  2. // directory entries will be published. When the list

  3. // of entries is exhausted, the channel will be closed.

  4. func ListDirectory(dir string) chan string

首先,最明显的不同: 第一个示例将目录读入切片然后返回整个切片,如果出错则返回错误。这是同步发生的, ListDirectory的调用者会阻塞,直到读取了所有目录条目。根据目录的大小,这可能需要很长时间,并且可能会分配大量内存来构建目录条目。

让我们看看第二个例子。 这个示例更像是 Go 语言风格, ListDirectory返回一个通道,通过该通道传递目录条目。当通道关闭时,表明没有更多目录条目。由于在 ListDirectory返回后发生了通道的填充, ListDirectory可能会启动一个 goroutine来填充通道。

注意:
第二个版本实际上不必使用 Go 协程; 它可以分配一个足以保存所有目录条目而不阻塞的通道,填充通道,关闭它,然后将通道返回给调用者。但这样做不太现实,因为会消耗大量内存来缓冲通道中的所有结果。

通道版本的 ListDirectory还有两个问题:

  • 通过使用关闭通道作为没有其他项目要处理的信号,在中途遇到了错误时, ListDirectory无法告诉调用者通过通道返回的项目集是否完整。调用者无法区分空目录和读取目录的错误。两者都导致从 ListDirectory返回的通道立即关闭。

  • 调用者必须持续从通道中读取,直到它被关闭,因为这是调用者知道此通道的是否停止的唯一方式。这是对 ListDirectory使用的严重限制,即使可能已经收到了它想要的答案,调用者也必须花时间从通道中读取。就中型到大型目录的内存使用而言,它可能更有效,但这种方法并不比原始的基于切片的方法快。

以上两种实现所带来的问题的解决方案是使用回调,该回调是在执行时在每个目录条目的上下文中调用函数。

 
   
   
 
  1. func ListDirectory(dir string, fn func(string))

毫不奇怪,这就是 filepath.WalkDir函数的工作方式。

贴士:
如果你的函数启动了 goroutine,你必须为调用者提供一种明确停止 goroutine的方法。 把异步执行函数的决定留给该函数的调用者通常会更容易些。

8.3. 永远不要启动一个停止不了的 goroutine。

前面的例子显示当一个任务时没有必要时使用 goroutine。 但使用 Go 语言的原因之一是该语言提供的并发功能。 实际上,很多情况下你希望利用硬件中可用的并行性。 为此,你必须使用 goroutines

这个简单的应用程序在两个不同的端口上提供 http服务,端口 8080用于应用程序服务,端口 8001用于访问 /debug/pprof终端。

 
   
   
 
  1. package main

  2. import (

  3.    "fmt"

  4.    "net/http"

  5.    _ "net/http/pprof"

  6. )

  7. func main() {

  8.    mux := http.NewServeMux()

  9.    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {

  10.        fmt.Fprintln(resp, "Hello, QCon!")

  11.    })

  12.    go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug

  13.    http.ListenAndServe("0.0.0.0:8080", mux)                       // app traffic

  14. }

虽然这个程序不是很复杂,但它代表了真实应用程序的基础。

该应用程序存在一些问题,因为它随着应用程序的增长而显露出来,所以我们现在来解决其中的一些问题。

 
   
   
 
  1. func serveApp() {

  2.    mux := http.NewServeMux()

  3.    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {

  4.        fmt.Fprintln(resp, "Hello, QCon!")

  5.    })

  6.    http.ListenAndServe("0.0.0.0:8080", mux)

  7. }

  8. func serveDebug() {

  9.    http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)

  10. }

  11. func main() {

  12.    go serveDebug()

  13.    serveApp()

  14. }

通过将 serveApp和 serveDebug处理程序分解成为它们自己的函数,我们将它们与 main.main分离。 也遵循了上面的建议,并确保 serveApp和 serveDebug将它们的并发性留给调用者。

但是这个程序存在一些可操作性问题。 如果 serveApp返回,那么 main.main将返回,导致程序关闭并由你使用的进程管理器来重新启动。

贴士:
正如 Go 语言中的函数将并发性留给调用者一样,应用程序应该将监视其状态和检测是否重启的工作留给另外的程序来做。 不要让你的应用程序负责重新启动自己,最好从应用程序外部处理该过程。

然而, serveDebug是在一个单独的 goroutine中运行的,返回后该 goroutine将退出,而程序的其余部分继续。 由于 /debug处理程序已停止工作很久,因此操作人员不会很高兴发现他们无法在你的应用程序中获取统计信息。

我们想要确保的是,如果任何负责提供此应用程序的 goroutine停止,我们将关闭该应用程序。

 
   
   
 
  1. func serveApp() {

  2.    mux := http.NewServeMux()

  3.    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {

  4.        fmt.Fprintln(resp, "Hello, QCon!")

  5.    })

  6.    if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil {

  7.        log.Fatal(err)

  8.    }

  9. }

  10. func serveDebug() {

  11.    if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil {

  12.        log.Fatal(err)

  13.    }

  14. }

  15. func main() {

  16.    go serveDebug()

  17.    go serveApp()

  18.    select {}

  19. }

现在 serverApp和 serveDebug检查从 ListenAndServe返回的错误,并在需要时调用 log.Fatal。 因为两个处理程序都在 goroutine中运行,所以我们将 main goroutine停在 select{}中。

这种方法存在许多问题:

  1. 如果 ListenAndServer返回 nil错误,则不会调用 log.Fatal,并且该端口上的 HTTP服务将在不停止应用程序的情况下关闭。

  2. log.Fatal调用 os.Exit,它将无条件地退出程序; defer不会被调用,其他 goroutines也不会被通知关闭,程序就停止了。 这使得编写这些函数的测试变得困难。

贴士: 
只在 main.main或 init函数中的使用 log.Fatal

我们真正想要的是任何错误发送回 goroutine的调用者,以便它可以知道 goroutine停止的原因,可以干净地关闭程序进程。

 
   
   
 
  1. func serveApp() error {

  2.    mux := http.NewServeMux()

  3.    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {

  4.        fmt.Fprintln(resp, "Hello, QCon!")

  5.    })

  6.    return http.ListenAndServe("0.0.0.0:8080", mux)

  7. }

  8. func serveDebug() error {

  9.    return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)

  10. }

  11. func main() {

  12.    done := make(chan error, 2)

  13.    go func() {

  14.        done <- serveDebug()

  15.    }()

  16.    go func() {

  17.        done <- serveApp()

  18.    }()

  19.    for i := 0; i < cap(done); i++ {

  20.        if err := <-done; err != nil {

  21.            fmt.Println("error: %v", err)

  22.        }

  23.    }

  24. }

我们可以使用通道来收集 goroutine的返回状态。通道的大小等于我们想要管理的 goroutine的数量,这样发送到 done通道就不会阻塞,因为这会阻止 goroutine的关闭,导致它泄漏。

由于没有办法安全地关闭 done通道,我们不能使用 for range来循环通道直到获取所有 goroutine发来的报告,而是循环我们开启的多个 goroutine,即通道的容量。

现在我们有办法等待每个 goroutine 干净地退出并记录他们遇到的错误。所需要的只是一种从第一个 goroutine转发关闭信号到其他 goroutine的方法。

 
   
   
 
  1. func serve(addr string, handler http.Handler, stop <-chan struct{}) error {

  2.    s := http.Server{

  3.        Addr:    addr,

  4.        Handler: handler,

  5.    }

  6.    go func() {

  7.        <-stop // wait for stop signal

  8.        s.Shutdown(context.Background())

  9.    }()

  10.    return s.ListenAndServe()

  11. }

  12. func serveApp(stop <-chan struct{}) error {

  13.    mux := http.NewServeMux()

  14.    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {

  15.        fmt.Fprintln(resp, "Hello, QCon!")

  16.    })

  17.    return serve("0.0.0.0:8080", mux, stop)

  18. }

  19. func serveDebug(stop <-chan struct{}) error {

  20.    return serve("127.0.0.1:8001", http.DefaultServeMux, stop)

  21. }

  22. func main() {

  23.    done := make(chan error, 2)

  24.    stop := make(chan struct{})

  25.    go func() {

  26.        done <- serveDebug(stop)

  27.    }()

  28.    go func() {

  29.        done <- serveApp(stop)

  30.    }()

  31.    var stopped bool

  32.    for i := 0; i < cap(done); i++ {

  33.        if err := <-done; err != nil {

  34.            fmt.Println("error: %v", err)

  35.        }

  36.        if !stopped {

  37.            stopped = true

  38.            close(stop)

  39.        }

  40.    }

  41. }

现在,每次我们在 done通道上收到一个值时,我们关闭 stop通道,这会导致在该通道上等待的所有 goroutine关闭其 http.Server。 这反过来将导致其余所有的 ListenAndServe goroutines 返回。 一旦我们开启的所有 goroutine都停止了, main.main就会返回并且进程会干净地停止。

贴士:
自己编写这种逻辑是重复而微妙的。 参考下这个包: https://github.com/heptio/workgroup,它会为你完成大部分工作。


** 引用: **

1. https://gaston.life/books/effective-programming/

2. https://talks.golang.org/2014/names.slide#4

3. https://www.infoq.com/articles/API-Design-Joshua-Bloch

1. https://www.lysator.liu.se/c/pikestyle.html

2. https://speakerdeck.com/campoy/understanding-nil

3. https://www.youtube.com/watch?v=Ic2y6w8lMPA

4. https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88

5. https://golang.org/doc/go1.4#internalpackages

6. https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis

7. https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html

8. https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

9. https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201

10. https://blog.golang.org/errors-are-values

11. http://www.gopl.io/


原文链接:Practical Go: Real world advice for writing maintainable Go programs

  • 如有翻译有误或者不理解的地方,请评论指正

  • 待更新的译注之后会做进一步修改翻译

  • 翻译:田浩浩

  • 邮箱:llitfkitfk@gmail.com


所有译文在:https://github.com/llitfkitfk/go-best-practice


以上是关于[译]Go语言最佳实战[二]的主要内容,如果未能解决你的问题,请参考以下文章

[译] Go语言测试进阶版建议与技巧

Go 语言项目开发实战 极客时间百度网盘

[译] 使用 Go 语言编写一个简单的 SHELL

GO语言的实战学习(猜谜游戏和在线词典)| 青训营笔记

[译] Go语言使用TCP_NODELAY控制发包流量

[译]从其他语言调用Go函数