Use Go Channels as Promises and Async/Await

原文地址:https://levelup.gitconnected.com/use-go-channels-as-promises-and-async-await-ee62d93078ec

If you’ve ever programmed with Javascript, you definitely know about Promise and async/await. C#, *Java, Python, *and some other programming languages apply the same pattern but with other names such as Task or Future.

On the contrary, Go doesn’t follow the pattern at all. Instead, it introduces goroutines and channels. However, it isn’t difficult to replicate the pattern with goroutines and channels.


Single async/await

First, let’s experiment with a simple use case: await a result from an async function.

1
2
3
4
5
6
7
8
9
10
// Javascript.

const longRunningTask = async () => {
// Simulate a workload.
sleep(3000)
return Math.floor(Math.random() * Math.floor(100))
}

const r = await longRunningTask()
console.log(r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Go.

package main

import (
"fmt"
"math/rand"
"time"
)

func longRunningTask() <-chan int32 {
r := make(chan int32)

go func() {
defer close(r)

// Simulate a workload.
time.Sleep(time.Second * 3)
r <- rand.Int31n(100)
}()

return r
}

func main() {
r := <-longRunningTask()
fmt.Println(r)
}

Single async/await in Javascript vs. Golang

To declare an “async” function in Go:

  • The return type is <-chan ReturnType.
  • Within the function, create a channel by make(chan ReturnType) and return the created channel at the end of the function.
  • Start an anonymous goroutine by go func() {...} and implement the function’s logic inside that anonymous function.
  • Return the result by sending the value to channel.
  • At the beginning of the anonymous function, add defer close(r) to close the channel once done.

To “await” the result, simply read the value from channel by v := <- fn().


Promise.all()

It’s very common that we start multiple async tasks then wait for all of them to finish and gather their results. Doing that is quite simple in both Javascript and Golang.

1
2
3
4
5
6
7
8
9
10
// Javascript.

const longRunningTask = async () => {
// Simulate a workload.
sleep(3000)
return Math.floor(Math.random() * Math.floor(100))
}

const [a, b, c] = await Promise.all(longRunningTask(), longRunningTask(), longRunningTask())
console.log(a, b, c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Go.

package main

import (
"fmt"
"math/rand"
"time"
)

func longRunningTask() <-chan int32 {
r := make(chan int32)

go func() {
defer close(r)

// Simulate a workload.
time.Sleep(time.Second * 3)
r <- rand.Int31n(100)
}()

return r
}

func main() {
aCh, bCh, cCh := longRunningTask(), longRunningTask(), longRunningTask()
a, b, c := <-aCh, <-bCh, <-cCh

fmt.Println(a, b, c)
}

We have to do it in 2 lines of code and introduce 3 more variables, but it’s clean and simple enough.

We can not do <-longRun(), <-longRun(), <-longRun(), which will longRun() one by one instead all in once.


Promise.race()

Sometimes, a piece of data can be received from several sources to avoid high latencies, or there’re cases that multiple results are generated but they’re equivalent and the only first response is consumed. This first-response-win pattern, therefore, is quite popular. Achieving that in both Javascript and Go is very simple.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Javascript.

const one = async () => {
// Simulate a workload.
sleep(Math.floor(Math.random() * Math.floor(2000)))
return 1
}

const two = async () => {
// Simulate a workload.
sleep(Math.floor(Math.random() * Math.floor(1000)))
sleep(Math.floor(Math.random() * Math.floor(1000)))
return 2
}

const r = await Promise.race(one(), two())
console.log(r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// Go.

package main

import (
"fmt"
"math/rand"
"time"
)

func one() <-chan int32 {
r := make(chan int32)

go func() {
defer close(r)

// Simulate a workload.
time.Sleep(time.Millisecond * time.Duration(rand.Int63n(2000)))
r <- 1
}()

return r
}

func two() <-chan int32 {
r := make(chan int32)

go func() {
defer close(r)

// Simulate a workload.
time.Sleep(time.Millisecond * time.Duration(rand.Int63n(1000)))
time.Sleep(time.Millisecond * time.Duration(rand.Int63n(1000)))
r <- 2
}()

return r
}

func main() {
var r int32
select {
case r = <-one():
case r = <-two():
}

fmt.Println(r)
}

select-case is the pattern that Go designed specifically for racing channel operations. We can even do more stuff within each case, but we’re focusing only on the result so we just leave them all empty.


Promise.then() and Promise.catch()

Because Go’s error propagation model is very different from Javascript, there’s any clean way to replicate Promise.then() and Promise.catch(). In Go, error is returned along with return values instead of being thrown as exception. Therefore, if your function can fail, you can consider changing your return <-chan ReturnType into <-chan ReturnAndErrorType, which is a struct holding both the result and error.

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

介绍

大家好,我在接下来的两个会议中的目标是向大家提供有关编写 Go 代码最佳实践的建议。

这是一个研讨会形式的演讲,不会有幻灯片,而是直接从文档开始。

贴士: 在这里有最新的文章链接
https://dave.cheney.net/practical-go/presentations/qcon-china.html

编者的话

  • 终于翻译完了 Dave 大神的这一篇《Go 语言最佳实践
  • 耗时两周的空闲时间
  • 翻译的同时也对 Go 语言的开发与实践有了更深层次的了解
  • 有兴趣的同学可以翻阅 Dave 的另一篇博文SOLID Go 语言设计(第六章节也会提到)
  • 同时在这里也推荐一个 Telegram Docker 群组(分享/交流): https://t.me/dockertutorial

正文

1. 指导原则

如果我要谈论任何编程语言的最佳实践,我需要一些方法来定义“什么是最佳”。如果你昨天来到我的主题演讲,你会看到 Go 团队负责人 Russ Cox 的这句话:

Software engineering is what happens to programming when you add time and other programmers. (软件工程就是你和其他程序员花费时间在编程上所发生的事情。)
— Russ Cox

Russ 作出了软件编程与软件工程的区分。 前者是你自己写的一个程序。 后者是很多人会随着时间的推移而开发的产品。 工程师们来来去去,团队会随着时间增长与缩小,需求会发生变化,功能会被添加,错误也会得到修复。 这是软件工程的本质。

我可能是这个房间里 Go 最早的用户之一,但要争辩说我的资历给我的看法更多是假的。相反,今天我要提的建议是基于我认为的 Go 语言本身的指导原则:

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

注意:
你会注意到我没有说性能或并发。 有些语言比 Go 语言快一点,但它们肯定不像 Go 语言那么简单。 有些语言使并发成为他们的最高目标,但它们并不具有可读性及生产力。
性能和并发是重要的属性,但不如简单性,可读性和生产力那么重要。

1.1. 简单性

我们为什么要追求简单? 为什么 Go 语言程序的简单性很重要?

我们都曾遇到过这样的情况: “我不懂这段代码”,不是吗? 我们都做过这样的项目:你害怕做出改变,因为你担心它会破坏程序的另一部分; 你不理解的部分,不知道如何修复。

这就是复杂性。 复杂性把可靠的软件中变成不可靠。 复杂性是杀死软件项目的罪魁祸首。

简单性是 Go 语言的最高目标。 无论我们编写什么程序,我们都应该同意这一点:它们很简单。

1.2. 可读性

Readability is essential for maintainability.
(可读性对于可维护性是至关重要的。)
— Mark Reinhold (2018 JVM 语言高层会议)

为什么 Go 语言的代码可读性是很重要的?我们为什么要争取可读性?

Programs must be written for people to read, and only incidentally for machines to execute. (程序应该被写来让人们阅读,只是顺便为了机器执行。)
— Hal Abelson 与 Gerald Sussman (计算机程序的结构与解释)

可读性很重要,因为所有软件不仅仅是 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 程序员应该觉得他们可以通过 Go 语言完成很多工作。

有人开玩笑说, Go 语言是在等待 C++ 语言程序编译时设计的。快速编译是 Go 语言的一个关键特性,也是吸引新开发人员的关键工具。虽然编译速度仍然是一个持久的战场,但可以说,在其他语言中需要几分钟的编译,在 Go 语言中只需几秒钟。这有助于 Go 语言开发人员感受到与使用动态语言的同行一样的高效,而且没有那些语言固有的可靠性问题。

对于开发人员生产力问题更为基础的是,Go 程序员意识到编写代码是为了阅读,因此将读代码的行为置于编写代码的行为之上。Go 语言甚至通过工具和自定义强制执行所有代码以特定样式格式化。这就消除了项目中学习特定格式的摩擦,并帮助发现错误,因为它们看起来不正确。

Go 程序员不会花费整天的时间来调试不可思议的编译错误。他们也不会将浪费时间在复杂的构建脚本或在生产中部署代码。最重要的是,他们不用花费时间来试图了解他们的同事所写的内容。

当他们说语言必须扩展时,Go 团队会谈论生产力。

2. 标识符

我们要讨论的第一个主题是标识符。 标识符是一个用来表示名称的花哨单词; 变量的名称,函数的名称,方法的名称,类型的名称,包的名称等。

Poor naming is symptomatic of poor design. (命名不佳是设计不佳的症状。)
— Dave Cheney

鉴于 Go 语言的语法有限,我们为程序选择的名称对我们程序的可读性产生了非常大的影响。 可读性是良好代码的定义质量,因此选择好名称对于 Go 代码的可读性至关重要。

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

Obvious code is important. What you can do in one line you should do in three.
(清晰的代码很重要。在一行可以做的你应当分三行做。(if/else 吗?))
— Ukiah Smith

Go 语言不是为了单行而优化的语言。 Go 语言不是为了最少行程序而优化的语言。我们没有优化源代码的大小,也没有优化输入所需的时间。

Good naming is like a good joke. If you have to explain it, it’s not funny.
(好的命名就像一个好笑话。如果你必须解释它,那就不好笑了。)
— Dave Cheney

清晰的关键是在 Go 语言程序中我们选择的标识名称。让我们谈一谈所谓好的名字:

  • 好的名字很简洁。 好的名字不一定是最短的名字,但好的名字不会浪费在无关的东西上。好名字具有高的信噪比。

  • 好的名字是描述性的。 好的名字会描述变量或常量的应用,而不是它们的内容。好的名字应该描述函数的结果或方法的行为,而不是它们的操作。好的名字应该描述包的目的而非它的内容。描述东西越准确的名字就越好。

  • 好的名字应该是可预测的。 你能够从名字中推断出使用方式。这是选择描述性名称的功能,但它也遵循传统。这是 Go 程序员在谈到习惯用语时所谈论的内容。

让我们深入讨论以下这些属性。

2.2. 标识符长度

有时候人们批评 Go 语言推荐短变量名的风格。正如 Rob Pike 所说,“ Go 程序员想要正确的长度的标识符”。 [1]

Andrew Gerrand 建议通过对某些事物使用更长的标识,向读者表明它们具有更高的重要性。

The greater the distance between a name’s declaration and its uses, the longer the name should be. (名字的声明与其使用之间的距离越大,名字应该越长。)
— Andrew Gerrand [2]

由此我们可以得出一些指导方针:

  • 短变量名称在声明和上次使用之间的距离很短时效果很好。
  • 长变量名称需要证明自己的合理性; 名称越长,需要提供的价值越高。冗长的名称与页面上的重量相比,信号量较小。
  • 请勿在变量名称中包含类型名称。
  • 常量应该描述它们持有的值,而不是该如何使用。
  • 对于循环和分支使用单字母变量,参数和返回值使用单个字,函数和包级别声明使用多个单词
  • 方法、接口和包使用单个词。
  • 请记住,包的名称是调用者用来引用名称的一部分,因此要好好利用这一点。

我们来举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
}

在此示例中,变量 p 的在第 10 行被声明并且也只在接下来的一行中被引用。 p 在执行函数期间存在时间很短。如果要了解 p 的作用只需阅读两行代码。

相比之下,people 在函数第 7 行参数中被声明。sumcount 也是如此,他们用了更长的名字。读者必须查看更多的行数来定位它们,因此他们名字更为独特。

我可以选择 s 替代 sum 以及 c(或可能是 n)替代 count,但是这样做会将程序中的所有变量份量降低到同样的级别。我可以选择 p 来代替 people,但是用什么来调用 for ... range 迭代变量。如果用 person 的话看起来很奇怪,因为循环迭代变量的生命时间很短,其名字的长度超出了它的值。

贴士:
与使用段落分解文档的方式一样用空行来分解函数。 在 AverageAge 中,按顺序共有三个操作。 第一个是前提条件,检查 people 是否为空,第二个是 sumcount 的累积,最后是平均值的计算。

2.2.1. 上下文是关键

重要的是要意识到关于命名的大多数建议都是需要考虑上下文的。 我想说这是一个原则,而不是一个规则。

两个标识符 iindex 之间有什么区别。 我们不能断定一个就比另一个好,例如

1
2
3
for index := 0; index < len(s); index++ {
//
}

从根本上说,上面的代码更具有可读性

1
2
3
for i := 0; i < len(s); i++ {
//
}

我认为它不是,因为就此事而论, iindex 的范围很大可能上仅限于 for 循环的主体,后者的额外冗长性(指 index)几乎没有增加对于程序的理解。

但是,哪些功能更具可读性?

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

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

在此示例中,oidSNMP 对象 ID 的缩写,因此将其缩短为 o 意味着程序员必须要将文档中常用符号转换为代码中较短的符号。 类似地将 index 替换成 i,模糊了 i 所代表的含义,因为在 SNMP 消息中,每个 OID 的子值称为索引。

贴士: 在同一声明中长和短形式的参数不能混搭。

2.3. 不要用变量类型命名你的变量

你不应该用变量的类型来命名你的变量, 就像您不会将宠物命名为“狗”和“猫”。 出于同样的原因,您也不应在变量名字中包含类型的名字。

变量的名称应描述其内容,而不是内容的类型。 例如:

1
var usersMap map[string]*User

这个声明有什么好处? 我们可以看到它是一个 map,它与 *User 类型有关。 但是 usersMap 是一个 map,而 Go 语言是一种静态类型的语言,如果没有定义变量,不会让我们意外地使用到它,因此 Map 后缀是多余的。

接下来, 如果我们像这样来声明其他变量:

1
2
3
4
var (
companiesMap map[string]*Company
productsMap map[string]*Products
)

usersMapcompaniesMapproductsMap 三个 map 类型变量,所有映射字符串都是不同的类型。 我们知道它们是 map,我们也知道我们不能使用其中一个来代替另一个 - 如果我们在需要 map[string]*User 的地方尝试使用 companiesMap, 编译器将抛出错误异常。 在这种情况下,很明显变量中 Map 后缀并没有提高代码的清晰度,它只是增加了要输入的额外样板代码。

我的建议是避免使用任何类似变量类型的后缀。

贴士:
如果 users 的描述性都不够用,那么 usersMap 也不会。

此建议也适用于函数参数。 例如:

1
2
3
4
5
type Config struct {
//
}

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

命名 *Config 参数 config 是多余的。 我们知道它是 *Config 类型,就是这样。

在这种情况下,如果变量的生命周期足够短,请考虑使用 confc

如果有更多的 *Config,那么将它们称为 originalupdatedconf1conf2 会更具描述性,因为前者不太可能被互相误解。

贴士:
不要让包名窃取好的变量名。
导入标识符的名称包括其包名称。 例如,context 包中的 Context 类型将被称为 context.Context。 这使得无法将 context 用作包中的变量或类型。

1
func WriteLog(context context.Context, message string)

上面的栗子将会编译出错。 这就是为什么 context.Context 类型的通常的本地声明是 ctx,例如:

1
func WriteLog(ctx context.Context, message string)

2.4. 使用一致的命名方式

一个好名字的另一个属性是它应该是可预测的。 在第一次遇到该名字时读者就能够理解名字的使用。 当他们遇到常见的名字时,他们应该能够认为自从他们上次看到它以来它没有改变意义。

例如,如果您的代码在处理数据库请确保每次出现参数时,它都具有相同的名称。 与其使用 d * sql.DBdbase * sql.DBDB * sql.DBdatabase * sql.DB 的组合,倒不如统一使用:

1
db *sql.DB

这样做使读者更为熟悉; 如果你看到db,你知道它就是 *sql.DB 并且它已经在本地声明或者由调用者为你提供。

类似地,对于方法接收器: 在该类型的每个方法上使用相同的接收者名称。 在这种类型的方法内部可以使读者更容易使用。

注意:
Go 语言中的短接收者名称惯例与目前提供的建议不一致。 这只是早期做出的选择之一,已经成为首选的风格,就像使用 CamelCase 而不是 snake_case 一样。

贴士:
Go 语言样式规定接收器具有单个字母名称或从其类型派生的首字母缩略词。 你可能会发现接收器的名称有时会与方法中参数的名称冲突。 在这种情况下,请考虑将参数名称命名稍长,并且不要忘记一致地使用此新参数名称。

最后,某些单字母变量传统上与循环和计数相关联。 例如,ijk 通常是简单 for 循环的循环归纳变量。n 通常与计数器或累加器相关联。v 是通用编码函数中值的常用简写,k 通常用于 map 的键,s 通常用作字符串类型参数的简写。

与上面的 db 示例一样,程序员认为 i 是一个循环归纳变量。 如果确保 i 始终是循环变量,而且不在 for 循环之外的其他地方中使用。 当读者遇到一个名为 ij 的变量时,他们知道循环就在附近。

贴士:
如果你发现自己有如此多的嵌套循环,ijk 变量都无法满足时,这个时候可能就是需要将函数分解成更小的函数。

2.5. 使用一致的声明样式

Go 至少有六种不同的方式来声明变量

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

我确信还有更多我没有想到的。 这可能是 Go 语言的设计师意识到的一个错误,但现在改变它为时已晚。 通过所有这些不同的方式来声明变量,我们如何避免每个 Go 程序员选择自己的风格?

我想就如何在程序中声明变量提出建议。 这是我尽可能使用的风格。

  • 声明变量但没有初始化时,请使用 var 当声明变量稍后将在函数中初始化时,请使用 var 关键字。

    1
    2
    3
    4
    5
    6
    var players int    // 0

    var things []Thing // an empty slice of Things

    var thing Thing // empty Thing struct
    json.Unmarshall(reader, &thing)

    var 表示此变量已被声明为指定类型的零值。 这也与使用 var 而不是短声明语法在包级别声明变量的要求一致 - 尽管我稍后会说你根本不应该使用包级变量。

  • 在声明和初始化时,使用 := 在同时声明和初始化变量时,也就是说我们不会将变量初始化为零值,我建议使用短变量声明。 这使得读者清楚地知道 := 左侧的变量是初始化过的。

为了解释原因,让我们看看前面的例子,但这次是初始化每个变量:

1
2
3
4
5
6
var players int = 0

var things []Thing = nil

var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)

在第一个和第三个例子中,因为在 Go 语言中没有从一种类型到另一种类型的自动转换; 赋值运算符左侧的类型必须与右侧的类型相同。 编译器可以从右侧的类型推断出声明的变量的类型,上面的例子可以更简洁地写为:

1
2
3
4
5
6
var players = 0

var things []Thing = nil

var thing = new(Thing)
json.Unmarshall(reader, thing)

我们将 players 初始化为 0,但这是多余的,因为 0players 的零值。 因此,要明确地表示使用零值, 我们将上面例子改写为:

1
var players int

第二个声明如何? 我们不能省略类型而写作:

1
var things = nil

因为 nil 没有类型。 [2]相反,我们有一个选择,如果我们要使用切片的零值则写作:

1
var things []Thing

或者我们要创建一个有零元素的切片则写作:

1
var things = make([]Thing, 0)

如果我们想要后者那么这不是切片的零值,所以我们应该向读者说明我们通过使用简短的声明形式做出这个选择:

1
things := make([]Thing, 0)

这告诉读者我们已选择明确初始化事物。

下面是第三个声明,

1
var thing = new(Thing)

既是初始化了变量又引入了一些 Go 程序员不喜欢的 new 关键字的罕见用法。 如果我们用推荐地简短声明语法,那么就变成了:

1
thing := new(Thing)

这清楚地表明 thing 被初始化为 new(Thing) 的结果 - 一个指向 Thing 的指针 - 但依旧我们使用了 new 地罕见用法。 我们可以通过使用紧凑的文字结构初始化形式来解决这个问题,

1
thing := &Thing{}

new(Thing) 相同,这就是为什么一些 Go 程序员对重复感到不满。 然而,这意味着我们使用指向 Thing{} 的指针初始化了 thing,也就是 Thing 的零值。

相反,我们应该认识到 thing 被声明为零值,并使用地址运算符将 thing 的地址传递给 json.Unmarshall

1
2
var thing Thing
json.Unmarshall(reader, &thing)

贴士:
当然,任何经验法则,都有例外。 例如,有时两个变量密切相关,这样写会很奇怪:

1
2
var min int
max := 1000

如果这样声明可能更具可读性

1
min, max := 0, 1000

综上所述:

在没有初始化的情况下声明变量时,请使用 var 语法。

声明并初始化变量时,请使用 :=

贴士:
使复杂的声明显而易见。
当事情变得复杂时,它看起来就会很复杂。例如

1
var length uint32 = 0x80

这里 length 可能要与特定数字类型的库一起使用,并且 length 明确选择为 uint32 类型而不是短声明形式:

1
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. 注释应该解释其原因。

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

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

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

1
2
3
4
5
// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
results = append(results, execute(seen, dep))
}

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

1
2
3
4
5
6
return &v2.Cluster_CommonLbConfig{
// Disable HealthyPanicThreshold
HealthyPanicThreshold: &envoy_type.Percent{
Value: 0,
},
}

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

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

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

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

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

1
2
3
4
5
6
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

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

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

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

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

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

1
2
// registry of SQL drivers
var registry = make(map[string]*sql.Driver)

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

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

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

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

3.2. 公共符号始终要注释

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

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

  • 任何既不明显也不简短的公共功能必须予以注释。
  • 无论长度或复杂程度如何,对库中的任何函数都必须进行注释
    1
    2
    3
    4
    5
    6
    7
    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)
    这条规则有一个例外; 您不需要注释实现接口的方法。 具体不要像下面这样做:
    1
    2
    // Read implements the io.Reader interface
    func (r *FileReader) Read(buf []byte) (int, error)
    这个注释什么也没说。 它没有告诉你这个方法做了什么,更糟糕是它告诉你去看其他地方的文档。 在这种情况下,我建议完全删除该注释。

这是 io 包中的一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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

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

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

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

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

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

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

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

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

4. 包的设计

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

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

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

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

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

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

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

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

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

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

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

4.2. 避免使用类似 basecommonutil 的包名称

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

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

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

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

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

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

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

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

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

  • 当由另一个包引用时,net/http 包中的 Get 函数变为 http.Get
  • 当导入到其他包中时,strings 包中的 Reader 类型变为 strings.Reader
  • net 包中的 Error 接口显然与网络错误有关。

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

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

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

1
2
3
4
5
6
7
8
9
10
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 的断言。

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

1
2
3
4
5
6
7
8
9
10
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 语言更喜欢使用 guard clauses 并尽早返回错误。

4.4. 让零值更有用

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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()
}

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

1
2
3
4
5
func main() {
var b bytes.Buffer
b.WriteString("Hello, world!\n")
io.Copy(os.Stdout, &b)
}

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

1
2
3
4
5
type slice struct {
array *[...]T // pointer to the underlying array
len int
cap int
}

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

1
2
3
4
5
6
7
8
9
func main() {
// s := make([]string, 0)
// s := []string{}
var s []string

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

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

1
2
3
4
5
func main() {
var s1 = []string{}
var s2 []string
fmt.Println(reflect.DeepEqual(s1, s2))
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 语言中有两种很好的方法可以实现松散耦合

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

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

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

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

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

5. 项目结构

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

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

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

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

5.1. 考虑更少,更大的包

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

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

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

注意:
你可能会听到人们说 exportednot exported, 跟 publicprivate 是同义词。

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

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

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

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

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

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

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

以下是我的经验法则:

  • 开始时使用一个 .go 文件。为该文件指定与文件夹名称相同的名称。例如: package http 应放在名为 http 的目录中名为 http.go 的文件中。
  • 随着包的增长,您可能决定将各种职责任务拆分为不同的文件。例如:messages.go 包含 RequestResponse 类型,client.go 包含 Client 类型,server.go包含 Server 类型。
  • 如果你的文件中 import 的声明类似,请考虑将它们组合起来。或者确定 import 集之间的差异并移动它们。
  • 不同的文件应该负责包的不同区域。messages.go 可能负责网络的 HTTP 请求和响应,http.go 可能包含底层网络处理逻辑,client.goserver.go 实现 HTTP 业务逻辑请求的实现或路由等等。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6. API 设计

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

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

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

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

6.1. 设计难以被误用的 API

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

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

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

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

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

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

1
2
Max(8, 10) // 10
Max(10, 8) // 10

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

但是,却不适用于 CopyFile

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

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

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

1
2
3
4
5
6
7
8
9
10
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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
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 请求的 http.HandlerServe 允许第二个参数为 nil,需要注意的是调用者通常会传递 nil,表示他们想要使用 http.DefaultServeMux 作为隐含参数。

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

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

两者完全相同。

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

1
2
3
4
5
6
7
8
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。 像下面这样:

1
http.Serve(nil, nil)

会导致 panic

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

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

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

1
2
3
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", nil)

对比

1
2
3
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)

这种混乱值得拯救吗?

1
2
3
4
const root = http.Dir("/htdocs")
mux := http.NewServeMux()
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", mux)

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

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

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

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

1
func ShutdownVMs(ids []string) error

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

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

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

1
2
3
if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
// apply the non zero parameters
}

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

1
2
3
4
5
6
7
8
9
// 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
}

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

1
2
3
if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
// apply the non zero parameters
}

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

1
if anyPositive() { ... }

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

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

1
2
3
4
5
6
7
8
9
10
11
12
// 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
}

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

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

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

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

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

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

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

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

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

我们能做什么 ?

1
2
3
// 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 遵循单一功能原则,它不可能读取它刚刚写入的文件来验证其内容 - 这应该是另一段代码的功能。

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

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

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

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

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

1
2
3
// 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 的任何事物中。

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

7. 错误处理

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

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

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

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

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

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

7.1.1. 计算行数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 方法,递增计数器直到我们到达文件的末尾,然后我们返回读取的行数。

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0

for sc.Scan() {
lines++
}
return lines, sc.Err()
}

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

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

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

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

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

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

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

7.1.2. WriteResponse

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 构造状态码并检查错误。 然后对于每个标题,我们写入键值对,每次都检查错误。 最后,我们使用额外的 \r\n 终止标题部分,检查错误之后将响应主体复制到客户端。 最后,虽然我们不需要检查 io.Copy 中的错误,但我们需要将 io.Copy 返回的两个返回值形式转换为 WriteResponse 的单个返回值。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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. 错误只处理一次

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

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

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

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

1
2
3
4
5
6
7
8
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 期间发生错误,则会写入日志文件,注明错误发生的文件与行数,并且错误也会返回给调用者,调用者可能会记录该错误并将其返回到上一级,一直回到程序的顶部。

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

1
2
3
4
5
6
7
8
9
10
11
12
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
}

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

1
2
unable to write: io.EOF
could not write config: io.EOF

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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 语言风格是使用 guard clauses 以及检查前提条件作为函数进展并提前返回。

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

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

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

7.2.1. 为错误添加相关内容

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 错误,则 errorError() 方法会报告以下类似的内容;

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

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

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

  1. 检查它是否为 nil
  2. 输出或记录它。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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]样式错误,

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

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

1
2
3
4
5
6
7
8
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)
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 包,你可以以人和机器都可检查的方式向错误值添加上下文。 如果昨天你来听我的演讲,你会知道这个库在被移植到即将发布的 Go 语言版本的标准库中。

8. 并发

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

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

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

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

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

这个程序有什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 {} 将阻塞 main goroutine,因为它不执行任何 IO、等待锁定、发送或接收通道数据或以其他方式与调度器通信。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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()
}
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。 但是这也只是治疗了症状,而不是病根。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)
}
}

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

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

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

8.2. 将并发性留给调用者

以下两个 API 有什么区别?

1
2
// ListDirectory returns the contents of dir.
func ListDirectory(dir string) ([]string, error)
1
2
3
4
// 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 可能会启动一个 goroutine 来填充通道。

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

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

  • 通过使用关闭通道作为没有其他项目要处理的信号,在中途遇到了错误时, ListDirectory 无法告诉调用者通过通道返回的项目集是否完整。调用者无法区分空目录和读取目录的错误。两者都导致从 ListDirectory 返回的通道立即关闭。
  • 调用者必须持续从通道中读取,直到它被关闭,因为这是调用者知道此通道的是否停止的唯一方式。这是对 ListDirectory 使用的严重限制,即使可能已经收到了它想要的答案,调用者也必须花时间从通道中读取。就中型到大型目录的内存使用而言,它可能更有效,但这种方法并不比原始的基于切片的方法快。

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 将返回,导致程序关闭并由你使用的进程管理器来重新启动。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 检查从 ListenAndServe 返回的错误,并在需要时调用 log.Fatal。因为两个处理程序都在 goroutine 中运行,所以我们将 main goroutine 停在 select{} 中。

这种方法存在许多问题:

  1. 如果 ListenAndServer 返回 nil 错误,则不会调用 log.Fatal,并且该端口上的 HTTP 服务将在不停止应用程序的情况下关闭。
  2. log.Fatal 调用 os.Exit,它将无条件地退出程序; defer 不会被调用,其他 goroutines 也不会被通知关闭,程序就停止了。 这使得编写这些函数的测试变得困难。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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)
}
}
}

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

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

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

事实证明,要求 http.Server 关闭是有点牵扯的,所以我将这个逻辑转给辅助函数。serve 助手使用一个地址和 http.Handler,类似于 http.ListenAndServe,还有一个 stop 通道,我们用它来触发 Shutdown 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 通道,这会导致在该通道上等待的所有 goroutine 关闭其 http.Server。 这反过来将导致其余所有的 ListenAndServe goroutines 返回。 一旦我们开启的所有 goroutine 都停止了,main.main 就会返回并且进程会干净地停止。

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


**引用: **

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

  • 如有翻译有误或者不理解的地方,请评论指正
  • 待更新的译注之后会做进一步修改翻译
  • 翻译:田浩
  • 邮箱:llitfkitfk@gmail.com

Golang并发编程

Go语言的并发是基于 goroutine 的,goroutine 类似于线程,但并非线程。可以将 goroutine 理解为一种虚拟线程。Go语言运行时会参与调度 goroutine,并将 goroutine 合理地分配到每个 CPU 中,最大限度地使用CPU性能。

Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 goroutine。

下面我们来看一个例子,在线演示:https://play.golang.org/p/U9U-qjuY0t1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"time"
)

func main() {
// 创建一个goroutine
go runing()
// 创建一个匿名的goroutine
go func() {
fmt.Println("喜特:" + time.Now().String())
}()

// 这里sleep一下是因为main方法如果执行完了,main该程序创建的所有goroutine都会退出
time.Sleep(5 * time.Second)
}

func runing() {
fmt.Println("法克:" + time.Now().String())
time.Sleep(3 * time.Second)
}

输出:
法克:2009-11-10 23:00:00 +0000 UTC m=+0.000000001
喜特:2009-11-10 23:00:00 +0000 UTC m=+0.000000001

执行结果说明fuck函数中的sleep三秒并没有影响喜特的输出。

如果说 goroutine 是Go语言程序的并发体的话,那么 channel 就是它们之间的通信机制。一个 channel 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。每个 channel 都有一个特殊的类型,也就是 channel 可发送数据的类型。一个可以发送 int 类型数据的 channel 一般写为 chan int。

下面我们利用goroutine+channel来实现一个生产消费者模型,示例代码如下,在线执行:https://play.golang.org/p/lqUBugLdU-I

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"fmt"
"time"
)

func main() {
// 创建一个通道
channel := make(chan int64)

// 异步去生产
go producer(channel)

// 数据消费
consumer(channel)
}

// 生产者
func producer(channel chan<- int64) {
for {
// 将数据写入通道
channel <- time.Now().Unix()
// 睡1秒钟
time.Sleep(time.Second)
}
}

// 消费者
func consumer(channel <-chan int64) {
for {
timestamp := <-channel
fmt.Println(timestamp)
}
}

输出为如下:(每秒钟打印一次)
1257894000
1257894001
1257894002
1257894003

Golang指针类型和值类型

Java中值类型和引用类型都是定死的,int、double、float、long、byte、short、char、boolean为值类型,其他的都是引用类型,而Go语言中却不是这样。

在Go语言中:

  • &表示取地址,例如你有一个变量a那么&a就是变量a在内存中的地址,对于Golang指针也是有类型的,比如a是一个string那么&a是一个string的指针类型,在Go里面叫&string。
  • *表示取值,接上面的例子,假设你定义b := &a 如果你打印b,那么输出的是&a的内存地址,如果要取值,那么需要使用:*b

下面我们来看下例子,在线运行:https://play.golang.org/p/jxAKyVMjnoy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
)

func main() {
a := "123"
b := &a

fmt.Println(a)
fmt.Println(b)
fmt.Println(*b)
}

输出结果为:
123
0x40c128
123

Java程序员Go语言入门简介

为什么是Go语言

  • 类C的语法,这意味着Java、C#、JavaScript程序员能很快的上手
  • 有自己的垃圾回收机制
  • 跨平台、编译即可执行无需安装依赖环境
  • 支持反射

Go语言简介

Go 语言(或 Golang)起源于 2007 年,并在 2009 年正式对外发布。Go 是非常年轻的一门语言,它的主要目标是“兼具Python等动态语言的开发速度和 C/C++ 等编译型语言的性能与安全性”。

数据类型

数据类型 说明
bool 布尔
string 字符串
int uint8,uint16,uint32,uint64,int8,int16,int32,int64
float float32,float64
byte byte

参考:https://www.runoob.com/go/go-data-types.html

基本语法

HelloWorld

在线运行示例:https://play.golang.org/p/-4RylAqUV36

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

var name string

func init() {
name = "world"
}

func main() {
fmt.Println("hello " + name)
}

我们来执行一下:

1
2
$ go run main.go # main.go 为刚刚创建的那个文件的名称
$ hello world

变量

变量声明

在线运行示例:https://play.golang.org/p/zPqCkRZgrgp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
)

func main() {
var name string // 声明
name = "gaoyoubo" // 赋值
fmt.Println(name)

var age int = 18 // 声明并赋值
fmt.Println(age)
}

类型推断

在线运行示例:https://play.golang.org/p/0My8veBvtJ8

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
)

func main() {
name := "gaoyoubo"
fmt.Println(name)

age := 18
fmt.Println(age)
}

函数

  • 函数可以有多个返回值
  • 隐式的指定函数是private还是public,函数首字母大写的为public、小写的为private
  • 没有类似Java中的try cachethrow,Go语言是通过将error作为返回值来处理异常。
  • 不支持重载

下面我们通过一个示例来了解一下,在线运行示例:https://play.golang.org/p/PYy3ueuPFS6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main

import (
"errors"
"fmt"
"strconv"
)

func main() {
log1()

log2("hello world")

ret1 := add1(1, 1)
fmt.Println("add1 result:" + strconv.Itoa(ret1))

ret2, err := Add2(0, 1)
if err == nil {
fmt.Println("Add2 result:" + strconv.Itoa(ret2))
} else {
fmt.Println("Add2 error", err)
}
}

// 私有、无入参、无返回值
func log1() {
fmt.Println("execute func log1")
}

// 私有、入参、无返回值
func log2(msg string) {
fmt.Println("execute func log2:" + msg)
}

// 私有、两个入参、一个返回值
func add1(count1, count2 int) int {
total := count1 + count2
fmt.Println("execute func add3, result=" + strconv.Itoa(total))
return total
}

// Public、两个入参、多个返回值
func Add2(count1, count2 int) (int, error) {
if count1 < 1 || count2 < 1 {
return 0, errors.New("数量不能小于1")
}
total := count1 + count2
return total, nil
}

该示例输出结果为:

1
2
3
4
5
execute func log1
execute func log2:hello world
execute func add3, result=2
add1 result:2
Add2 error 数量不能小于1

但函数有多个返回值的时候,有时你只关注其中一个返回值,这种情况下你可以将其他的返回值赋值给空白符:_,如下:

1
2
3
4
_, err := Add2(1, 2)
if err != nil {
fmt.Println(err)
}

空白符特殊在于实际上返回值并没有赋值,所以你可以随意将不同类型的值赋值给他,而不会由于类型不同而报错。

结构体

Go语言不是像Java那样的面向对象的语言,他没有对象和继承的概念。也没有class的概念。在Go语言中有个概念叫做结构体(struct),结构体和Java中的class比较类似。下面我们定义一个结构体:

1
2
3
4
5
type User struct {
Name string
Gender string
Age int
}

上面我们定义了一个结构体User,并为该结构体分别设置了三个公有属性:Name/Gender/Age,下面我们来创建一个User对象。

1
2
3
4
5
user := User{
Name: "hahaha",
Gender: "男",
Age: 18, // 值得一提的是,最后的逗号是必须的,否则编译器会报错,这就是go的设计哲学之一,要求强一致性。
}

结构体的属性可以在结构体内直接声明,那么如何为结构体声明函数(即Java中的方法)呢,我们来看下下面的示例:在线运行示例:https://play.golang.org/p/01_cTu0RzdH

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

type User struct {
Name string
Gender string
Age int
}

// 定义User的成员方法
func (u *User) addAge() {
u.Age = u.Age + 1
}

func main() {
user := User{
Name: "哈", // 名称
Gender: "男", // 性别
Age: 18, // 值得一提的是,最后的逗号是必须的,否则编译器会报错,这就是go的设计哲学之一,要求强一致性。
}
user.addAge()
fmt.Println(user.Age)
}

指针类型和值类型

Java中值类型和引用类型都是定死的,int、double、float、long、byte、short、char、boolean为值类型,其他的都是引用类型,而Go语言中却不是这样。

在Go语言中:

  • &表示取地址,例如你有一个变量a那么&a就是变量a在内存中的地址,对于Golang指针也是有类型的,比如a是一个string那么&a是一个string的指针类型,在Go里面叫&string。
  • *表示取值,接上面的例子,假设你定义b := &a 如果你打印b,那么输出的是&a的内存地址,如果要取值,那么需要使用:*b

下面我们来看下例子,在线运行:https://play.golang.org/p/jxAKyVMjnoy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
)

func main() {
a := "123"
b := &a

fmt.Println(a)
fmt.Println(b)
fmt.Println(*b)
}

输出结果为:
123
0x40c128
123

并发编程

Go语言的并发是基于 goroutine 的,goroutine 类似于线程,但并非线程。可以将 goroutine 理解为一种虚拟线程。Go语言运行时会参与调度 goroutine,并将 goroutine 合理地分配到每个 CPU 中,最大限度地使用CPU性能。

Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 goroutine。

下面我们来看一个例子(在线演示:https://play.golang.org/p/U9U-qjuY0t1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"time"
)

func main() {
// 创建一个goroutine
go runing()
// 创建一个匿名的goroutine
go func() {
fmt.Println("喜特:" + time.Now().String())
}()

// 这里sleep一下是因为main方法如果执行完了,main该程序创建的所有goroutine都会退出
time.Sleep(5 * time.Second)
}

func runing() {
fmt.Println("法克:" + time.Now().String())
time.Sleep(3 * time.Second)
}

输出:
法克:2009-11-10 23:00:00 +0000 UTC m=+0.000000001
喜特:2009-11-10 23:00:00 +0000 UTC m=+0.000000001

执行结果说明fuck函数中的sleep三秒并没有影响喜特的输出。

如果说 goroutine 是Go语言程序的并发体的话,那么 channel 就是它们之间的通信机制。一个 channel 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。每个 channel 都有一个特殊的类型,也就是 channel 可发送数据的类型。一个可以发送 int 类型数据的 channel 一般写为 chan int。

下面我们利用goroutine+channel来实现一个生产消费者模型,示例代码如下:(在线执行:https://play.golang.org/p/lqUBugLdU-I)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"fmt"
"time"
)

func main() {
// 创建一个通道
channel := make(chan int64)

// 异步去生产
go producer(channel)

// 数据消费
consumer(channel)
}

// 生产者
func producer(channel chan<- int64) {
for {
// 将数据写入通道
channel <- time.Now().Unix()
// 睡1秒钟
time.Sleep(time.Second)
}
}

// 消费者
func consumer(channel <-chan int64) {
for {
timestamp := <-channel
fmt.Println(timestamp)
}
}

输出为如下:(每秒钟打印一次)
1257894000
1257894001
1257894002
1257894003

Java程序员觉得不好用的地方

  • 异常处理
  • 没有泛型
  • 不支持多态、重载
  • 不支持注解(但是他的struct中的属性支持tag

参考

Go 简易教程

关于本书

授权许可

本书中的内容使用 CC BY-NC-SA 4.0(署名 - 非商业性使用 - 相同方式共享4.0许可协议)授权。你不必为此书付费。
你可以免费的复制、发布、修改或者展示此书。但是,这本书的版权归原作者Karl Seguin所有,不要将此书用于商业目的。

关于许可证的全部内容你可以浏览以下网站:

http://creativecommons.org/licenses/by-nc-sa/4.0/

最新版本

这本书的最新版本可以在以下网站获得:
http://github.com/karlseguin/the-little-go-book

前言

每次提起学习一门新语言,我真的是又爱又恨。一方面,语言是我们的行事之本,即使一些小的变化都会对事情有重大的影响。可能有时一闪而过的 灵感 就会对你如何编程产生长久的影响力,并重新定义你对其他语言的期望。而头疼的是,语言的设计是呈增量式的,要学习新的关键字、类型系统、代码风格以及新的库、社区和范例真的是难言其苦。相比于所有其他必须学习的事情,花时间在一门新的语言上貌似真的是很糟糕的投资。

即便如此,我们还是得走下去。我们 必须 得乐于每天一点点地进步,因为“语言是我们的行事之本”。虽然语言的变化往往会是循序渐进的,但它影响范围仍然很广,包括了有生产率、可读性、性能、可测试性、依赖性管理、错误处理、文档、简要、社区、标准库等等。所以,有好点的说法来形容 千刀万剐 么?

留给我们一个重要问题就是:为什么选择 Go? 。对于我来说,有两条原因。第一条,它是一种相对简单的语言,且具有相对简单的标准库。在很多方面,Go 的特性语法是为了简化我们在过去几十年中添加到编程语言中的一些复杂特性。另外一条原因就是对于许多开发者来说,它将补充您的知识面。

Go 是作为系统语言(例如:操作系统,设备驱动程序)创建的,因此它针对的是 C 和 C++ 开发人员。按照 Go 团队的说法,应用程序开发人员已经成为 Go 的主要用户而不是系统开发人员了,这个说法我也是相信的。为什么?我不能权威的代表系统开发人员说话,但对于我们这些构建网站,服务,桌面应用程序等的人来说,它可以部分的归结为对一类系统的新兴需求,这类系统介于低级系统应用程序和更高级的应用程序之间。

可能 Go 语言有消息传递机制,缓存,重计算数据分析,命令行接口,体制和监控,我不知道给 Go 语言什么样的标签,但是在我的职业生涯中,随着系统的复杂性不断增加,以及动辄成千上万的并发,显然对定制基础类型系统的需求不断增加。你可以使用 Ruby 或者 Python 构建这样的系统(大多人都这样做),但这些类型的系统可以从更严格的类型系统和更高的性能中受益。类似地,你可以使用 Go 来构建网站(很多人都愿意这样做),但我仍然喜欢 Node 或者 Ruby 对这类系统展现出的表现力。

Go 语言还擅长其他领域。比如,当运行一个编译过的 Go 程序时,他没有依赖性。你不必担心用户是安装了 Ruby 或者 JVM,如果这样,你还要考虑版本。出于这个原因,Go 作为命令行程序以及其他并发类型的工具(日志收集器)的开发语言变得越来越流行。

坦白来说,学习 Go 可以有效利用你的时间。你不必担心会花费很长时间学习 Go 甚至掌握它,你最终会从你的努力中得到一些实用的东西。

作者注解

对于写这本书我犹豫再三,主要有两个原因。第一个是 Go 有自己的文档,特别是 Effective Go

另一个是在写一本关于语言类的书的时候我会有点不安。当我们写 《 The Little MongoDB Book》 这本书的时候,我完全假设大多数读者已经理解了关系型数据库和建模的基本知识。在写 《The Little Redis Book》这本书的时候,你也可以同样假设读者已经熟悉键值存储。

在我考虑未来的某些章节的时候,我知道不能再做出同样的假设。你花多长时间学习并理解接口,这是个新的概念,我希望你从中学到的不仅仅是 Go 有提供接口,并且还有如何使用它们。最终,我希望你向我反馈本书的哪部分讲得太细或者太粗,我会感到很欣慰,也算是我对读者们的小小要求了。

第一章 · 基础

入门

如果你想去尝试运行 Go 的代码,你可以去看看 Go Playground ,它可以在线运行你的代码并且不要安装任何东西。这也是你在 Go 的论坛区和 StackOverflow 等地方寻求帮助时分享 Go 代码的最常用方法。

Go 的安装很简单。你可以用源码去安装,但是我还是建议你使用其中一个预编译的二进制文件。当你 跳转到下载页面,你将会看到 Go 语言的在各个平台上的安装包。我们会避免这些东西并且学会如何在自己的平台上安装好 Go。正如你所看到的的那样,安装 Go 并不是很难。

除了一些简单的例子, Go 被设计成代码在工作区内运行。工作区是一个文件夹,这个文件夹由 binpkg,以及src子文件夹组成的。你可能会试图强迫 Go 遵循自己的风格-不要这么去做。

一般,我把我的项目放在 ~/code 文件夹下。比如,~/code/blog 目录就包含了我的 blog 项目。对于 Go 来说,我的工作区域就是 ~/code/go ,然后我的 Go 写的项目代码就在 ~/code/go/src/blog 文件夹下。

简单来说,无论你希望把你的项目放在哪里,你最好创建一个 go 的文件夹,再在里面创建一个 src 的子文件夹。

OSX / Linux 系统安装 Go

下载适合你自己电脑系统的 tar.gz 文件。对于 OSX 系统来说,你可能会对 go#.#.#.darwin-amd64-osx10.8.tar.gz 感兴趣,其中 #.#.# 代表 Go 的最新版本号。

通过 tar -C /usr/local -xzf go#.#.#.darwin-amd64-osx10.8.tar.gz 命令将文件加压缩到 /usr/local 目录下

设置两个环境变量:

1.GOPATH 指向的是你的工作目录,对我来说,那个目录就是 $HOME/code/go
2.我们需要将 Go 的二进制文件添加到的 PATH变量中。

你可以通过下面的 shell 去设置这两个环境变量:

echo 'export GOPATH=$HOME/code/go' >> $HOME/.profile
echo 'export PATH=$PATH:/usr/local/go/bin' >> $HOME/.profile

你需要将这些环境变量激活。你可以关掉 shell 终端,然后在打开 shell 终端,或者你可以在 shell 终端运行 source $HOME/.profile

在命令终端输入 go version,你将会得到一个 go version go1.3.3 darwin / amd64 的输出,Go 就安装完成了。

Windows 系统

下载最新的 zip 文件。如果你的电脑是 64 位的系统,你将需要 go#.#.#.windows-amd64.zip ,这里的 #.#.# 是 Go 的最新版本号。

解压缩 go#.#.#.windows-amd64.zip 文件到你选择的位置。 c:\Go这个位置是个不错的选择。

在系统中设置两个环境变量:

  1. GOPATH 同样的指向的是你的工作目录。这个变量看起来像c:\users\goku\work\go 这个样子。
  2. 添加 c:\Go\bin 到系统的 PATH 环境变量。

你可以通过「系统」 控制面板的 「高级」 选项卡上的 「环境变量」按钮设置环境变量。 某些版本的 Windows 通过「系统」控制面板中的「高级系统选项」选项此控制面板。

打开一个 cmd 命令终端,输入 go version。 你会得到一个 go version go1.3.3 windows/amd64 的输出,即表示 Go 安装完成。

Go 是一门编译型,具有静态类型和类 C 语言语法的语言,并且有垃圾回收(GC)机制。这是什么意思?

编译

编译是将源代码翻译为更加低级的语言的过程——翻译成汇编语言(例如 Go),或是翻译成其他中间语言(如 Java 和 C#)。

编译型语言可能会让你很不爽,因为编译过程实在是太慢了。如果每次都需要花好几分钟甚至好几个小时去等待代码编译的话,很难进行快速迭代。而编译速度是 Go 的主要优化目标之一。这对我们这些从事大型项目开发或者是习惯用解释型快速看到程序结果的人来说,确实是一件好事。

编译型语言注重于运行速度和无依赖执行程序(至少对于 C/C++ 和 Go 来说是这样的,直接将依赖编译到程序中)。

静态类型

静态类型意味着变量必须是特定的类型(如:int, string, bool, []byte 等等),这可以通过在声明变量的时候,指定变量的类型来实现,或者让编译器自行推断变量的类型(我们将很快可以看到实例)。

关于静态类型的东西,可以说的还有很多,但是我相信通过看代码能更好的理解静态类型是什么。如果你习惯于动态类型语言, 你可能会发现这很麻烦。这种想法没错,但是静态类型语言也有优点,特别是当你将静态类型和编译配对使用时。这两者经常混为一谈。确实当你有其中一个的时候,通常也会有另一个,但是这不是硬性规定的。使用强类型系统,编译器能够检测除语法错误之外的问题从而进一步优化。

类 C 语法

当说到一门语言是类 C 语法的时候,通常意味着如果你用过其他类 C 语言如:C,C++,Java,JavaScript 和 C#,你会觉得 Go 的语法很熟悉——最少表面上是这样的。举个例子,&& 用于逻辑 AND,== 用于判断是否相等,{} 是块的开始和结束,数组下标的起始值为 0。

类 C 语法也倾向于用分号表示作为语句结束符,并将条件写在括号中。Go 不支持这些,但是仍然使用括号来控制优先级。例如,一个 if 语句是这样的:

1
2
3
if name == "Leto" {
print("the spice must flow")
}

在很多复杂系统中,括号符还是很有用的:

1
2
3
if (name == "Goku" && power > 9000) || (name == "gohan" && power < 4000)  {
print("super Saiyan")
}

除此之外,Go 要比 C# 或 Java 更接近 C - 不仅是语法方面,还有目的方面。这反映在语言的简洁和简单上,希望你在学习它的时候能慢慢体会这一点。

变量和申明

如果我们用 x = 4 来申明和赋值变量,那么我们就可以同时开始和结束对变量的查看了。遗憾的是,Go 更为复杂些。我们将通过简单的示例来开始我们的学习。然后,在下一章中,我们会在创建和使用结构体时,进一步扩展。尽管如此,你可能还得花一段时间来适应,才能感受到它带给你的舒适感。

你可能会想:“哇!这有什么复杂的?”。 让我们开始一个例子。

下面的例子是 Go 中,申明变量和赋值最为明确的方法,但也是最为冗长的方法:

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
)

func main() {
var power int
power = 9000
fmt.Printf("It's over %d\n", power)
}

这里我们定义了一个 int 类型的变量 power。默认情况下,Go 会为变量分配默认值。Integers 的默认值是 0,booleans 默认值是 false,strings 默认值是 "" 等等。下面,我们创建一个值为 9000 的名为 power 的变量。我们可以将定义和赋值两行代码合并起来:

1
var power int = 9000

不过,这么写太长了。Go 提供了一个方便的短变量声明运算符 := ,它可以自动推断变量类型:

1
power := 9000

这非常方便,它可以跟函数结合使用,就像这样:

1
2
3
4
5
6
7
func main() {
power := getPower()
}

func getPower() int {
return 9001
}

值得注意的是要用 := 来声明变量以及给变量赋值。相同变量不能被声明两次(在相同作用域下),如果你尝试这样,会收到错误提示。

1
2
3
4
5
6
7
8
9
func main() {
power := 9000
fmt.Printf("It's over %d\n", power)

// 编译器错误:
// := 左侧不是新的变量
power := 9001
fmt.Printf("It's also over %d\n", power)
}

编辑器会告诉你 * := 左侧不是新的变量*。这就意味着当我们首次声明一个变量时应该使用 := ,后面再给变量赋值时应该使用 =。这似乎很有道理,但是凭空来记忆且需要根据情况来切换却是很难的事。

如果你仔细阅读代码的错误信息,你会发现 variables 单词是个复数,即有多个变量,那是因为go支持多个变量同时赋值(使用 = 或者 :=):

1
2
3
4
func main() {
name, power := "Goku", 9000
fmt.Printf("%s's power is over %d\n", name, power)
}

另外,多个变量赋值的时候,只要其中有一个变量是新的,就可以使用:=。例如:

1
2
3
4
5
6
7
func main() {
power := 1000
fmt.Printf("default power is %d\n", power)

name, power := "Goku", 9000
fmt.Printf("%s's power is over %d\n", name, power)
}

尽管变量 power 使用了两次:=,但是编译器不会在第 2 次使用 :=时报错,因为这里有一个新的 name变量,它可以使用:=。然后你不能改变 power 变量的类型,它已经被声明成一个整型,所以只能赋值整数。

到目前为止,你最后需要了解的一件事是,Go 会像 import 一样,不允许你在程序中拥有未使用的变量。例如:

1
2
3
4
func main() {
name, power := "Goku", 1000
fmt.Printf("default power is %d\n", power)
}

这将不会通过编译,因为 name 是一个被申明但是未被使用的变量,就像 import 的包未被使用时,也将会导致编译失败,但总的来说,我认为这有助于提高代码的清洁度和可读性。

还有更多关于的申明和赋值的技巧。初始化一个变量时,请使用: var NAME TYPE;给变量申明及赋值时,请使用: NAME := VALUE ; 给之前已经申明过的变量赋值时,请使用: NAME = VALUE

垃圾回收

一些变量,在创建的时候,就拥有一个简单定义的生命周期。对于函数中的变量,会在函数执行完后进行销毁。在别的语言中,对于编译器而言,这不会很明显。例如:函数返回的变量,或者由其他变量和对象所调用的变量,它们的生命周期是很难确定的。 如果没有垃圾回收机制,那么开发人员就得知道有哪些不需要用到的变量,并将它们释放。就像 C 语言,你需要使用 free(str); 来释放变量。

语言的垃圾回收机制(像:Ruby, Python, Java, JavaScript, C# , Go)是会对变量进行跟踪,并在没有使用它们的时候,进行释放。垃圾回收会增加一些额外的开销,但是也减少了一些致命性的 BUG。

运行 go 代码

创建一个简单的程序然后学习如何编译和运行它。打开你的文本编辑器写入下面的代码:

1
2
3
4
5
package main

func main() {
println("it's over 9000!")
}

保存文件并命名为 main.go 。 你可以将文件保存在任何地方;不必将这些琐碎的例子放在 go 的工作空间内。

接下来,打开一个 shell 或者终端提示符,进入到文件保存的目录内, 对于我而言, 应该输入 cd ~/code 进入到文件保存目录。

最后,通过敲入以下命令来运行程序:

1
go run main.go

如果一切正常(即你的 golang 环境配置的正确),你将看到 it’s over 9000!

但是编译步骤是怎么样的呢? go run 命令已经包含了编译运行。它使用一个临时目录来构建程序,执行完然后清理掉临时目录。你可以执行以下命令来查看临时文件的位置:

1
go run --work main.go

明确要编译代码的话,使用 go build:

1
go build main.go

这将产生一个可执行文件,名为 main ,你可以执行该文件。如果是在 Linux / OSX 系统中,别忘了使用 ./ 前缀来执行,也就是输入 ./main

在开发中,你既可以使用 go run 也可以使用 go build 。但当你正式部署代码的时候,你应该部署通过 go build 产生的二进制文件并且执行它。

入口函数 Main

希望刚才执行的代码是可以理解的。我们刚刚创建了一个函数,并且使用内置函数 println 打印出了字符串。难道仅因为这里只有一个选择,所以 go run 知道执行什么吗??不。在 go 中程序入口必须是 main 函数,并且在 main 包内。

我们将在后面的章节中详细介绍。目前,我们将专注于理解 go 基础,一直会在 main 包中写代码。

如果你想尝试,你可以修改代码并且可以更改包名。使用 go run 执行程序将出现一个错误。 接着你可以将包名改回 main ,换一个不同的方法名,你会看到一个不同的错误。尝试使用 go build 代替 go run 来执行刚才的代码,注意代码编译时,没有入口点可以执行。但当你构建一个库时,确实完全正确的。

导入包

Go 有很多内建函数,例如 println,可以在没有引用情况下直接使用。但是,如果不使用 Go 的标准库直接使用第三方库,我们就无法走的更远。import 关键字被用于去声明文件中代码要使用的包。

修改下我们的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"os"
)

func main() {
if len(os.Args) != 2 {
os.Exit(1)
}
fmt.Println("It's over", os.Args[1])
}

你可以这样运行:

1
go run main.go 9000

我们现在用了 Go 的两个标准包:fmtos 。我们也介绍了另一个内建函数 lenlen 返回字符串的长度,字典值的数量,或者我们这里看到的,它返回了数组元素的数量。如果你想知道我们这里为什么期望得到两个参数,它是因为第一个参数 – 索引0处 – 总是当前可运行程序的路径。(更改程序将它打印出来亲自看看就知道了)

你可能注意到了,我们在函数名称前加上了前缀包名,例如,fmt.PrintLn。这是不同于其他很多语言的。后续的章节中我们将学习到更多关于包的知识。现在,知道如何导入以及使用一个包就是一个好的开始。

在 Go 中,关于导包是很严格的。如果你导入了一个包却没有使用将会导致编译不通过。尝试运行下面的程序:

1
2
3
4
5
6
7
8
9
package main

import (
"fmt"
"os"
)

func main() {
}

你应该会得到两个关于 fmtos 被导入却没有被使用的错误。这很烦人的是不是呀?绝对是这样的,不过随着时间的推移,你将慢慢习惯它(虽然仍然烦人,不过要以 Go 的思维写 Go)。Go 如此严格是因为没用的导入会降低编译速度;诚然,我们大多数人不会关注这个问题。

另一个需要记住的事情是 Go 的标准库已经有了很好的文档。你可以访问 https://golang.org/pkg/fmt/#Println 去看更多关于 PrintLn 函数的信息。你可以点击那个部分的头去看源代码。另外,也可以滚动到顶部查看关于 Go 格式化功能的更多消息。

如果没有互联网,你可以这样在本地获取文档:

1
godoc -http=:6060

然后浏览器中访问 http://localhost:6060

函数声明

这是个好时机指出函数是可以返回多个值的。让我们看三个函数:一个没有返回值,一个有一个返回值,一个有两个返回值。

1
2
3
4
5
6
7
8
func log(message string) {
}

func add(a int, b int) int {
}

func power(name string) (int, bool) {
}

我们可以像这样使用最后一个:

1
2
3
4
value, exists := power("goku")
if exists == false {
// 处理错误情况
}

有时候,你仅仅关注其中一个返回值。这个情况下,你可以将其他的返回值赋值给空白符_

1
2
3
4
_, exists := power("goku")
if exists == false {
// handle this error case
}

这不仅仅是一个惯例。_ ,空白标识符,特殊在于实际上返回值并没有赋值。这让你可以一遍又一遍地使用 _ 而不用管它的类型。

最后,关于函数声明还有些要说的。如果参数有相同的类型,您可以用这样一个简洁的用法:

1
2
3
func add(a, b int) int {

}

返回多个值可能是你经常使用的,你也可能会频繁地使用 _ 丢弃一个值。命名返回值和稍微冗长的参数声明不太常用。尽管如此,你将很快遇到他们,所以了解他们很重要。

继续之前

我们之前看了很多小的独立片段,在这点上,可能会感到有点脱节。我们将慢慢地构建更大的例子,将这些小的片段组合在一起。

如果你之前用的是动态类型语言,那么类型和声明的复杂性看起来像是在倒退。我并没有不同意你,在某些系统中动态语言可能更加有效。

如果你来自静态类型的语言,你可能对 Go 感到满意。类型推断以及多值返回的设计非常棒?(尽管这不是 Go 独有)。希望随着我们了解更多,你将会慢慢爱上这干净简洁的语法。

第二章 · 结构体

Go 不是像 C ++,Java,Ruby和C#一样的面向对象的(OO)语言。它没有对象和继承的概念,也没有很多与面向对象相关的概念,例如多态和重载。

Go所具有的是结构体的概念,可以将一些方法和结构体关联。Go 还支持一种简单但有效的组合形式。 总的来说,它会使代码变的更简单,但在某一些场合,你会错过面向对象提供的一些特性。(值得指出的是,通过组合实现继承是一场古老的战斗呐喊,Go 是我用过的第一种坚定立场的语言,在这个问题上。)

虽然 Go 不会像你以前使用的面向对象语言一样,但是你会注意到结构的定义和类的定义之间有很多相似之处。下面的代码定义了一个简单的 Saiyan 结构体:

1
2
3
4
type Saiyan struct {
Name string
Power int
}

我们将看明白怎么往这个结构体添加一个方法,就像面向对象类,会有方法作为 它的一部分。在这之前,我们先要知道如何申明结构体。

声明和初始化

当我们第一次看到变量和声明时,我们只看了内置类型,比如整数和字符串。既然现在我们要讨论结构,那么我们需要扩展讨论范围到指针。

创建结构的值的最简单的方式是:

1
2
3
4
goku := Saiyan{
Name: "Goku",
Power: 9000,
}

注意: 上述结构末尾的逗号 , 是必需的。没有它的话,编译器就会报错。你将会喜欢上这种必需的一致性,尤其当你使用一个与这种风格相反的语言或格式的时候。

我们不必设置所有或哪怕一个字段。下面这些都是有效的:

1
2
3
4
5
6
goku := Saiyan{}

// or

goku := Saiyan{Name: "Goku"}
goku.Power = 9000

就像未赋值的变量其值默认为 0 一样,字段也是如此。

此外,你可以不写字段名,依赖字段顺序去初始化结构体 (但是为了可读性,你应该把字段名写清楚):

1
goku := Saiyan{"Goku", 9000}

以上所有的示例都是声明变量 goku 并赋值。

许多时候,我们并不想让一个变量直接关联到值,而是让它的值为一个指针,通过指针关联到值。一个指针就是内存中的一个地址;指针的值就是实际值的地址。这是间接地获取值的方式。形象地来说,指针和实际值的关系就相当于房子和指向该房子的方向之间的关系。

为什么我们想要一个指针指向值而不是直接包含该值呢?这归结为 Go 中传递参数到函数的方式:就像复制。知道了这个,尝试理解一下下面的代码呢?

1
2
3
4
5
6
7
8
9
func main() {
goku := Saiyan{"Goku", 9000}
Super(goku)
fmt.Println(goku.Power)
}

func Super(s Saiyan) {
s.Power += 10000
}

上面程序运行的结果是 9000,而不是 19000,。为什么?因为 Super 修改了原始值 goku 的复制版本,而不是它本身,所以,Super 中的修改并不影响上层调用者。现在为了达到你的期望,我们可以传递一个指针到函数中:

1
2
3
4
5
6
7
8
9
func main() {
goku := &Saiyan{"Goku", 9000}
Super(goku)
fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
s.Power += 10000
}

这一次,我们修改了两处代码。第一个是使用了 & 操作符以获取值的地址(它就是 取地址 操作符)。然后,我们修改了 Super 参数期望的类型。它之前期望一个 Saiyan 类型,但是现在期望一个地址类型 *Saiyan,这里 *X 意思是 指向类型 X 值的指针 。很显然类型 Saiyan*Saiyan 是有关系的,但是他们是不同的类型。

这里注意到我们仍然传递了一个 goku 的值的副本给 Super,但这时 goku 的值其实是一个地址。所以这个副本值也是一个与原值相等的地址,这就是我们间接传值的方式。想象一下,就像复制一个指向饭店的方向牌。你所拥有的是一个方向牌的副本,但是它仍然指向原来的饭店。

我们可以证实一下这是一个地址的副本,通过修改其指向的值(尽管这可能不是你真正想做的事情):

1
2
3
4
5
6
7
8
9
func main() {
goku := &Saiyan{"Goku", 9000}
Super(goku)
fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
s = &Saiyan{"Gohan", 1000}
}

上面的代码,又一次地输出 9000。就像许多语言表现的那样,包括 Ruby,Python, Java 和 C#,Go 以及部分的 C#,只是让这个事实变得更明显一些。

同样很明显的是,复制一个指针比复制一个复杂的结构的消耗小多了。在 64 位的机器上面,一个指针占据 64 bit 的空间。如果我们有一个包含很多字段的结构,创建它的副本将会是一个很昂贵的操作。指针的真正价值在于能够分享它所指向的值。我们是想让 Super 修改 goku 的副本还是修改共享的 goku 值本身呢?

所有这些并不是说你总应该使用指针。这章末尾,在我们见识了结构的更多功能以后,我们将重新检视 指针与值这个问题。

结构体上的函数

我们可以把一个方法关联在一个结构体上:

1
2
3
4
5
6
7
8
type Saiyan struct {
Name string
Power int
}

func (s *Saiyan) Super() {
s.Power += 10000
}

在上面的代码中,我们可以这么理解,*Saiyan 类型是 Super 方法的接受者。然后我们可以通过下面的代码去调用 Super 方法:

1
2
3
goku := &Saiyan{"Goku", 9001}
goku.Super()
fmt.Println(goku.Power) // 将会打印出 19001

构造器

结构体没有构造器。但是,你可以创建一个返回所期望类型的实例的函数(类似于工厂):

1
2
3
4
5
6
func NewSaiyan(name string, power int) *Saiyan {
return &Saiyan{
Name: name,
Power: power,
}
}

这种模式以错误的方式惹恼了很多开发人员。一方面,这里有一点轻微的语法变化;另一方面,它确实感觉有点不那么明显。

我们的工厂不必返回一个指针;下面的形式是完全有效的:

1
2
3
4
5
6
func NewSaiyan(name string, power int) Saiyan {
return Saiyan{
Name: name,
Power: power,
}
}

结构体的字段

到目前为止的例子中,Saiyan 有两个字段 NamePower,其类型分别为 stringint。字段可以是任何类型 – 包括其他结构体类型以及目前我们还没有提及的 array,maps,interfaces 和 functions 等类型。

例如,我们可以扩展 Saiyan 的定义:

1
2
3
4
5
type Saiyan struct {
Name string
Power int
Father *Saiyan
}

然后我们通过下面的方式初始化:

1
2
3
4
5
6
7
8
9
gohan := &Saiyan{
Name: "Gohan",
Power: 1000,
Father: &Saiyan {
Name: "Goku",
Power: 9001,
Father: nil,
},
}

New

尽管缺少构造器,Go 语言却有一个内置的 new 函数,使用它来分配类型所需要的内存。 new(X) 的结果与 &X{} 相同。

1
2
3
goku := new(Saiyan)
// same as
goku := &Saiyan{}

如何使用取决于你,但是你会发现大多数人更偏爱后一种写法无论是否有字段需要初始化,因为这看起来更具可读性:

1
2
3
4
5
6
7
8
9
10
goku := new(Saiyan)
goku.name = "goku"
goku.power = 9001

//vs

goku := &Saiyan {
Name: "goku",
Power: 9000,
}

无论你选择哪一种,如果你遵循上述的工厂模式,就可以保护剩余的代码而不必知道或担心内存分配细节

组合

Go 支持组合, 这是将一个结构包含进另一个结构的行为。在某些语言中,这种行为叫做 特质 或者 混合。 没有明确的组合机制的语言总是可以做到这一点。在 Java 中, 可以使用 继承 来扩展结构。但是在脚本中并没有这种选项, 混合将会被写成如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Person {
private String name;

public String getName() {
return this.name;
}
}

public class Saiyan {
// Saiyan 中包含着 person 对象
private Person person;

// 将请求转发到 person 中
public String getName() {
return this.person.getName();
}
...
}

这可能会非常繁琐。Person 的每个方法都需要在 Saiyan 中重复。Go 避免了这种复杂性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Person struct {
Name string
}

func (p *Person) Introduce() {
fmt.Printf("Hi, I'm %s\n", p.Name)
}

type Saiyan struct {
*Person
Power int
}

// 使用它
goku := &Saiyan{
Person: &Person{"Goku"},
Power: 9001,
}
goku.Introduce()

Saiyan 结构体有一个 Person 类型的字段。由于我们没有显示地给它一个字段名,所以我们可以隐式地访问组合类型的字段和函数。然而,Go 编译器确实给了它一个字段,下面这样完全有效:

1
2
3
4
5
goku := &Saiyan{
Person: &Person{"Goku"},
}
fmt.Println(goku.Name)
fmt.Println(goku.Person.Name)

上面两个都打印 「Goku」。

组合比继承更好吗?许多人认为它是一种更好的组织代码的方式。当使用继承的时候,你的类和超类紧密耦合在一起,你最终专注于结构而不是行为。

指针类型和值类型

当你写 Go 代码的时候,很自然就会去问自己 应该是值还是指向值的指针呢? 这儿有两个好消息,首先,无论我们讨论下面哪一项,答案都是一样的:

  • 局部变量赋值
  • 结构体指针
  • 函数返回值
  • 函数参数
  • 方法接收器

第二,如果你不确定,那就用指针咯。

正如我们已经看到的,传值是一个使数据不可变的好方法(函数中改变它不会反映到调用代码中)。有时,这是你想要的行为,但是通常情况下,不是这样的。

即使你不打算改变数据,也要考虑创建大型结构体副本的成本。相反,你可能有一些小的结构:

1
2
3
4
type Point struct {
X int
Y int
}

这种情况下,复制结构的成本能够通过直接访问 XY 来抵消,而没有其它任何间接操作。

还有,这些案例都是很微妙的,除非你迭代成千上万个这样的指针,否则你不会注意到差异。

继续之前

从实际的角度看,这章介绍了结构体,如何使一个结构体的实例成为函数的接收者,以及添加指针到现有的 Go 类型系统知识中。下面的章节将建立在我们已经了解了什么是结构体以及其内部工作原理之上。

第三章 · 映射、数组和切片

至此,我们已经学了一部分简单的类型和结构。现在,让我们开始学习 Arrays (数组), Slices (切片) 和 Maps (映射) 吧。

数组

如果你学过 Python , Ruby , Perl , JavaScript 或者 PHP (或者更多其它的语言),那么你肯定习惯 动态数组 编程啦。这些数组的长度可以在添加数据的时候自行调整的。在 Go 中,像其它大部分语言一样,数据的长度是固定的。我们在声明一个数组时需要指定它的长度,一旦指定了长度,那么它的长度值是不可以改变的了:

1
2
var scores [10]int
scores[0] = 339

上面的数组最多可以容纳 10 个元素,索引是从 scores[0]scores[9] 。试图访问超过界限的索引系统将会抛出编译或运行时错误。

我们可以在初始化数组的时候指定值:

1
scores := [4]int{9001, 9333, 212, 33}

我们可以使用 len 函数来获取数组的长度。range 函数在遍历迭代的时候使用:

1
2
3
for index, value := range scores {

}

数组非常高效但是很死板。很多时候,我们在事前并不知道数组的长多是多少。针对这个情况,slices (切片) 出来了。

切片

在Go语言中,我们很少直接使用数组。取而代之的是使用切片。切片是轻量的包含并表示数组的一部分的结构。 这里有几种创建切片的方式,我们来看看什么情况下使用它们。首先在数组的基础之上进行一点点变化:

1
scores := []int{1,4,293,4,9}

和数组申明不同的是,我们的切片没有在方括号中定义长度。为了理解两者的不同,我们来看看另一种使用make来创建切片的方式:

1
scores := make([]int, 10)

我们使用 make 关键字代替 new, 是因为创建一个切片不仅是只分配一段内存(这个是 new关键字的功能)。具体来讲,我们必须要为一个底层数组分配一段内存,同时也要初始化这个切片。在上面的代码中,我们初始化了一个长度是 10 ,容量是 10 的切片。长度是切片的长度,容量是底层数组的长度。在使用 make 创建切片时,我们可以分别的指定切片的长度和容量:

1
scores := make([]int, 0, 10)

上面的代码创建了一个长度是 0 ,容量是 10 的切片。(如果你仔细观察的话,你会注意到 makelen 重载了。Go 的一些特性没有暴露出来给开发者使用,这也许会让你感到沮丧。)

为了更好的理解切片的长度和容量之间的关系,我们来看下面的的例子:

1
2
3
4
5
func main() {
scores := make([]int, 0, 10)
scores[7] = 9033
fmt.Println(scores)
}

我们上面的这个例子不能运行,为什么呢?因为切片的长度是 0 。没错,底层数组可以放 10 个元素,但是我们需要显式的扩展切片,才能访问到底层数组的元素。一种扩展切片的方式是通过 append的关键字来实现:

1
2
3
4
5
func main() {
scores := make([]int, 0, 10)
scores = append(scores, 5)
fmt.Println(scores) // prints [5]
}

但是那并没有改变原始代码的意图。追加一个值到长度为0的切片中将会设置第一个元素。无论什么原因,我们崩溃的代码想去设置索引为7的元素值。为了实现这个,我们可以重新切片:

1
2
3
4
5
6
func main() {
scores := make([]int, 0, 10)
scores = scores[0:8]
scores[7] = 9033
fmt.Println(scores)
}

我们可以调整的切片大小最大范围是多少呢?达到它的容量,这个例子中,是10。你可能在想 这实际上并没有解决数组固定长度的问题。但是 append 是相当特别的。如果底层数组满了,它将创建一个更大的数组并且复制所有原切片中的值(这个就很像动态语言 PHP,Python,Ruby,JavaScript 的工作方式)。这就是为什么上面的例子中我们必须重新将 append 返回的值赋值给 scores 变量:append 可能在原有底层数组空间不足的情况下创建了新值。

如果我告诉你 Go 使用 2x 算法来增加数组长度,你猜下面将会打印什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
scores := make([]int, 0, 5)
c := cap(scores)
fmt.Println(c)

for i := 0; i < 25; i++ {
scores = append(scores, i)

// 如果容量改变了
// Go 必须增加数组长度来容纳新的数据
if cap(scores) != c {
c = cap(scores)
fmt.Println(c)
}
}
}

初始 scores 的容量是5。为了存储25个值,它必须扩展三次容量,分别是 10,20,最终是40。

最后一个例子,考虑这个:

1
2
3
4
5
func main() {
scores := make([]int, 5)
scores = append(scores, 9332)
fmt.Println(scores)
}

这里输出是 [0, 0, 0, 0, 0, 9332],可能你觉得是[9332, 0, 0, 0, 0]?对一个用户而言,这可能逻辑上是正确的。然而,对于一个编译器,你告诉他的是追加一个值到一个已经有5个值的切片。

最终,这有四种方式初始化一个切片:

1
2
3
4
names := []string{"leto", "jessica", "paul"}
checks := make([]bool, 10)
var names []string
scores := make([]int, 0, 20)

什么时候该用哪个呢?第一个不用过多解释。当你事先知道数组中的值的时候,你可以使用这个方式。

当你想要写入切片具体的索引时,第二个方法很有用,例如:

1
2
3
4
5
6
7
func extractPowers(saiyans []*Saiyans) []int {
powers := make([]int, len(saiyans))
for index, saiyan := range saiyans {
powers[index] = saiyan.Power
}
return powers
}

第三个版本是指向空的切片,用于当元素数量未知时与 append 连接。

最后一个版本是让我们声明一个初始的容量。如果我们大概知道元素的数量将是很有用的。

即使当你知道大小的时候,append 也可以使用,取决于个人偏好:

1
2
3
4
5
6
7
func extractPowers(saiyans []*Saiyans) []int {
powers := make([]int, 0, len(saiyans))
for _, saiyan := range saiyans {
powers = append(powers, saiyan.Power)
}
return powers
}

切片作为数组的包装是一个很强大的概念。许多语言有切片数组的概念。JavaScript 和 Ruby 数组都有一个 slice 方法。Ruby 中你可以使用 [START..END] 获取一个切片,或者 Python 中可以通过 [START:END] 实现。然而,在这些语言中,一个切片实际上是复制了原始值的新数组。如果我们使用 Ruby,下面这段代码的输出是什么呢?

1
2
3
4
scores = [1,2,3,4,5]
slice = scores[2..4]
slice[0] = 999
puts scores

答案是 [1, 2, 3, 4, 5] 。那是因为 slice 是一个新数组,并且复制了原有的值。现在,考虑 Go 中的情况:

1
2
3
4
scores := []int{1,2,3,4,5}
slice := scores[2:4]
slice[0] = 999
fmt.Println(scores)

输出是 [1, 2, 999, 4, 5]

这改变了你编码的方式。例如,许多函数采用一个位置参数。JavaScript 中,如果你想去找到字符串中前五个字符后面的第一个空格(当然,在Go中切片也可以用于字符串),我们会这样写:

1
2
haystack = "the spice must flow";
console.log(haystack.indexOf(" ", 5));

在 Go 中,我们这样使用切片:

1
strings.Index(haystack[5:], " ")

我们可以从上面的例子中看到,[X:]从 X 到结尾 的简写,然而 [:X]从开始到 X 的简写。不像其他的语言,Go 不支持负数索引。如果我们想要切片中除了最后一个元素的所有值,可以这样写:

1
2
scores := []int{1, 2, 3, 4, 5}
scores = scores[:len(scores)-1]

上面是从未排序的切片中移除元素的有效方法的开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
scores := []int{1, 2, 3, 4, 5}
scores = removeAtIndex(scores, 2)
fmt.Println(scores) // [1 2 5 4]
}

// 不会保持顺序
func removeAtIndex(source []int, index int) []int {
lastIndex := len(source) - 1
// 交换最后一个值和想去移除的值
source[index], source[lastIndex] = source[lastIndex], source[index]
return source[:lastIndex]
}

最后,我们已经了解了切片,我们再看另一个通用的内建函数:copy。正常情况下,将值从一个数组复制到另一个数组的方法有5个参数,sourcesourceStartcount,,destinationdestinationStart。使用切片,我们仅仅需要两个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import (
"fmt"
"math/rand"
"sort"
)

func main() {
scores := make([]int, 100)
for i := 0; i < 100; i++ {
scores[i] = int(rand.Int31n(1000))
}
sort.Ints(scores)

worst := make([]int, 5)
copy(worst, scores[:5])
fmt.Println(worst)
}

花点时间试试上面的代码,并尝试改动。去看看如果你这么做 copy(worst[2:4], scores[:5]) 或者复制多于或少于 5 个值给 worst 会发什么?

映射

Go语言中的映射,就好比其他语言中的hash表或者字典。它们的工作方式就是:定义键和值,并且可以获取,设置和删除其中的值。

映射和切片一样,使用 make 方法来创建。让我们来看看一个例子:

1
2
3
4
5
6
7
8
9
func main() {
lookup := make(map[string]int)
lookup["goku"] = 9001
power, exists := lookup["vegeta"]

// prints 0, false
// 0 is the default value for an integer
fmt.Println(power, exists)
}

我们使用 len方法类获取映射的键的数量。使用delete方法来删除一个键对应的值:

1
2
3
4
5
// returns 1
total := len(lookup)

// has no return, can be called on a non-existing key
delete(lookup, "goku")

映射是动态变化的。然而我们可以通过传递第二个参数到 make方法来设置一个初始大小:

1
lookup := make(map[string]int, 100)

如果你事先知道映射会有多少键值,定义一个初始大小将会帮助改善性能。

当你需要将映射作为结构体字段的时候,你可以这样定义它:

1
2
3
4
type Saiyan struct {
Name string
Friends map[string]*Saiyan
}

初始上述结构体的一种方式是:

1
2
3
4
5
goku := &Saiyan{
Name: "Goku",
Friends: make(map[string]*Saiyan),
}
goku.Friends["krillin"] = ... //加载或者创建 Krillin

Go 还有一种定义和初始化值的方式。像 make,这种特定用于映射和数组。我们可以定义为复合方式:

1
2
3
4
lookup := map[string]int{
"goku": 9001,
"gohan": 2044,
}

我们可以使用 for 组合 range 关键字迭代映射:

1
2
3
for key, value := range lookup {
...
}

迭代映射是没有顺序的。每次迭代查找将会随机返回键值对。

指针和值

第二章我们讨论了到底是传值还是传指针。现在我们有相同的问题在映射和数组上,到底该使用他们哪个?

1
2
3
a := make([]Saiyan, 10)
// 或者
b := make([]*Saiyan, 10)

许多开发者认为应该传递 b 或者返回它在一个函数中会更加高效。然而,传递/返回的是切片的副本,但是切片本身就是一个引用。所以传递返回切片本身,没有什么区别。

当你改变切片或者映射值的时候,你将看到不同。这一点上,和我们在第二章看到的逻辑相同。所以决定使用指针数组还是值数组归结为你如何使用单个值,而不是你用数组还是映射。

继续之前

Go 中数组和映射的工作方式类似于其他语言。如果你习惯了使用动态数组,这可能就有点小的调整,但是 append 应该能解决你大多的不适应。如果我们超越数组的表面语法,将会发现切片。切片功能强大,并且他们对代码的清晰度产生了巨大的影响。

还有一些边缘情况没有覆盖到,不过你不太可能遇到他们。即使遇到了,希望我们在这里建立的基础帮助你理解正在发生的事情。

第四章 · 代码组织和接口

现在来看一下如何组织我们的代码。

包管理

为了组织复杂的库和系统代码,我们需要学习关于包的知识。在 Go 语言中,包名遵循 Go 项目的目录结构。如果我们建立一个购物系统,我们可能以 “shopping” 包名作为一个开始,然后把所有源代码文件放到 $GOPATH/src/shopping/ 目录中。

我们不会去想把所有东西都放在这个文件夹中。例如,我们可能想单独把数据库逻辑放在它自己的目录中。为了实现这个,我们创建一个子目录 $GOPATH/src/shopping/db 。子目录中文件的包名就是 db,但是为了从另一个包访问它,包括 shopping 包,我们需要导入 shopping/db

换句话说,当你想去命名一个包的时候,可以通过 package 关键字,提供一个值,而不是完整的层次结构(例如:「shopping」或者 「db」)。当你想去导入一个包的时候,你需要指定完整路径。

接下来,我们去尝试下。在你的 Go 的工作目录 src 文件夹下(我们已经在基础那一章节中介绍了),创建一个新的文件夹叫做 shopping ,然后在 shopping 文件夹下创建一个 db 文件夹。

shopping/db 文件夹下,创建一个叫做 db.go 的文件,然后在 db.go 文件中添加如下的代码:

1
2
3
4
5
6
7
8
9
10
11
package db

type Item struct {
Price float64
}

func LoadItem(id int) *Item {
return &Item{
Price: 9.001,
}
}

需要注意包名和文件夹名是一样的。而且很明显我们实际并没有连接数据库。这里使用这个例子只是为了展示如何组织代码。

现在,创建在主目录 shopping 下创建一个叫 pricecheck.go 的文件。它的内容是:

1
2
3
4
5
6
7
8
9
10
11
12
13
package shopping

import (
"shopping/db"
)

func PriceCheck(itemId int) (float64, bool) {
item := db.LoadItem(itemId)
if item == nil {
return 0, false
}
return item.Price, true
}

很有可能认为导入 shopping/db 有点特别,因为我们已经在 shopping 包/目录中。实际上,我们正在导入 $GOPATH/src/shopping/db,这意味着只要你在你的工作区间 src/test 目录中有一个名为 db 的包,你就可以轻松导入它。

你正在构建一个包,除了我们看到的你不再需要任何东西。为了构建一个可执行程序,你仍然需要 main 包。我比较喜欢的方式是在 shopping 目录下创建一个 main 子目录,然后再创建一个叫 main.go 的文件,下面是它的内容:

1
2
3
4
5
6
7
8
9
10
package main

import (
"shopping"
"fmt"
)

func main() {
fmt.Println(shopping.PriceCheck(4343))
}

现在,你可以进入你的 shopping 项目运行代码,输入:

1
go run main/main.go

循环导入

当你编写更复杂的系统的时,你必然会遇到循环导入。例如,当 A 包导入 B 包,B 包又导入 A 包(间接或者直接导入)。这是编译器不能允许的。

让我们改变我们的 shopping 结构以复现这个错误。

Item 定义从 shopping/db/db.go 移到 shopping/pricecheck.go。你的 pricecheck.go 文件像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package shopping

import (
"shopping/db"
)

type Item struct {
Price float64
}

func PriceCheck(itemId int) (float64, bool) {
item := db.LoadItem(itemId)
if item == nil {
return 0, false
}
return item.Price, true
}

如果你尝试运行代码,你会从 db/db.go 得到两个关于 Item 未定义的错误。这看起来是说 Item 不存在 db 包中。它已经被移动到 shopping 包中,我们需要将 shopping/db/db.go 改变成:

1
2
3
4
5
6
7
8
9
10
11
package db

import (
"shopping"
)

func LoadItem(id int) *shopping.Item {
return &shopping.Item{
Price: 9.001,
}
}

现在但你尝试运行代码的时候,你将会得到 不允许循环导入 的错误。我们可以通过引入另一个包含共享结构体的包来解决这个问题。你的目录现在看起来像这个样子:

1
2
3
4
5
6
7
8
9
$GOPATH/src
- shopping
pricecheck.go
- db
db.go
- models
item.go
- main
main.go

pricecheck.go 将仍然导入 shopping/db,但是 db.go 现在导入 shopping/models 而不是 shopping,因此打破了循环。因为我们将共享的 Item 结构体移动到 shopping/models/item.go,我们现在需要去改变 shopping/db/db.gomodels 包中引用 Item 结构体。

1
2
3
4
5
6
7
8
9
10
11
package db

import (
"shopping/models"
)

func LoadItem(id int) *models.Item {
return &models.Item{
Price: 9.001,
}
}

你经常需要共享某些代码,不止 models,所以你可能有其他类似叫做 utilities 的目录,这些共享包的重要原则是他们不从 shopping 包或者任何子包中导入任何东西。在后面的章节中,我们将介绍可以帮助我们解决这些类型依赖关系的接口。

可见性

Go 用了一个简单的规则去定义什么类型和函数可以包外可见。如果类型或者函数名称以一个大写字母开始,它就具有了包外可见性。如果以一个小写字母开始,它就不可以。

这也可以应用到结构体字段。如果一个字段名以一个小写字母开始,只有包内的代码可以访问他们。

例如,我们的 items.go 文件中有个这样的函数:

1
2
3
func NewItem() *Item {
// ...
}

它可以通过 models.NewItem() 这样被调用。但是如果函数命名为 newItem,我们将不能从不同的包访问它了。

去试试更改 shopping 代码中的函数,类型以及字段的名称。例如,如果你将 ItemPrice 字段命名为 price,你应该会获得一个错误。

包管理

我们用来 buildrungo 命令有一个 get 子命令,用于获取第三方库。go get 支持除了这个例子中的各种协议,我们可以从 Github 中获取一个库,意味着,你需要在你的电脑中安装 git

假设你已经安装了 Git,在 shell 中输入命令:

1
go get github.com/mattn/go-sqlite3

go get 获取远端的文件并把它们存储在你的工作区间中。去看看你的 $GOPATH/src 目录,你会发现除了我们创建的 shopping 项目之外,还有一个 github.com 目录,在里面,你会看到一个包含了 go-sqlite3 目录的 mattn 目录。

我们刚才只是讨论了如何导入我们工作区间的包。为了导入新安装的 go-sqlite3 包,我们要这样导入:

1
2
3
import (
"github.com/mattn/go-sqlite3"
)

我知道这看起来像一个 URL,实际上,它只是希望导入在 $GOPATH/src/github.com/mattn/go-sqlite3 找到的 go-sqlite3 包。

依赖管理

go get 还有一些其他的技巧。如果我们在一个项目内使用 go get,它将浏览所有文件,查找 imports 的第三方库然后下载他们。某种程度上,我们的源代码变成了 Gemfile 或者 package.json

如果你调用 go get -u ,它将更新所有包(或者你可以通过 go get -u FULL_PACKAGE_NAME 更新一个具体的包)。

最后,你可能发现了 go get 的不足。一方面,这儿没有办法指定一个版本。他总是指向 master/head/trunk/default。这是一个较大的问题如果你有两个项目需要同一个库的不同版本。

为了解决这个问题,你可以使用一个第三方的依赖管理工具。他们仍然很年轻,但 goopgodep 是可信的。更多完整的列表在 go-wiki

接口

接口是定义了合约但并没有实现的类型。举个例子:

1
2
3
type Logger interface {
Log(message string)
}

那这样做有什么作用呢?其实,接口有助于将代码与特定的实现进行分离。例如,我们可能有各种类型的日志记录器:

1
2
3
type SqlLogger struct { ... }
type ConsoleLogger struct { ... }
type FileLogger struct { ... }

针对接口而不是具体实现的编程会使我们很轻松的修改(或者测试)任何代码都不会产生影响。

你会怎么用?就像任何其它类型一样,它结构可以这样:

1
2
3
type Server struct {
logger Logger
}

或者是一个函数参数(或者返回值):

1
2
3
func process(logger Logger) {
logger.Log("hello!")
}

在像 C# 或者 Java 这类语言中,当类实现接口时,我们必须显式的:

1
2
3
4
5
public class ConsoleLogger : Logger {
public void Logger(message string) {
Console.WriteLine(message)
}
}

在 Go 中,下面的情况是隐式发生的。如果你的结构体有一个函数名为 Log 且它有一个 string 类型的参数并没有返回值,那么这个结构体被视为 Logger 。这减少了使用接口的冗长:

1
2
3
4
type ConsoleLogger struct {}
func (l ConsoleLogger) Log(message string) {
fmt.Println(message)
}

Go 倾向于使用专注的接口。Go 的标准库基本上由接口组成。像 io 包有一些常用的接口诸如 io.Readerio.Writerio.Closer 等。如果你编写的函数只需要一个能调用 Close() 的参数,那么你应该接受一个 io.Closer 而不是像 io 这样的父类型。
接口可以成为其他接口的一部分,也就是说接口也可以与其他接口组成新的接口。例如, io.ReadCLoser 的接口是由 io.Reader 接口和 io.Closer 接口组成的。

最后,接口通常用于避免循环导入。由于它们没有具体的实现,因此它们的依赖是有限的。

继续之前

最后,如何围绕 Go 的工作区间构建你的代码,你只有在写了几个非测试的项目之后才会适应。最重要的是记着包名和目录结构之间的紧密关系(不仅仅在一个项目之内,而是整个工作区间)。

Go 处理类型可见性也是简单有效,而且也是一致的。有一些我们没看过的东西,比如常量和全局变量,但是放心,他们的可见性仍有相同的命名规则决定。

最后,如果你初次接触接口,你可能需要花点时间理解他们。然而,当你第一次看到一个期望类似 io.Reader 的函数时,你会发现自己很感谢作者没有要求他或者她需要的东西。

第五章 · 花絮

这章中,我们将讨论 Go 功能杂记,放在其他地方都不太合适。

错误处理

Go 首选错误处理方式是返回值,而不是异常。考虑 strconv.Atoi 函数,它将接受一个字符串然后将它转换为一个整数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"os"
"strconv"
)

func main() {
if len(os.Args) != 2 {
os.Exit(1)
}

n, err := strconv.Atoi(os.Args[1])
if err != nil {
fmt.Println("not a valid number")
} else {
fmt.Println(n)
}
}

你可以创建你自己的错误类型。唯一的要求是你必须实现内建 error 接口的契约:

1
2
3
type error interface {
Error() string
}

更一般地,我们可以通过导入 error 包然后使用它的 New 函数创建我们自己的错误:

1
2
3
4
5
6
7
8
9
10
11
12
import (
"errors"
)


func process(count int) error {
if count < 1 {
return errors.New("Invalid count")
}
...
return nil
}

Go 标准库中有一个使用 error 变量的通用模式。例如, io 包中有一个 EOF 变量它是这样定义的:

1
var EOF = errors.New("EOF")

这是一个包级别的变量(被定义在函数之外),可以被其他包访问(首字母大写)。各种函数可以返回这个错误,例如,当我们从一个文件或者 STDIN 读取时。如果它具有上下文意义,那么您应该使用此错误。作为调用者,我们可以这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
"io"
)

func main() {
var input int
_, err := fmt.Scan(&input)
if err == io.EOF {
fmt.Println("no more input!")
}
}

作为最后一点,Go 确实有 panicrecover 函数。 panic 就像抛出异常,而 recover 就像 catch,它们很少使用。

Defer

尽管 Go 有一个垃圾回收器,一些资源仍然需要我们显示地释放他们。例如,我们需要在使用完文件之后 Close() 他们。这种代码总是很危险。一方面来说,当我们在写一个函数的时候,很容易忘记关闭我们声明了 10 行的东西。另一方面,一个函数可能有多个返回点。Go 给出的解决方案是使用 defer 关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"os"
)

func main() {
file, err := os.Open("a_file_to_read")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
// 读取文件
}

如果你尝试运行上面的代码,你将会得到错误(文件不存在)。这里只是演示 defer 如何工作。无论什么情况,在函数返回之后(本例中为 main() ),defer 将被执行。这使您可以在初始化的位置附近释放资源并处理多个返回点。

go语言风格

大多数 Go 程序遵循相同的格式化规则,换句话说,一个 tab 键用于缩进,左括号和他们的声明语句在同一行。

我知道,你可能有自己的风格,并且想坚持它。这也是我长期以来所做的事情,但我很高兴我最终放弃了。一个大原因是 go fmt 命令。它易于使用而且具有权威性(所以就没有人争论无意义的偏好)。

当你在一个项目内的时候,你可以运用格式化规则到这个项目及其所有子目录:

1
go fmt ./...

试一试,它不仅缩进你的代码,也对齐了声明的字段和按字母书序导入。

初始化的 if

Go 对 if 语句做了稍微修改,支持在条件语句被求值之前先进行初始化:

1
2
3
if x := 10; count > x {
...
}

这是一个比较蠢的例子,更现实的是,你可能会像下面这样做:

1
2
3
if err := process(); err != nil {
return err
}

有意思的是,虽然 err 不能在 if 语句之外使用,但他可以在任何 else if 或者 else 之内使用。

空接口和转化

在大多数面向对象的语言中,经常有一个内建的叫 object 的基类,是所有其他类的超类。Go 没有继承,也没有这样一个超类。不过他确实有一个没有任何方法的空接口: interface{}。因为空接口没有方法,可以说所有类型都实现了空接口,并且由于空接口是隐式实现的,因此每种类型都满足空接口契约。

如果我们像,我们可以定义如下签名的 add 函数:

1
2
3
func add(a interface{}, b interface{}) interface{} {
...
}

为了将一个接口变量转化为一个显式的类型,又可以用 .(TYPE)

1
return a.(int) + b.(int)

提醒,如果底层类型不是 int,上面的结果将是 error。

你也可以访问强大的类型转换:

1
2
3
4
5
6
7
8
switch a.(type) {
case int:
fmt.Printf("a is now an int and equals %d\n", a)
case bool, string:
// ...
default:
// ...
}

你将会看到,使用空接口可能超出了你的期望。但是虽然它将让代码看起来不那么好看,来回转换代码有时看起来也很丑陋并且危险,但在一个静态语言中,它是唯一的选择。

字符串和字节数组

字符串和字节数组是紧密相关的。我们可以轻松地在他们之间转换:

1
2
3
stra := "the spice must flow"
byts := []byte(stra)
strb := string(byts)

实际上,这种转换方式在各种类型之间是通用的。一些函数显示地需要一个 int32 或者 int64 或者它们的无符号部分。你可能发现你必须这样做:

1
int64(count)

然而,当它涉及到字节和字符串时,这可能是你经常做的事情。一定记着当你使用 []byte(X) 或者 string(X) 时,你实际上创建了数据的副本。这是必要的,因为字符串是不可变的。

那些由 Unicode 码点 runes 构成的字符串,如果你获取字符串的长度,你可能不能得到你期望的。下面的结果是3:

fmt.Println(len("椒"))

如果你用 range 迭代一个字符串,你将得到 runes,而不是字节。当然,当你将字符串转换为 []byte 类型时,你将得到正确的数据。

函数类型

函数是一种类型:

1
type Add func(a int, b int) int

它可以用在任何地方 – 作为字段类型,参数或者返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
)

type Add func(a int, b int) int

func main() {
fmt.Println(process(func(a int, b int) int{
return a + b
}))
}

func process(adder Add) int {
return adder(1, 2)
}

这样使用函数会帮助我们从具实现中解耦代码,更像在使用接口实现。

继续之前

我们研究了使用 Go 编程的各个方面,最值得注意的是,我们看到了错误的处理行为以及如何释放连接和打开的文件资源。许多人不喜欢 Go 的错误处理方法。这感觉像是倒退了一步。 有时,我同意。然而,我也发现这会让代码更容易理解。 defer 是一种不寻常但是实用的资源管理方法。事实上,它不仅限于资源管理。您可以将 defer 用于任何目的, 比如函数退出时的日志记录。

当然,我们还没有看到 Go 提供的所有花絮,但在你解决遇到的任何问题时你应该感到足够舒服。

第六章 · 并发

Go 通常被描述为一种并发友好的语言。 原因是它提供了两种强大机制的简单语法: 协程通道

Go协程

协程 类似于一个线程,但是由 Go 而不是操作系统预定。在 协程 中运行的代码可以与其他代码同时运行。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"time"
)

func main() {
fmt.Println("start")
go process()
time.Sleep(time.Millisecond * 10) // this is bad, don't do this!
fmt.Println("done")
}

func process() {
fmt.Println("processing")
}

这里有一些有趣的事情, 但最重要的是我们如何开始一个 协程 。 我们只需使用 go 关键字,然后使用我们想要执行的函数。如果我们只想运行一部分代码, 如上所述, 我们可以使用匿名函数。需要注意的是,匿名函数不只是可以在 协程 中使用,其他地方也可以。

1
2
3
go func() {
fmt.Println("processing")
}()

协程 易于创建且开销很小。最终多个 协程 将会在同一个底层的操作系统线程上运行。这通常也称为 M:N 线程模型,因为我们有 M 个应用线程( 协程 )运行在 N 个操作系统线程上。结果就是,一个 协程 的开销和系统线程比起来相对很低(几KB)。在现代的硬件上,有可能拥有数百万个 协程

此外,这里还隐藏了映射和调度的复杂性。我们只需要说 这段代码需要同时并发执行 然后让 Go 自己去实现它。

如果我们回到我们的例子中,你将会注意到我们使用 Sleep 让程序等了几毫秒。这是因为主进程在退出前 协程 才会有机会去执行(主进程在退出前不会等待全部 协程 执行完毕)。要解决这个问题,我们需要协调我们的代码。

同步

创建一个协程是微不足道的, 它们开销很小我们可以启动很多; 但是,需要协调并发代码。为了解决这个问题, Go 提供了 通道。 在我们学习 通道 之前,我认为了解并发编程的基础知识非常重要。

编写并发代码要求您特别注意在哪里读取和写入一个值。 在某些方面, 例如没有垃圾回收的语言 – 它需要您从一个新的角度去考虑您的数据,始终警惕着可能存在的危险。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"time"
)

var counter = 0

func main() {
for i := 0; i < 20; i++ {
go incr()
}
time.Sleep(time.Millisecond * 10)
}

func incr() {
counter++
fmt.Println(counter)
}

你觉得将会输出什么呢?

如果你认为输出的是 1, 2, ... 20 这既不对也没错。如果你运行了以上的代码确实可能得到这个输出。可是,这个操作就很让人懵逼的。 啥?因为我们可能有多个 (这个情况下两个) 协程 同时写入一个相同变量 counter 。或者,同样糟糕的是,一个协程要读取 counter 时,另一个协程正在写入。

这个真的很危险吗?当然啦! counter++ 看起来可能是一行很简单的代码,但它是实际上被拆分为多个汇编语句 – 确切的性质依赖于你跑程序的平台。如果你运行这个例子,你将经常看到那些数字是以一种乱七八糟的顺序打印的,亦或数字是重复的/丢失的。别着急还会有更糟糕的情况, 比方说系统崩溃或者访问并增加任意区块的数据!

从变量中读取变量是唯一安全的并发处理变量的方式。 你可以有想要多少就多少的读取者, 但是写操作必须要得同步。 有太多的方法可以做到这个了,包括使用一些依赖于特殊的 CPU 指令集的真原子操作。然而, 常用的操作还是使用互斥量(译者注:mutex):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"fmt"
"time"
"sync"
)

var (
counter = 0
lock sync.Mutex
)

func main() {
for i := 0; i < 20; i++ {
go incr()
}
time.Sleep(time.Millisecond * 10)
}

func incr() {
lock.Lock()
defer lock.Unlock()
counter++
fmt.Println(counter)
}

互斥量序列化会锁住锁下的代码访问。因为默认的的 sync.Mutex 是未锁定状态,这儿我们就得先定义 lock sync.Mutex

这操作是不看着超简单? 这个例子是具有欺骗性的。当我们进行并发编程时会产生一系列严重的 Bug。 首先,并不是经常能很明显知道什么代码需要保护。使用这样粗糙的锁操作(覆盖着大量代码的锁操作)确实很诱人,这就违背了我们当初进行并发编程的初心了。 我们肯定是需要个优雅的锁操作; 否则,我们最终会把多条快速通道走成单车道的。

另外一个问题是与死锁有关。 使用单个锁时,这没有问题,但是如果你在代码中使用两个或者更多的锁,很容易出现一种危险的情况,当协程A拥有锁 lockA **,想去访问锁 **lockB **,同时协程B拥有锁 **lockB 并需要访问锁 lockA

实际上我们使用一个锁时也有可能发生死锁的问题,就是当我们忘记释放它时。 但是这和多个锁引起的死锁行为相比起来,这并不像多锁死锁那样危险(因为这真的 很难发现),当你试着运行下面的代码时,您可以看见发生了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"time"
"sync"
)

var (
lock sync.Mutex
)

func main() {
go func() { lock.Lock() }()
time.Sleep(time.Millisecond * 10)
lock.Lock()
}

到现在为止还有很多并发编程我们没有看到过。 首先,有一个常见的锁叫读写互斥锁。它主要提供了两种锁功能: 一个锁定读取和一个锁定写入。它的区别是允许多个同时读取,同时确保写入是独占的。在 Go 中, sync.RWMutex 就是这种锁。另外 sync.Mutex 结构不但提供了LockUnlock 方法 ,也提供了RLockRUnlock 方法;其中 R 代表 Read.。虽然读写锁很常用,它也给开发人员带来了额外的负担:我们不但要关注我们正在访问的数据,还要注意如何访问。

此外,部分并发编程不只是通过为数不多的代码按顺序的访问变量; 它也需要协调多个协程。 例如,休眠10毫秒并不是一个特别优雅的解决方案。如果一个协程消耗的时间需要超过10毫秒怎么办?如果协程消耗更少的时间而我们浪费周期怎么办?又或者可以等待协程运行完毕, 我们想另外一个协程 嗨, 我有新的数据需要你处理?

这些事在没有 通道 的情况下都是可以完成的。当然对于更简单的情况,我相信你应该 应该 使用基本的功能比如 sync.Mutexsync.RWMutex, 但正如我们将会在下一节中看到的那样, 通道 旨在让并发编程更简洁和不容易出错。

通道

并发编程的最大调整源于数据的共享。如果你的协程间不存在数据共享,你完全没必要担心同步问题。但是并非所有系统都是如此简单。现实中,许多系统考虑了相反的目的:跨多个请求共享数据。内存缓存和数据库就是最好的例证。这种情况已经成为一个日趋增长的现实。

通道在共享不相关数据的情况下,让并发编程变得更健壮。通道是协程之间用于传递数据的共享管道。换而言之,一个协程可以通过一个通道向另外一个协程传递数据。因此,在任意时间点,只有一个协程可以访问数据。

一个通道,和其他任何变量一样,都有一个类型。这个类型是在通道中传递的数据的类型。例如,创建一个通道用于传递一个整数,我们要这样做:

1
c := make(chan int)

这个通道的类型是 chan int。因此,要将通道传递给函数,我们的函数签名看起来是这个样子的:

1
func worker(c chan int) { ... }

通道只支持两个操作:接收和发送。可以这样往通道发送一个数据:

1
CHANNEL <- DATA

这样从通道接收数据:

1
VAR := <-CHANNEL

箭头预示着数据流向。当发送的时候,数据流向通道。接收的时候,数据流出通道。

在我们开始第一个例子之前还需要知道的是,接收和发送操作是阻塞的。也就是,当我们从一个通道接收的时候, goroutine 将会直到数据可用才会继续执行。类似地,当我们往通道发送数据的时候,goroutine 会等到数据接收到之后才会继续执行。

考虑这样一个系统,我们希望在各个 goroutine 中处理即将到来的数据。这是一个很平常的需求。如果我们在接收数据的 goroutine 上进行数据密集型处理,那么我们可能导致客户端超时。首先,我们先实现我们的 worker。这可能是一个简单的函数,但是我们让它成为结构的一部分,因此我们之前没有看到这样的 goroutines:

1
2
3
4
5
6
7
8
9
10
type Worker struct {
id int
}

func (w Worker) process(c chan int) {
for {
data := <-c
fmt.Printf("worker %d got %d\n", w.id, data)
}
}

我们的 worker 是简单的。他一直等到数据可用然后处理它。尽职尽责,它一直在一个循环中做这个,永远等待更多的数据去处理。

为了去用这个,第一件事情是启动一些 workers:

1
2
3
4
5
c := make(chan int)
for i := 0; i < 5; i++ {
worker := &Worker{id: i}
go worker.process(c)
}

然后,给这些 worker 一些活干:

1
2
3
4
for {
c <- rand.Int()
time.Sleep(time.Millisecond * 50)
}

这里有一个完整的可运行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"fmt"
"time"
"math/rand"
)

func main() {
c := make(chan int)
for i := 0; i < 5; i++ {
worker := &Worker{id: i}
go worker.process(c)
}

for {
c <- rand.Int()
time.Sleep(time.Millisecond * 50)
}
}

type Worker struct {
id int
}

func (w *Worker) process(c chan int) {
for {
data := <-c
fmt.Printf("worker %d got %d\n", w.id, data)
}
}

我们不知道哪个 worker 将得到什么数据。但我们能确保的是 Go 保证了发送到通道的数据只会被一个接收器接收。

记着,唯一的共享状态时通道,我们可以安全地同时从它接收和发送数据。通道提供了所有我们需要的同步代码保证,在任何时间只有一个 goroutine 可以访问特定的数据。

缓冲通道

上面给出的代码中,如果有超过能处理的数据到来会发什么?你可以通过更改 worker 接收到数据之后的暂停时间来模拟这个。

1
2
3
4
5
for {
data := <-c
fmt.Printf("worker %d got %d\n", w.id, data)
time.Sleep(time.Millisecond * 500)
}

我们的主代码中发生的是,接收用户数据的代码(刚刚使用随机数生成器模拟的)是阻塞,因为没有接收器可用。

在某些情况下,你可能需要担保数据被处理掉,这个时候就需要开始阻塞客户端。在某些情况下,你可能会降低这种担保。这有几种常用的策略实现它。第一个就是缓冲数据。如果没有worker可用,我们想去临时存储数据在某些队列中。通道内建这种缓冲容量,当我们使用 make 创建通道的时候,可以设置通道的长度:

1
c := make(chan int, 100)

你可以对此更改进行更改,但你会注意到处理仍然不稳定。缓冲通道不会增加容量,他们只提供待处理工作的队列,以及处理突然飙升的任务量的好方法。在我们的示例中,我们不断推送比 worker 可以处理的数据更多的数据。

然而,我们实际上可以通过查看通道的 len 来理解缓冲通道是什么。

1
2
3
4
5
for {
c <- rand.Int()
fmt.Println(len(c))
time.Sleep(time.Millisecond * 50)
}

你可以看到通道长度一直增加直到满了,这个时候往我们的通道发送数据将再一次阻塞。

Select

即使有缓冲,在某些时候我们需要开始删除消息。我们不能为了让 worker 轻松而耗尽所有内存。为了实现这个,我们使用 Go 的 select

语法上,select 看起来有一点像 switch。使用它,我们提供当通道不能发送数据的时候处理代码。首先,让我们移除通道缓冲来看看 select 如何工作:

1
c := make(chan int)

接下来,改变我们的 for 循环:

1
2
3
4
5
6
7
8
9
10
for {
select {
case c <- rand.Int():
// 可选的代码在这里
default:
// 这里可以留空以静默删除数据
fmt.Println("dropped")
}
time.Sleep(time.Millisecond * 50)
}

我们将每秒推送20条消息,但是我们的 worker 每秒仅仅能处理10条。也就是说,一般的消息,将被丢掉。

这只是我们能使用 select 实现的一个开始。select 的主要目的是管理多个通道,select 将阻塞直到第一个通道可用。如果没有通道可用,如果提供了 default ,那么他就会被执行。如果多个通道都可用了,随机挑选一个。

很难用一个简单的例子来证明这个行为,因为它是一个相当高级的功能。下一节可能有助于证明这个。

超时

我们看过了缓冲消息以及简单地将他们丢弃。另一个通用的选择是去超时。我们将阻塞一段时间,但不会永远。这在 Go 中也是很容易实现的。虽然,语法很难遵循,但是这样一个简洁有用的功能我不能将它排除在外。

为了阻塞最长时间,我们可以用 time.After 函数。我们来一起看看它并试着超越魔法。为了去用这个,我们的发送器将变成:

1
2
3
4
5
6
7
8
for {
select {
case c <- rand.Int():
case <-time.After(time.Millisecond * 100):
fmt.Println("timed out")
}
time.Sleep(time.Millisecond * 50)
}

time.After 返回了一个通道,所以我们在 select 中使用它。这个通道可以在指定时间之后被写入。就这样,没有其他魔法了。如果你比较好奇,这里有一个 after 的实现,看起来大概就是这个样子咯:

1
2
3
4
5
6
7
8
func after(d time.Duration) chan bool {
c := make(chan bool)
go func() {
time.Sleep(d)
c <- true
}()
return c
}

回到我们的 select,还有两个东西可以试试。首先,如果添加回 default 会发生什么?能猜到吗?试试它。如果你不确定,记着如果没有可用的通道,default 将会立即触发。

还有,time.After 是一个 chan time.Time 类型的通道。上面的例子中,我们仅仅是简单地丢弃掉了发送到通道的值。如果你想要,你可以接受它:

1
2
case t := <-time.After(time.Millisecond * 100):
fmt.Println("timed out at", t)

注意力重新回到我们的 select,可以看到我们发送给 c 但是却从 time.After 接收。无论我们从哪里接收,发送给谁,或者任何通道的组合,select 工作方式是相同的:

  • 第一个可用的通道被选择。
  • 如果多个通道可用,随机选择一个。
  • 如果没有通道可用,default 情况将被执行。
  • 如果没有 default,select 将会阻塞。

最后,在 for 中看到一个 select 是很常见的:

1
2
3
4
5
6
7
8
9
for {
select {
case data := <-c:
fmt.Printf("worker %d got %d\n", w.id, data)
case <-time.After(time.Millisecond * 10):
fmt.Println("Break time")
time.Sleep(time.Second)
}
}

继续之前

如果你是并发编程的新手,那么看起来似乎都是压倒性的。 它绝对需要非常多的关注。Go旨在让它变得更容易。

Goroutines 有效的抽象了我们需要并发执行的代码。通道帮助消除数据共享时共享数据可能发生的一些严重错误。这不仅可以消除错误, 还可以改变并发编程的方式。你只用考虑通过信息传递实现并发编程,而不是危险的代码区域。

话虽如此,我仍然广泛使用 syncsync / atomic 包中的各种同步原语。我觉得比较重要的是通过使用这两种方式比较舒适。我建议你首先关注通道,但是当你遇到一个需要短暂锁的简单示例时,请考虑使用互斥锁或读写互斥锁。

总结

我最近听说 Go 被描述为一个枯燥的语言。枯燥是因为很容易去学,很容易写,以及最重要的,易读。或许,我确实认为这个实现不太好,毕竟,我确实花了三章讨论类型和如何声明变量。

如果你有静态类型语言的工作经历,我们所看到的内容仅仅只是一个复习。Go 使得指针可用性增强,并且切片是数组的包装,对于经验丰富的 Java 或 C#开发人员来说可能并不算是压倒性优势。

如果你曾经大多在使用动态语言,你可能会感到有点不同。它是一个值得学习的东西。其中最重要的是声明和各种初始化的语法。尽管我是 Go 的粉丝,Go 尽管也在简单性方面取得了一些进展,但它并不简单。不过,它归纳为一些基本的规则(比如你只能声明变量一次以及 := 确实声明了变量)以及基本理解(比如 new(X) 或者 &X{} 仅仅只是分配内存,但是切片,映射以及通道需要更多的初始化,所以用 make)。

除了这些,Go 给了我们简单但有效的方式组织我们的代码。接口,基于返回的错误处理,用于资源管理的 defer以及实现组合的简单方式。

最后但也最重要的是内置并发支持。关于 协程 ,除了有效和简单(无论如何简单易用)之外,几乎没有什么可说的了。这是一个很好的抽象。 通道 更为复杂。我一直认为在使用高级包装器之前先理解最基本使用方法。我认为不通过 通道 学习并发编程是很有用的。但是,对我来说,我觉得 通道 的实现方式不像一个简单的抽象。它们几乎都是自己的基本构件。我这样说是因为它们改变了你编写和思考并发编程的方式。 鉴于并发编程有多么困难,这绝对是一件好事。

以及实现组合的简单方式。

最后但也最重要的是内置并发支持。关于 协程 ,除了有效和简单(无论如何简单易用)之外,几乎没有什么可说的了。这是一个很好的抽象。 通道 更为复杂。我一直认为在使用高级包装器之前先理解最基本使用方法。我认为不通过 通道 学习并发编程是很有用的。但是,对我来说,我觉得 通道 的实现方式不像一个简单的抽象。它们几乎都是自己的基本构件。我这样说是因为它们改变了你编写和思考并发编程的方式。 鉴于并发编程有多么困难,这绝对是一件好事。

Golang transaction 事务使用的正确姿势

第一种写法

这种写法非常朴实,程序流程也非常明确,但是事务处理与程序流程嵌入太深,容易遗漏,造成严重的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func DoSomething() (err error) {
tx, err := db.Begin()
if err != nil {
return
}


defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // re-throw panic after Rollback
}
}()


if _, err = tx.Exec(...); err != nil {
tx.Rollback()
return
}
if _, err = tx.Exec(...); err != nil {
tx.Rollback()
return
}
// ...


err = tx.Commit()
return
}

第二种写法

下面这种写法把事务处理从程序流程抽离了出来,不容易遗漏,但是作用域是整个函数,程序流程不是很清晰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func DoSomething() (err error) {
tx, err := db.Begin()
if err != nil {
return
}


defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // re-throw panic after Rollback
} else if err != nil {
tx.Rollback()
} else {
err = tx.Commit()
}
}()


if _, err = tx.Exec(...); err != nil {
return
}
if _, err = tx.Exec(...); err != nil {
return
}
// ...
return
}

第三种写法

写法三是对写法二的进一步封装,写法高级一点,缺点同上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func Transact(db *sql.DB, txFunc func(*sql.Tx) error) (err error) {
tx, err := db.Begin()
if err != nil {
return
}


defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // re-throw panic after Rollback
} else if err != nil {
tx.Rollback()
} else {
err = tx.Commit()
}
}()


err = txFunc(tx)
return err
}


func DoSomething() error {
return Transact(db, func (tx *sql.Tx) error {
if _, err := tx.Exec(...); err != nil {
return err
}
if _, err := tx.Exec(...); err != nil {
return err
}
})
}

我的写法

经过总结和实验,我采用了下面这种写法,defer tx.Rollback() 使得事务回滚始终得到执行。 当 tx.Commit() 执行后,tx.Rollback() 起到关闭事务的作用, 当程序因为某个错误中止,tx.Rollback() 起到回滚事务,同事关闭事务的作用。

普通场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func DoSomething() (err error) {
tx, _ := db.Begin()
defer tx.Rollback()

if _, err = tx.Exec(...); err != nil {
return
}
if _, err = tx.Exec(...); err != nil {
return
}
// ...


err = tx.Commit()
return
}

循环场景

(1) 小事务 每次循环提交一次 在循环内部使用这种写法的时候,defer 不能使用,所以要把事务部分抽离到独立的函数当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func DoSomething() (err error) {
tx, _ := db.Begin()
defer tx.Rollback()

if _, err = tx.Exec(...); err != nil {
return
}
if _, err = tx.Exec(...); err != nil {
return
}
// ...


err = tx.Commit()
return
}


for {
if err := DoSomething(); err != nil{
// ...
}
}

(2) 大事务 批量提交 大事务的场景和普通场景是一样的,没有任何区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func DoSomething() (err error) {
tx, _ := db.Begin()
defer tx.Rollback()

for{
if _, err = tx.Exec(...); err != nil {
return
}
if _, err = tx.Exec(...); err != nil {
return
}
// ...
}

err = tx.Commit()
return
}

参考链接:
https://stackoverflow.com/questions/16184238/database-sql-tx-detecting-commit-or-rollback
原文地址:
http://hopehook.com/2017/08/21/golang_transaction/

go mod 的使用

从Go1.11开始,golang官方支持了新的依赖管理工具go mod

命令行说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜  ~ go mod
Go mod provides access to operations on modules.

Note that support for modules is built into all the go commands,
not just 'go mod'. For example, day-to-day adding, removing, upgrading,
and downgrading of dependencies should be done using 'go get'.
See 'go help modules' for an overview of module functionality.

Usage:

go mod <command> [arguments]

The commands are:

download download modules to local cache
edit edit go.mod from tools or scripts
graph print module requirement graph
init initialize new module in current directory
tidy add missing and remove unused modules
vendor make vendored copy of dependencies
verify verify dependencies have expected content
why explain why packages or modules are needed

Use "go help mod <command>" for more information about a command.
  • go mod download: 下载依赖的module到本地cache
  • go mod edit: 编辑go.mod
  • go mod graph: 打印模块依赖图
  • go mod init: 在当前目录下初始化go.mod(就是会新建一个go.mod文件)
  • go mod tidy: 整理依赖关系,会添加丢失的module,删除不需要的module
  • go mod vender: 将依赖复制到vendor下
  • go mod verify: 校验依赖
  • go mod why: 解释为什么需要依赖

在新项目中使用

使用go mod并不要求你的项目源码放到$GOPATH下,所以你的新项目可以放到任意你喜欢的路径。在项目根目录下执行go mod init,会生成一个go.mod文件。然后你可以在其中增加你的依赖,如下:

1
2
3
4
5
6
7
8
module github.com/gaoyoubo/xxx

go 1.12

require (
github.com/go-sql-driver/mysql v1.4.1
.... 你的依赖类似这样,添加到这里,一行一条。
)

然后执行go mod download,将依赖下载到本地。这些依赖并不是下载到你的项目目录下,而是会下载到$GOPATH/pkg/mod目录下,这样所有使用go mod的项目都可以共用。

在旧项目中使用

在旧项目中使用非常简单,只需要一下两个步骤:

  • go mod init: 在项目根目录下执行该命令,会在项目根目录下生成一个go.mod文件。
  • go mod tidy: 在项目根目录下执行该命令,go mod会自动分析你当前项目所需要的依赖,并且将他们下载下来。

如何升级依赖

运行 go get -u 将会升级到最新的次要版本或者修订版本(x.y.z, z是修订版本号y是次要版本号)
运行 go get -u=patch 将会升级到最新的修订版本
运行 go get package@version 将会升级到指定的版本

Golang和Java构建工具调查

Github:https://github.com/blindpirate/report-of-build-tools-for-java-and-golang

A Survey on Build Tools of Golang and Java

Java

Conclusion

In January 2017, the usage of build tools in Github’s top 1000 Java repositories is as follows:

Tool Name Reference Count
Gradle 627
Maven 264
Ant 52
Npm 4
Bazel 3
Make 1

And the trending over the past 8 years is:

trending

Algorithm

  • Clone top 1000 Java repositories to local disk
  • Analyze the repositories by identity files:
Tool Name Identity Files
Gradle build.gradle
Maven pom.xml
Ant build.xml
Npm package.json
Bazel BUILD
Make Makefile/makefile

How

  • Make sure Git/Groovy 2.4+/JDK 1.7+ are installed.
  • Run groovy GithubTopRankCrawler.groovy -l java -d <path to store the 1000 repos> to clone all repositories locally.
  • Run groovy JavaBuildToolScanner.groovy -d <path to store the 1000 repos> to analyze these repos.

Golang

Conclusion

There are various package management tools for golang as listed here. But which one is the most popular?

The usage of package manage tools in Github’s top 1000 Go repositories is as follows:

Tool Name Url Reference Count (Feb 2017) Reference Count (Nov 2017)
Makefile Makefile 199 181
dep dep N/A 94
godep godep 119 90
govendor govendor 65 84
glide glide 64 77
gvt gvt 25 16
trash trash 7 13
submodule submodule 8 6
gpm/johnny-deps gpm johnny-deps 7 6
glock glock 5 4
gom gom 4 2
gopack gopack 3 2
gopm gopm 3 1
goop goop 1 1
gvend gvend 2 0

dep had a first release in May 2017, did not exist for first stats.

Technically, make is not a package management tool, here it is just for comparison.

Submodule refers to a set of tools which use git submodule to manage dependencies such as manul and Vendetta and so on.

Algorithm

  • Clone top 1000 Go repositories to local disk
  • Analyze the repositories by identity files:
Tool Name Identity Files
godep Godeps/Godeps.json
govendor vendor/vendor.json
gopm .gopmfile
gvt vendor/manifest
gvend vendor.yml
glide glide.yaml or glide.lock
trash vendor.conf
gom Gomfile
bunch bunchfile
goop Goopfile
goat .go.yaml
glock GLOCKFILE
gobs goproject.json
gopack gopack.config
nut Nut.toml
gpm/johnny-deps Godeps
Makefile makefile or Makefile
submodule .gitmodules

How

  • Make sure Git/Groovy 2.4+/JDK 1.7+ are installed.
  • Run groovy GithubTopRankCrawler.groovy -l go -d <path to store the 1000 repos> to clone all repositories locally. You can use -s to do the shallow clone and decrease disk usage.
  • Run groovy GoBuildToolScanner.groovy <path to store the 1000 repos> to analyze these repos.

IDE Goland DEBUG报错

在升级GO版本到1.11后发现Goland的Debug报错,错误信息如下:

1
could not launch process: decoding dwarf section info at offset 0x0: too short

原因是Goland的dlv不是新版本,导致不能debug调试。

解决办法:

  1. 更新dlv:go get -u github.com/derekparker/delve/cmd/dlv
  2. 修改goland配置,Help->Edit Custom Properties中增加新版dlv的路径配置:dlv.path=$GOPATH/bin/dlv
  3. 重启Goland,再次使用debug调试工具,就没有问题了。

参考:https://stackoverflow.com/questions/43014884/mac-osx-jetbrains-gogland-delve-debugging-meet-could-not-launch-process-could/43014980#43014980

Go生成csv文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main
import (
"encoding/csv"
"os"
)

func main() {
f, err := os.Create("test.csv")
if err != nil {
panic(err)
}
defer f.Close()

w := csv.NewWriter(f)
data := [][]string{
{"1", "11", "11"},
{"2", "22", "22"}
}
w.WriteAll(data)
w.Flush()
}

Go语言第二次实战应用

使用场景,由于历史原因造成redis计数不准确,需要将数据从数据库中count一次,然后同步到redis。

使用到的包

  • github.com/go-sql-driver/mysql
  • github.com/go-redis/redis

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package main

import (
"database/sql"
"fmt"
"strconv"
"github.com/go-redis/redis"
_ "github.com/go-sql-driver/mysql"
)

// redis 客户端
var client *redis.Client
var db *sql.DB

const (
dbUrl = "username:password@tcp(localhost:3306)/dianping_new_db?charset=utf8"
)

func init() {
// 初始化redis客户端
client = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
db, _ = sql.Open("mysql", dbUrl)
}

func main() {
var dianpingMap map[string]string
dianpingMap = make(map[string]string)

listById(func(id string, dianpingId string) {
_, ok := dianpingMap[dianpingId]
if ok {
return
}

count := getCount(dianpingId)

redisKey := "dianping-count-" + dianpingId
client.HSet(redisKey, "replyCount", count)

dianpingMap[dianpingId] = dianpingId

fmt.Println("id", id, "dianpingId", dianpingId, "count", count)
})

client.Close()
db.Close()
}

type Handler func(id string, dianpingId string)

func listById(handler Handler) {
var cursor = 0
for {
sql := "select id, dianping_id from t_dianping_reply "
if cursor > 0 {
sql += " where id > " + strconv.Itoa(cursor)
}
sql += " order by id asc limit 10 "

rows, err := db.Query(sql)
if err != nil {
panic(err)
} else {
for rows.Next() {
var id, dianpingId string

rows.Scan(&id, &dianpingId)

handler(id, dianpingId)

cursor, _ = strconv.Atoi(id)
}
}
rows.Close()
}
}

func getCount(dianpingId string) int {
rows, err := db.Query("select count(*) from t_dianping_reply where dianping_id = ? and status = 0", dianpingId)
count := 0
if err != nil {
panic(err)
return count
}
if rows.Next() {
rows.Scan(&count)
}
rows.Close()
return count
}

Go语言的第一次实战应用

最近开始在熟悉Go语言,正巧产品同学需要分析一些数据,数据是放在ElasticSearch中的,打算使用Go语言来进行分析。
Go语言的优势就是对linux兼容很好,可以直接成linux上的可执行文件,无需其他任何环境的支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
package main

import (
"encoding/json"
"log"
"os"
elastic "gopkg.in/olivere/elastic.v3"
"strings"
"fmt"
"strconv"
)

const (
url = "http://xxx.xxx.xxx.xxx:9200"
)

type Message struct {
Id int
MucangId string
Message string
CreateTime string
}

func main() {
errorLog := log.New(os.Stdout, "es ", log.LstdFlags)

client, err := elastic.NewClient(elastic.SetURL(url), elastic.SetErrorLog(errorLog))
if err != nil {
panic(err)
}

var cursor int
for {
fmt.Println(strconv.Itoa(cursor))

boolQuery := elastic.NewBoolQuery().Filter(
elastic.NewTermQuery("appinfoId", 1),
elastic.NewRangeQuery("createTime").Gt("2017-05-22 00:00:00 +0800"),
elastic.NewMatchPhraseQuery("message", "向你求助"))

if cursor > 0 {
boolQuery.Filter(elastic.NewRangeQuery("id").Gt(cursor))
}

searchResult, err := client.Search().
Index("push-record").
Type("message").
Query(boolQuery).
Sort("id", true).
From(0).Size(100).
Pretty(true).
Do()

if err != nil {
panic(err)
}

if searchResult.Hits.TotalHits > 0 {
for _, hit := range searchResult.Hits.Hits {
var msg Message
json.Unmarshal(*hit.Source, &msg)

cursor = msg.Id

b, _ := json.Marshal(hit.Source)
append("/home/gaoyoubo/push.log", string(b))
}
} else {
break
}
}
}

// 追加文件
func append(path string, content string) {
file, _ := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
content = strings.Join([]string{content, "\n"}, "")
buf := []byte(content)
defer file.Close()
file.Write(buf)
}