阅读 3483

开始起飞:Golang 编码技巧分享

0. 引子

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

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

我对作者的文章进行整理翻译,提取精炼,加上自己的理解,分享出来。希望也能给大家带来帮助。

希望大家支持原作者,原汁原味的内容可以点击 链接 阅读。文中部分例子为个人添加,如有不足敬请包容指出^ _ ^

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

1. 指导原则

个人认为,编码的最佳实践本质是为了提高代码的迭代产能,减少bug的几率。(成本、效率、稳定)

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

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

1.1 简洁

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

1.2 可读性

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

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

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,可以参考

关注下面的标签,发现更多相似文章
评论