开始起飞-golang编码技巧分享--Dave Cheney博客读后整理

0. 引子

阅读了Dave Cheney 关于go编码的博客:Practical Go: Real world advice for writing maintainable Go programshtml

实际应用下来,对我这个go入门者,提高效果显著。git

我对做者的文章进行整理翻译,提取精炼,加上本身的理解,分享出来。但愿也能给你们带来帮助。程序员

但愿你们支持原做者,原汁原味的内容能够点击 连接 阅读。文中部分例子为我的添加,若有不足敬请包容指出^ _ ^github

(PS:如涉及侵权,请与我联系,我会及时删除文章,知识传播无界,望你们支持)golang

1. 指导原则

我的认为,编码的最佳实践本质是为了提升代码的迭代产能,减小bug的概率。(成本、效率、稳定)算法

做者Dave Cheney提到,go语言的最佳实践的指导原则,须要考虑3点sql

  1. 简洁
  2. 可读性
  3. 开发效率

1.1 简洁

简洁是对于人而言的,若是代码很复杂,甚至违法人的惯性理解,那么修改和维护是牵一发而动全身的。数据库

1.2 可读性

由于代码被阅读的次数远远多于被修改的次数。在做者看来,代码被人的阅读和修改的需求,比被机器执行的需求更强烈。go编码最佳实践第一步就应该肯定代码的可读性。编程

在我我的看来,相似于一致性算法中, raft为何比paxos传播和应用更广,一个很重要的缘由就是raft更加易于理解,raft做者在论文中也提到,raft设计的最重要的初衷就是,paxos太难懂了。可读性的重要性应该排在首位的。json

1.3 开发效率

良好的编码习惯,能够提升代码的交流效率。使得同事们看到代码就知道实现了什么,而没必要去逐行阅读,大大节约了时间,提升开发效率。

此外,对于go语言自己而言,不管在编译速度仍是debug时间花费上,go相对C++也是开发效率大大提升的。

2. 命名

命名对编写可读性好的go程序相当重要!

曾经听到这样的一个言论:对变量的命名要像给本身孩子起名同样慎重。

其实,不光是变量命名,还包括function、method、type、package等,命名都很重要。

2.1 选择辨识度高的名字,而不是选择简短的名字

就像编码不是为了在尽可能短的行数内,写完程序。而是为了写出可读性高的程序。

一样的,咱们的命名标识也不是越短越好,而是容易被他人理解。

一个好名字应该具有的特色:

  1. 简短:一个好名字应该在具有高辨识度的状况下,尽可能简短。

    1. 好比一个判断用户登陆权限的方法:坏名字是judgeAuth(容易歧义),judgeUserLoginAuthority(冗长)
    2. 好的例子judgeLoginAuth
  2. 描述性的:一个好的名字应该是描述变量和常量的用途,而非他们的内容;描述function的结果,或者method的行为,而不是他们的操做;描述package的目的,而非包含的内容。描述的准确性衡量了名字的好坏。

    1. 好比设计一个用来主从选举的包。坏的package名字leader_operation,好的名字election
    2. 坏的function或者method名字ReturnElection,好的名字NewElection
    3. 坏的变量或者常量名字ElectionState,好的名字Role
  3. 可预测的:一个好的名字,仅经过名字,你们就能够推断他们的用途。应该遵循你们的惯用理解。下面会详细阐述。好比

    1. i,j,k经常使用来在迭代中描述引用计数值
    2. n一般用来表示计数累加值
    3. v一般表示一个编码函数的值
    4. k一般用在map中的key
    5. s一般用来表示字符串

2.2 命名的长度

关于名字的长度,咱们有这些建议:

  1. 若是变量的声明和它被最后一次使用的距离很短,可使用短的变量名
  2. 若是一个变量很重要,那么能够避免歧义,容许变量名称长一些,消除歧义
  3. 变量的名字中请不要包含变量的类型名
  4. 常量的名字应该描述他们保存的值,而不是如何使用该值
  5. 单个字母的名字能够用做迭代、逻辑分支判断、参数和返回值。包和函数的名字请使用多个字母的组合。
  6. method、interface、package 请使用单个单词
  7. pakcage名字也是调用方引用时须要注明的,因此请利用package的名字

举一个做者文中的例子说明:

type Person struct {
    Name string
    Age  int
}

// AverageAge returns the average age of people.
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
}

在这个例子中,people 距离最后一次使用间隔7行,而变量p是用来迭代perple的,p距离最后一次使用间隔1行。因此p可使用1个字母命名,而people则使用单词来命名。

其实这里是防止人们阅读代码时,阅读过多行数后,忽然发现一个上下文不理解的词,再去找定义,致使可读性差。

同时,注意例子中的空行的使用。一个是函数之间的空行,另外一个是函数内的空行:在函数里干了3件事:异常判断;累加age;返回。在这3者之间添加空行,能够增长可读性。

2.2.1 上下文是关键

以上强调的原则须要在上下文中去实际判断才行,万事无绝对。

func (s *SNMP) Fetch(oid []int, index int) (int, error)

func (s *SNMP) Fetch(o []int, i int) (int, error)

相比,显然使用oid命名更具有可读性,而使用短变量o则不容易理解。

2.3 变量的命名不要携带变量的类型

由于golang 是一个强类型的语言,在变量的命名中包含类型是信息冗余的,并且容易致使误解错误。举个做者的例子:

var usersMap map[string]*User

咱们将一个从string 到 User 的map结构,命名为UsersMap,看起来合情合理,可是变量的类型中已经包含了map,没有必要再在变量中注明了。

做者的话来说:若是Users 描述不清楚,nameUsersMap也不见得多清楚。

对于函数的名称一样适用,好比:

type Config struct {
    //
}

func WriteConfig(w io.Writer, config *Config)

config 的名称有冗余了,类型中已经说明它是一个*Config了,若是变量在函数中最后一次引用的距离足够短,那么适用简称c或者conf 会更简洁。

提示:不要让包名抢占了好的变量名。好比context这个包,若是使用 func WriteLog(context context.Context, message string),那么编译的时候会报错,由于包名和变量名冲突了。因此通常使用的时候,会使用 func WriteLog(ctx context.Context, message string)

2.4 使用一致的命名

尽可能不要将常见的变量名,换成其余的意思,这样会形成读者的歧义。

并且对于代码中一个类型的变量,不要屡次改换它的名字,尽可能使用一个名字。好比对于数据库处理的变量,不要每次出现不一样的名字,好比d *sql.DBdbase *sql.DBDB *sql.DB,最好使用惯用的,一致的名字db *sql.DB。这样你在其余的代码中,看到变量db时,也能推测到它是*sql.DB

还有一些惯用的短变量名字,这里提一下:

  • i, j, k 用做循环中的索引
  • n 用在计数和累加
  • v 表示值
  • k 表示一个map或者slice 的key
  • s 表示字符串

2.5 使用一致的声明类型

对于一个变量的声明有多重声明类型:

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

在做者看来,这是go的设计者犯的错误,可是来不及改正了,新的版本要保持向前兼容。有这么多种声明的方式,咱们怎么选择本身的类型呢。

做者给出了这些建议:

  • 当声明一个变量,可是不去初始化时,使用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 things []Ting = make([]Thing, 0)

vs

var things = make([]Thing, 0)

vs

things := make([]Thing, 0)

对于go来讲,= 右侧的类型,就是=左侧的类型,上面三个例子中,最后一个使用:=的例子,既能充分标识类型,又足够简洁。

22.6 做为团队的一员

编程生涯大部分时间都是和做为团队的一员,参与其中。做者建议你们最好保持团队原来的编码风格,即便那不是你偏心的风格。要不人会致使整个工程风格不一致,这会更糟糕。

3. 注释

注释很重要,注释应该作到如下3点之一:

  1. 解释作了什么
  2. 解释怎么作
  3. 解释为何这么作

举个例子

这是适合对外方法的注释,解释了作了什么,怎么作的

/ Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.
The second form is ideal for commentary inside a method:

这是适合方法内的注释,解释了作了什么

// 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,
        },
}

将value 设置成0的做用并很差理解,增长注释大大增长可理解性。

3.1 变量和常量的注释应该描述他们的内容,而不是他们的做用

在上文中提到,变量和常量的名字又应该描述他们的目的。然而他们的注释最好描述他们的内容。

const randomNumber = 6 // determined from an unbiased die

在这个例子中,注释描述了为何randomNumber 被赋值为6,注释没有描述在哪里randomNumer会被使用。再看一些例子:

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

这里区分一下,内容表示100表明什么,表明RFC 7231,可是100的目的是表示StatusContinue。

提示,对于没有初始值的变量,注释应该描述谁来初始化这些变量

// sizeCalculationDisabled indicates whether it is safe
// to calculate Types' widths and alignments. See dowidth.
var sizeCalculationDisabled bool

3.2 要对公共的名称添加文档

由于dodoc 是你的项目package的文档,因此你应该在每一个公共的名称上添加注释,包括变量,常量,函数,方法。

这里给出两个谷歌风格指南的准则:

  • 任何不是简练清晰的公共的函数,都应该添加注释
  • 库中的任何函数,无论名称多长或者多么负责,都必须增长注释

举个例子:

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
}
提示:在写函数的内容前,最好先把函数的注释写出

3.2.1 不要在不完善的代码上写注释,而是从新它

若是遇到了不完善的代码,应该记录一个issue,以便后续去修复。

传统的方法是在代码上记录一个todo,以便提醒。好比

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

3.2.2 若是要在一段代码上添加注释,要想一想可否重构它

好的代码自己就是注释。若是要在一段代码上添加注释,要问问本身,可否优化这段代码,而不用添加注释。

函数应该只作一件事,若是你发现要在这个函数的注释里,提到其余函数,那么该想一想拆解这个冗余的函数。

此外,函数越精简,越便于测试。并且函数名自己就是最好的注释。

4. package设计

每一个go 的package 实际上都是本身的小型go程序。就比如一个function或者method的实现对调用者无关同样,包内的对外暴露的function,method和类型的实现,和调用者无关。

一个好的go长须应该努力下降耦合度,这样随着项目的演化,一个package的变化不会影响到整个程序的其余package。

接下来会讨论如何设计一个package,包括名字,类型,和编写method和funciton的一些技巧。

4.1 一个好的packag首先有一个好名字

package 的名字应该尽可能简短,最好用一个单词表示。考虑package名字的时候,不要想着我要在package内写哪些类型,而是想着这个package要提供哪些服务。要以package提供哪些服务命名。

4.1.1 一个好的package名字应该是惟一的

一个项目那的package名字应该都是不一样的。若是你发现可能要取相同的pcakge名字,那么多是如下缘由:

  1. package的名字太通用了
  2. 这个package提供的服务与另外一个package重合了。若是是这种状况,要考虑你的package设计了

4.2 package名字避免使用base,common,util

若是package内包含了一些列不相关的function,那么很难说明这个package提供了哪些服务。这经常会致使package名字取一些通用的名字,相似utilities

大的项目中,常常会出现像utils或者helpers这样的package名字。它们每每在依赖的最底层,以免循环导入问题。可是这样也致使出现一些通用的包名称,而且体现不出包的用意。

做者的建议是将utilshelpers这样的package名字取取消掉:分析函数被调用的场景,若是可能的话,将函数转移到调用者的package内,即便这涉及一些代码的拷贝。

提示:代码重复,比错误的抽象,代价更低

提示:使用单词的复数命名通用的包。好比strings包含了string处理的通用函数。

咱们应该尽量的减小package的数量,好比如今有三个包commonclientserver,咱们能够将其组合为一个包het/http,用client.go和server.go来区分client和server,避免引入过多的冗余包。

提示,标识符的名字包含了包名,好比 net/httpGETfunction,调用的使用写做 http.Get,在标识符起名和package起名时要考虑这一点

4.3 尽早Return

go语言没有trycatch来作exception处理。每每经过return一个错误来进行错误处理。若是错误返回在程序底部,阅读代码的人每每要在大脑里记住不少逻辑情形判断,不清晰明了。

来看一个例子

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")
}

对比

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
}

前者要阅读一些逻辑处理,最后return 错误。后者首先将错误场景明确,并return。显而后者更加易读。

4.4 充分利用空值

若是一个变量声明,可是不给定初始值,则会被自动赋值为空值。若是充分利用这些默认的空值,可让代码更加精简。

  • int 默认值是0
  • 指针默认值是nil
  • slice,map,channel默认值是nil

好比对于sync.Mutex,默认值是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()
}

一样的,由于slice的append是返回一个新的slice,因此咱们能够向一个nil slice直接append:

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

    s = append(s, "Hello")
    s = append(s, "world")
    fmt.Println(strings.Join(s, " "))
}

4.5 避免package级别的状态

书写可维护的程序关键是保持松耦合--对一个package的更改,不该该影响到其余不直接依赖这个package的其余package。

有两个保持所耦合的方法:

  1. 使用interface描述function或者method的行为
  2. 避免使用全局状态

在go程序中,变量声明能够在function或者method做用域内,也能够在package做用域内。若是一个变量是public变量,而且首字母大写,那么全部包均可以访问到这个变量。

可变的全局变量会致使程序之间,各个独立部分紧耦合。它对程序中的每一个function都是不可见的参数。若是变量类型人为改变,或者被其余函数改变,那么任何依赖这个变量的函数都会崩溃。

若是你想减小全局变量带来的耦合:

  1. 将相关的变量转移到struct的参数中
  2. 使用interface减小类型和类型实现之间的耦合

5. 项目结构

5.1 使用尽量少的,尽量大的package

由于go语言中表述可见性的方法是用首字母区分,大写表示可见,小写表示不可见。若是一个标识符是可见的,那么它能够被任何任何其余的package使用。

鉴于此,怎么才能避免过于复杂的package依赖结构?

提示:除了 cmd/internal以外的每一个package,都应该包含一些源码。

做者的建议是,使用尽量少的package,尽量大的package。你们的默认行为应该是不建立新的pcakge,若是建立过多的package,这会致使不少类型是public的。接下来会阐述更多的细节。

5.1.1 经过import语句管理文件中的代码

若是你在这样的规则设计package:以提供调用者什么服务来安排。那么是应该在一个package中的不一样的file也如此设计呢?这里给出一些建议:

  • 每一个package开始于一个与目录同名的.go文件。好比package http应该在一个http目录下的http.go文件中定义
  • 随着package内代码的增加,将不一样的功能分布在不一样的文件中。好比message.go包含RequestResponse类型。client.go包含Client类型,server.go包含Server类型。
  • 若是你发现你的文件中有类似的import声明,尝试合并他们,或者将他们的区别找出来,而且移动到新的包中。
  • 不一样的文件应该具有不一样的职责,好比message.go应该负责HTTP序列化请求和响应。http.go应该包含底层的网络处理逻辑,client.goserver.go实现了HTTP业务逻辑,请求路由等。
提示:以名词命名文件名

提示:go编译器并行编译不一样的package,以及package不一样的medhod和function。因此改变package内的函数位置不影响编译时间。

5.1.2 内部的测试好于外部的测试

go工具支持使用testingpacakge在两个地方写测试用例。假设你的包叫作http2,那么你能够增长一个http2_test.go文件,使用package http2。这样测试用例和代码在同一个package内,这称为内部测试。

go工具也支持一个特别的package声明:以test结尾的包名字好比package http_test。这容许你的测试用例文件与代码文件在同一个package目录下,然而编译时,这些测试用例并不会做为你的package代码的一部分。他们存在于本身的package内。这叫作外部测试。

当编写单元测试时,做者推荐使用内部测试。内部测试可让你直接测试function或者method。

然而,应该将Example测试用例放到外部测试文件中。这样当读者阅读godoc时,这些例子具有包前缀的标识,还易于拷贝。

提示:以上的建议有一些例外,如 net/http,http并不表示是net的子包,若是你设计了一个这种package的层级结构,存在目录内不包含任何的.go文件,那么以上的建议不适用。

5.1.3 使用internal包减小对外暴露的公共API

若是你的项目中包含了多个package,而且有一些函数被其余package使用,可是并不想将这些函数做为对外项目的公共API,那么可使用internal/。将代码放到此目录下,可使得首字母大写的function只对本项目内公开调用,不对其余项目公开。

举例来讲,/a/b/c/internal/d/e/f 的目录结构,c做为一个项目,internal目录下的包只能被/a/b/cimport,不能被其余层级项目import:如/a/b/g

5.2 保持主函数尽可能精简

main函数以及main包应该尽可能精简。由于在项目中只有一个main包,同时程序只可能在main.main或者main.init被调用一次。这致使在main.mian中很难编写测试用例。应该将业务逻辑移动到其余的package中

提示: main应该解析参数,打开数据库链接,初始化logger等,将执行逻辑转移到其余package。

6. API设计

6.1 设计不会被滥用的API

若是在简单的场景,API被使用都很困难,那么API的调用将会很复杂。若是API的调用很复杂,那么它将会难以阅读,而且容易被忽视。

6.1.1 警戒使用同类型的多参数函数

给定两个或者更多相同类型的参数的函数,每每看起来很简单,可是不容易使用。举例:

func Max(a, b int) int
func CopyFile(to, from string) error

这二者的区别是什么呢?本命想第一个比较两个数的最大值,第二个将一个文件进行拷贝,可是这不是最重要的事情。

Max(8, 10) // 10
Max(10, 8) // 10

Max 的参数是能够交换位置的。不会引发歧义。

然而,对于CopyFile则不一样。

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

这二者究竟是从哪一个文件复制到哪一个文件呢。这很容易带来混淆和歧义。

一个可行的解决办法是引入一个辅助类型,增长此method:

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")
}

在上述的解决方法中,CopyTo 总会被正确的使用,不会带来歧义。

提示:带有多个同类型多参数的API很难被正确的使用。

6.2 不该该强迫API的调用方提供他们不须要关注的参

若是你的API没必要要求调用方提他们不关注的参数,那么API将会更加的易于理解。

6.2.1 鼓励将nil做为参数

若是用户不须要关注API的某个参数值,可使用nil做为默认参数。这里给出一个net/httppackage的例子:

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有两个参数,一个是监听的地址,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)

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)
}

能够想象在Server(l, handler)中,会有if handler is nil``,使用DefaultServeMux`的逻辑。可是,以下的调用会致使panic:

http.Serve(nil, nil)
提示:不用将可为nil和不可为nil的参数放到一个函数的参数中。

http.ListenAndServe的做者想让在通常状况下,用户理解更加简单,可是可能会致使使用上的不安全。

在代码行数上,显示的使用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)
提示:慎重考虑辅助函数给程序员节省的时间到底有多少。清晰比简洁更重要。

6.2.2 vars参数比[]T参数更好

将slice 做为做为一个函数的参数很常见。

func ShutdownVms(ids []string) error

将slice做为一个函数的参数有一个前提,就是假定大多数时候,函数的参数有多个值。可是实际上,做者发现大多数时候,函数的参数只有一个值,这时候每每要讲单个参数封装成slice,知足函数的参数格式。

此外,由于ids参数是一个slice,能够将一个空slice或者nil做为参数,编译的时候也不会报错。而在单测时,你也要考虑到这种场景。

再给出一个例子,若是须要判断一些参数非0,能够经过如下的方式:

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
}

这看起来简洁了不少。可是也存在一个问题,若是不给任何的参数,那么anyPositive会返回true,这不符合预期。

若是咱们更改参数的形式,让调用者清楚至少应该传入一个参数,那么就会好不少,好比:

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

6.3 让函数定义他们须要的行为

若是须要将一个数据结构写到磁盘中。能够像以下这么写:

// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error

可是上述的例子存在一些问题:函数名字叫作Save明确了是持久化到硬盘,可是若是后续有需求要持久化到其余主机的磁盘上,那么还须要改函数名字,而且告知全部的调用者。

由于它将内容写到了磁盘上,Save函数也不便于测试。为了校验行为的正确性,自测用例不得不读取文件。

咱们也须要却道f是写到了一个车临时的目录,而且每次都会被清理。

*os.File也包含了不少方法,并不都是与Save相关的。

如何优化呢?

// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error

使用io.ReadWriteCloser接口能够更通用的描述函数的做用。并且拓展了Save的功能。

当调用者保存到本地磁盘时,接口实现传入*os.File能够更明确的标识调用者的意图。

如何进一步优化呢?

首先,若是Save遵循单一职责原则,那么它本身没法读取文件去验证内容,校验将由其余代码进行。

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

因此咱们能够缩小传入接口的方法范围,只进行写入和关闭文件。

其次,Save的接口提供了关闭数据流的方法。那么就要考虑何时使用WC关闭文件:也许Save会无条件的关闭,或者在写入成功时关闭。

这带来一个问题:对于Save的调用者来讲,也谢写入成功数据以后,调用者还想继续追加内容。

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

一个更好的解决方法是重写Save,只提供io.Writer,只进行文件的写入。

进行一系列优化后,Save的做用很明确,能够保存数据到实现接口io.Writer的地方。这既带来可拓展性,也减小了歧义:它只用来保存,不进行数据流的关闭以及读取操做。

7. 错误处理

做者在他的博客中已经写过了错误处理:

inspection-errors

constant-error

此处只补充一些博客中不涉及的内容。

7.1 经过消除错误,将错误处理程序消除

比提示错误处理更好的是,不须要进行错误处理。(改进代码以便没必要进行错误处理)

这一部分做者从John Ousterhout的近期的书籍《A philosophy of Software Design》中得到启发。这本书中有一张叫作“定义不复存在的错误”(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
}

根据以前的建议,函数的入参使用的是接口io.Reader而不是*File。这个函数的功能是统计io.Reader读入的内容。

这个函数使用ReadString函数统计是否到结尾,而且累加。可是因为引入了错误处理,看起来有一些奇怪:

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

之因此这样书写,是由于ReadString函数当遇到结尾时会返回error。

咱们能够这样改进:

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

改进的版本使用bufio.Scaner替换了bufio.Reader,这替改进了错误处理。

若是扫描器检查到了文本的一行,sc.Scan()返回true,若是检测不到或遇到其余错误,则返回false。而不是返回error。这简化了错误处理。而且咱们能够将错误放到sc.Err()中进行返回。

7.1.2 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
}

WriteResponse函数中,有不少的错误处理过程,这看起来十分重复繁琐。来看一个改进方法:

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,它包含了io.Writer,而且有本身的Write函数。当须要向response写入数据时,调用新定义的结构。而新结构中处理了error的状况,这样就没必要每次在WriteResponse中显示的处理err。

(个人思考是,这样虽然简化了err处理,可是这样增长了读者的阅读负担。并不能说是一种简化)

7.2 一次只处理一个错误

一个错误的返回只应该被处理一次,若是想互联错误则能够不去处理它:

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

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发生错误时,咱们将其计入了log,可是却仍然把错误返回了。能够想象,在调用WriteAll的函数中,也会进行计入log,而且返回err。这致使不少荣誉的log被计入。它的调用者可能进行以下行为:

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

可是在WriteConfig 的调用中看来,发生了错误,可是却没有任何上下文信息:

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

7.2.1 为错误增长上下文信息

咱们可使用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
}

这样既不会重复增长log,也能够保留错误的上下文信息。

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

使用fmt.Errorf来注解错误信息看起来很好,可是它也有一个缺点,它掩盖了原始的错误信息。做者认为将错误本来的返回对于松耦合的项目很重要。这有两种状况,错误的原始类型才可有可无:

  1. 判断是否为nil
  2. 将错误信息写入log

可是有一些场景你须要保留原始的错误信息。这种状况下你可使用erros包:

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)
    }
}

这样错误信息会是以下的内容:

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

使用errrors包既能够知足阅读者的需求,封装错误信息的上下文,又能够知足程序判断error原始类型的需求。

8. 并发

不少项选择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 {
    }
}

这个一个简单的实现http 服务的程序,可是它也作了一些其余的事情:它在结尾的地方for死循环,这浪费了cpu,并且for内没有使用管道等通讯机制,它将main处于阻塞状态。没法正常退出。

由于go runtime是协程方式调度,这个程序将会在单个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()
    }
}

这看起来也有一些愚蠢,这表明没有真正理解问题的所在。

(Goshed()是指让出cpu时间片,让其余goroutine运行)

若是你对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避免了浪费cpu,可是并无解决根本问题。

解决的方法是不要在协程中运行http.ListenAndServe(),而是在main.main goroutine中运行。

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)
    }
}

http.ListenAndServer中有实现了阻塞。做者提到许多的go程序员过分使用了go并发,适度才是关键。

这里插入一下本身的理解:

通常在程序的退出处理上,要进行阻塞,并监听相关信号(错误信息,退出消息,信号:sigkill/sigterm),通常select和channel 来配合使用。这里http.ListenAndServe本身实现了select的阻塞,因此没必要再本身实现一套。

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

首先,第一个API将全部的内容获取出,放到一个slice中返回,这是一个同步调用的接口,直到列出全部的内容,才返回。有可能耗费内存,或者花费大量的时间。

第二个API更具有go风格,它是一个异步接口。启动一个goroutine后,返回一个channel。后台goroutine会将目录内容写到channel中。若是channel关闭,证实内容写完了。

第二个channel版本的API有两个问题:

  1. 调用者没法区分出错的场景和空内容的场景,在调用者看来,就是channel关闭了。
  2. 即便调用者提早获取到了须要的内容,也没法提早结束从channel中读取,直到channel关闭。这个方法对目录内容多,占用内存的场景更好,可是这并不比直接返回slice更快。

有一个更更好的解放方法是使用回调函数:

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

这就是filepath.WalkDir的实现方法。

8.3 当goroutine将要中止时,不要启动它

这里给出监听两个不一样端口的http服务的例子:8080是应用的端口,8001是请求性能分析/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解耦。咱们也遵循了上面的建议,将并发性交给调用者去作,好比go serveDebug()

可是上面的改进程序也存在必定的问题。若是serveApp异常出错返回,那么main.main也将返回,致使程序退出。并被其余托管程序重启(好比supervisor)

提示:就像将并发调用交给调用者同样,程序自己的状态监控和重启,应该交给外部程序来作。

然而,serveDebug处在一个独立的goroutine,当它有错误返回时,并不影响其余的goroutine运行。这时调用者发现/debug处理程序没法工做了,也会很困惑。

咱们须要确保任何一个相当重要的goroutine若是异常退出了,那么整个程序也应该退出。

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 {}
}

上面的程序中,serverAppserveDebug都在http服务异常时,获取error并写log。在主函数中,使用select进行阻塞。这存在几个问题:

  1. 若是ListenAndServer返回nil,那么log.Fatal不会处理异常。这时可能端口已经关闭了,可是main没法感知。
  2. log.Fatal调用了os.Exitos.Exit会无条件的结束程序,defers语句不会被执行,其余的goroutine也没法被通知到应该关闭。这个程序直接退出了,也不便于写单元测试。
提示:只应该在 main.main或者init函数中使用 log.Fatal

咱们应该作什么来保证各个goroutine安全退出,而且作好退出的清理工做呢?

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)
        }
    }
}

咱们可使用一个channel来收集返回的error信息,channel的容量和goroutine相同,例子中是2,在main函数中,经过阻塞的等待channel读取,来确保goroutine退出时,main函数能够感知到。

因为没有安全的关闭channel,咱们不使用for range`语句去便利channel,而是使用channel的容量做为读取的边界条件。

如今咱们有了获取goroutine错误信息的机制。咱们须要的还有从一个goroutine获取信号,并转发给其余的goroutine的机制。

下面的例子中,咱们增长了一个辅助函数serve,它实现了http.ListenAndServe的启动http服务的功能,而且增长了一个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)
        }
    }
}

上面的例子,咱们每次启动goroutine会获得一个donechannel,当从done读物到错误信息时,close stop channel,会使得其余goroutine 正常退出。如此,就能够实现main函数正常的退出。

提示,本身写这种处理退出的逻辑会显得重复和微妙。开源代码有实现相似的事情: https://github.com/heptio/workgroup,能够参考