【译】Go 语言实践:编写可维护的程序的建议

本文为 QCon 2018 上海站主题演讲嘉宾、Heptio 资深工程师、著名 Go 语言专家 David Cheney 关于 Go 语言实践的英文分享。 本文主要基于译文cloud.tencent.com/developer/a… 整理发布html

引言

接下来这两场我将给你们一些编写 Go 代码的最佳实践。git

今天这是一个研讨会风格的演讲,我会摒弃那些绚丽的 PPT,而是使用您们能够直接带走的文档程序员

您能够在这里找到这个演讲最新的在线版本: dave.cheney.net/practical-g…github

1.指导原则

咱们要谈论在一个编程语言中的最佳实践,那么咱们首先应该明确什么是“最佳”。若是您们听了我昨天那场讲演的话,您必定看到了来自 Go 团队的 Russ Cox 讲的一句话:golang

软件工程,是您在编程过程当中增长了工期或者开发人员以后发生的那些事。 — Russ Coxsql

Russ 是在阐述软件“编程”和软件“工程”之间的区别,前者是您写的程序,然后者是一个让更多的人长期使用的产品。软件工程师会来来去去地更换,团队也会成长或者萎缩,需求也会发生变化,新的特性也会增长,bug 也会被修复,这就是软件“工程”的本质。数据库

我多是现场最先的 Go 语言用户,但与其说个人主张来自个人资历,不如说我今天讲的是真实来自于 Go 语言自己的指导原则,那就是:编程

  1. 简单性
  2. 可读性
  3. 生产率

您可能已经注意到,我并无提性能或者并发性。实际上有很多的语言执行效率比 Go 还要高,但它们必定没有 Go 这么简单。有些语言也以并发性为最高目标,但它们的可读性和生产率都很差。 性能和并发性都很重要,但它们不如简单性、可读性和生产率那么重要。json

1.1 简单性

为何咱们要力求简单,为何简单对 Go 语言编程如此重要?api

咱们有太多的时候感叹“这段代码我看不懂”,是吧?咱们惧怕修改一丁点代码,生怕这一点修改就致使其余您不懂的部分出问题,而您又没办法修复它。

这就是复杂性。复杂性把可读的程序变得不可读,复杂性终结了不少软件项目。

简单性是 Go 的最高目标。不管咱们写什么程序,咱们都应该能一致认为它应当简单。

1.2 可读性

Readability is essential for maintainability. — Mark Reinhold, JVM language summit 2018 可读性对于可维护性相当重要。

为何 Go 代码的可读性如此重要?为何咱们应该力求可读性?

Programs must be written for people to read, and only incidentally for machines to execute. — Hal Abelson and Gerald Sussman, Structure and Interpretation of Computer Programs 程序应该是写来被人阅读的,而只是顺带能够被机器执行。

可阅读性对全部的程序——不只仅是 Go 程序,都是如此之重要,是由于程序是人写的而且给其余人阅读的,事实上被机器所执行只是其次。

代码被阅读的次数,远远大于被编写的次数。一段小的代码,在它的整个生命周期,可能被阅读成百上千次。

The most important skill for a programmer is the ability to effectively communicate ideas. — Gastón Jorquera ^1 程序员最重要的技能是有效沟通想法的能力。

可读性是弄清楚一个程序是在作什么事的关键。若是您都不知道这个程序在作什么,您如何去维护这个程序?若是一个软件不可用被维护,那就可能被重写,而且这也多是您公司最后一次在 GO 上面投入了。

若是您仅仅是为本身我的写一个程序,可能这个程序是一次性的,或者使用这个程序的人也只有您一个,那您想怎样写就怎样写。但若是是多人合做贡献的程序,或者由于它解决人们的需求、知足某些特性、运行它的环境会变化,而在一个很长的时间内被不少人使用,那么程序的可维护性则必须成为目标。

编写可维护的程序的第一步,那就是确保代码是可读的。

1.3 生产率

Design is the art of arranging code to work today, and be changeable forever. — Sandi Metz > 设计是一门艺术,要求编写的代码当前可用,而且之后仍能被改动。

我想重点阐述的最后一个基本原则是生产率。开发者的生产率是一个复杂的话题,但归结起来就是:为了有效的工做,您由于一些工具、外部代码库而浪费了多少时间。Go 程序员应该感觉获得,他们在工做中能够从不少东西中受益了。(Austin Luo:言下之意是,Go 的工具集和基础库完备,不少东西触手可得。)

有一个笑话是说,Go 是在 C++ 程序编译过程当中被设计出来的。快速的编译是 Go 语言用以吸引新开发者的关键特性。编译速度仍然是一个不变的战场,很公平地说,其余语言须要几分钟才能编译,而 Go 只须要几秒便可完成。这有助于 Go 开发者拥有动态语言开发者同样的高效,但却不会面临那些动态语言自己可靠性的问题。

Go 开发者意识到代码是写来被阅读的,而且把阅读放在编写之上。Go 致力于从工具集、习惯等方面强制要求代码必须编写为一种特定样式,这消除了学习项目特定术语的障碍,同时也能够仅仅从“看起来”不正确便可帮助开发者发现潜在的错误。

Go 开发者不会整日去调试那些莫名其妙的编译错误。他们也不会整日浪费时间在复杂的构建脚本或将代码部署到生产中这事上。更重要的是他们不会花时间在尝试搞懂同事们写的代码是什么意思这事上。

当 Go 语言团队在谈论一个语言必须扩展时,他们谈论的就是生产率。

2标识符

咱们要讨论的第一个议题是标识符。标识符是一个名称的描述词,这个名称能够是一个变量的名称、一个函数的名称、一个方法的名称、一个类型的名称或者一个包的名称等等。

鉴于 Go 的语法限制,咱们为程序中的事物选择的名称对咱们程序的可读性产生了过大的影响。良好的可读性是评判代码质量的关键,所以选择好名称对于 Go 代码的可读性相当重要。

2.1选择清晰的名称,而不是简洁的名称

Go 不是专一于将代码精巧优化为一行的那种语言,Go 也不是致力于将代码精炼到最小行数的语言。咱们并不追求源码在磁盘上占用的空间更少,也不关心录入代码须要多长时间。

这个清晰度的关键就是咱们为 Go 程序选择的标识符。让咱们来看看一个好的名称应当具有什么吧:

  • 好的名称是简洁的。一个好的名称未必是尽量短的,但它确定不会浪费任何无关的东西在上面,好名字具备高信噪比。
  • 好的名称是描述性的。一个好的名称应该描述一个变量或常量的使用,而非其内容。一个好的命名应该描述函数的结果或一个方法的行为,而不是这个函数或方法自己的操做。一个好的名称应该描述一个包的目的,而不是包的内容。名称描述的东西越准确,名称越好。
  • 好的名称是可预测的。您应该可以从名称中推断出它的使用方式,这是选择描述性名称带来的做用,同时也遵循了传统。Go 开发者在谈论惯用语时,便是说的这个。

接下来让咱们深刻地讨论一下。

2.2 标识符长度

有时候人们批评 Go 风格推荐短变量名。正如 Rob Pike 所说,“Go 开发者想要的是合适长度的标识符”。^1 Andrew Gerrand 建议经过使用更长的标识符向读者暗示它们具备更高的重要性。

据此,咱们能够概括一些指导意见:

  • 短变量名称在声明和上次使用之间的距离很短时效果很好。
  • 长变量名须要证实其不一样的合理性:越长的变量名,越须要更多的理由来证实其合理。冗长、繁琐的名称与他们在页面上的权重相比,携带的信息很低。
  • 不要在变量名中包含其类型的名称。
  • 常量须要描述其存储的值的含义,而不是怎么使用它。
  • 单字母变量可用于循环或逻辑分支,单词变量可用于参数或返回值,多词短语可用于函数和包这一级的声明。
  • 单词可用于方法、接口和包
  • 请记住,包的命名将成为用户引用它时采用的名称,确保这个名称更有意义。 让咱们来看一个示例:
type Person struct {
Name string
Age  int
}
func AverageAge(people []Person) int {
if len(people) == 0 {
return 0
}
var count, sum int
for _, p := range people {
sum += p.Age
count += 1
}
return sum / count
}
复制代码

在这个示例中,范围变量p在定义以后只在接下来的一行使用。p在整页源码和函数执行过程当中都只生存一小段时间。对p感兴趣的读者只须要查看两行代码便可。

与之造成对比的是,变量people在函数参数中定义,而且存在了 7 行,同理的还有sum和count,这他们使用了更长的名称,读者必须关注更普遍的代码行。

我也能够使用s而不是sum,用c(或n)而不是count,但这会将整个程序中的变量都汇集在相同的重要性上。我也能够使用p而不是people,可是这样又有一个问题,那就是for ... range循环中的变量又用什么?单数的 person 看起来也很奇怪,生存时间极短命名却比导出它的那个值更长。

Austin Luo:这里说的是,若数组people用变量名p,那么从数组中获取的每个元素取名就成了问题,好比用person,即便使用person看起来也很奇怪,一方面是单数,一方面person的生存周期只有两行(很短),命名比生存周期更长的p(people)还长了。 小窍门:跟使用空行在文档中分段同样,使用空行将函数执行过程分段。在函数AverageAge中有按顺序的三个操做。第一个是先决条件,检查当people为空时咱们不会除零,第二个是累加总和和计数,最后一个是计算平均数。

2.2.1 上下文是关键

绝大多数的命名建议都是根据上下文的,意识到这一点很重要。我喜欢称之为原则,而不是规则。 i和index 这两个标识符有什么不一样?咱们很难确切地说其中一个比另外一个好,好比:

for index := 0; index < len(s); index++ {
}
复制代码

上述代码的可读性,基本上都会认为比下面这段要强:

for i := 0; i < len(s); i++ {
}
复制代码

但我表示不赞同。由于不管是i仍是index,都是限定于for循环体的,更冗长的命名,并无让咱们更容易地理解这段代码。

话说回来,下面两段代码那一段可读性更强呢?

func (s *SNMP) Fetch(oid []int, index int) (int, error)
复制代码

或者

func (s *SNMP) Fetch(o []int, i int) (int, error)
复制代码

在这个示例中,oid是SNMP对象 ID 的缩写,所以将其略写为 o 意味着开发者必须将他们在文档中看到的常规符号转换理解为代码中更短的符号。一样地,将index简略为i,减小了其做为SNMP消息的索引的含义。

小窍门:在参数声明中不要混用长、短不一样的命名风格。

2.3 命名中不要包含所属类型的名称

正如您给宠物取名同样,您会给狗取名“汪汪”,给猫取名为“咪咪”,但不会取名为“汪汪狗”、“咪咪猫”。出于一样的缘由,您也不该在变量名称中包含其类型的名称。

变量命名应该体现它的内容,而不是类型。咱们来看下面这个例子:

var usersMap map[string]*User
复制代码

这样的命名有什么好处呢?咱们能知道它是个 map,而且它与*User类型有关,这可能还不错。可是 Go 做为一种静态类型语言,它并不会容许咱们在须要标量变量的地方意外地使用到这个变量,所以Map后缀其实是多余的。 如今咱们来看像下面这样定义变量又是什么状况:

var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
复制代码

如今这个范围内咱们有了三个 map 类型的变量了:usersMap,companiesMap,以及 productsMap,全部这些都从字符串映射到了不一样的类型。咱们知道它们都是 map,咱们也知道它们的 map 声明会阻止咱们使用一个代替另外一个——若是咱们尝试在须要map[string]*User的地方使用companiesMap,编译器将抛出错误。在这种状况下,很明显Map后缀不会提升代码的清晰度,它只是编程时须要键入的冗余内容。(Austin Luo:陈旧的思惟方式)

个人建议是,避免给变量加上与类型相关的任何后缀。

小窍门:若是users不能描述得足够清楚,那usersMap也必定不能。

这个建议也适用于函数参数,好比:

type Config struct {
}
func WriteConfig(w io.Writer, config *Config)
复制代码

Config参数命名为config是多余的,咱们知道它是个Config,函数签名上写得很清楚。

在这种状况建议考虑conf或者c——若是生命周期足够短的话。

若是在一个范围内有超过一个*Config,那命名为conf一、conf2的描述性就比original、updated更差,并且后者比前者更不容易出错。

NOTE:不要让包名占用了更适合变量的名称。 导入的标识符是会包含它所属包的名称的。 例如咱们很清楚context.Context是包context中的类型Context。这就致使咱们在咱们本身的包里,再也没法使用context做为变量或类型名了。 func WriteLog(context context.Context, message string) 这没法编译。这也是为何咱们一般将context.Context类型的变量命名为ctx的缘由,如: func WriteLog(ctx context.Context, message string)

2.4 使用一致的命名风格

一个好名字的另外一个特色是它应该是可预测的。阅读者应该能够在第一次看到的时候就可以理解它如何使用。若是遇到一个约定俗称的名字,他们应该可以认为和上次看到这个名字同样,一直以来它都没有改变意义。 例如,若是您要传递一个数据库句柄,请确保每次的参数命名都是同样的。与其使用 d *sql.DBdbase *sql.DBDB *sql.DBdatabase *sql.DB,还不如都统一为:

db *sql.DB
复制代码

这样作能够增进熟悉度:若是您看到db,那么您就知道那是个*sql.DB,而且已经在本地定义或者由调用者提供了。 对于方法接收者也相似,在类型的每一个方法中使用相同的接收者名称,这样可让阅读者在跨方法阅读和理解时更容易主观推断。

Austin Luo:“接收者”是一种特殊类型的参数。^2 比如func (b *Buffer) Read(p []byte) (n int, err error),它一般只用一到两个字母来表示,但在不一样的方法中仍然应当保持一致。 注意:Go 中对接收者的短命名规则惯例与目前提供的建议不一致。这只是早期作出的选择之一,而且已经成为首选的风格,就像使用CamelCase而不是snake_case同样。

小窍门:Go 的命名风格规定接收器具备单个字母名称或其派生类型的首字母缩略词。有时您可能会发现接收器的名称有时会与方法中参数的名称冲突,在这种状况下,请考虑使参数名称稍长,而且仍然不要忘记一致地使用这个新名称。

最后,某些单字母变量传统上与循环和计数有关。例如,i,j,和k一般是简单的for循环变量。n一般与计数器或累加器有关。 v一般是某个值的简写,k一般用于映射的键,s一般用做string类型参数的简写。

与上面db的例子同样,程序员指望i是循环变量。若是您保证i始终是一个循环变量——而不是在for循环以外的状况下使用,那么当读者遇到一个名为i或者j的变量时,他们就知道当前还在循环中。

小窍门:若是您发如今嵌套循环中您都使用完i,j,k了,那么很显然这已经到了将函数拆得更小的时候了。 使用一致的声明风格

2.5 使用一致的声明风格

Go 中至少有 6 种声明变量的方法(Austin Luo:做者说了 6 种,但只列了 5 种)

  • var x int = 1
  • var x = 1
  • var x int; x = 1
  • var x = int(1)
  • x := 1

我敢确定还有更多我没想到的。这是 Go 的设计师认识到多是一个错误的地方,但如今改变它为时已晚。有这么多不一样的方式来声明变量,那么咱们如何避免每一个 Go 程序员选择本身个性独特的声明风格呢?

我想展现一些在我本身的程序里声明变量的建议。这是我尽量使用的风格。

  • 只声明,不初始化时,使用var。在声明以后,将会显式地初始化时,使用var关键字。
var players int // 0
var things []Thing // an empty slice of Things
var thing Thing // empty Thing struct
json.Unmarshall(reader, &thing)
复制代码

var关键字代表这个变量被有意地声明为该类型的零值。这也与在包级别声明变量时使用var而不是短声明语法(Austin Luo::=)的要求一致——尽管我稍后会说您根本不该该使用包级变量。

  • 既声明,也初始化时,使用:=。当同时要声明和初始化变量时,换言之咱们不让变量隐式地被初始化为零值时,我建议使用短声明语法的形式。这使得读者清楚地知道:=左侧的变量是有意被初始化的。 为解释缘由,咱们回头再看看上面的例子,但这一次每一个变量都被有意初始化了:
var players int = 0
var things []Thing = nil
var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)
复制代码

第一个和第三个示例中,由于 Go 没有从一种类型到另外一种类型的自动转换,赋值运算符左侧和右侧的类型一定是一致的。编译器能够从右侧的类型推断出左侧所声明变量的类型。对于这个示例能够更简洁地写成这样:

var players = 0
var things []Thing = nil
var thing = new(Thing)
json.Unmarshall(reader, thing)
复制代码

因为0是players的零值,所以为players显式地初始化为0就显得多余了。因此为了更清晰地代表咱们使用了零值,应该写成这样:

var players int
复制代码

那第二条语句呢?咱们不能忽视类型写成:

var things = nil
复制代码

由于nil根本就没有类型^2。相反,咱们有一个选择,咱们是否但愿切片的零值?

var things []Thing
复制代码

或者咱们是否但愿建立一个没有元素的切片?

var things = make([]Thing, 0)
复制代码

若是咱们想要的是后者,这不是个切片类型的零值,那么咱们应该使用短声明语法让阅读者很清楚地明白咱们的选择:

things := make([]Thing, 0)
复制代码

这告诉了读者咱们显式地初始化了things。 再来看看第三个声明:

var thing = new(Thing)
复制代码

这既显式地初始化了变量,也引入了 Go 程序员不喜欢并且很不经常使用的new关键字。若是咱们遵循短命名语法的建议,那么这句将变成:

thing := new(Thing)
复制代码

这很清楚地代表,thing被显式地初始化为new(Thing)的结果——一个指向Thing的指针——但仍然保留了咱们不经常使用的new。咱们能够经过使用紧凑结构初始化的形式来解决这个问题,

thing := &Thing{}
复制代码

这和new(Thing)作了一样的事——也所以不少 Go 程序员对这种重复感受不安。不过,这一句仍然意味着咱们为thing明确地初始化了一个Thing{}的指针——一个Thing的零值。

在这里,咱们应该意识到,thing被初始化为了零值,而且将它的指针地址传递给了json.Unmarshall:

var thing Thing
json.Unmarshall(reader, &thing)
复制代码

注意:固然,对于任何经验法则都有例外。好比,有些变量之间很相关,那么与其写成这样: var min int max := 1000不如写成这样更具可读性: min, max := 0, 1000

综上所述:

  • 只声明,不初始化时,使用var。
  • 既声明,也显式地初始化时,使用:=。

小窍门:使得机巧的声明更加显而易见。 当某件事自己很复杂时,应当使它看起来就复杂。 var length uint32 = 0x80 这里的length可能和一个须要有特定数字类型的库一块儿使用,而且length被很明确地指定为uint32类型而不仅是短声明形式: length := uint32(0x80) 在第一个例子中,我故意违反了使用var声明形式和显式初始化程序的规则。这个和我惯常形式不一样的决定,可让读者意识到这里须要注意。

2.6 成为团队合做者

我谈到了软件工程的目标,即生成可读,可维护的代码。而您的大部分职业生涯参与的项目可能您都不是惟一的做者。在这种状况下个人建议是遵照团队的风格。

在文件中间改变编码风格是不适合的。一样,即便您不喜欢,可维护性也比您的我的喜爱有价值得多。个人原则是:若是知足gofmt,那么一般就不值得再进行代码风格审查了。

小窍门:若是您要横跨整个代码库进行重命名,那么不要在其中混入其余的修改。若是其余人正在使用 git bisect,他们必定不肯意从几千行代码的重命名中“跋山涉水”地去寻找您别的修改。

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. 注释应该解释“为何这么作”。

第一种形式适合公开的符号:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.
复制代码

第二种形式适合方法内的注释:

// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
        results = append(results, execute(seen, dep))
}
复制代码

第三种形式,“为何这么作”,这是独一无二的,没法被前两种取代,也没法取代前两种。第三种形式的注释用于解释更多的情况,而这些情况每每难以脱离上下文,不然将没有意义,这些注释就是用来阐述上下文的。

return &v2.Cluster_CommonLbConfig{
  // Disable HealthyPanicThreshold
  HealthyPanicThreshold: &envoy_type.Percent{
    Value: 0,
  },
}
复制代码

在这个示例中,很难当即弄清楚把HealthyPanicThreshold的百分比设置为零会产生什么影响。注释就用来明确将值设置为0其实是禁用了panic阈值的这种行为。

3.1 变量和常量上的注释应当描述它的内容,而非目的

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

const randomNumber = 6 // determined from an unbiased die
复制代码

这个示例的注释描述了“为何”randomNumber被赋值为 6,也说明了 6 这个值是从何而来的。但它没有描述randomNumber会被用到什么地方。下面是更多的例子:

const (
    StatusContinue           = 100 // RFC 7231, 6.2.1
    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
    StatusProcessing         = 102 // RFC 2518, 10.1

    StatusOK                 = 200 // RFC 7231, 6.3.1
复制代码

如在 RFC 7231 的第 6.2.1 节中定义的那样,在 HTTP 语境中 100 被当作StatusContinue

小窍门:对于那些没有初始值的变量,注释应当描述谁将负责初始化它们 // sizeCalculationDisabled indicates whether it is safe // to calculate Types' widths and alignments. See dowidth. var sizeCalculationDisabled bool 这里,经过注释让读者清楚函数dowidth在负责维护sizeCalculationDisabled的状态。 小窍门:隐藏一目了然的东西 Kate Gregory 提到一点^3,有时一个好的命名,能够省略没必要要的注释。 // registry of SQL drivers var registry = make(mapstringsql.Driver) 注释是源码做者加的,由于registry没能解释清楚定义它的目的——它是个注册表,可是什么的注册表? 经过重命名变量名为sqlDrivers,如今咱们很清楚这个变量的目的是存储 SQL 驱动。 var sqlDrivers = make(mapstringsql.Driver) 如今注释已经多余了,能够移除。

3.2 老是为公开符号写文档说明

由于 godoc 将做为您的包的文档,您应该老是为每一个公开的符号写好注释说明——包括变量、常量、函数和方法——全部定义在您包内的公开符号。 这里是 Go 风格指南的两条规则:

  • 任何既不明显也不简短的公共功能必须加以注释。
  • 不管长度或复杂程度如何,都必须对库中的任何函数进行注释。
package ioutil

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)
复制代码

对这个规则有一个例外:您不须要为实现接口的方法进行文档说明,特别是不要这样:

// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)
复制代码

这个注释等于说明都没说,它没有告诉您这个方法作了什么,实际上更糟的是,它让您去找别的地方的文档。在这种状况我建议将注释整个去掉。

这里有一个来自io这个包的示例:

// LimitReader returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying implementation is a *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
  R Reader // underlying reader
  N int64  // max bytes remaining
}

func (l *LimitedReader) Read(p []byte) (n int, err error) {
  if l.N <= 0 {
    return 0, EOF
  }
  if int64(len(p)) > l.N {
    p = p[0:l.N]
  }
  n, err = l.R.Read(p)
  l.N -= int64(n)
  return
}
复制代码

请注意,LimitedReader的声明紧接在使用它的函数以后,而且LimitedReader.Read又紧接着定义在LimitedReader以后,即使LimitedReader.Read自己没有文档注释,那和很清楚它是io.Reader的一种实现。

小窍门:在您编写函数以前先写描述这个函数的注释,若是您发现注释很难写,那就代表您正准备写的这段代码必定难以理解。

3.2.1 不要为坏的代码写注释,重写它

Don’t comment bad code — rewrite it — Brian Kernighan 不要为坏的代码写注释——重写它

为粗制滥造的代码片断着重写注释是不够的,若是您遭遇到一段这样的注释,您应该发起一个问题(issue)从而记得后续重构它。技术债务只要不是过多就没有关系。 在标准库的惯例是,批注一个 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 Package 事实上自身都是一个小的 Go 程序。正如函数或方法的实现对其调用者不重要同样,构成公开 API 的函数、方法、类型的实现——其行为——对调用者也不重要。

一个好的 Go Package 应该致力于较低的源码级耦合,这样,随着项目的增加,对一个包的更改不会级联影响其余代码库。那些“世界末日”似的重构让代码的更新优化变得极其困难,也让工做在这样的代码库上的开发者的生产效率极度地受限。

在这一节中我会来谈一谈包的设计,包括包的命名、类型的命名,以及编写方法和函数的一些小技巧。 一个好的包从它的名称开始

4.1 一个好的包从它的名称开始

编写一个好的 Go 程序包从命名开始。好好思考您的软件包的名字,仅用一个词来描述它是什么。(Austin Luo:就如同“电梯游说”同样,您只能在极短的时间极少的话语的状况下描述您要表达的东西。) 正如我在上一节讲变量命名同样,包的名称也一样很是重要。以个人经验来看,咱们应当思考的不是“我在这个包里应当放哪些类型”,而是“包提供的服务都应该作什么”。一般这个问题的答案不该该是“这个包提供了某某类型”,而是“这个包让您能够进行 HTTP 通讯”。

小窍门:以包“提供”的东西来命名,而不是以“包含”的东西来命名。

4.1.1好的包名应该是惟一的

在您的项目里,每一个包名都应该是惟一的。这个建议很容易理解,也很容易遵照。包的命名应该源于它的目的——若是您发现有两个包须要取相同的名字,那多是下面两种状况:

  • 包的名称太通用了。
  • 和另一个相似名称的包重复了。在这种状况下,您应该从新评审设计或者直接将这两个包合并。

4.2 避免将包命名为base、common、util

一个低劣的名称一般是“utility”。这些一般是随着时间推移沉淀下来的通用帮助类或者工具代码。这种包里一般混合有各类不相关的功能,而且由于其通用性,以致于难以准确地描述这个包都提供了些什么。这一般致使包名来源于这个包“包含”的东西——一堆工具。

像utils或helpers这样的名称,一般在一些大型项目中找到,这些项目中已经开发了较深的层次结构,而且但愿在共享这些帮助类函数时,避免循环导入。虽然打散这些工具函数到新的包也能打破循环导入,可是由于其自己是源于项目的设计问题,包名称并未反映其目的,所以打散它也仅仅只起到了打破导入循环的做用而已。

针对优化utils或helpers这种包名,个人建议是分析它们是在哪里被使用,而且是否有可能把相关函数挪到调用者所在的包。即使这可能致使一些重复的帮助类代码,但这也比在两个包之间引入一个导入依赖来的更好。

A little duplication is far cheaper than the wrong abstraction. — Sandy Metz (一点点的)重复远比错误的抽象更值得。

在多个地方使用工具类方法的状况下,优先选择多个包(的设计),每一个包专一于一个单独的方面,而不是整个包。(Austin Luo:Separation Of Concerns。)

小窍门:使用复数形式命名工具包。好比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 快速返回,而不是深层嵌套

正如 Go 并不使用异常来控制执行流程,也不须要深度缩进代码只为了在顶层结构添加一个try...catch...块。与把成功执行的路径向右侧一层一层深度嵌套相比,Go 风格的代码是随着函数的执行,成功路径往屏幕下方移动。个人朋友 Mat Ryer 称这种方式为“视线”编码。^4

这是经过“保护条款”来实现的(Austin Luo: 相似咱们常说的防护式编程):条件代码块在进入函数时当即断言前置条件。这里是bytes包里的一个示例:

func (b *Buffer) UnreadRune() error {
  if b.lastRead <= opInvalid {
    return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
  }
  if b.off >= int(b.lastRead) {
    b.off -= int(b.lastRead)
  }
  b.lastRead = opInvalid
  return nil
}
复制代码

一旦进入UnreadRune,就会检查b.lastRead,若是以前的操做不是ReadRune就会当即返回错误。从这里开始,函数执行下去的其他部分,咱们就能明确确定b.lastRead比opInvalid大了。

与没有使用“保护条款”的相同功能代码对比看看:

func (b *Buffer) UnreadRune() error {
  if b.lastRead > opInvalid {
    if b.off >= int(b.lastRead) {
      b.off -= int(b.lastRead)
    }
    b.lastRead = opInvalid
    return nil
  }
  return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}
复制代码

最一般的、成功的状况,被缩进到了第一个if条件中了。而且成功的退出条件 return nil,须要很是当心地与闭口括号(})对应。接下来,最后一行代码返回了一个错误,而且咱们须要回退跟踪到函数的开口括号({)才知道执行控制流何时到达了这里。 对于读者和维护程序员来讲,这更容易出错,所以 Go 更喜欢使用“保护条款”并尽早返回错误。

4.4 让零值变得有意义

假设没有明确提供显示初始化器,每一个变量声明以后都会被自动初始化为零内存对应的值,这就是零值。零值与其类型有关:数值类型为0,指针为nil,切片、映射、管道等也一样(为nil)。

始终将值设置为已知默认值,对于程序的安全性和正确性很是重要,而且能够使 Go 程序更简单,更紧凑。这就是 Go 程序员在说“给您的结构一个有用的零值”时所表达的意思。

咱们来看sync.Mutex这类型。它有两个未导出的整数型字段,表示互斥锁的内部状态。因为零值,不管什么时候一个sync.Mutex类型变量被声明后,这些字段都将被设置为0。sync.Mutex类被故意地编码为这样,使得它无需被显式初始化便可使得零值有意义。

type MyInt struct {
	mu  sync.Mutex
	val int
}

func main() {
	var i MyInt

	// i.mu is usable without explicit initialisation.
	i.mu.Lock()
	i.val++
	i.mu.Unlock()
}
复制代码

Austin Luo:原文为“useful”,我在此译为“有意义”而不是“有用”,意在强调其零值是符合业务的、符合逻辑的,而且也是初始的、默认的,而不是“不用管它,让它为零好了”。 这与变量的命名也息息相关,好比: isCacheEnabled bool // 缓存是否被启用 isCacheDisabled bool // 缓存是否被禁用 对于上述两个变量,看起来都差很少,随意定义其中一个便可,惟一的差异只是一个表示启用一个表示禁用而已。可是结合考虑“业务要求默认启用缓存”和“bool 的零值为 false”,那么显然咱们应该定义isCacheDisabled bool而不是前者。一方面,调用者不显式赋值时默认零值为false,另外一方面值为false时表达的含义与业务要求默认启用缓存一致。 这才使得零值真正地有意义,正如示例中注释的那行i.mu同样,不显示初始化其表明的是默认锁是可用的。

另外一个有意义零值的类型示例是bytes.Buffer。您能够无需显式初始化地声明bytes.Buffer而后当即开始向它写入数据。

func main() {
  var b bytes.Buffer
  b.WriteString("Hello, world!\n")
  io.Copy(os.Stdout, &b)
}
复制代码

切片的一个有用性质是它的零值为nil,咱们只须要去看看切片的运行时定义便可理解它的合理性:

type slice struct {
  array *[...]T // pointer to the underlying array
  len   int
  cap   int
}
复制代码

此结构的零值将暗示len和cap的值为0,而且指向内存的指针array,保存切片背后数组的内容,其值也为nil。这意味着您不须要显式make切片,您只需声明它便可。

func main() {
  // s := make([]string, 0)
  // s := []string{}
  var s []string

  s = append(s, "Hello")
  s = append(s, "world")
  fmt.Println(strings.Join(s, " "))
}
复制代码

NOTE:var s []string看起来和上面被注释掉的两行很像,但又不彻底相同。要判断值为nil的切片和长度为零的切片的区别是能够办到的,下面的代码将输出false:

func main() {
	var s1 = []string{}
	var s2 []string
	fmt.Println(reflect.DeepEqual(s1, s2))
}
复制代码

一个意外可是有用的惊喜是未初始化的指针——nil指针,您能够在nil值的类型上调用方法,这能够简单地用于提供默认值。

type Config struct {
	path string
}

func (c *Config) Path() string {
	if c == nil {
		return "/usr/home"
	}
	return c.path
}

func main() {
	var c1 *Config
	var c2 = &Config{
		path: "/export",
	}
	fmt.Println(c1.Path(), c2.Path())
}
复制代码

4.5避免包级别的状态

编写可维护的程序的一个关键方面是松耦合——更改一个包,应该把对没有直接依赖它的包的影响降到最低。

在 Go 中有两种很好的方法能够实现松散耦合:

  • 使用接口来描述函数或方法所需的行为。
  • 避免使用全局状态。

在 Go 中,咱们能够在函数或方法范围内声明变量,也能够在包的范围内声明变量。当变量是公开的,标识符首字母为大写,那么其范围其实是整个程序——任何包均可以在任什么时候候观察到它的类型和存储的内容。

可变的全局状态在程序的独立部分之间引入了紧耦合,由于全局变量对于程序中的每一个函数都是隐匿的参数!若是全局变量的类型变化了,那么任何依赖该变量的函数将会被打破。程序其余任何部分对变量值的修改,都将致使依赖该变量状态的函数被打破。

Austin Luo:全局变量对每一个函数都是可见的,但开发者可能意识不到全局变量的存在(即隐匿的参数),即便意识到并使用了全局变量,也可能意识不到该变量可能在别处被修改,致使全局变量的使用不可靠,依赖该变量状态(值)的函数被打破。

若是您想减小全局变量带来的耦合,那么:

  1. 将相关变量做为字段移动到须要它们的结构上。
  2. 使用接口来减小行为与该行为的实现之间的耦合。

5 项目结构

让咱们来看看多个包合并在一块儿组成项目的状况。一般这应该是一个单独的 git 仓库,但在未来, Go 开发者将交替使用 moduleproject

和包同样,每一个项目也应该有一个清晰的目的。若是您的项目是个库,那么它应该只提供一个东西,好比 XML 解析,或者日志记录。您应该避免将多个不一样的目的混杂在同一个项目中,这有助于避免common库的出现。

小窍门:根据个人经验,common 库与其最大的消费者(使用者)紧密相连,这使得在不锁定步骤的状况下单独升级common或者消费者以进行升级或者修复变得很困难,从而带来不少不相关的更改和 API 破坏。

若是您的项目是一个应用程序,好比您的 Web 应用,Kubernetes 控制器等等,那么在您的项目中可能有一个或多个 main 包。好比,我维护的那个 Kubernetes 控制器里有一个单独的 cmd/contour 包,用来提供到 Kubernetes 集群的服务部署,以及用于调试的客户端。

5.1 考虑更少、更大的包

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

Go 没有提供创建可见性的详细方法:好比 Java 的 publicprotectedprivate和隐式 default 访问修饰符,也没有至关于 C++ 的friend类的概念。

在 Go 中咱们只有两种访问修饰符,公开和私有,这由标识符首字母的大小写决定。若是标识符是公开的,命名首字母就是大写的,则这个标识符能够被其余任何 Go 包引用。

注意:您可能听到有人说导出和非导出,那是公开和私有的同义词。

鉴于对包里的符号可见性控制手段的有限,Go 程序员要怎么作才能避免建立过于复杂的包层次结构呢?

小窍门:除 cmd/internal/ 以外,每一个包都应该包含一些源代码。

我反复建议的是偏向更少、更大的包。您的默认选项并非建立新的包,那将致使为了建立宽而浅的 API 平面时您不得不公开太多的类型。

接下来的几节让咱们更详细地探讨这些建议。

小窍门:来自 Java? 若是您有开发 Java 或 C# 的背景,考虑这样的经验规则:一个 Java 包等效于一个独立的 .go 源文件;一个 Go 包等效于整个 Maven 模块或 .NET 程序集。

5.1.1 经过 import 语句将代码整理到多个文件中

若是您根据包提供给调用者的功能来整理包,那么在 Go 包里整理源文件是否是也应该按相同的方式?您如何知道何时您应该将一个 .go 文件拆分红多个文件?您如何知道是否是过度拆分,而应当考虑整合多个 .go 文件?

这里是我用到的一些经验规则:

  • 从单一的 .go 文件开始,而且使用与包相同的名字。好比包 http 的第一个文件应该是 http.go,而且放到名为 http 的文件夹中。
  • 随着包的逐渐增加,您能够根据职责拆分不一样的部分到不一样的文件。例如,将 RequestResponse 类型拆分到 message.go 中,将 Client 类型拆分到 client.go 中,将 Server 类型拆分到 server.go 中。
  • 若是您发现您的文件具备很类似的 import 声明时,考虑合并它们,不然肯定二者的具体差别并优化重构它们。
  • 不一样的文件应该负责包的不一样区域。messages.go 可能负责网络相关的 HTTP 请求和响应编组,http.go 可能包含低级网络处理逻辑,client.goserver.go 实现 HTTP 请求建立或路由的业务逻辑,等等。

小窍门:源文件名应当考虑名词。 注意:Go 编译器并行编译各个包。在包中,Go 编译器并行地编译各个函数(方法在 Go 中只是花哨的函数)。修改包源码中代码的排列分布不影响编译时间。

5.1.2 内部测试优于外部测试

Go 工具集容许您在两处编写包的测试。假设您的包名是 http2,您能够使用 package http2 声明并编写一个 http2_test.go 文件,这样作将会把 http2_test.go 中的代码当成 http2 包的一部分编译进去。这一般称为内部测试。

Go 工具集一样支持一个以 test 结尾的特定声明的包,例如 package http_test,即便这些测试代码不会被视为正式代码同样编译到正式的包里,而且他们有本身独立的包名,也容许您的测试文件和源码文件同样放置在一块儿。这容许让您像在外部另一个包里调用同样编写测试用例,这咱们称之为外部测试。

在编写单元测试时我推荐使用内部测试。这让您能够直接测试每一个函数或方法,避免外部测试的繁文缛节。 可是,您应该把 Example 测试函数放到外部测试中。这确保了在 godoc 中查看时,示例具备适当的包前缀,而且能够轻松地进行复制粘贴。

小窍门:避免复杂的包层次结构,克制分类的渴望 只有一个例外,这咱们将在后面详述。对于 Go 工具集来说,Go 包的层次结构是没有意义的。例如,net/http 并非 net 的子或子包。 若是您建立了不包含任何 .go 文件的中间目录,则不适用此建议。

5.1.3 使用 internal 包收敛公开的 API 表面

若是您的项目包含多个包,则可能有一些导出的函数——这些函数旨在供项目中的其余包使用,却又不打算成为项目的公共 API 的一部分。若是有这样的状况,则 go 工具集会识别一个特殊的文件夹名——非包名—— 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 函数存在,包括单元测试。

因为 main.main 是一个单例,所以 main.main 的调用中有不少假定,而这些假定又只在 main.mainmain.init 期间调用,而且只调用一次。这致使很难为 main.main 中的代码编写单元测试,所以您的目标应该是将您的业务逻辑从主函数中移出,最好是压根从主程序包中移出。

Austin Luo:这里主要是讲,因为整个程序(包括单元测试在内)只容许存在一个 main.main,所以在 main.main 中编写过多的代码将致使这些代码很难被测试覆盖,所以应当将这些代码从 main.main 中——甚至从 main 包中——独立出来,以便可以写单元测试进行测试。(文中的“假定”是针对测试而言,“假定” main 中的代码能够正常运行。) 小窍门:main 应当解析标识,打开数据库链接,初始化日志模块等等,而后将具体的执行交给其余高级对象。

6 API设计

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

到此为止我给出的全部建议,也仅仅是建议。这是我写 Go 程序时遵照的方式,但也并无强制推行到代码评审中。 可是,在审查 API 时,我就不太宽容了。由于以前我所说的一切均可以在不破坏向后兼容性的状况下获得修正,他们大多只是实施细节而已。

但说到包的开放 API,在初始设计中投入大量精力是值得的,由于后续的更改将是破坏性的,特别是对于已经使用 API 的人来讲。

6.1 设计难以被误用的 API

APIs should be easy to use and hard to misuse. — Josh Bloch ^3 API 应当易用而且难以被误用 若是您从这个演讲中得到任何收益,那就应该是 Josh Bloch 的这个建议。若是 API 很难用于简单的事情,那么 API 的每次调用都会很复杂。当 API 的实际调用很复杂时,它将不那么明显,更容易被忽视。

6.1.1 警戒具备多个相同类型参数的函数

一个看起来很简单,但实际很难正确使用的 API 的例子,就是具备两个及以上的相同类型参数的状况。让咱们来对好比下两个函数签名:

func Max(a, b int) int
func CopyFile(to, from string) error
复制代码

这两个函数有什么不一样?很显然一个是返回两个数的最大数,另外一个是复制文件,但这都不是重点。

Max(8, 10) // 10
Max(10, 8) // 10
复制代码

Max是可交换的,参数的顺序可有可无,8 和 10 比较不管如何都是 10 更大,不管是 8 与 10 比较,仍是 10 与 8 比较。 可是,对于 CopyFile 就不具备这样的特性了:

CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")
复制代码

哪条语句将 presentation.md 复制了一份,哪条语句又是用上周的版本覆盖了 presentation.md ?没有文档说明,您很难分辨。代码评审者在没有文档时也对您参数传入的顺序是否正确不得而知。

一个可行的解决方案是,引入一个帮助类,用来正确地调用 CopyFile

type Source string

func (src Source) CopyTo(dest string) error {
	return CopyFile(dest, string(src))
}

func main() {
	var from Source = "presentation.md"
	from.CopyTo("/tmp/backup")
}
复制代码

这样 CopyFile 就老是能够被正确地调用——这也能够经过单元测试肯定,也能够被设置为私有,进一步下降了误用的可能性。

小窍门:具备多个相同类型参数的 API 很难被正确使用。

6.2 针对默认用例设计 API

几年前我作过一次关于使用功能选项^7使 API 在默认用例时更易用的报告^6。 本演讲的主旨是您应该为常见用例设计 API。另外一方面,您的 API 不该要求调用者提供那些他们不关心的参数。

#####6.2.1 不鼓励使用 nil 做为参数

我讲述本章开宗明义时建议您不要强迫 API 的调用者在他们不关心这些参数意味着什么的状况下为您提供那些参数。当我说针对默认用例的设计 API 时,这就是个人意思。

这里有个来自 net/http 包的示例:

package http

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

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

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

//

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

//

// ListenAndServe always returns a non-nil error.

func ListenAndServe(addr string, handler Handler) error {
复制代码

ListenAndServe 有两个参数,一个 TCP 地址用来监听传入链接,一个 http.Handler 用来处理传入的 HTTP 请求。Serve 容许第二个参数为 nil,而且注意,调用者一般都会传入 nil 用来表示他们但愿使用 http.DefaultServeMux 做为隐式参数。

如今Serve的调用者就有两个方式来作一样的事情:

http.ListenAndServe("0.0.0.0:8080", nil)
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
复制代码

两个方式都作彻底同样的事情。

这种 nil 的行为是病毒式的。在 http 包中一样有个 http.Serve 帮助类,您能够合理地想象 ListenAndServe 是这样创建的:

func ListenAndServe(addr string, handler Handler) error {
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	defer l.Close()
	return Serve(l, handler)
}
复制代码

由于ListenAndServe容许调用者为第二个参数传递nil,因此http.Serve也支持这种行为。事实上,http.Serve 是“当 handlernil,则使用 DefaultServeMux”这个逻辑的一个实现。容许其中一个参数传入 nil 可能致使调用者觉得他们能够给两个参数都传入 nil(Austin Luo:调用者可能想,既然第二个参数有默认实现,那第一个参数可能也有),但像这样调用:

http.Serve(nil, nil)
复制代码

将致使一个丑陋的 panic 。

小窍门:在函数签名中不要混用可为 nil 和不可为 nil 的参数。

http.ListenAndServe 的做者尝试在常规情况时让 API 的使用者更轻松,但可能反而致使这个包难于被安全地使用。 显示地指定 DefaultServeMux 或隐式地指定nil,并无在代码行数上带来不一样。

const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", nil)
复制代码

相较于

const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
复制代码

而且,仅仅为了节省一行代码,这样的混乱是值得的吗?

const root = http.Dir("/htdocs")
mux := http.NewServeMux()
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", mux)
复制代码

小窍门:认真考虑帮助类将节省程序员的时间。清晰比多个选择好。 小窍门:避免公开只用于测试的参数 避免公开导出仅在测试做用域上具备不一样值的 API。相反,使用 Public 包装隐藏这些参数,使用在测试做用域的帮助类来设置测试范围中的属性。

6.2.2 首选可变参数(var args)而非切片参数

编写一个处理切片的函数或方法是很常见的:

func ShutdownVMs(ids []string) error
复制代码

这仅仅是我举的一个例子,但在我工做中更加常见。像这样的签名的问题是,他们假设被调用时会有多个实体。可是,我发现不少时候这些类型的函数却只有一个参数,为了知足函数签名的要求,它必须在一个切片内“装箱”。(Austin Luo:如示例,函数定义时预期会有多个 id,但实际调用时每每只有一个 id,为了知足前面,必须构造一个切片,并把 id 装进去。)

此外,因为 ids 是个切片,您能够向函数传入一个空的切片甚至 nil,编译器也会容许。这就增长了更多的测试用例,由于您应当覆盖这些场景。

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

if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
// apply the non zero parameters
}
复制代码

鉴于 if 语句变得很是长,我想将这个校验放到单独的函数中,这是优化的结果:

// anyPostive indicates if any value is greater than zero.
func anyPositive(values ...int) bool {
	for _, v := range values {
		if v > 0 {
			return true
		}
	}
	return false
}
复制代码

这使我可以向读者明确执行内部块的条件:

if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
        // apply the non zero parameters
}
复制代码

但对于 anyPositive 仍是有一个问题,有人可能会意外地像这样调用它:

if anyPositive(){...}
复制代码

在这种状况下anyPositive会返回false,由于它将不会执行迭代并当即返回false。这还不是世界上最糟糕的事情——(更糟糕的是)没有传入参数时这段代码的逻辑将会变成“anyPositive是否返回true?”。 然而,假如能够这样那就更好了:更改 anyPositive 的签名,使得强制调用者应该传递至少一个参数。咱们能够像这样组合常规参数和可变参数:

func anyPositive(first int, rest ...int) bool {
  if first > 0 {
return true
  }
  for _, v := range rest {
if v > 0 {
return true
}
  }
  return false
}
复制代码

如今anyPositive的调用就不能少于一个参数了。

6.3 让函数自身定义它所需的行为

假设咱们有个将文档保存写入磁盘的工做任务。

// Save将doc的内容写入文件f。
func Save(f * os.File,doc * Document)错误
复制代码

我能够这样描述这个函数,Save,它以一个 *os.File 做为目标来保存写入 Document。但这有一些问题。

签名 Save 排除了将数据写入网络位置的可能。假设网络存储成为后续的需求,可能不得不更改函数签名,从而影响其全部调用者。

Save 也对测试不友好,由于这是直接对磁盘的文件进行操做。所以,为了验证其操做,测试用例不得不在文件被写入以后从新去读取写入的内容。并且我还必须确保 f 最终从临时位置被删除。 同时 *os.File 也定义了不少与 Save 无关的方法,好比读取目录,检查一个路径是否为符号连接等。若是 Save 函数的签名只描述 *os.File 的一部分就更好了。

咱们能够怎么作呢?

// Save writes the contents of doc to the supplied
// ReadWriterCloser.
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 遵循单一职责原则,它不太可能读取它刚刚编写的文件以校验其内容——这应该是另外一段代码的责任。

// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
复制代码

所以,咱们能够将传递给 Save 的接口缩小到只是写和关闭两个方面。

其次,经过 Save 附带提供一种关闭其流的机制(Austin Luo:因为 io.WriteCloser 的存在,Save 隐含了关闭流的含义)。咱们继承了这种机制,使其仍然看起来像一个文件,这就提出了在什么状况下 wc 会被关闭的问题。

可能 Save 会无条件地调用 Close,或者在成功的状况下才调用 Close。 这给 Save 的调用者带来一个问题,那就是但愿在写入文档以后再向数据流写入其余数据时怎么办?

// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
复制代码

一个更好的解决方案是,从新定义 Save,只持有一个 io.Writer,将除了向数据流写入数据以外的其余全部职责都彻底剥离出来。 经过在 Save 函数上遵循接口隔离原则,其结果是实际需求的最核心描述同时做为一个函数——它只须要一个可写的对象——而且是最一般的状况,咱们如今能够使用 Save 来向任何 io.Writer 的实现保存数据。

7 错误处理

我已经作了好几场关于错误处理的演讲,在个人博客里也写了不少相关的内容,昨天的那一节我也讲了不少了,所以我不打算再赘述了。

7.1 经过消除错误来消除错误处理

您昨天可能听了个人讲演,我谈到了关于改进错误处理的建议草案。可是您知道有什么是比改进错误处理语法更好的吗?那就是根本不用处理错误。

注意:我并非说“移除您的错误处理”。我建议的是,修改您的代码,从而无需处理错误。 本节是从 John Ousterhout 的新书《A philosophy of Software Design》^9中获得的启示。其中一章是“Define Errors Out of Existence”,咱们来把这个建议放到 Go 中来看看。

7.1.1 统计行数

让咱们来写一个统计文件行数的函数。

func CountLines(r io.Reader) (int, error) {
	var (
		br    = bufio.NewReader(r)
		lines int
		err   error
	)

	for {
		_, err = br.ReadString('\n')
		lines++
		if err != nil {
			break
		}
	}

	if err != io.EOF {
		return 0, err
	}
	return lines, nil
}
复制代码

因为咱们要遵循上一节的建议,CountLines持有了一个 io.Reader,而非 *File——提供要计数内容的 io.Reader 是调用者的职责。 咱们构造了一个bufio.Reader,并将它放到循环中调用ReadString方法,累加一个计数器,直到文件末尾,而后咱们返回读取到的行数。 至少这是咱们指望的的代码,但这个函数由于错误处理变得更加复杂。例如,这里有个奇怪的结构:

_, err = br.ReadString('n')
lines++
if err != nil {
  break
}
复制代码

咱们在判断错误以前累加了计数——这看起来很怪异。 我之因此写成这样,是由于ReadString在遇到换行符以前若是遇到文件结尾则会返回一个错误,若是文件中没有最终换行符,则会发生这种状况。 为了修复这个问题,咱们从新排列逻辑以累加行数,而后查看是否须要退出循环。

注意:这个逻辑依然不够完美,您能发现 bug 吗?

错误尚未检查完毕。ReadString在遇到文件末尾时会返回io.EOF。这是符合预期的,ReadString须要某种方式“叫停,后面没有更多的东西可读取了”。所以在咱们向CountLine的调用者返回错误以前,咱们须要检查错误不是io.EOF,而且在这种状况下才将其进行传播,不然咱们返回 nil 说一切正常。 Russ Cox 觉察到错误处理可能会模 糊函数操做,我想这就是个很好的例子。让咱们来看一个优化的版本:

func CountLines(r io.Reader) (int, error) {
  sc := bufio.NewScanner(r)
  lines := 0
  for sc.Scan() {
lines++
  }
  return lines, sc.Err()
}
复制代码

这个优化的版本选择使用 bufio.Scanner 而不是 bufio.Reader。 在 bufio.Scanner 的封装下使用 bufio.Reader,但它提供了一个很好的抽象层,帮助咱们移除了 CountLines 操做模糊不清的错误。

注意:bufio.Scanner 能够根据任何模式扫描,但默认只查找换行。 sc.Scan() 这个方法,在匹配到一行文本而且没有遇到错误时会返回 true,所以,for 循环会在遇到文件结尾或者遇到错误时退出。类型 bufio.Scanner 会记录它遇到的第一个错误,一旦退出,咱们能够使用 sc.Err() 方法获取到这个错误。 最后,sc.Err() 会合理处理 io.EOF,而且在遇到文件结尾但没有其余错误时,将错误转化为 nil。 小窍门:当您发现本身遇到难以消除的错误时,请尝试将某些操做提取到帮助类中。

7.1.2 写入响应

个人第二个例子受到了博客文章“Errors are values”^10的启发。 以前的讲演中咱们已经看过如何打开、写入和关闭文件。错误处理还存在,但不是那么难以消除,咱们能够使用 ioutil.ReadFileioutil.WriteFile 来封装。可是当咱们处理低级别的网络协议时,有必要经过 I/O 来构建响应,这就让错误处理可能变得重复。考虑构建 HTTP 响应的 HTTP 服务器的这个片断:

type Header struct {
	Key, Value string
}

type Status struct {
	Code   int
	Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
	_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
	if err != nil {
		return err
	}

	for _, h := range headers {
		_, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
		if err != nil {
			return err
		}
	}

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

	_, err = io.Copy(w, body)
	return err
}
复制代码

首先咱们使用 fmt.Fprintf 构造了状态行而且检查了错误。而后为每一个请求头写入键和值,一样检查了错误。最后咱们使用 rn 终结了请求头这一段,仍然检查了错误。接下来复制响应体到客户端。最后,尽管咱们不用检查 io.Copy 的错误,但咱们也须要将 io.Copy 的双返回值转换为 WriteResponse 所需的单返回值。 这有太多的重复工做了。咱们能够经过引入一个小的封装类 errWriter 来让这件事变得更容易。 errWriter 知足 io.Writer 的契约,所以它能够用来包装现有的 io.WritererrWriter 将写入传递给底层的 Writer,直到检测到错误,从这开始,它会丢弃任何写入并返回先前的错误。

type errWriter struct {
	io.Writer
	err error
}

func (e *errWriter) Write(buf []byte) (int, error) {
	if e.err != nil {
		return 0, e.err
	}
	var n int
	n, e.err = e.Writer.Write(buf)
	return n, nil
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
	ew := &errWriter{Writer: w}
	fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

	for _, h := range headers {
		fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
	}

	fmt.Fprint(ew, "\r\n")
	io.Copy(ew, body)
	return ew.err
}
复制代码

使用 errWriter 替换 WriteResponse 能够显着提升代码的清晰度。每一个操做再也不须要用错误检查来自我修复。经过检查 ew.err 字段来将报告错误移动到函数的末尾,同时也避免由于 io.Copy 的多返回值而引发恼人的转换。

7.2 错误只处理一次

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

// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
        w.Write(buf)
}
复制代码

若是错误您一次都不处理,那您就忽略了它。就像咱们看到的这样,w.WriteAll 的错误彻底被丢弃了。

可是对单一错误作出屡次处理决定,也是有问题的。如下是我常常遇到的代码。

func WriteAll(w io.Writer, buf []byte) error {
	_, err := w.Write(buf)
	if err != nil {
		log.Println("unable to write:", err) // annotated error goes to log file
		return err                           // unannotated error returned to caller
	}
	return nil
}
复制代码

在这个示例中,若是 w.Write 产生了一个错误,则会在日志文件中写一行日志,记录错误发生的文件和代码行,而且错误又同时被返回给了调用者,调用者又可能去记录日志,继续返回,直至回溯到程序的顶部。 调用者可能也会作一样的事,

func WriteConfig(w io.Writer, conf \*Config) error {
  buf, err := json.Marshal(conf)
  if err != nil {
log.Printf("could not marshal config: %v", err)
return err
  }
  if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
  }
  return nil
}
复制代码

到头来在您的日志中会出现重复的行,

unable to write: io.EOF
could not write config: io.EOF
复制代码

但在程序的顶部,您获得了一个没有上下文的原始错误,

err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF
复制代码

我想进一步深刻研究这一点,由于我不认为记录而且返回错误仅仅是我的偏好的问题。

func WriteConfig(w io.Writer, conf \*Config) error {
  buf, err := json.Marshal(conf)
  if err != nil {
log.Printf("could not marshal config: %v", err)
// oops, forgot to return
  }
  if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
  }
  return nil
}
复制代码

我看到的不少问题是程序员忘记在错误处返回。正如咱们以前谈到的那样,Go 风格应当使用保护条款,检查函数进行下去的前提条件,并提早返回。

在这个示例中,做者处理了错误,记录了日志,但忘记返回,这将致使一个难以觉察的 bug。

在 Go 的错误处理契约中,若是出现错误,您不能对其余返回值的内容作出任何假设。就像上例中若是 JSON 反序列化失败,buf 的内容未知,可能什么都不包含,但包含了 1/2 的 JSON 片断会更糟糕。

由于程序员在检查和日志记录了错误以后忘记返回,一个混乱的缓冲区被传递给了 WriteAll,它又可能执行成功,这样配置文件就会被错误地覆盖了。但此时函数会正常返回,而且发生问题的惟一迹象只是单个日志行记录了 JSON 编码失败,而不是编写配置文件失败。

7.2.1 向错误添加上下文

这个 bug 的发生是由于做者尝试向错误消息添加上下文信息。他们试图给本身留下一个线索,指引他们回到错误的源头。

让咱们看看使用 fmt.Errorf 来作一样的事的另外一种方法。

func WriteConfig(w io.Writer, conf *Config) error {
	buf, err := json.Marshal(conf)
	if err != nil {
		return fmt.Errorf("could not marshal config: %v", err)
	}
	if err := WriteAll(w, buf); err != nil {
		return fmt.Errorf("could not write config: %v", err)
	}
	return nil
}

func WriteAll(w io.Writer, buf []byte) error {
	_, err := w.Write(buf)
	if err != nil {
		return fmt.Errorf("write failed: %v", err)
	}
	return nil
}
复制代码

经过将错误的注释与返回组合到一行,则以就更难以忘记返回错误,从而避免意外继续。 若是写文件时发生一个 I/O 错误,错误对象的 Error() 方法将会报告以下信息: could not write config: write failed: input/output error

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

fmt.Errorf 模式适用于提示错误信息,但其代价是原始的错误类型被掩盖了。我认为,将错误视为不透明的值对于生成松散耦合的软件很重要,因此若是对错误值所作的惟一事情是以下两个方面的话,则原始错误是什么类型就可有可无了。

  1. 检查是否为 nil
  2. 打印或记录日志

可是,在某些场景,可能并不常见,您确实须要恢复原始错误。在这种状况下,您能够使用相似个人 errors 包来备注这样的错误。

func ReadFile(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.Wrap(err, "open failed")
	}
	defer f.Close()

	buf, err := ioutil.ReadAll(f)
	if err != nil {
		return nil, errors.Wrap(err, "read failed")
	}
	return buf, nil
}

func ReadConfig() ([]byte, error) {
	home := os.Getenv("HOME")
	config, err := ReadFile(filepath.Join(home, ".settings.xml"))
	return config, errors.WithMessage(err, "could not read config")
}

func main() {
	_, err := ReadConfig()
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}
复制代码

如今报告的错误将会是很好的 K&D ^11 风格的错误: could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

而且错误值保留了对原始缘由的引用。

func main() {
	_, err := ReadConfig()
	if err != nil {
		fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))
		fmt.Printf("stack trace:\n%+v\n", err)
		os.Exit(1)
	}
}
复制代码

从而您能够恢复原始的错误,而且打印其堆栈:

original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory
stack trace:
open /Users/dfc/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/dfc/devel/practical-go/src/errors/readfile2.go:16
main.ReadConfig
/Users/dfc/devel/practical-go/src/errors/readfile2.go:29
main.main
/Users/dfc/devel/practical-go/src/errors/readfile2.go:35
runtime.main
/Users/dfc/go/src/runtime/proc.go:201
runtime.goexit
/Users/dfc/go/src/runtime/asm_amd64.s:1333
could not read config
复制代码

使用 errors 包让您得能够以人和机器都能检测到的方式向错误添加上下文。若是您昨天来看了个人讲演,就会知道 error 的包装正在进入即将发布的 Go 版本的标准库。

8 并发

咱们选择 Go 开发项目一般是由于其并发的特性。Go 团队已经不遗余力使 Go 中的并发性廉价(在硬件资源方面)并具备高性能,可是使用 Go 的并发性写出既不高性能也不可靠的代码仍然是可能的。在我即将离开的时候,我想留下一些关于避免并发特性带来的陷阱的建议。

Go 特性支持的第一类并发是针对通道、select 语句和 go 语句的。若是你从书籍或者培训课程中正式地学习过,你可能注意到并发这一节老是在最后才会讲到。这里也不例外,我选择最后才讲并发,好像它是对于 Go 程序员来讲应该掌握的常规技能以外的附加部分。

这有两个方面。一方面 Go 的主旨是简单、轻量的并发模型。做为一个产品,咱们的语言几乎只靠这方面进行兜售。另外一方面,有一种说法认为并发实际上并不容易使用,不然做者并不会放到一本书的最后一章,咱们回首咱们以前的努力时也不会带有遗憾。

本节讨论使用 Go 原生的并发特性时的一些陷阱。

8.1保持本身忙碌,不然本身作

这段程序有什么问题?

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, GopherCon SG")
	})
	go func() {
		if err := http.ListenAndServe(":8080", nil); err != nil {
			log.Fatal(err)
		}
	}()

	for {
	}
}
复制代码

这段代码按咱们预期在执行,他开启了一个简单的 Web 服务。但它同时又干了些别的事情,那就是在一个无限循环中浪费 CPU。这是由于main的最后一行for {}循环阻塞了主的协程,由于它不作任何输入输出,也不等待锁,也不在通道上作发送或接收,或以其余方式与调度程序通讯。

因为 Go 运行时主要是协同安排的,所以该程序将在单个 CPU 上无效地循环,而且可能最终被实时锁定。

咱们要怎么修复它呢?这里有个建议:

package main

import (
	"fmt"
	"log"
	"net/http"
	"runtime"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, GopherCon SG")
	})
	go func() {
		if err := http.ListenAndServe(":8080", nil); err != nil {
			log.Fatal(err)
		}
	}()

	for {
		runtime.Gosched()
	}
}
复制代码

这可能看起来很愚蠢,但这是我看到的最一般的解决方案。这是不了解根本问题的症结所在。

如今,若是你对 Go 稍有经验,你可能写成这样:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, GopherCon SG")
	})
	go func() {
		if err := http.ListenAndServe(":8080", nil); err != nil {
			log.Fatal(err)
		}
	}()

	select {}
}
复制代码

任何一个空的 select 语句都会永远阻塞在那。这是个颇有用的性质,由于如今咱们不想仅仅由于调用runtime.GoSched()就让整个 CPU 都“旋转”起来。但这样作,咱们只治了标,没有治本。 我想向你提出另外一种解决方案,但愿这一方案已经被采用了。与其让http.ListenAndServe在一个协程中执行并带来一个“主协程中应该作什么”的问题,不如简单地由主协程本身来执行http.ListenAndServe

小窍门:Go 程序的 main.mian 函数退出,则 Go 程序都会无条件退出,不论其余协程正在作什么。

package main
import (
  "fmt"
  "log"
  "net/http"
)
func main() {
  http.HandleFunc("/", func(w http.ResponseWriter, r \*http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
  })
  if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
  }
}
复制代码

总之,这是个人第一个简易:若是你的协程在其余协程返回结果以前什么事都不能干,一般就应该直接了当地本身作这件事,而不是委托其余协程去作。 这一般也消除了将结果从协程引导回其发起者所需的大量状态跟踪和通道操做。

小窍门:许多 Go 程序员滥用协程,特别是初学者。与生活中的全部事情同样,适度是成功的关键。

8.2 将并发留给调用者

下面两个 API 的区别是什么?

// ListDirectory returns the contents of dir.
func ListDirectory(dir string) ([]string, error)

// ListDirectory returns a channel over which
// directory entries will be published. When the list
// of entries is exhausted, the channel will be closed.
func ListDirectory(dir string) chan string
复制代码

首先,显著的区别是,第一个例子读取目录到切片,而后将整个切片返回,不然若是有问题则返回一个错误。这是同步发生的,ListDirectory的调用者将被阻塞直到整个目录被读完。依赖于目录有多大,这个过程可能持续很长时间,也可能由于构建一个目录条目名称的切片,而分配大量的内存。

让咱们来看看第二个例子。这更一个更像 Go,ListDirectory返回了一个传输目录条目的通道,当通道关闭时,代表没有更多目录条目了。因为通道信息发生在ListDirectory返回以后,ListDirectory内部可能开启了一个协程。

注意:第二个版本实际上没有必要真的使用一个协程。它能够分配一个足以保存全部目录条目而不阻塞的通道,填充通道,关闭它,而后将通道返回给调用者。但这不太可能,由于这会消耗大量内存来缓冲通道中的全部结果。

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

  • 经过使用通道的关闭做为没有更多项目要处理的信号,ListDirectory在中途遇到错误就没法告知调用者返回的集合是不完整的。调用者也没法区分空目录和一读取就产生错误的状况,这两种结果对于ListDirectory返回的通道来讲都是当即关闭。
  • 调用者必须持续读取通道的内容直到通道关闭,由于这是让调用者知道协程已经结束的惟一办法。这是对ListDirectory的使用的一个严重限制。调用者必须花时间从通道读取数据,哪怕调用者已经接收到它想要的信息。就须要使用大量内存的中型到大型目录而言,它可能更有效,但这种方法并不比原始的基于切片的方法快。

以上两个实现中的问题,其解决方案是使用回调。一个在每一个目录条目上执行的函数。

func ListDirectory(dir string, fn func(string))
复制代码

绝不奇怪,filepath.WalkDir就是这么作的。

小窍门:若是你的函数开启了一个协程,那么你必须给调用者提供一个中止协程的途径。将异步执行函数的决策留给该函数的调用者一般更容易。

8.3 不要启动一个永不中止的协程

上一个例子演示了没有必要的状况下使用协程。但使用 Go 的驱动缘由之一是该语言提供的第一类并发功能。实际上,在许多状况下,您但愿利用硬件中可用的并行性。为此,你必须使用协程。 这个简单的应用,在两个不一样的端口上提供 http 服务,端口 8080 用于应用自己的流量,8081 用于访问 /debug/pprof 终结点。

package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug
http.ListenAndServe("0.0.0.0:8080", mux) // app traffic
}
复制代码

尽管这个程序不是很复杂,但它表明了一个基本的真实应用程序。 随着应用程序的增加,有一些问题也显现出来,让咱们如今来解决其中的一些。

func serveApp() {
  mux := http.NewServeMux()
  mux.HandleFunc("/", func(resp http.ResponseWriter, req \*http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
  })
  http.ListenAndServe("0.0.0.0:8080", mux)
}
func serveDebug() {
  http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}
func main() {
  go serveDebug()
  serveApp()
}
复制代码

经过将 serveAppserveDebug 的功能放到到各自的函数中,咱们把他们从 main.main 分离出来。咱们照样遵循了上面的建议,把 serveAppserveDebug 的并发性留给了调用者。 可是这个程序有一些可操做性上的问题。若是serveApp返回则main.main会返回并致使程序关闭,最终由您正在使用的任何进程管理器从新启动。

小窍门:正如函数的并发性留给调用者同样,应用应该将状态监视、重启留给程序的唤起者。不要让你的应用程序担负重启自身的责任,这是一个最好从应用程序外部处理的过程。 可是,serveDebug 是在另外一个协程中执行的,若是它退出,也仅仅是这个协程自身退出,程序的其余部分将继续运行。因为/debug处理程序中止工做,您的操做人员会很不高兴地发现他们没法在应用程序中获取统计信息。 咱们要确保的是,负责服务此应用程序的任何协程中止,都关闭应用程序。

func serveApp() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(resp, "Hello, QCon!")
	})
	if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil {
		log.Fatal(err)
	}
}

func serveDebug() {
	if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil {
		log.Fatal(err)
	}
}

func main() {
	go serveDebug()
	go serveApp()
	select {}
}
复制代码

如今咱们经过必要时调用 log.Fatal 来检查 serverAppserveDebugListenAndServe 返回的错误。因为两个处理器都是在协程中运行,咱们使用 select{} 来阻塞主协程。 这种方法存在许多问题:

  1. 若是 ListenAndServe 返回一个 nillog.Fatal不会被调用,则对应的 HTTP 服务会中止,而且应用程序不会退出。
  2. log.Fatal 会调用 os.Exit 无条件终止进程,defer 不会被调用,其余协程不会被通知关闭,应用程序会中止。这会使得为这些函数编写测试用例变得很困难。

小窍门:只在 main.maininit 函数里使用 log.Fatal

咱们须要的是,把任何错误都传回协程的发起者,以便于咱们弄清楚为何协程会中止,而且能够干净地关闭进程。

func serveApp() error {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(resp, "Hello, QCon!")
	})
	return http.ListenAndServe("0.0.0.0:8080", mux)
}

func serveDebug() error {
	return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}

func main() {
	done := make(chan error, 2)
	go func() {
		done <- serveDebug()
	}()
	go func() {
		done <- serveApp()
	}()

	for i := 0; i < cap(done); i++ {
		if err := <-done; err != nil {
			fmt.Println("error: %v", err)
		}
	}
}
复制代码

咱们能够使用一个通道来收集协程返回的状态。通道的大小与咱们要管理的协程数一致,从而使得向 done 通道发送状态时不会被阻塞,不然这将阻塞协程的关闭,致使泄漏。

因为没有办法安全地关闭 done 通道,咱们不能使用 for range 循环通道知道全部协程都上报了信息,所以咱们循环开启协程的次数,这也等于通道的容量。 如今咱们有办法等待协程干净地退出,而且记录发生的日志。咱们所需的仅仅是将一个协程的关闭信号,通知到其余协程而已。

其结果是,通知一个 http.Server 关闭这事被引入进来。因此我将这个逻辑转换为辅助函数。serve 帮助咱们持有一个地址和一个 http.Handler,相似 http.ListenAndServe 以及一个用于触发 Shutdown 方法的 stop 通道。

func serve(addr string, handler http.Handler, stop <-chan struct{}) error {
	s := http.Server{
		Addr:    addr,
		Handler: handler,
	}

	go func() {
		<-stop // wait for stop signal
		s.Shutdown(context.Background())
	}()

	return s.ListenAndServe()
}

func serveApp(stop <-chan struct{}) error {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(resp, "Hello, QCon!")
	})
	return serve("0.0.0.0:8080", mux, stop)
}

func serveDebug(stop <-chan struct{}) error {
	return serve("127.0.0.1:8001", http.DefaultServeMux, stop)
}

func main() {
	done := make(chan error, 2)
	stop := make(chan struct{})
	go func() {
		done <- serveDebug(stop)
	}()
	go func() {
		done <- serveApp(stop)
	}()

	var stopped bool
	for i := 0; i < cap(done); i++ {
		if err := <-done; err != nil {
			fmt.Println("error: %v", err)
		}
		if !stopped {
			stopped = true
			close(stop)
		}
	}
}
复制代码

如今,每当咱们从 done 通道接收到一个值,就关闭 stop 通道,从而致使全部等待在这个通道上的协程关闭 http.Server。这将致使全部剩余的 ListenAndServe 协程返回。一旦咱们启动的协程中止,main.main 便返回继而进程干净地中止了。

小窍门:本身写这个逻辑是重复和微妙的。考虑相似这个包的东西,github.com/heptio/work… 它将为你完成大部分工做。

【完】

参考连接

  1. gaston.life/books/effec…
  2. talks.golang.org/2014/names.…
  3. www.infoq.com/articles/AP…
  4. www.lysator.liu.se/c/pikestyle…
  5. speakerdeck.com/campoy/unde…
  6. www.youtube.com/watch?v=Ic2…
  7. medium.com/@matryer/li…
  8. golang.org/doc/go1.4#i…
  9. dave.cheney.net/2014/10/17/…
  10. commandcenter.blogspot.com/2014/01/sel…
  11. dave.cheney.net/2016/04/27/…
  12. www.amazon.com/Philosophy-…
  13. blog.golang.org/errors-are-…
  14. www.gopl.io/

整理自cloud.tencent.com/developer/a…

原文dave.cheney.net/practical-g…

相关文章
相关标签/搜索