Go中的字符串,字节,rune和字符(character)

本文翻译自《Strings, bytes, runes and characters in Go》。

Rob Pike

2013/08/23

介绍

上一篇博客文章解释了切片(slice)在Go中的工作方式,并使用了一些示例来说明它背后的机制。在此背景下,本文将讨论Go中的字符串。起初,字符串对于一篇博客文章来说可能太简单了,但想要很好地使用它们,不仅需要了解它们是如何工作的,还需要了解字节、字符(character)和符文(rune)之间的区别,Unicode和UTF-8之间的区别、字符串和字符串字面量(string literal)之间的区别以及其他更微妙的区别。

编写这个话题的一种方法是,给出常见问题的答案,例如“当我在位置n索引Go字符串时,为什么我得不到第n个字符?”,正如你所看到的,这个问题的答案可以让我们了解文本在现代世界中是如何工作的。

Joel Spolsky的著名博客文章“每个软件开发人员绝对、积极地必须了解的Unicode和字符集”,是对其中一些问题的一个极好的介绍,它独立于Go语言。它提出的许多观点将在这里重复提及。

什么是字符串?

让我们从一些基础知识开始。

在Go中,一个字符串实际上是一个只读字节片。如果你不确定字节片是什么或者它是如何工作的,请阅读上一篇博客文章;我们在这里假设你已阅读。

重要的是要提前声明:一个字符串可以包含任意的字节,不一定是Unicode文本、UTF-8文本或任何其他预定义的格式。就字符串的内容而言,它完全等同于一个字节片。

下面是一个字符串字面量(稍后将详细介绍),它使用\xNN表示法来定义一个包含一些特殊字节值的字符串常量。(当然,字节的范围从十六进制值00到FF,包括00和FF。)

const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"

打印输出字符串

因为我们的示例字符串中的一些字节不是有效的ASCII,甚至不是有效的UTF-8,所以直接打印字符串会产生难看的输出。以下是简单的打印输出这个字符串的语句:

fmt.Println(sample)

产生这种混乱的输出(确切的输出与你的系统环境有关,不同的系统环境可能有不同的输出):

��=� ⌘

为了弄清楚这个字符串里到底装着什么,我们需要把它拆开,检查一下每个部分。有几种方法可以做到这一点。最明显的是对其内容进行循环,并单独取出字节,如以下for循环中所示:

for i := 0; i < len(sample); i++ {
        fmt.Printf("%x ", sample[i])
    }

正如上面所示,对字符串进行索引访问到的是单个字节,而不是一个个字符(character)。我们将在下面详细讨论这个主题。现在,让我们只使用字节。这是逐字节遍历循环的输出:

bd b2 3d bc 20 e2 8c 98

注意各个字节如何与定义字符串的十六进制转义符相匹配。 把混乱的字符串输出为人类可读的形式的较简单的方法是,使用fmt.Printf%x(十六进制数)格式。它将字符串的顺序字节输出为十六进制数字,每个字节对应两个十六进制数字。

fmt.Printf("%x\n", sample)

输出如下:

bdb23dbc20e28c98

你可以与之前的输出比较一下。

一个很好的技巧是在该格式中使用“空格”标志,在%x之间加一个空格:

fmt.Printf("% x\n", sample)

输出如下:

bd b2 3d bc 20 e2 8c 98

注意字节之间的空格。

还有更多。%q(带引号)格式将转义字符串中任何不可打印的字节序列,因此输出是明确的。

当字符串的大部分内容可以理解为文本,但也有一些特殊字符需要清除时,这种技巧很方便;对于上文中的字符串,它输出:

"\xbd\xb2=\xbc ⌘"

如果我们注视一下,我们可以看到隐藏在噪音中的是一个ASCII等号和一个普通空格,最后出现了著名的瑞典“兴趣地点(Place of Interest)”符号。该符号的Unicode码值为U+2318,被编码为UTF-8字节:e2 8c 98,位于空格(十六进制值20)之后。

如果我们对字符串中的奇怪字符感到陌生或困惑,我们可以在%q格式中使用“加号+”标志。此标志不仅转义不可打印的字节序列,而且转义任何非ASCII字节,都按UTF-8编码来解析。结果是,它打印输出了格式正确的UTF-8编码的Unicode码值,该值表示字符串中的非ASCII数据:

fmt.Printf("%+q\n", sample)

使用该格式,上述瑞典语符号的Unicode值显示为\u开头的转义符:

"\xbd\xb2=\xbc \u2318"

这些打印输出技巧在调试字符串内容时很有用,在后续的讨论中也很方便。同样值得指出的是,所有这些方法对字节片的行为与对字符串的行为完全相同。

以下是我们在上文列出过的打印输出的选项(标志),作为一个完整的程序示例给出:

package main

import "fmt"

func main() {
    const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"

    fmt.Println("Println:")
    fmt.Println(sample)

    fmt.Println("Byte loop:")
    for i := 0; i < len(sample); i++ {
        fmt.Printf("%x ", sample[i])
    }
    fmt.Printf("\n")

    fmt.Println("Printf with %x:")
    fmt.Printf("%x\n", sample)

    fmt.Println("Printf with % x:")
    fmt.Printf("% x\n", sample)

    fmt.Println("Printf with %q:")
    fmt.Printf("%q\n", sample)

    fmt.Println("Printf with %+q:")
    fmt.Printf("%+q\n", sample)
}

[练习:修改上面的例子,输出字节切片而不是字符串。提示:使用转换来创建切片。]

[练习:在每个字节上使用%q格式对字符串进行循环。输出会告诉你什么?]

UTF-8和字符串字面量

正如我们所看到的,对字符串进行索引会返回字节,而不是字符(character):字符串只是一堆字节。这意味着,当我们在字符串中存储一个字符值时,我们是按字节存储它的。让我们看一个更可控的例子,看看这是如何发生的。

这里有一个简单的程序,它用三种不同的方式打印带有单个字符的字符串常量,一种是打印输出纯字符串,一种是只打印输出ASCII字符,还有一种是打印输出十六进制数的单个字节。为了避免混淆,我们创建了一个“原始字符串(raw string)”,用后引号(back quotes)括起来,这样它就只能包含字符串字面量。(用双引号括起来的常规字符串里面可以包含转义字符,如上文所示。但用后引号括起来的原始字符串里面的字符不会被转义。)

func main() {
    const placeOfInterest = `
⌘`

    fmt.Printf("plain string: ")
    fmt.Printf("%s", placeOfInterest)
    fmt.Printf("\n")

    fmt.Printf("quoted string: ")
    fmt.Printf("%+q", placeOfInterest)
    fmt.Printf("\n")

    fmt.Printf("hex bytes: ")
    for i := 0; i < len(placeOfInterest); i++ {
        fmt.Printf("%x ", placeOfInterest[i])
    }
    fmt.Printf("\n")
}

输出:

plain string: ⌘
quoted string: "\u2318"
hex bytes: e2 8c 98

这提醒我们,Unicode码值U+2318,即符号,在字符串中由字节e2 8c 98表示,并且这些字节是十六进制数2318的UTF-8编码。

根据你对UTF-8的熟悉程度,它可能很明显,也可能很微妙,但值得花点时间解释一下字符串的UTF-8表示是如何创建的。简单的事实是:它是在编写源代码时创建的。

Go语言的源代码被定义为UTF-8文本;不允许使用其他编码。这意味着,当我们在源代码中编写以下文本时

`⌘`

用于编写源代码的文本编辑器将符号⌘的UTF-8编码放入源代码文本中。当我们打印输出十六进制数的字节时,我们只是简单地输出文本编辑器放置在文件中的字节数据。

简而言之,Go的源代码是UTF-8文本,因此其字符串字面量也是UTF-8文本。如果该字符串字面量里不包含转义序列(原始字符串就不包含),则构造的字符串就是引号之间的源代码文本。因此,通过定义和构造,原始字符串将始终包含其内容里的有效UTF-8文本。类似地,除非像本文开头示例中的字符串(用\xNN表示法来定义一个包含一些特殊字节值的字符串字面量)那样包含不能被解析为UTF-8编码的字节序列,否则普通字符串字面量也将始终包含有效的UTF-8文本。

有些人认为Go字符串总是UTF-8文本,但事实并非如此,正如我们在本文开头所展示的,字符串值可以包含任意字节,里面可能包含不能被解析为UTF-8编码的字节序列。

总之,Go字符串可以包含任意字节,但当我们从字符串字面量(非\xNN表示法)构建字符串时,里面的字节序列(几乎总是)符合UTF-8编码的。

码点(Code point)、字符(character)和rune

到目前为止,我们在使用“字节(byte)”和“字符(character)”这两个词时非常小心。这部分是因为字符串包含字节,部分是因为“字符”的概念有点难以定义。Unicode标准使用术语“码点(code point,也有翻译为‘码值’的)”来指代由单个数字表示的字符。例如码点U+2318,具有十六进制数值2318,表示符号“⌘”。(有关该码点的更多信息,请参阅其Unicode页面。)

举一个更普通的例子,Unicode码点U+0061是小写拉丁字母“a”。

但是小写带重音的字母“à”呢?这也是一个字符,也是一个码点(U+00E0),但它有其他表示形式。例如,我们可以使用“组合”重音码点U+0300,并将其附加到小写字母a(码点是U+0061),来创建相同的字符“à”。通常,一个字符可以由许多不同的码点序列表示,因此也可以编码为不同的UTF-8字节序列。

因此,计算机中的字符(character)的概念是模糊的,或者至少是令人困惑的,所以我们谨慎地使用它。为了使事情变得可靠,有一些规范化的技术可以保证给定的字符总是由相同的码点表示,但这个主题偏离本文的主题太远了。稍后的博客文章将解释Go库如何解决规范化问题。

“码点”这个词有点晦涩难懂,所以Go为这个概念引入了一个较短的术语:rune。这个术语出现在库和源代码中,其含义与“码点”完全相同,还有一个有趣的补充。

Go语言将rune定义为类型int32类型的别名,因此当整数值表示码点时,程序就会很清晰。此外,你可能认为的字符常量在Go中被称为“rune常量”。例如'⌘'的类型是rune,值是整数0x2318

总之,以下是一些重点:

  • Go源代码总是UTF-8文本。
  • 字符串可以包含任意字节。
  • 字符串字面量,不存在字节级转义字符的话,始终包含有效的UTF-8字节序列。
  • 代表Unicode码点的序列,称为rune。
  • Go中不能保证字符串中的字符是标准化的。

范围循环

除了Go源代码是UTF-8文本之外,实际上Go还有一个特殊对待UTF-8的地方,那就是在字符串上使用for range循环时。

我们已经看到了普通for循环的情况。相比之下,for range循环在每次迭代中解码一个UTF-8编码的rune。每次循环时,循环的索引是当前rune的起始位置,以字节为单位,循环的值是当前rune的Unicode码点。下面是一个使用另一种方便的Printf函数的格式%#U的示例,它显示了rune的Unicode码点的值及其打印输出的字符:

    const nihongo = "日本語"
    for index, runeValue := range nihongo {
        fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
    }

输出显示每个Unicode码点如何占用多个字节:

U+65E5 '日' starts at byte position 0
U+672C '本' starts at byte position 3
U+8A9E '語' starts at byte position 6

[练习:将一个非法的UTF-8字节序列放入字符串中。循环的迭代会发生什么?]

Go的标准库为解析UTF-8文本提供了强大的支持。

如果for range循环不足以满足你的目的,那么你需要的设施很可能是由库中的包提供的。 最重要的包是unicode/utf8,它包含用于验证、反组装(disassemble)和重新组装UTF-8字符串的辅助函数代码。这里有一个与上面的for range示例等效的程序,但使用该包中的DecodeRunInString函数来完成这项工作。函数的返回值是rune及其宽度(以UTF-8编码的字节为单位)。

    const nihongo = "日本語"
    for i, w := 0, 0; i < len(nihongo); i += w {
        runeValue, width := utf8.DecodeRuneInString(nihongo[i:])
        fmt.Printf("%#U starts at byte position %d\n", runeValue, i)
        w = width
    }

运行它以查看它是否执行相同的操作。for range循环和DecodeRunInString函数被定义为生成完全相同的迭代序列。

你可以查看unicode/utf8包的官方文档,了解它还提供了哪些其他功能。

结论

为了回答开头提出的问题:字符串是从字节构建的,因此对字符串进行索引会产生字节,而不是字符(character)。字符串甚至可能不包含字符(character)。事实上,“字符(character)”这一定义是模糊的,试图通过“定义字符串是由字符组成的”来解决二义性是一种错误的做法。

关于Unicode、UTF-8和多语言文本处理,还有很多话要说,但这应该写成另一篇文章。目前,我们希望你能更好地了解Go字符串的行为,尽管它们可能包含任意字节,但UTF-8是其设计的核心部分。

Go模糊测试

本文翻译自《Go Fuzzing》。

从Go 1.18开始,Go的标准工具链支持模糊测试。OSS-fuzz支持原生的Go模糊测试。

尝试一下教程:模糊测试入门

概览

模糊测试是一种自动测试,它不断地操纵程序的输入来发现Bug。Go的模糊测试使用覆盖率导向来智能地遍历被模糊测试的代码,以查找Bug并向用户报告。由于模糊测试可以触及程序员经常错过的边缘情况,因此它对于发现安全漏洞和Bug尤其有用。

下面是一个模糊测试的例子,突出显示了它的主要组件。

编写模糊测试

要求

以下是模糊测试必须遵循的规则:

  • 模糊测试必须使用一个名为FuzzXxx的函数,它只接受一个*testing.F参数,并且没有返回值。
  • 模糊测试的代码必须放在*_test.go文件中才能运行。
  • 模糊测试的目标必须是对(*testing.F).fuzz方法的调用,该方法接受*testing.T作为第一个参数,然后是用于模糊测试的参数,没有返回值。
  • 每个模糊测试必须只有一个目标。
  • 所有种子语料库的条目,都必须匹配模糊测试的目标函数的参数的类型,并且顺序也要相同。对(*testing.F).Add和模糊测试的testdata/fuzz目录中的任何语料库文件的调用都是如此。
  • 模糊测试的参数只能是以下类型:string, []byte、int, int8, int16, int32/rune, int64、uint, uint8/byte, uint16, uint32, uint64、float32, float64、bool

建议

以下是一些建议,可以帮助你充分利用模糊测试:

  • 模糊测试的目标函数应该是能快速运行完毕的和结果确定的,这样模糊测试的引擎才能有效地工作,Bug和代码覆盖率可以很容易地再现。
  • 由于模糊测试的目标函数是在多个工作线程之间以不确定的顺序并行执行的,因此模糊测试的目标函数的状态不应持续到每次调用结束,其行为也不应依赖于全局状态。

运行模糊测试

有两种运行测试的模式:使用单元测试(默认的go test),或者使用模糊测试(go test -fuzz=FuzzTestName)。

默认情况下,模糊测试的运行方式与单元测试非常相似。每个种子语料库条目都将针对模糊测试的目标函数进行测试,在退出之前报告任何发现的Bug。

要启用模糊测试,请使用-fuzz标志运行go test,提供一个匹配单个模糊测试函数的正则表达式。默认情况下,该包中的所有其他测试都将在模糊测试开始之前运行。这是为了确保模糊测试不会报告任何现有测试已经发现了的问题。

请注意,运行模糊测试的时间长短由你决定。如果模糊测试的执行没有发现任何错误,那么它很有可能无限期地运行下去。未来将支持使用OSS fuzz等工具持续运行这些模糊测试,请参阅Issue#50192

注意:模糊测试应该在支持代码覆盖率检测的平台上运行(目前AMD64和ARM64平台都支持),这样语料库就可以在运行时有意义地增长,并且在模糊测试的同时可以覆盖到更多的代码。

命令行输出

当模糊测试正在运行时,模糊测试的引擎生成新的输入数据,并使用模糊测试的目标函数运行它们。默认情况下,它会继续运行,直到找到一个导致测试失败的输入数据,或者用户终止测试程序(例如使用组合键Ctrl^C)。

输出将如下所示:

~ go test -fuzz FuzzFoo
fuzz: elapsed: 0s, gathering baseline coverage: 0/192 completed
fuzz: elapsed: 0s, gathering baseline coverage: 192/192 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 202)
fuzz: elapsed: 6s, execs: 680218 (118402/sec), new interesting: 12 (total: 203)
fuzz: elapsed: 9s, execs: 1039901 (119895/sec), new interesting: 19 (total: 210)
fuzz: elapsed: 12s, execs: 1386684 (115594/sec), new interesting: 21 (total: 212)
PASS
ok      foo 12.692s

第一行显示了“基线代码覆盖率”,是在模糊测试开始之前收集的。

为了收集基线代码覆盖率,模糊测试的引擎会执行种子语料库生成的语料库,以确保没有错误发生,并了解到现有语料库已经提供的代码覆盖率是多少。

以下几行信息提供了对模糊测试的执行过程的深入了解:

  • elapsed:测试开始后过去的时间。
  • execs:针对模糊测试的目标函数运行的输入数据总数(后面括号里的数据是统计每秒运行了多少数据)。
  • new interesting:在模糊测试执行期间添加到生成的语料库中的“interesting”输入数据的总数(整个语料库的总大小)

为了让输入数据变得“interesting”,它必须将代码覆盖范围扩展到现有的语料库(种子语料库和之前生成的语料库)所能达到的范围之外。通常情况下,新的interesting的数据在测试的一开始时快速增长,最终放缓,随着新的代码分支的发现,偶尔会爆发式增长。

随着语料库中的数据开始覆盖更多的代码行,你应该会看到新的interesting数字随着时间的推移逐渐减少,如果模糊测试的引擎找到了新的代码路径,interesting数字偶尔会爆发式增长。

导致测试失败的输入数据

模糊测试时可能会出现失败,原因有以下几种:

  • 代码或测试中引发panic
  • 模糊测试的目标函数里面调用了t.Fail函数,可以直接调用,也可以通过t.Errort.Fatal等函数简介调用。
  • 出现不可恢复的错误,例如os.Exit或堆栈溢出。
  • 模糊测试的目标函数花了太长时间才完成。目前,执行目标函数的超时时间是1秒。这可能是由于死锁或无限循环,或者代码就是要执行很长时间。这就是为什么我们在上文建议你的目标函数执行得要快的原因之一。

如果发生了一个错误,模糊测试的引擎将尝试将输入数据最小化到尽可能小、人类最容易读的值,这个值仍然会引发这个错误。要对此进行配置,请参阅下文自定义设置小节。

最小化完成后,模糊测试的引擎将记录错误消息,输出将以以下内容作为结束信息:

 Failing input written to testdata/fuzz/FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
    To re-run:
    go test -run=FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
FAIL
exit status 1
FAIL    foo 0.839s

模糊测试的引擎会将这个导致测试失败的输入数据写入模糊测试的种子语料库,现在它将被go test命令默认运行,在修复这个Bug后用作回归测试。

你的下一步将是诊断这个Bug并修复,通过重新运行go test命令来验证是否修复成功。如果修复成功,就与对应的测试数据文件(作为回归测试)一起提交补丁。

默认的go命令的设置应该适用于模糊参数的大多数用例。因此,通常情况下,在命令行上执行模糊参数应该如下所示:

$ go test -fuzz={FuzzTestName}

但是,go命令在运行模糊测试时确实提供了一些设置,在cmd/go软件包的文档中进行了说明。

以下几个设置值得关注:

  • -fuzztime:模糊测试的目标函数在退出之前执行的总时间或迭代次数,默认为无限期。
  • -fuzzminimizetime:在每次最小化尝试期间,执行模糊测试的目标函数的时间或迭代次数,默认为60秒。你可以通过设置-fluzzminimizetime 0来完全禁用最小化。
  • -parallel:每一次运行模糊测试时的进程数,默认为$GOMAXPROCS。目前,在模糊测试期间设置-cpu标志没有任何效果。

语料库文件的格式

语料库文件以一种特殊的格式编码。种子语料库程序生成的语料库都使用相同的格式。

以下是语料库文件的一个示例:

go test fuzz v1
[]byte("hello\\xbd\\xb2=\\xbc ⌘")
int64(572293)

第一行用于通知模糊测试的引擎,语料库文件使用的编码的版本。尽管目前还没有计划编码格式的未来版本,但设计时必须支持这种可能性。

下面的每一行都是组成语料库条目的值,如果需要,可以直接复制到Go代码中。

在上面的例子中,我们有一个[]byte,后面跟一个int64。这些类型必须按顺序与模糊测试的目标函数的参数完全匹配,如下所示:

f.Fuzz(func(*testing.T, []byte, int64) {})

指定自己的种子语料库的条目的最简单方法是使用(*testing.F).Add方法。在上面的例子中,看起来是这样的:

f.Add([]byte("hello\\xbd\\xb2=\\xbc ⌘"), int64(572293))

然而,你可能有一些大的二进制文件,不希望将其作为代码复制到测试中,而是保留为testdata/fuzz/{FuzzTestName}目录中的单个种子语料库的条目。golang.org/x/toolscmd/file2fuzz上的file2fuzz工具可用于将这些二进制文件转换为[]byte编码的语料库文件。下载然后安装这个工具:

$ go install golang.org/x/tools/cmd/file2fuzz@latest

查看帮助:

$ file2fuzz -h

资源

教程:

文档:

  • testing包的文档描述了测试,包括编写模糊测试时使用的testing.F类型。
  • cmd/go包的文档描述了与模糊相关联的标志。

技术细节:

术语

语料库条目(corpus entry):语料库中的一个输入,可以在进行模糊测试时使用。这可以通过一个特殊格式的文件给出,也可以通过调用 (*testing.F).Add函数添加。

覆盖率导向(coverage guidance):一种模糊测试方法,它根据代码覆盖率是否扩展来确定哪些语料库条目值得保留以备将来使用。

失败的输入数据(failing input):失败的输入数据是指一个语料库条目,让模糊测试的目标函数再运行时导致运行出错或引发panic

模糊测试的目标函数(fuzz target):模糊测试的目标函数,在进行模糊测试时执行语料库条目和模糊测试的引擎生成的测试数据。把函数传递给 (*testing.F).Fuzz,就变成了模糊测试的目标函数。

模糊测试(fuzz test):测试文件中的一个函数,形式为FuzzXxx(*testing.F),可用于模糊测试。

模糊测试的过程(fuzzing):一种自动测试,它不断地操纵程序的输入,以发现代码里可能的Bug或容易受到攻击的漏洞等问题。

模糊测试的参数(fuzzing arguments):将传递给模糊测试的目标函数并由变异器(mutator)进行变化的参数值。

模糊测试的引擎(fuzzing engine):一个管理模糊测试的工具,功能包括维护语料库、调用变异器、识别新的代码覆盖率和报告发现的Bug。

生成的语料库(generated corpus):由模糊测试的引擎在进行模糊测试时随时间维护的语料库,以跟踪测试进度。它存储在$GOCACHE/fuzz目录中。这些条目仅在模糊测试时使用。

变异器(mutator):模糊测试时使用的一种工具,在将语料库条目传递给模糊测试的目标函数之前,随机地修改它们。

(package):同一个目录中的Go源文件的集合,这些文件被编译在一起。请参阅Go语言规范中的Packages部分

种子语料库(seed corpus):用户提供的用于模糊测试的语料库,用于引导模糊测试的引擎。它由f.Add函数添加的语料库条目,以及testdata/fuzz/{FuzzTestName}目录中的文件组成。默认情况下,无论是否模糊测试,这些条目都会被go test运行。

测试文件(test file):xxx_test.go文件,可能包含普通测试、基准测试、示例和模糊测试等代码。

漏洞(vulnerability):代码中对安全性敏感的弱点,可被攻击者利用。

反馈

如果你遇到任何问题或对某个功能有想法,请提交一个issue

对于有关该功能的讨论和一般性反馈,你也可以参加Gophers Slack中的#fuzzing频道

教程:模糊测试入门

本文翻译自《Tutorial: Getting started with fuzzing》。

本教程介绍Go中模糊测试的基本知识。通过模糊测试,随机数据会针对你的测试程序运行,试图找到程序漏洞或导致程序崩溃的输入。模糊测试可以发现的一些漏洞包括SQL注入、缓冲区溢出、拒绝服务和跨站点脚本攻击等。

有关本教程中的术语,请参阅Go Fuzzing词汇表

你将完成以下部分:

1 为代码创建一个文件夹

2 添加要测试的代码

3 添加一个单元测试

4 添加一个模糊测试

5 修复两个Bug

6 探索其他资源

注意:有关其他教程,请参见教程

注意:Go fuzzing目前支持Go fuzzing文档中列出的一个内置类型子集,并会在未来添加更多的内置类型。

先决条件

  • 安装Go 1.18或更高版本。有关安装说明,请参阅安装Go
  • 用于编辑代码的工具。你拥有的任何文本编辑器都可以正常工作。
  • 一种命令行终端。Go在Linux和Mac上使用任何终端都能很好地工作,以及Windows中的PowerShell或CMD。
  • 支持模糊测试的运行环境。带覆盖检测的Go fuzzing目前仅在AMD64和ARM64架构上可用。

为代码创建一个文件夹

首先,为要编写的代码创建一个文件夹。

1 打开命令提示符并转到家目录。 在Linux或Mac上:

$ cd

在Windows:

C:\> cd %HOMEPATH%

本教程的其余部分将显示一个$作为提示符。本教程使用的命令也适用于Windows。

2.在命令提示符下,为代码创建一个名为fuzz的目录。

$ mkdir fuzz
$ cd fuzz

3.创建一个模块来保存代码。

运行go mod init命令,给出代码的模块路径:

$ go mod init example/fuzz
go: creating new go.mod: module example/fuzz

注意:对于生产代码,你应该指定一个更适合你自己需求的模块路径。有关详细信息,请参阅管理依赖关系

接下来,你将添加一些简单的代码来反转字符串,稍后我们将对其进行模糊测试。

添加要测试的代码

在这一步中,你将添加一个函数来反转字符串。

编写代码

1.使用文本编辑器,在fuzz目录中创建一个名为main.go的文件。

2.在main.go文件顶部,粘贴以下包声明:

package main

独立程序(与库相对)始终位于main包中。

3.在包声明下面,粘贴以下函数声明:

func Reverse(s string) string {
    b := []byte(s)
    for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
        b[i], b[j] = b[j], b[i]
    }
    return string(b)
}

此函数将接受一个字符串,一次循环一个字节,最后返回反转后的字符串。

:此代码基于golang.org/x/example中的stringutil.Revirse函数。

4.在main.go的顶部,在包声明的下面,粘贴以下main函数来初始化字符串,反转它,打印输出,然后重复:

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev := Reverse(input)
    doubleRev := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q\n", rev)
    fmt.Printf("reversed again: %q\n", doubleRev)
}

此函数将运行一些反转字符串的操作,然后将输出打印到命令行。这有助于查看运行中的代码,并可能有助于调试。

5.main函数使用fmt包,因此需要导入它。 第一行代码应该如下所示:

package main

import "fmt"

运行代码

在包含main.go的目录的命令行中,运行代码:

$ go run .
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"

你可以看到原始字符串,反转后的结果,然后再次反转的结果,这等于原始字符串。

现在代码已经能够运行,是时候对其进行测试了。

添加一个单元测试

在这一步中,你将为Reverse函数编写一个基本的单元测试。

编写代码

1.使用文本编辑器,在fuzz目录中创建一个名为reverse_test.go的文件。

2.将以下代码粘贴到reverse_test.go中:

package main

import (
    "testing"
)

func TestReverse(t *testing.T) {
    testcases := []struct {
        in, want string
    }{
        {"Hello, world", "dlrow ,olleH"},
        {" ", " "},
        {"!12345", "54321!"},
    }
    for _, tc := range testcases {
        rev := Reverse(tc.in)
        if rev != tc.want {
                t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
}

这个简单的测试将断言列出的输入字符串将被正确地反转。

运行代码

使用go test运行单元测试:

$ go test
PASS
ok      example/fuzz  0.013s

接下来,你将把单元测试更改为模糊测试。

添加一个模糊测试

单元测试有局限性,即每个输入都必须由开发人员添加到测试中。模糊测试的一个好处是,它为你的代码提供输入,并可能识别出你给出的测试用例没有达到的边缘条件。

在本节中,你将把单元测试转换为模糊测试,这样你就可以用更少的工作生成更多的输入!

请注意,你可以将单元测试、基准测试和模糊测试放在同一个*_test.go文件中,但在本例中,你将把单元测试转换为模糊测试。

编写代码

在文本编辑器中,将reverse_test.go中的单元测试替换为以下模糊测试:

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // 使用f.Add函数提供一个种子测试用例
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

模糊测试也有一些局限性。在单元测试中,你可以预测Reverse函数的预期输出,并验证实际输出是否满足这些预期。

例如,在测试用例Reverse("Hello, world")中,单元测试预期其输出为"dlrow, olleH"

使用模糊测试时,你无法预测输出,因为你无法控制输入。

不过,你可以在模糊测试中验证Reverse函数的一些特性。在这个模糊测试中检查的两个属性是:

  • 将字符串反转两次后将和原始值相同。
  • 反转后的字符串的编码应该是有效的UTF-8。

请注意单元测试和模糊测试之间的语法差异:

  • 该函数以FuzzXxx而不是TestXxx开始,并且参数类型采用*testing.F而不是*test.T
  • 在你期望看到t.Run的地方,你会看到f.Fuzz,它接受一个目标函数,其参数的类型是*testing.T和要模糊测试的类型。单元测试的输入使用f.Add作为一个种子语料库提供给模糊测试。

请确保已导入新的程序包unicode/utf8

package main

import (
    "testing"
    "unicode/utf8"
)

随着单元测试转换为模糊测试,是时候再次运行测试了。

运行代码

1.在不模糊的情况下运行模糊测试,以确保种子输入通过。

$ go test
PASS
ok      example/fuzz  0.013s

如果该文件中有其他测试,并且你只希望运行模糊测试,那么你可以执行go test -run=FuzzReverse

2.运行FuzzReverse函数进行模糊测试,查看任何随机生成的字符串作为输入是否会导致该函数执行失败。这也是使用go test命令执行的,使用一个新的标志-fuzz,设置它的参数值为Fuzz

$ go test -fuzz=Fuzz

另一个有用的标志是-fuzzztime,它限制模糊测试所能花费的时间。例如,在下面的测试中指定-fuzztime 10s意味着,只要之前没有发生测试失败,测试将在10秒后退出。请参阅cmd/go文档的这一部分,以查看其他标志。

现在,运行刚才复制的命令:

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: minimizing 38-byte failing input file...
--- FAIL: FuzzReverse (0.01s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd"

    Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
    To re-run:
    go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
FAIL
exit status 1
FAIL    example/fuzz  0.030s

模糊测试时如果发生测试失败,导致问题的输入就会被写入种子语料库文件,该文件将在下次调用go test时运行,即使没有使用-fuzz标志。要查看导致测试失败的输入,请在文本编辑器中打开testdata/fuzz/FuzzReverse目录里的语料库文件。种子语料库文件可能包含不同的字符串,但格式是一样的。

go test fuzz v1
string("泃")

语料库文件的第一行指示编码版本。下面的每一行表示组成语料库条目的每种类型的值。由于本例模糊测试的目标只接受1个输入,因此版本号之后只有1个值。

3.在没有使用-fuzz标志的情况下再次运行go test;将使用新的导致测试失败的种子语料库条目:

$ go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a (0.00s)
        reverse_test.go:20: Reverse produced invalid string
FAIL
exit status 1
FAIL    example/fuzz  0.016s

既然我们的测试失败了,是时候调试了。

修复两个Bug

修复非法字符串Bug

在本节中,你将调试故障并修复Bug。

在继续之前,你可以花一些时间思考这个问题,并尝试自己解决这个问题。

诊断这个错误

有几种不同的方法可以调试此错误。如果使用VS代码作为文本编辑器,则可以设置调试器进行调查。

在本教程中,我们将把有用的调试信息记录到终端。 首先,考虑utf8.ValidString的文档:

ValidString reports whether s consists entirely of valid UTF-8-encoded runes.

当前的Reverse函数逐字节反转字符串,这就是我们的问题所在。为了保留原始字符串的UTF-8编码的字符,我们必须逐字符反转字符串。

检查为什么输入(在本例是汉字“泃”) 导致反转时Reverse生成一个无效字符串,你可以检查反转后的字符串中的字符个数。

编写代码

在文本编辑器中,用以下内容替换FuzzReverse函数中的模糊测试的目标函数:

f.Fuzz(func(t *testing.T, orig string) {
    rev := Reverse(orig)
    doubleRev := Reverse(rev)
    t.Logf("Number of runes: orig=%d, rev=%d, doubleRev=%d", utf8.RuneCountInString(orig), utf8.RuneCountInString(rev), utf8.RuneCountInString(doubleRev))
    if orig != doubleRev {
        t.Errorf("Before: %q, after: %q", orig, doubleRev)
    }
    if utf8.ValidString(orig) && !utf8.ValidString(rev) {
        t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
    }
})

如果发生错误,或者使用-v执行测试,则此t.Logf将打印日志信息到命令行,这可以帮助你调试此特定问题。

运行代码

使用go test运行测试:

$ go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
        reverse_test.go:16: Number of runes: orig=1, rev=3, doubleRev=1
        reverse_test.go:21: Reverse produced invalid UTF-8 string "\x83\xb3\xe6"
FAIL
exit status 1
FAIL    example/fuzz    0.598s

整个种子语料库使用的字符串,其中每个字符都是一个字节。但是,诸如“泃”字符可能占用几个字节。因此,逐字节反转字符串将使多字节字符无效。

注意:如果你对Go如何处理字符串感到好奇,请阅读博客文章《Go中的字符串、字节、rune和字符》,以获得更加深入的理解。

对上述Bug有了更好的了解后,请你更正Reverse函数中的错误。

修复Bug

要更正Reverse函数,让我们按rune而不是按字节遍历字符串。

编写代码

在文本编辑器中,将现有的Reverse函数替换为以下函数:

func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

关键的区别在于,Reverse现在迭代字符串中的每个符文,而不是每个字节。

运行代码

1.使用go test运行测试

$ go test
PASS
ok      example/fuzz  0.016s

现在测试通过了!

2.用go test -fuzz再次模糊测试它,看看是否有任何新的bug

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/37 completed
fuzz: minimizing 506-byte failing input file...
fuzz: elapsed: 0s, gathering baseline coverage: 5/37 completed
--- FAIL: FuzzReverse (0.02s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:33: Before: "\x91", after: "�"

    Failing input written to testdata/fuzz/FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
    To re-run:
    go test -run=FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
FAIL
exit status 1
FAIL    example/fuzz  0.032s

我们可以看到,经过两次反转后,字符串与原来的字符串不同。这一次输入本身就是无效的unicode。这怎么可能发生呢?

让我们再次调试。

修复两次逆转字符串产生的Bug

在本节中,你将调试两次逆转字符串产生的Bug并修复该Bug。

在继续之前,你可以花一些时间思考这个问题,并尝试自己解决这个问题。

诊断这个错误

和以前一样,有几种方法可以调试此Bug。使用调试器(debugger)将是一种很好的方法。

在本教程中,我们将在Reverse函数中记录有用的调试信息。 仔细观察反转的字符串以发现此Bug。在Go中,一个字符串是一个只读的字节切片(a string is a read only slice of bytes),可以包含非UTF-8的字节。原始字符串是一个带有一个字节“\x91”的字节切片。当输入字符串被转换为[]rune时,Go将字节切片编码为UTF-8字符切片,并将第一个字节“\x91”替换为一个UTF-8字符。当我们将这个UTF-8字符与原始字节切片进行比较时,它们显然不相等。

编写代码

1.在文本编辑器中,将Reverse函数替换为以下内容。

func Reverse(s string) string {
    fmt.Printf("input: %q\n", s)
    r := []rune(s)
    fmt.Printf("runes: %q\n", r)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

这将帮助我们了解在将字符串转换为rune切片时出现的问题。

运行代码

这一次,我们只想运行失败的测试来检查日志。为此,我们将使用go test -run。 要在FuzzXxx/testdata中运行特定的语料库条目,可以提供{FuzzTestName}/{filename}来运行。这在调试时很有帮助。在这种情况下,将-run标志设置为等于测试失败的测试用例的散列值。从你的终端复制并粘贴唯一的散列值,它可能与下面的不同:

$ go test -run=FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0
input: "\x91"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
        reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
        reverse_test.go:18: Before: "\x91", after: "�"
FAIL
exit status 1
FAIL    example/fuzz    0.145s

我们知道了输入是无效的unicode字符,让我们修复Reverse函数中的Bug。

修复Bug

为了解决这个问题,如果Reverse的输入不是有效的UTF-8编码的字符串,我们就返回一个错误。

编写代码

1.在文本编辑器中,用以下内容替换现有的Reverse函数。

func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

如果输入的字符串包含无效的UTF-8字符,则返回一个错误。

2.由于Reverse函数现在返回一个错误,修改main函数的代码以丢弃额外的错误值。

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev, revErr := Reverse(input)
    doubleRev, doubleRevErr := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
    fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}

这些对Reverse函数的调用应该返回nil错误(没有错误发生),因为输入的字符串是有效的UTF-8编码。

3.将需要导入errorsunicode/utf8包。main.go中的import语句应该如下所示。

import (
    "errors"
    "fmt"
    "unicode/utf8"
)

4.修改reverse_test.go文件以检查错误,如果Reverse函数返回了一个错误,则跳过本次测试。

func FuzzReverse(f *testing.F) {
    testcases := []string {"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err1 := Reverse(orig)
        if err1 != nil {
            return
        }
        doubleRev, err2 := Reverse(rev)
        if err2 != nil {
             return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

如果不想用直接return的话,你也可以调用t.Skip()函数来停止执行本次模糊测试。

运行代码

1.使用go test运行测试

$ go test
PASS
ok      example/fuzz  0.019s

2.用go test -fuzz=Fuzz运行模糊测试,然后几秒钟后,用组合键ctrl-C停止模糊测试。模糊测试将一直运行,直到遇到失败的输入,或者你通过-fuzztime标志指定最长运行时间。默认情况下,如果没有发生测试失败,模糊测试将永远运行下去,但可以使用组合键ctrl-C中断进程。

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/38 completed
fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 86342 (28778/sec), new interesting: 2 (total: 35)
fuzz: elapsed: 6s, execs: 193490 (35714/sec), new interesting: 4 (total: 37)
fuzz: elapsed: 9s, execs: 304390 (36961/sec), new interesting: 4 (total: 37)
...
fuzz: elapsed: 3m45s, execs: 7246222 (32357/sec), new interesting: 8 (total: 41)
^Cfuzz: elapsed: 3m48s, execs: 7335316 (31648/sec), new interesting: 8 (total: 41)
PASS
ok      example/fuzz  228.000s

3.使用go test -fuzz=Fuzz -fuzztime 30s,如果没有遇到测试失败的用例,总共将进行30秒时间的模糊测试。

$ go test -fuzz=Fuzz -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 80290 (26763/sec), new interesting: 12 (total: 12)
fuzz: elapsed: 6s, execs: 210803 (43501/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 9s, execs: 292882 (27360/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 12s, execs: 371872 (26329/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 15s, execs: 517169 (48433/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 18s, execs: 663276 (48699/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 21s, execs: 771698 (36143/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 24s, execs: 924768 (50990/sec), new interesting: 16 (total: 16)
fuzz: elapsed: 27s, execs: 1082025 (52427/sec), new interesting: 17 (total: 17)
fuzz: elapsed: 30s, execs: 1172817 (30281/sec), new interesting: 17 (total: 17)
fuzz: elapsed: 31s, execs: 1172817 (0/sec), new interesting: 17 (total: 17)
PASS
ok      example/fuzz  31.025s

模糊测试通过了!

除了-fuzz标志之外,还添加了几个新的标志,可以在文档中查看。

有关模糊测试输出中使用的术语的更多信息,请参阅文章Go模糊测试。例如,“new interesting”指的是扩展了代码覆盖率的已存在的模糊测试语料库的输入。在模糊测试的一开始,“new interesting”输入的数量会急剧增加,随着新的代码路径的发现,“new interesting”输入的数量还会激增几次,然后随着时间的推移逐渐减少。

结论

得好!你刚刚向自己介绍了Go中的模糊测试。

下一步是在代码中选择一个你想模糊测试的函数,并尝试它!如果模糊测试在你的代码中发现了一个错误,请考虑将其添加到trophy case(译者注:向别人炫耀你发现的Go标准库Bug)中。

如果你遇到任何问题或对某个功能有想法,请在此提交问题

对于有关模糊测试的功能的讨论和反馈,你也可以加入Gophers Slack中的#fuzzing频道。 请查看go.dev/security/fuzz上的文档以获得进一步的阅读。

完整的代码

以下是本文中出现过的全部代码:

— main.go —

package main

import (
    "errors"
    "fmt"
    "unicode/utf8"
)

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev, revErr := Reverse(input)
    doubleRev, doubleRevErr := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
    fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}

func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

— reverse_test.go —

package main

import (
    "testing"
    "unicode/utf8"
)

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc) // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err1 := Reverse(orig)
        if err1 != nil {
            return
        }
        doubleRev, err2 := Reverse(rev)
        if err2 != nil {
            return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

教程:使用Go和Gin开发RESTful API

本文翻译自《Tutorial: Developing a RESTful API with Go and Gin》。

目录

先决条件

设计API端点

为代码创建文件夹

创建数据

编写处理程序以返回所有条目

编写处理程序以添加一个新条目

编写处理程序以返回一个特定条目

结论

全部代码

本教程介绍了使用Go和Web开发框架Gin编写RESTful web服务API的基本知识。

如果你对Go及其工具有基本的熟悉,你将从本教程中获得最大的收获。如果这是你第一次接触Go,请先参阅教程:Go快速入门

Gin简化了许多与构建web应用程序(包括web服务)相关的编程任务。在本教程中,你将使用Gin来路由请求、检索请求详细信息,并为发送JSON响应。

在本教程中,你将构建一个具有两个端点的RESTful API服务器。你的示例项目将是一个关于老式爵士乐唱片的数据存储库。

本教程包括以下部分:

1 设计API端点。

2 为代码创建一个文件夹。

3 创建数据。

4 编写一个处理程序以返回所有条目。

5 编写一个处理程序来添加一个新条目。

6 编写一个处理程序以返回一个特定条目。

先决条件

  • 安装Go 1.16或更高版本。有关安装说明,请参阅安装Go
  • 用于编辑代码的工具。你拥有的任何文本编辑器都可以正常工作。
  • 一种命令行终端。Go在Linux和Mac上使用任何终端都能很好地工作,以及Windows中的PowerShell或CMD。
  • curl程序。在Linux和Mac上,应该已经安装了。在Windows上,它包含在Windows 10 Insider版本17063及更高版本中。对于早期的Windows版本,你可能需要安装它。有关更多信息,请参阅Tar and Curl Come to Windows

设计API端点

你将建立一个API,提供对一家出售老式黑胶唱片的商店的访问。因此,你需要提供API端点,用户可以通过客户端访问这些端点来获取和添加相册。

在开发API时,通常从设计端点开始。如果端点易于理解,将方便API的用户使用。

以下是你将在本教程中创建的API端点:

/albums

  • GET–获取所有相册的列表,以JSON形式返回。
  • POST–以JSON形式发送的请求数据,添加一个新相册。

/albums/:id

  • GET–通过相册ID获取相册,并以JSON形式返回相册数据。

为代码创建文件夹

首先,为你要编写的代码创建一个项目。

1 打开命令行终端并转到家目录。 在Linux或Mac上:

$ cd

在Windows上:

C:\> cd %HOMEPATH%

2 使用命令行终端,为代码创建一个名为web-service-gin的目录:

$ mkdir web-service-gin
$ cd web-service-gin

3 创建一个可以在其中管理依赖关系的模块。

运行go mod init命令,为其提供代码所在模块的路径:

$ go mod init example/web-service-gin
go: creating new go.mod: module example/web-service-gin

此命令创建一个go.mod文件,你添加的依赖项将列在该文件中进行跟踪。有关使用路径命名模块的详细信息,请参阅管理依赖关系

接下来,你将设计用于处理数据的数据结构。

创建数据

为了简化教程,你将把数据存储在内存中。更典型的API将与数据库交互。

请注意,将数据存储在内存中意味着每次停止服务器时,相册相关数据都会丢失,然后在启动服务器时重新创建。

编写代码

1 使用文本编辑器,在web-service-gin目录中创建一个名为main.go的文件。你将在该文件中编写Go代码。

2 在文件顶部的main.go中,粘贴以下包声明。

package main

独立可运行的程序(与库相对)始终位于main包中。

3 在包声明下面,粘贴album结构体的以下声明。你将使用它将相册数据存储在内存中。

代码中的结构体标记(json:"artist"等)指定在将结构体的内容序列化为JSON时,字段的名称如何转换。如果没有它们,JSON将使用结构体的大写的字段名——这种风格在JSON中并不常见。

// album represents data about a record album.
type album struct {
    ID     string  `json:"id"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

在刚添加的结构体声明下面,粘贴下面的album结构体片段,其中包含将启动你的项目的数据。

// albums slice to seed record album data.
var albums = []album{
    {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
    {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
    {ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
}

接下来,你将编写代码来实现你的第一个API端点。

编写处理程序以返回所有条目

当客户端在GET /albums上发出请求时,你希望以JSON的形式返回所有的相册信息。

为此,你将编写以下代码:

  • 响应的逻辑
  • 将请求路径映射到响应的逻辑

但你首先要添加依赖项,然后添加依赖于它们的代码。

编写代码

1 在上一节中添加的结构体代码下面,粘贴以下代码以获得相册信息的列表。

这个getAlbums函数从相册结构体的切片albums创建JSON,并将JSON写入响应。

// getAlbums以JSON格式的数据响应一个相册信息的列表。
func getAlbums(c *gin.Context) {
    c.IndentedJSON(http.StatusOK, albums)
}

在此代码中,你:

  • 编写了一个接受gin.Context参数的getAlbums函数。请注意,你可以给这个函数起任何名称——Gin和Go都不需要特定的函数名称格式。

gin.Context是Gin最重要的组成部分。它携带请求的详细信息、验证和序列化JSON等。(尽管名称相似,但这与Go内置的context包不同。)

该函数的第一个参数是要发送到客户端的HTTP状态代码。在这里,你传递net/http包的StatusOK常量,表示200 OK这一HTTP状态代码。

请注意,你可以将Context.IndetedJSON函数替换为Context.JSON函数,以发送更紧凑的JSON数据。在实践中,缩进形式的JSON数据在调试时更具可读性,而且也不会比紧凑的JSON数据大很多。

2 在main.go顶部附近的albums切片声明下方,粘贴下面的代码,将处函数分配给API端点。

这设置了一个关联,getAlbums函数处理对/albums路径的请求。

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)

    router.Run("localhost:8080")
}

在此代码中,你:

  • 使用Default函数初始化Gin路由器。
  • 使用GET函数将GET HTTP方法和/albums路径与处理函数相关联。

请注意,你传递的是getAlbums函数的名称。这与传递函数的结果不同,传递函数的结果是传递getAlbums()(注意括号)。

  • 使用Run函数将路由器关联到一个http服务器并启动服务器。

3 在main.go的顶部,就在包声明的下方,导入用到的包。

第一行代码应该如下所示:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

4 保存main.go文件。

运行代码

1 引入Gin模块作为依赖项。 在命令行中,使用go get添加github.com/gin-gonic/gin模块作为你的example/web-service-gin模块的依赖项。使用句点参数表示“下载当前目录中代码的所有依赖项”:

$ go get .
go get: added github.com/gin-gonic/gin v1.7.2

Go解析并下载依赖项,以满足你在上一步中添加的import声明。

2 在包含main.go文件的目录的命令行中,运行代码。使用句点参数表示“在当前目录中运行代码”:

$ go run .

一旦代码运行,你就有了一个正在运行的HTTP服务器,可以向其发送请求。

3 在一个新的命令行窗口中,使用curl工具向正在运行的web服务发出请求。

$ curl http://localhost:8080/albums

将会返回以下JSON格式的数据:

[
        {
                "id": "1",
                "title": "Blue Train",
                "artist": "John Coltrane",
                "price": 56.99
        },
        {
                "id": "2",
                "title": "Jeru",
                "artist": "Gerry Mulligan",
                "price": 17.99
        },
        {
                "id": "3",
                "title": "Sarah Vaughan and Clifford Brown",
                "artist": "Sarah Vaughan",
                "price": 39.99
        }
]

你已经启动了一个API!在下一节中,你将使用代码创建另一个API端点,以处理添加一个信息条目的POST请求。

编写处理程序以添加一个新条目

当客户端在/albums上发出POST请求时,你希望将请求正文中描述的相册信息添加到现有的数据中。

为此,你将编写以下内容:

  • 将一条新相册的信息添加到现有列表里的一段代码。
  • 将POST请求路由到你的上述代码的一段代码。

编写代码

1 添加代码以将相册信息数据添加到相册列表中。

import语句之后的某个位置,粘贴以下代码。(文件的末尾是粘贴这段代码的好位置,但Go并没有强制函数的声明顺序。)

// postAlbums函数从请求体中获取JSON数据添加一条相册信息数据。
func postAlbums(c *gin.Context) {
    var newAlbum album

    // 调用BindJSON函数把接收到的JSON数据转换为newAlbum。
    if err := c.BindJSON(&newAlbum); err != nil {
        return
    }

    // 把newAlbum添加到albums列表里。
    albums = append(albums, newAlbum)
    c.IndentedJSON(http.StatusCreated, newAlbum)
}

在此代码中,你:

  • 使用Context.BindJSON函数将请求正文绑定到newAlbum结构体变量。
  • 将从JSON数据转换得到的album结构体变量添加到albums切片。
  • 在响应中添加一个201状态代码,以及表示你成功创建一条新相册信息数据。

2 更改main函数,使用router.POST函数添加路由,如下所示:

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

在此代码中,你:

  • /albums路径上的POST方法与postAlbums函数相关联。

使用Gin,你可以将处理程序与HTTP方法和API路径相关联。通过这种方式,你可以根据客户端使用的HTTP方法将发送到某个API路径的请求单独路由到某个处理函数。

运行代码

1 如果服务器仍在运行,请停止它。

2 在包含main.go的目录的命令行中,运行代码:

$ go run .

3 从另一个命令行窗口,使用curl工具向正在运行的web服务发出请求:

$ curl http://localhost:8080/albums \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"id": "4","title": "The Modern Sound of Betty Carter","artist": "Betty Carter","price": 49.99}'

该命令运行后应该会显示添加的相册信息的JSON数据和HTTP响应状态数据:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Wed, 02 Jun 2021 00:34:12 GMT
Content-Length: 116

{
    "id": "4",
    "title": "The Modern Sound of Betty Carter",
    "artist": "Betty Carter",
    "price": 49.99
}

4 与上一节一样,使用curl工具检索相册信息的完整列表,你可以使用该列表来确认是否添加了一个新相册的信息:

$ curl http://localhost:8080/albums \
    --header "Content-Type: application/json" \
    --request "GET"

该命令会显示相册信息的列表:

[
        {
                "id": "1",
                "title": "Blue Train",
                "artist": "John Coltrane",
                "price": 56.99
        },
        {
                "id": "2",
                "title": "Jeru",
                "artist": "Gerry Mulligan",
                "price": 17.99
        },
        {
                "id": "3",
                "title": "Sarah Vaughan and Clifford Brown",
                "artist": "Sarah Vaughan",
                "price": 39.99
        },
        {
                "id": "4",
                "title": "The Modern Sound of Betty Carter",
                "artist": "Betty Carter",
                "price": 49.99
        }
]

在下一节中,你将添加代码来处理对特定一个相册信息的GET请求。

编写处理程序以返回一个特定条目

当客户端请求GET /albums/[id]时,你希望返回ID值与id路径参数匹配的相册的信息。

为此,你将:

  • 添加代码以检索请求的相册的信息数据。
  • 将API路径映射到上述代码。

编写代码

1 在上一节中添加的postAlbums函数下面,粘贴以下代码以检索特定的相册。

getAlbumByID函数将提取请求路径中的id,然后查找匹配的相册的信息。

// getAlbumByID函数使用客户端发送过来的id参数定位并返回相册数据作为响应。
func getAlbumByID(c *gin.Context) {
    id := c.Param("id")

    // 循环遍历albums列表,查找匹配id参数的album。
    for _, a := range albums {
        if a.ID == id {
            c.IndentedJSON(http.StatusOK, a)
            return
        }
    }
    c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
}

在这段代码中,你:

  • 使用Context.Param从URL中检索id路径参数。将此处理程序映射到API路径时,将在API路径中包含id参数的占位符。
  • 循环遍历albums切片中的album结构体变量,查找ID字段值与id参数值匹配的那个结构体。如果找到了,则将该album序列化为JSON,并将其作为响应返回,并返回一个200 OK的HTTP状态码。

如上所述,真实世界的服务可能会使用数据库查询来执行此查找。

2 最后,更改main函数,添加一个router.GET路由到getAlbumByID函数,其中的API路径是/albums/:id,如以下所示:

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.GET("/albums/:id", getAlbumByID)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

在此代码中,你:

  • /albums/:id这个API路径与getAlbumByID函数相关联。在Gin中,API路径中前面的冒号表示这个条目是一个路径参数。

运行代码

1 停止运行之前的程序。

2 在包含main.go的目录的命令行中,运行代码:

$ go run .

3 从另一个命令行窗口,使用curl工具向正在运行的web服务发出请求:

$ curl http://localhost:8080/albums/2

该命令执行后应该显示你给出ID的相册信息的JSON数据:

{
        "id": "2",
        "title": "Jeru",
        "artist": "Gerry Mulligan",
        "price": 17.99
}

如果找不到对应的相册信息,你将获得带有错误消息的JSON。

结论

恭喜你刚刚使用Go和Gin编写了一个简单的RESTful web服务。

建议的下一个主题:

全部代码

本节包含使用本教程构建的应用程序的全部代码:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

// album represents data about a record album.
type album struct {
    ID     string  `json:"id"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

// albums slice to seed record album data.
var albums = []album{
    {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
    {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
    {ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
}

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.GET("/albums/:id", getAlbumByID)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

// getAlbums responds with the list of all albums as JSON.
func getAlbums(c *gin.Context) {
    c.IndentedJSON(http.StatusOK, albums)
}

// postAlbums adds an album from JSON received in the request body.
func postAlbums(c *gin.Context) {
    var newAlbum album

    // Call BindJSON to bind the received JSON to
    // newAlbum.
    if err := c.BindJSON(&newAlbum); err != nil {
        return
    }

    // Add the new album to the slice.
    albums = append(albums, newAlbum)
    c.IndentedJSON(http.StatusCreated, newAlbum)
}

// getAlbumByID locates the album whose ID value matches the id
// parameter sent by the client, then returns that album as a response.
func getAlbumByID(c *gin.Context) {
    id := c.Param("id")

    // Loop through the list of albums, looking for
    // an album whose ID value matches the parameter.
    for _, a := range albums {
        if a.ID == id {
            c.IndentedJSON(http.StatusOK, a)
            return
        }
    }
    c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
}

Go文档注释

本文翻译自《Go Doc Comments》。

“文档注释(Doc Comments)”是指直接出现在顶级(译者注:包级作用域(全局作用域),非块级作用域(非局部作用域))的package、const、func、type和var声明之前的注释,其中没有换行符。每个导出的(大写字母开头的,公有的)名称都应该有一个文档注释。

go/docgo/doc/comment包提供了从go源代码中提取文档的能力,各种工具都利用了这一功能。go doc命令查找并打印给定包或符号(symbol)的文档注释。(符号是顶级的const、func、type或var。)web服务器pkg.go.dev显示公开的go包的文档(当其许可证允许时)。为该网站提供服务的程序是golang.org/x/pkgsite/cmd/pkgsite,它也可以在本地运行以查看私有模块的文档,也可以在没有互联网连接的情况下运行。语言服务器gols能在IDE中编辑Go源文件时提供文档。

本页的其余部分介绍了如何编写Go文档注释。

包(Packages)

每个包都应该有一个介绍自己的包注释。它提供了与整个包相关的信息,并通常设定了对这个包的期望。特别是在大型软件包中,包注释可以简要概述API的最重要部分,并根据需要链接到其他文档注释。

如果包本身很简单,那么包注释可以很简短。例如:

// Package path implements utility routines for manipulating slash-separated
// paths.
//
// The path package should only be used for paths separated by forward
// slashes, such as the paths in URLs. This package does not deal with
// Windows paths with drive letters or backslashes; to manipulate
// operating system paths, use the [path/filepath] package.
package path

[path/filepath]中的方括号创建了一个文档链接

从这个例子中可以看出,Go文档注释使用了完整的句子。对于包注释,这意味着第一句话以“Package”开头。

对于多文件包,包注释应该仅放在一个源文件中。如果多个文件都具有包注释,那么会将它们连接起来,形成整个包的一个大注释。

命令(Commands)

命令的包注释类似,但它描述的是程序的行为,而不是包中的Go语法符号。第一句通常以程序本身的名称开头,因为它位于句子的开头,所以要首字母大写。例如,以下是gofmt的包注释的删节版本:

/*
Gofmt formats Go programs.
It uses tabs for indentation and blanks for alignment.
Alignment assumes that an editor is using a fixed-width font.

Without an explicit path, it processes the standard input. Given a file,
it operates on that file; given a directory, it operates on all .go files in
that directory, recursively. (Files starting with a period are ignored.)
By default, gofmt prints the reformatted sources to standard output.

Usage:

    gofmt [flags] [path ...]

The flags are:

    -d
        Do not print reformatted sources to standard output.
        If a file's formatting is different than gofmt's, print diffs
        to standard output.
    -w
        Do not print reformatted sources to standard output.
        If a file's formatting is different from gofmt's, overwrite it
        with gofmt's version. If an error occurred during overwriting,
        the original file is restored from an automatic backup.

When gofmt reads from standard input, it accepts either a full Go program
or a program fragment. A program fragment must be a syntactically
valid declaration list, statement list, or expression. When formatting
such a fragment, gofmt preserves leading indentation as well as leading
and trailing spaces, so that individual sections of a Go program can be
formatted by piping them through gofmt.
*/
package main

注释的开头一段是使用有语义的换行符(译者注:对go doc和pkgsite等从注释生成文档的工具来说,这种换行符具有一定的语义)编写的,其中每个新句子或长短语都在一个新行上,这可以使差异随着代码和注释的发展而更容易阅读。后面的段落恰好没有遵循这一惯例,不必须一个句子占一行,可以从任何地方换行,只要满足可读性。无论哪种方式,go doc和pkgsite都可以在打印输出时重写文档注释的文本。例如:

$ go doc gofmt
Gofmt formats Go programs. It uses tabs for indentation and blanks for
alignment. Alignment assumes that an editor is using a fixed-width font.

Without an explicit path, it processes the standard input. Given a file, it
operates on that file; given a directory, it operates on all .go files in that
directory, recursively. (Files starting with a period are ignored.) By default,
gofmt prints the reformatted sources to standard output.

Usage:

    gofmt [flags] [path ...]

The flags are:

    -d
        Do not print reformatted sources to standard output.
        If a file's formatting is different than gofmt's, print diffs
        to standard output.
...

带缩进的行被视为预格式化的文本:它们不会被重写,而是在HTML和Markdown演示中以代码字体打印。(详见下文的“语法(Syntax)”小节。)

类型(Types)

类型的文档注释应该解释该类型的每个实例代表或提供了什么。如果API很简单,文档注释可以很短。例如:

package zip

// A Reader serves content from a ZIP archive.
type Reader struct {
    ...
}

默认情况下,程序员应该期望一个类型是并发安全的,即一次只能由单个goroutine使用。如果一个类型提供了更强的保证,文档注释应该说明这些保证。例如:

package regexp

// Regexp is the representation of a compiled regular expression.
// A Regexp is safe for concurrent use by multiple goroutines,
// except for configuration methods, such as Longest.
type Regexp struct {
    ...
}

Go类型还应该让零值具有特定意思。如果不显而易见,那么应该在文档注释里记录零值的含义。例如:

package bytes

// A Buffer is a variable-sized buffer of bytes with Read and Write methods.
// The zero value for Buffer is an empty buffer ready to use.
type Buffer struct {
    ...
}

对于具有导出的(公有的)字段的结构体,文档注释或每个字段的注释都应解释每个公有的字段的含义。例如,以下类型的文档注释解释了它的字段的含义:

package io

// 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.
type LimitedReader struct {
    R   Reader // underlying reader
    N   int64  // max bytes remaining
}

相反,以下类型的文档注释将解释留给每个字段的注释:

package comment

// A Printer is a doc comment printer.
// The fields in the struct can be filled in before calling
// any of the printing methods
// in order to customize the details of the printing process.
type Printer struct {
    // HeadingLevel is the nesting level used for
    // HTML and Markdown headings.
    // If HeadingLevel is zero, it defaults to level 3,
    // meaning to use <h3> and ###.
    HeadingLevel int
    ...
}

与包(上节)和函数(下节)一样,类型的文档注释以类型标识符的一个完整句子开头。明确的主题通常会使措辞更清晰,也会使文本更容易搜索,无论是在网页上还是在命令行上。例如:

$ go doc -all regexp | grep pairs
pairs within the input string: result[2*n:2*n+2] identifies the indexes
    FindReaderSubmatchIndex returns a slice holding the index pairs identifying
    FindStringSubmatchIndex returns a slice holding the index pairs identifying
    FindSubmatchIndex returns a slice holding the index pairs identifying the
$

函数(Funcs)

一个函数的文档注释应该解释它返回的内容,如果被调用会产生副作用,也应该解释它的作用。参数的名称或结果的名称可以在注释中直接引用,而无需任何特殊语法(例如反引号)。(这种惯例的一个后果是,我们通常要避免函数名、参数名和结果名使用像a这样可能被误认为是普通单词的名称。)例如:

package strconv

// Quote returns a double-quoted Go string literal representing s.
// The returned string uses Go escape sequences (\t, \n, \xFF, \u0100)
// for control characters and non-printable characters as defined by IsPrint.
func Quote(s string) string {
    ...
}

package os

// Exit causes the current program to exit with the given status code.
// Conventionally, code zero indicates success, non-zero an error.
// The program terminates immediately; deferred functions are not run.
//
// For portability, the status code should be in the range [0, 125].
func Exit(code int) {
    ...
}

如果一个文档注释需要解释多个返回结果,那么给每个返回结果取一个名字可以使文档注释更容易理解,即使函数体中没有用到这些名字。例如:

package io

// Copy copies from src to dst until either EOF is reached
// on src or an error occurs. It returns the total number of bytes
// written and the first error encountered while copying, if any.
//
// A successful Copy returns err == nil, not err == EOF.
// Because Copy is defined to read from src until EOF, it does
// not treat an EOF from Read as an error to be reported.
func Copy(dst Writer, src Reader) (n int64, err error) {
    ...
}

相反,当返回结果无需在文档注释中给出一个名字进行描述时,通常也不会在代码中给出一个名字,就像上面的Quote示例一样,以避免造成混淆。

这些规则都适用于普通函数和方法。对于方法,在列出一个类型的所有方法时,使用相同的接收者名称可以避免不必要的变化:

$ go doc bytes.Buffer
package bytes // import "bytes"

type Buffer struct {
    // Has unexported fields.
}
    A Buffer is a variable-sized buffer of bytes with Read and Write methods.
    The zero value for Buffer is an empty buffer ready to use.

func NewBuffer(buf []byte) *Buffer
func NewBufferString(s string) *Buffer
func (b *Buffer) Bytes() []byte
func (b *Buffer) Cap() int
func (b *Buffer) Grow(n int)
func (b *Buffer) Len() int
func (b *Buffer) Next(n int) []byte
func (b *Buffer) Read(p []byte) (n int, err error)
func (b *Buffer) ReadByte() (byte, error)
...

这个例子还显示了返回类型T或指针*T的公有的函数,可能带有额外的error返回结果,在假设它们是T的构造函数的情况下,与类型T及其方法一起展示。

默认情况下,程序员可以认为同时从多个goroutine里调用公有的函数是安全的;这一事实无需明确说明。

另一方面,如前一节所述,以任何方式使用类型的实例,包括调用方法,通常被认为一次只能在一个goroutine里使用。如果没有在类型的文档注释中写明其方法是并发安全的,那么应该在每个方法的注释中说明。例如:

package sql

// Close returns the connection to the connection pool.
// All operations after a Close will return with ErrConnDone.
// Close is safe to call concurrently with other operations and will
// block until all other operations finish. It may be useful to first
// cancel any used context and then call Close directly after.
func (c *Conn) Close() error {
    ...
}

请注意,函数和方法的注释主要关注操作返回什么结果或执行什么内容,应该详细说明调用方需要知道的内容。特殊案例对文档可能特别重要。例如:

package math

// Sqrt returns the square root of x.
//
// Special cases are:
//
//  Sqrt(+Inf) = +Inf
//  Sqrt(±0) = ±0
//  Sqrt(x < 0) = NaN
//  Sqrt(NaN) = NaN
func Sqrt(x float64) float64 {
    ...
}

文档注释不应解释内部细节,例如当前实现中使用的算法。这些最好写在函数体内部的注释里。当细节对调用方特别重要时,那么应该给出时间或空间的边界值。例如:

package sort

// Sort sorts data in ascending order as determined by the Less method.
// It makes one call to data.Len to determine n and O(n*log(n)) calls to
// data.Less and data.Swap. The sort is not guaranteed to be stable.
func Sort(data Interface) {
    ...
}

因为这个文档注释没有提到具体使用了哪种排序算法,所以将来容易使用不同的算法来实现。

常量(Consts)

Go的语法允许对声明进行分组,在这种情况下,一个文档注释可以解释说明一组相关的常量,而单个常量只能通过单行注释进行记录。例如:

package scanner // import "text/scanner"

// The result of Scan is one of these tokens or a Unicode character.
const (
    EOF = -(iota + 1)
    Ident
    Int
    Float
    Char
    ...
)

有时一组常量根本不需要文档注释。例如:

package unicode // import "unicode"

const (
    MaxRune         = '\U0010FFFF' // maximum valid Unicode code point.
    ReplacementChar = '\uFFFD'     // represents invalid code points.
    MaxASCII        = '\u007F'     // maximum ASCII value.
    MaxLatin1       = '\u00FF'     // maximum Latin-1 value.
)

另一方面,未加入到组里的常量通常使用以一个完整的句子开头的文档注释。例如:

package unicode

// Version is the Unicode edition from which the tables are derived.
const Version = "13.0.0"

有类型的常量显示在其类型的声明的下边,因此通常会省略它们的文档注释,而使用其类型的文档注释。例如:

package syntax

// An Op is a single regular expression operator.
type Op uint8

const (
    OpNoMatch        Op = 1 + iota // matches no strings
    OpEmptyMatch                   // matches empty string
    OpLiteral                      // matches Runes sequence
    OpCharClass                    // matches Runes interpreted as range pair list
    OpAnyCharNotNL                 // matches any character except newline
    ...
)

(有关HTML演示文稿,请参阅pkg.go.dev/regexp/syntax#Op。)

变量(Vars)

对变量的约定与对常量的约定相同。例如,这里有一组变量:

package fs

// Generic file system errors.
// Errors returned by file systems can be tested against these errors
// using errors.Is.
var (
    ErrInvalid    = errInvalid()    // "invalid argument"
    ErrPermission = errPermission() // "permission denied"
    ErrExist      = errExist()      // "file already exists"
    ErrNotExist   = errNotExist()   // "file does not exist"
    ErrClosed     = errClosed()     // "file already closed"
)

单个变量:

package unicode

// Scripts is the set of Unicode script tables.
var Scripts = map[string]*RangeTable{
    "Adlam":                  Adlam,
    "Ahom":                   Ahom,
    "Anatolian_Hieroglyphs":  Anatolian_Hieroglyphs,
    "Arabic":                 Arabic,
    "Armenian":               Armenian,
    ...
}

语法(Syntax)

Go文档注释有简单的语法,支持段落、标题、链接、列表和预格式化的代码块。为了在源代码文件中保持注释的轻量和可读,不支持更改字体或原始HTML标签等复杂语法。Markdown爱好者可以将Go文档注释语法视为Markdown语法的一个简化的子集。

可以使用代码格式化的官方程序gofmt重新格式化文档注释,以便使用规范的格式。Gofmt的目标是可读性和用户对源代码中注释编写方式的控制,但会调整表现形式,使特定注释的语义更加清晰,类似于在普通源代码中将1+2 * 3重新格式化为1 + 2*3

诸如//go:generate之类的指令注释不被视为文档注释的一部分,并且在提供的文档中被省略。Gofmt将指令注释移动到文档注释的末尾,前面有一行空行。例如:

package regexp

// An Op is a single regular expression operator.
//
//go:generate stringer -type Op -trimprefix Op
type Op uint8

指令注释是与正则表达式//(line |extern |export |[a-z0-9]+:[a-z0-9])。定义自己指令的工具应该使用//toolname:directive的形式。

Gofmt会删除文档注释的前导的空行和尾随的空行。

段落(Paragraphs)

段落是一段没有缩进的非空白行。我们已经看到了许多段落的例子。

一对连续的后引号(` U+0060)将被解释为Unicode左引号( U+201C),一对连续单引号(' U+0027)被理解为Unicode右引号( U+201D)。

Gofmt会在段落文本中保留换行符。这允许使用语义换行符,如上文所述。Gofmt用一个空行替换段落之间重复的空行。Gofmt还将连续的反引号或单引号重新格式化为Unicode版本的反引号或单引号。

标题(Headings)

标题是以数字符号(U+0023)开头的一行,然后是空格和标题文本。要被识别为标题,该行不能缩进,并用空行与相邻的段落文本隔开。

例如:

// Package strconv implements conversions to and from string representations
// of basic data types.
//
// # Numeric Conversions
//
// The most common numeric conversions are [Atoi] (string to int) and [Itoa] (int to string).
...
package strconv

更多说明:

// #This is not a heading, because there is no space.
//
// # This is not a heading,
// # because it is multiple lines.
//
// # This is not a heading,
// because it is also multiple lines.
//
// The next paragraph is not a heading, because there is no additional text:
//
// #
//
// In the middle of a span of non-blank lines,
// # this is not a heading either.
//
//     # This is not a heading, because it is indented.

#语法是在Go 1.19中添加的。在Go 1.19之前,标题是由满足某些条件的单行段落隐含地标识的,最显著的条件是没有任何终止标点符号的单行。

Gofmt将早期Go文档注释里的被视为隐式标题的行重新格式化为使用#开头的标题。如果重新格式化得不合适,也就是说,如果这一行本不是标题,那么使其成为普通段落的最简单方法是使用句点或冒号等终止符,或者将其分成两行。

文档连接(Doc links)

当一行的形式为“[Text]: URL”,并且是不缩进的非空白的行时,定义了一个链接目标。在同一文档注释中的其他文本中的“[Text]”表示对连接目标进行引用,类似于HTML中的<a href=“URL”>Text</a>。例如:

// Package json implements encoding and decoding of JSON as defined in
// [RFC 7159]. The mapping between JSON and Go values is described
// in the documentation for the Marshal and Unmarshal functions.
//
// For an introduction to this package, see the article
// “[JSON and Go].”
//
// [RFC 7159]: https://tools.ietf.org/html/rfc7159
// [JSON and Go]: https://golang.org/doc/articles/json_and_go.html
package json

通过将URL的声明保留在一个单独的小节中,这种格式只会在最小程度上中断实际文本流。它类似于Markdown的引用超链接的快捷方式的格式,但没有可选的标题文本。

如果没有相应的URL声明,那么(除了文档链接,将在下一节中介绍)“[Text]”不是超链接,并且在显示时保留方括号。每个文档注释都是独立考虑的:一个注释中的链接目标的定义不会影响其他注释。

尽管声明链接的块可能与普通段落交错,gofmt会将所有链接声明块移动到文档注释的末尾,最多分为两个块:第一块包含在注释中引用的所有链接,第二块包含注释中未引用的所有连接。第二块中的链接易于注意和修复(当链接声明有拼写错误时)或删除(但连接不再需要时)。

被识别为URL的纯文本会在HTML网页中自动显示为链接。

文档连接(Doc links)

文档链接的形式为“[Name1]”或“[Name1.Name2]”,用于指代当前软件包中导出的标识符,或“[pkg]”、“[pkg.Name1]”、“[pkg.Name1.Name1]”,用于表示其他软件包中的标识符。

例如:

package bytes

// ReadFrom reads data from r until EOF and appends it to the buffer, growing
// the buffer as needed. The return value n is the number of bytes read. Any
// error except [io.EOF] encountered during the read is also returned. If the
// buffer becomes too large, ReadFrom will panic with [ErrTooLarge].
func (b *Buffer) ReadFrom(r io.Reader) (n int64, err error) {
    ...
}

符号链接的方括号文本可以包括一个可选的前导星号,从而可以很容易地引用指针类型,例如[*bytes.Buffer]

在引用其他软件包时,“pkg”可以是完整的导入路径,也可以是现有导入的软件包的假定名称。假定的包名要么是重命名导入中的标识符,要么是goimports假定的名称。(当这个假定不正确时,Goimports会插入重命名,所以这个规则应该适用于几乎所有的Go代码。)例如,如果当前包导入encoding/json,则可以编写“[json.Decoder]”代替“[encoding/json.Decoder]”以链接到encoding/jsonDecoder的文档。如果一个包中的不同源文件使用相同的名称导入不同的包,那么就无法使用这种简写语法。

仅当“pkg”以域名(带点的路径元素)开头或者是标准库中的包之一(“[os]”、“[encoding/json]”等)时,才假定“pkg”是完整的导入路径。例如,[os.File][example.com/sys.File]是文档链接(后者将是一个损坏的链接),但[os/sys.File]不是,因为标准库中没有os/sys包。

为了避免与映射、泛型和数组类型在语法上出现冲突,文档链接的前后必须有标点符号、空格、制表符或行的开头或结尾。例如,文本“map[ast.Expr]TypeAndValue”就不包含文档链接。

列表(Lists)

列表是一段缩进或空行,其中每一行缩进以着重符号(着重号,表示强调)列表标记或数字列表标记开头。

着重号列表标记是星形、加号、短划线或Unicode项目符号(*、+、-、•;U+002A、U+002B、U+000D、U+2022),后跟空格或制表符,然后是文本。在着重号列表中,以着重号开头的每一行都是一个新的列表项。

例如:

package url

// PublicSuffixList provides the public suffix of a domain. For example:
//   - the public suffix of "example.com" is "com",
//   - the public suffix of "foo1.foo2.foo3.co.uk" is "co.uk", and
//   - the public suffix of "bar.pvt.k12.ma.us" is "pvt.k12.ma.us".
//
// Implementations of PublicSuffixList must be safe for concurrent use by
// multiple goroutines.
//
// An implementation that always returns "" is valid and may be useful for
// testing but it is not secure: it means that the HTTP server for foo.com can
// set a cookie for bar.com.
//
// A public suffix list implementation is in the package
// golang.org/x/net/publicsuffix.
type PublicSuffixList interface {
    ...
}

数字列表标记是任意长度的十进制数字,后跟句点或右括号,然后是空格或制表符,然后是文本。在数字编号列表中,以数字开头的每一行都是一个新的列表项。数字编号不会重复。

例如:

package path

// Clean returns the shortest path name equivalent to path
// by purely lexical processing. It applies the following rules
// iteratively until no further processing can be done:
//
//  1. Replace multiple slashes with a single slash.
//  2. Eliminate each . path name element (the current directory).
//  3. Eliminate each inner .. path name element (the parent directory)
//     along with the non-.. element that precedes it.
//  4. Eliminate .. elements that begin a rooted path:
//     that is, replace "/.." by "/" at the beginning of a path.
//
// The returned path ends in a slash only if it is the root "/".
//
// If the result of this process is an empty string, Clean
// returns the string ".".
//
// See also Rob Pike, “[Lexical File Names in Plan 9].”
//
// [Lexical File Names in Plan 9]: https://9p.io/sys/doc/lexnames.html
func Clean(path string) string {
    ...
}

列表项仅包含段落,而不包含代码块或嵌套的列表。这避免了计数空白符的问题,也避免了关于一个水平制表符(Tab)占多少个空白符的问题。

Gofmt重新格式化着重号列表,以使用破折号作为列表项的标记,在破折号之前使用两个空格作为缩进,与列表项连续的下一行使用四个空格作为缩进。

Gofmt重新格式化数字编号列表,在数字前使用一个空格,在数字后使用一个句点,与列表项连续的下一行使用四个空格作为缩进。

在列表和它前面的段落之间,Gofmt会保留一个空行,但并非需要一个空行(没有空行也可以)。Gofmt会在列表和下面的段落或标题之间插入一个空行。

代码块(Code blocks)

代码块是一段缩进或空行,不以着重号列表标记或数字编号列表标记开头。它被呈现为预格式化的文本(HTML中的<pre>块)。

代码块里通常包含Go代码。例如:

package sort

// Search uses binary search...
//
// As a more whimsical example, this program guesses your number:
//
//  func GuessingGame() {
//      var s string
//      fmt.Printf("Pick an integer from 0 to 100.\n")
//      answer := sort.Search(100, func(i int) bool {
//          fmt.Printf("Is your number <= %d? ", i)
//          fmt.Scanf("%s", &s)
//          return s != "" && s[0] == 'y'
//      })
//      fmt.Printf("Your number is %d.\n", answer)
//  }
func Search(n int, f func(int) bool) int {
    ...
}

当然,除了代码之外,代码块通常还包含预格式化的文本。例如:

package path

// Match reports whether name matches the shell pattern.
// The pattern syntax is:
//
//  pattern:
//      { term }
//  term:
//      '*'         matches any sequence of non-/ characters
//      '?'         matches any single non-/ character
//      '[' [ '^' ] { character-range } ']'
//                  character class (must be non-empty)
//      c           matches character c (c != '*', '?', '\\', '[')
//      '\\' c      matches character c
//
//  character-range:
//      c           matches character c (c != '\\', '-', ']')
//      '\\' c      matches character c
//      lo '-' hi   matches character c for lo <= c <= hi
//
// Match requires pattern to match all of name, not just a substring.
// The only possible returned error is [ErrBadPattern], when pattern
// is malformed.
func Match(pattern, name string) (matched bool, err error) {
    ...
}

Gofmt对代码块中的所有行缩进一个Tab,会替换非空白行的任何其他类型的缩进。Gofmt还会在每个代码块前后插入一个空行,将代码块与周围的段落文本清楚地区分开来。

常见错误和缺陷

文档注释中任何一段缩进或空行都显示为代码块,这一规则可以追溯到Go的最早几天。不幸的是,gofmt缺乏对文档注释的支持,导致许多现有注释虽然使用缩进,但gofmt不认为这是在创建代码块。

例如,这个未经修改的列表一直被godoc解释为三行段落后面跟着一行代码块:

package http

// cancelTimerBody is an io.ReadCloser that wraps rc with two features:
// 1) On Read error or close, the stop func is called.
// 2) On Read failure, if reqDidTimeout is true, the error is wrapped and
//    marked as net.Error that hit its timeout.
type cancelTimerBody struct {
    ...
}

会被godoc输出为:

cancelTimerBody is an io.ReadCloser that wraps rc with two features:
1) On Read error or close, the stop func is called. 2) On Read failure,
if reqDidTimeout is true, the error is wrapped and

    marked as net.Error that hit its timeout.

类似地,以下注释中的命令是一行段落,后面跟着一行代码块:

package smtp

// localhostCert is a PEM-encoded TLS cert generated from src/crypto/tls:
//
// go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com \
//     --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h
var localhostCert = []byte(`...`)

会被godoc输出为:

localhostCert is a PEM-encoded TLS cert generated from src/crypto/tls:

go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com \

    --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h

以下注释是一个两行的段落(第二行是“{”),后面是一个缩进六行的代码块和一行的段落:

// On the wire, the JSON will look something like this:
// {
//  "kind":"MyAPIObject",
//  "apiVersion":"v1",
//  "myPlugin": {
//      "kind":"PluginA",
//      "aOption":"foo",
//  },
// }

会被godoc输出为:

On the wire, the JSON will look something like this: {

    "kind":"MyAPIObject",
    "apiVersion":"v1",
    "myPlugin": {
        "kind":"PluginA",
        "aOption":"foo",
    },

}

另一个常见的文档注释编写错误是未缩进的Go函数定义或代码块语句,它们也用“{”和“}”括起来。

Go 1.19的gofmt中引入了对文档注释重新格式化的功能,通过在代码块周围添加空行,使此类文档注释编写错误更加显而易见。

一份2022年的分析发现,公共Go模块中只有3%的文档注释经Go 1.19的gofmt 草案重新格式化。仅限于这些文档注释,大约87%的gofmt重新格式化后的文档注释保留了人们通过阅读文档注释推断出的godoc的输出结构;大约6%的人被未缩进的列表、未缩进的多行shell命令和未缩进的大括号分隔的代码块所困扰。

基于这一分析,Go 1.19的gofmt使用了一些启发式算法,将未缩进的行合并到相邻的缩进列表或代码块中。经过这些调整,Go 1.19的gofmt将上述示例重新格式化为:

// cancelTimerBody is an io.ReadCloser that wraps rc with two features:
//  1. On Read error or close, the stop func is called.
//  2. On Read failure, if reqDidTimeout is true, the error is wrapped and
//     marked as net.Error that hit its timeout.

// localhostCert is a PEM-encoded TLS cert generated from src/crypto/tls:
//
//  go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com \
//      --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h

// On the wire, the JSON will look something like this:
//
//  {
//      "kind":"MyAPIObject",
//      "apiVersion":"v1",
//      "myPlugin": {
//          "kind":"PluginA",
//          "aOption":"foo",
//      },
//  }

这种重新格式化使含义更加清晰,并使文档注释在早期版本的Go中正确呈现。如果启发式算法做出了错误的决定,可以通过插入空行来重写它,以清楚地将段落文本与非段落文本分开。

即使有了这些启发算法,其他现有的注释也需要手动调整来纠正它们的呈现。最常见的错误是缩进了一行换行的文本。例如:

// TODO Revisit this design. It may make sense to walk those nodes
//      only once.

// According to the document:
// "The alignment factor (in bytes) that is used to align the raw data of sections in
//  the image file. The value should be a power of 2 between 512 and 64 K, inclusive."

在以上两种情况下,最后一行的缩进会使其成为一个代码块。解决办法是取消缩进。

另一个常见的错误是没有缩进列表或代码块里的换行。例如:

// Uses of this error model include:
//
//   - Partial errors. If a service needs to return partial errors to the
// client,
//     it may embed the `Status` in the normal response to indicate the
// partial
//     errors.
//
//   - Workflow errors. A typical workflow has multiple steps. Each step
// may
//     have a `Status` message for error reporting.

修复这个错误的方法就是缩进列表或代码块里的换行。

gofmt不支持嵌套列表语法,因此会把

// Here is a list:
//
//  - Item 1.
//    * Subitem 1.
//    * Subitem 2.
//  - Item 2.
//  - Item 3.

格式化为

// Here is a list:
//
//  - Item 1.
//  - Subitem 1.
//  - Subitem 2.
//  - Item 2.
//  - Item 3.

最佳解决方法是避免使用嵌套列表。另一个潜在的解决方法是混合使用两种列表标记,因为着重号标记不会在数字编号列表中引入数字列表项,反之亦然。例如:

// Here is a list:
//
//  1. Item 1.
//
//     - Subitem 1.
//
//     - Subitem 2.
//
//  2. Item 2.
//
//  3. Item 3.

Godoc:为Go代码生成文档

本文翻译自《Godoc: documenting Go code》。

Andrew Gerrand

2011/03/31

[注,20226:有关Go代码文档的更新指南,请参阅“Go文档注释”]

Go项目非常重视文档。文档是使软件可访问和可维护的重要组成部分。当然,文档必须写得又好又准确,但也必须易于书写和维护。理想情况下,它应该与代码本身耦合,这样文档就可以随着代码一起进化。程序员越容易制作出好的文档,就越能方便每个人。

为此,我们开发了godoc文档工具。本文描述了使用godoc制作文档方法,并介绍了如何按照我们的约定为自己的项目编写好的文档。

Godoc解析Go源代码(包括注释),并以HTML或纯文本形式生成文档。最终的结果是文档与它所记录的代码紧密结合。例如,通过godoc的web界面,你可以一键从函数的文档导航到其实现

Godoc在概念上与Python的Docstring和Java的Javadoc相似,但其设计得更简单。godoc读取的注释不是语言构造(不与Docstring一样),也不必须有机器可读的语法(不与Javadoc一样)。Godoc需要的只是好的注释,即使Godoc不存在,你也会想读这种注释。

约定很简单:要为一个类型、变量、常量、函数,甚至是一个包生成文档,请在其声明之前直接写一个常规注释,中间不能有空行。Godoc随后会将该注释生成文档。例如,以下是fmt包的Fprint函数的文档:

// Fprint formats using the default formats for its operands and writes to w.
// Spaces are added between operands when neither is a string.
// It returns the number of bytes written and any write error encountered.
func Fprint(w io.Writer, a ...interface{}) (n int, err error) {

请注意,这个注释是一个完整的句子,以它所描述的Go语言语法元素的名称开头。这一重要的约定使我们能够生成各种格式的文档,从纯文本到HTML再到UNIX手册页,并且当工具为了简洁而截断文档时,例如当它们提取第一行或第一个句子时,可以更好地阅读文档。

package的声明之上的注释会生成这个包的文档。这些注释可以很短,就像sort包的简短描述一样:

// Package sort provides primitives for sorting slices and user-defined
// collections.
package sort

注释也可以像gob包的概述一样详细。对于需要大量介绍性文档的包,该包使用了另一种约定:包注释放在自己的doc.go文件中,该文件只包含这些注释和一个package gob包声明语句。

在编写任何大小的包注释时,请记住它的第一句话将出现在godoc的包列表中。

与顶层声明语句不相邻的注释在godoc的输出中会被省略,除了一个明显的例外:以短语“BUG(who)”开头的顶层注释被识别为已知Bug,并在godoc的输出中包含在包文档的“Bugs”小节。“who”应该是可以提供更多信息的人的用户名。例如,这是字节包中的一个已知Bug:

// BUG(r): The rule Title uses for word boundaries does not handle Unicode punctuation properly.

有时,一个结构体的字段、一个函数、一个类型,甚至整个包都会变得多余或不再需要,但必须保留以与现有程序兼容。要发出不应使用某个标识符的信号,请在其文档注释中添加一段以“Deprecated:”开头的段落,后跟一些有关弃用的信息。

Godoc在将注释文本转换为HTML时使用了一些格式规则:

  • 随后的几行文本被视为同一段落的一部分;你必须使用一个空白行来分隔段落。
  • 预格式化的文本必须相对于周围的注释文本进行缩进(有关示例,请参阅gob的doc.go)。
  • URL将被转换为HTML链接;不需要特殊标记。

请注意,这些规则都不要求你做任何不同寻常的事情。

事实上,godoc最棒的地方在于它的易用性。因此,许多Go代码,包括所有的标准库,都已经遵循了这些约定。

你自己的代码只要有如上所述的注释就可以提供良好的文档。安装在$GOROOT/src/pkg中的任何Go软件包和任何GOPATH工作空间都可以通过godoc的命令行和HTTP接口进行访问,你可以通过-path标志或在源代码目录中运行“godoc.”来指定其他索引路径。有关更多详细信息,请参阅godoc文档

使用gob包来序列化与反序列化海量数据

本文翻译自《Gobs of data》。

Rob Pike

2011/03/24

介绍

要在网络上传输某种数据结构或将它存储在文件中,必须对它进行编码,然后解码。当然,有很多可用的编码格式:JSONXML、Google的protocol buffer等等。现在还有另一种,由Go的gob包提供。

为什么要定义一种新的编码格式?这需要大量的工作,而且多余。为什么不使用现有的格式之一?好吧,有一件事,我们做到了!Go本就支持刚才提到的所有编码的protocol buffer包在一个单独的存储库中,但它是下载频率最高的存储库之一)。对于许多目的,包括与用其他语言编写的工具和系统进行通信,它们都是正确的选择。

但对于Go特定的环境,例如用Go编写的两个服务器之间的通信,有机会构建一种更易于使用且可能更高效的编码格式。

Gob以一种外部定义的、独立于语言的编码所不能做到的方式工作。与此同时,还可以从现有的系统中吸取教训。

目标

gob包的设计考虑到了许多目标。

第一,也是最显然的一点,它必须非常易于使用。首先,因为Go支持反射机制,所以无需单独的接口定义或“协议编译器”。数据结构本身就是该包所需要的全部内容,以了解如何对其进行编码和解码。另一方面,这种方式意味着gob永远不会与其他编程语言通用,但没关系:gob就是以Go语言为中心的。

效率也很重要。以XML和JSON为例的文本形式的编码太慢了,无法在高效通信的网络中使用。二进制编码是必须的。

gob的数据流必须是自我描述的。从一开始读取的每个gob数据流都包含足够的信息,使得整个gob数据流可以由对其内容一无所知的代理程序解析。此特性意味着你将始终能够解码存储在文件中的gob数据流,即使在很久以后你已经忘记了它代表什么数据。

从我们使用Google的protocol buffer的经验中也学到了一些东西。

Protocol buffer的缺点

protocol buffer对gob的设计有很大影响,故意避免了它的三个特性。(protocol buffer不是自我描述的:如果你不知道用来编码成protocol buffer的数据的具体定义,你可能无法解码它。)

首先,protocol buffer只适用于我们在Go中称为结构体的数据类型。不能直接对整数或数组进行编码,只能对包含字段的结构体进行编码。这似乎是一个毫无意义的限制,至少在Go中是这样。如果你只想发送一个整数数组,为什么必须先将它放入一个结构体中?

其次,protocol buffer可以指定,每当对类型T的值进行编码或解码时,字段T.x和T.y都必需存在。尽管这样的必需字段(required field)看起来是个好主意,但它们的实现成本很高,因为编解码器在编码和解码时必须保留一个独立的数据结构,以便能够在所需字段丢失时进行报告。这也是一个维护问题。随着时间的推移,可能需要修改数据结构定义以删除必需字段,但这可能会导致现有客户端运行崩溃。最好不要在编码中包含必需字段。(protocol buffer也可以定义可选字段。如果我们没有定义必需字段,那么所有字段都是可选的。稍后将有更多关于可选字段的内容。)

第三个protocol buffer的错误特性是默认值。如果protocol buffer省略了“默认”字段的值,则解码该结构体的行为就好像这些字段默认被设置为该值一样。当你有getter和setter方法来控制对字段的访问时,这种想法非常有效,但当容器只是一个普通的结构体(译者注:没有getter和setter方法)时,就很难干净地处理了。必需字段的实现也很棘手:默认值在哪里定义,它们有什么类型(文本是UTF-8编码的吗?还是没有解释的字节码?一个浮点数用几个bit位来表示?)尽管看起来很简单,但它们在protocol buffer的设计和实现中有许多复杂之处。我们决定把这一特性排除在外,回到Go语言的琐碎但有效的默认规则:除非你另有设置,否则字段的值为其类型的“零值”,不需要传输。

因此,gob最终看起来像是一种通用的、简化的protocol buffer。那么它是如何工作的呢?

字段的值

编码的gob数据与int8或uint16之类的类型无关。相反,有点类似于Go中的常量,它的整数值是抽象的、无尺寸(sizeless)的数字,有符号或无符号。当你对int8字段进行编码时,它的值将作为一个无尺寸、可变长的整数来传输。当你对int64字段进行编码时,它的值也会作为一个无尺寸、可变长的整数进行传输。(有符号和无符号被区别对待,但无符号值也是无尺寸的。)如果两者的值都为7,则在网络上发送的位将相同。当接收者解码该值时,将其放入接收者的变量中,该变量可以是任意整数类型。因此,编码器发送来自int8字段的值7,接收者可以将其存储在int64字段中。这很好:这个值是一个整数,只要尺寸合适,一切都可行。(如果不合适,就会产生错误。)这种变量与其尺寸的解耦为编码器的实现提供了一些灵活性:随着软件的发展,我们可以扩展整数变量的类型,但仍然能够解码旧数据。

这种灵活性也用于指针。在传输之前,所有指针都被压平。int8、*int8、**int8、***int8等类型的值都作为整数值传输,然后可以将其存储在任何尺寸的int、*int或******int等中。

在解码结构体时,只有编码器发送的字段才会存储在目标字段中。给定值

type T struct{ X, Y, Z int } // 只有导出的(公有的)字段才会被编码然后解码。
var t = T{X: 7, Y: 0, Z: 8}

t的编码后的数据仅发送7和8。因为字段Y的值是零,所以它甚至不会被发送;没有必要发送零值。

相反,接收者可以将该值解码成这样的结构体:

type U struct{ X, Y *int8 } //注意:Y是*int8类型
var u U

并且仅设置X字段的值(int8类型的名为X的变量设置为7);Z字段被忽略了——没有哪个字段是Z?在解码结构体时,字段按名称和兼容的类型进行匹配,并且只有两者中都存在的字段才会受到影响。这种简单的方法巧妙地解决了“可选字段”问题:随着T类型通过添加字段而演变,过时的接收者仍可以使用它能够识别的类型的一部分来解码数据。因此,gob提供了可选字段的重要特性——可扩展性——而不需要任何额外的机制或注释。

使用整型数,我们可以构建所有其他类型:字节、字符串、数组、切片、映射,甚至浮点数。浮点数由IEEE 754标准的浮点位模式表示,存储为整数,只要你知道它的类型,它就可以正常工作。顺便说一句,该整数是以字节反转的顺序发送的,因为浮点数的常见值,如小整数,在低位端有很多零,我们可以避免发送这些零。

gob包的一个很好的特性是,它允许你通过让你的类型满足GobEncoderGobDecoder接口来定义自己的编码,行为类似于JSON包的MarshallerUnmarshaler函数,也类似于fmt包的Stringer接口。通过此功能,可以在传输数据时表示特殊特性、强制执行约束或隐藏秘密数据。有关详细信息,请参阅文档

在网络上传输类型

第一次发送某个指定的类型时,gob包会在数据流中包含该类型的描述信息。事实上,编码器用标准的gob编码格式对内部结构体进行编码,该内部结构体描述类型信息并为其提供一个唯一的编号。(基本类型,加上描述类型的结构体的布局,是由引导程序预定义的。)在描述了类型之后,可以通过其类型编号来引用它。

因此,当我们发送第一个类型T时,gob编码器发送T的描述信息,并用一个类型号码标记它,比如127。然后,所有值(包括第一个值)都以该数字为前缀,因此T值的数据流看起来如下:

("define type id" 127, definition of type T)(127, T value)(127, T value), ...

这些类型号码使描述递归类型和发送这些类型的值成为可能。因此,gob可以对树等类型进行编码:

type Node struct {
    Value       int
    Left, Right *Node
}

(这是给读者的一个练习,可以发现零默认规则(zero-defaulting rule)是如何实现这一点的,尽管gob并不呈现指针。)

有了类型信息,gob数据流是完全自我描述的,除了几个引导类型之外,引导类型是一个明确定义的起点。

构建一个小型解释器

第一次对给定类型的值进行编码时,gob包会构建一个特定于该数据类型的小型解释器。Go通过对该类型的反射来构建解释器,但是一旦构建了解释器,它就不依赖于反射。该解释器使用unsafe包和一些技巧将数据高速转换为编码字节。我们也可以使用反射并避免unsafe包,但速度会慢得多。(Go的protocol buffer实现也采用了类似的高速方法,其设计受到了gob实现的影响。)相同类型的后续值使用已经编译得到的解释器,因此可以立即对它们进行编码。

[更新:从Go 1.4开始,unsafe包不再被gob包使用,性能略有下降。]

解码的过程是相似的,但更难实现。当你解码一个值时,gob包会保存一个字节切片,代表给定编码器要解码的类型的值,再加上一个要解码的Go值。gob包也会构建一个解码器:gob类型与对应的解码器代码一起在网络上发送。然而,一旦解码器构建完成,由于它是一个不使用反射机制的引擎,使用不安全的方法来获得最大速度。

用法

虽然幕后做了很多事情,但gob是一个高效、易于使用的数据传输编码系统。下面是一个完整的示例,显示了对不同类型地编码和解码。注意发送和接收值是多么的容易;你所需要做的就是向gob包呈现值和变量,它就可以完成所有的工作。

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "log"
)

type P struct {
    X, Y, Z int
    Name    string
}

type Q struct {
    X, Y *int32
    Name string
}

func main() {
    // 初始化编码器和解码器。一般来说,enc和dec会被绑定到网络连接,并且可能运行在不同的协程里。
    var network bytes.Buffer        // 代表一个网络连接
    enc := gob.NewEncoder(&network) // 将写入网络
    dec := gob.NewDecoder(&network) // 将从网络读取
    // 编码(发送)值
    err := enc.Encode(P{3, 4, 5, "Pythagoras"})
    if err != nil {
        log.Fatal("encode error:", err)
    }
    // 解码(接收)值
    var q Q
    err = dec.Decode(&q)
    if err != nil {
        log.Fatal("decode error:", err)
    }
    fmt.Printf("%q: {%d,%d}\n", q.Name, *q.X, *q.Y)
}

rpc包构建在gob之上,将这种编码/解码自动化转变为跨网络的方法调用以及数据传输。这将是另一篇文章的主题。

细节

gob包的文档,尤其是文件doc.go,扩展了这里描述的许多细节,并包括一个完整的示例,显示了编码如何表示数据。如果你对gob实现的内部结构感兴趣,那么这是一个很好的起点。

Cgo介绍

本文翻译自《C? Go? Cgo!》。

Andrew Gerrand

2011/03/17

介绍

Cgo可以让Go包调用C代码。给定一个使用一些特殊特性编写的Go源文件,cgo输出Go和C文件,它们可以组合成单个Go包。

举个例子,这里有一个Go包,它提供了两个函数——RandomSeed——用来包装C的random函数和srrandom函数。

package rand

/*
#include <stdlib.h>
*/
import "C"

func Random() int {
    return int(C.random())
}

func Seed(i int) {
    C.srandom(C.uint(i))
}

让我们看看这里发生了什么,从import语句开始。

rand包导入“C”,但你会发现在标准Go库中没有这个包。这是因为C是一个“伪包(pseudo-package)”,一个被cgo解释为引用C命名空间的特殊名称。

rand包包含对C包的四个引用:对C.randomC.srrandom的调用、C.uint(i)import "C"语句。

Random函数调用标准C库的random函数并返回结果。在C语言中,random返回一个long类型的值,cgo将其表示为C.long类型。必须将其转换为Go类型,才能由该包外的Go代码使用,使用普通的Go类型转换语句即可:

func Random() int {
    return int(C.random())
}

下面是一个等效函数,它使用一个临时变量来更明确地说明这种类型转换:

func Random() int {
    var r C.long = C.random()
    return int(r)
}

Seed函数在某种程度上起相反的作用。它获取一个常规的Go的int类型的变量,将其转换为C的unsigned int类型,并将其传递给C函数srrandom

func Seed(i int) {
    C.srandom(C.uint(i))
}

注意,cgo知道C语言的unsigned int类型用C.uint来表示;有关这些数值类型的名称的完整列表,请参阅cgo文档

我们还没有研究这个例子的一个细节是import语句上方的注释。

/*
#include <stdlib.h>
*/
import "C"

Cgo认识这一注释。任何以#cgo开头、后跟一个空格字符的行都将被删除;这些都成为cgo的指令。在编译Go包的C语言部分代码时,剩余的行用作C语言头部。在上例中,这些行只是一个#include语句,但它们几乎可以是任何C代码。在构建Go包的C语言部分时,#cgo指令用于为编译器和链接器提供标志。

有一个限制:如果你的程序使用任何//export指令,那么注释中的C代码可能只包括这种声明(extern int f();),而不是定义(int f() { return 1; })。你可以使用//export指令使Go函数可被C语言代码访问。

#cgo//export指令记录在cgo文档中。

字符串相关

与Go不同,C没有显式的字符串类型。C中的字符串由一个以零结尾的字符数组表示。

Go和C字符串之间的转换是通过C.CStringC.GoStringC.GoStringN函数完成的。这些转换返回字符串数据的一个拷贝。

下一个示例实现了一个Print函数,该函数使用C的stdio库中的fputs函数将字符串写到标准输出:

package print

// #include <stdio.h>
// #include <stdlib.h>
import "C"
import "unsafe"

func Print(s string) {
    cs := C.CString(s)
    C.fputs(cs, (*C.FILE)(C.stdout))
    C.free(unsafe.Pointer(cs))
}

Go的内存管理器看不见由C代码进行的内存分配。因此当你使用C.CString(或任何C内存分配)创建一个C字符串时,你必须记住在完成调用后使用C.free释放内存。

C.CString的调用返回一个指向char数组头部的指针,我们将其转换为一个unsafe.Pointer指针。并用C.free释放它指向的内存分配。cgo程序中的一个常见惯用法是在分配内存后立即defer释放(尤其是当后面的代码比单个函数调用更复杂时),例如以下对Print函数的重写:

func Print(s string) {
    cs := C.CString(s)
    defer C.free(unsafe.Pointer(cs))
    C.fputs(cs, (*C.FILE)(C.stdout))
}

构建cgo包

要构建cgo包,只需像往常一样使用go buildgo install即可。这些go工具能识别特殊的“C”导入,并自动使用cgo来处理。

cgo相关资源

cgo命令文档提供了关于C伪包和构建过程的更多详细信息。Go树中的cgo示例演示了更高级的概念。

最后,如果你想知道在内部这一切是如何工作的,请查看运行时包的cgocall.go的介绍性注释。

Gofix介绍

本文翻译自《Introducing Gofix》。

Russ Cox

2011/04/15

下一个Go版本将在几个基本Go包中进行API的重大更改。实现HTTP服务器处理程序调用net.Dial调用os.Open使用反射包的代码将不会生成成功,除非将其更新为使用新的API。现在我们的版本更加稳定,频率也降低了,这将是一种常见情况。每一个API更改都发生在不同的每周快照中,并且可能由它们的开发者自己单独管理;然而,这些更改加在一起,更新现有代码时就需要大量的手动工作。

Gofix是一种新工具,它减少了更新现有代码所需的工作量。它从源代码文件中读取程序代码,查找使用旧API的地方,将其重写为使用当前API,然后将程序代码写回源代码文件。并非所有API更改都保留了旧API的所有功能,因此gofix无法始终做到完美。当gofix无法重写使用旧API的代码时,它会打印一条警告,给出源代码文件名和行号,以便开发人员检查和重写相关代码。Gofix负责处理简单、重复、乏味的更改,这样开发人员就可以专注于真正值得关注的更改。

每次我们对API进行重大更改时,我们都会在gofix中添加代码,以尽可能机械地处理代码转换。当你更新到新的Go版本并且你的代码不再能构建成功时,只需在源代码目录上运行gofix即可。

你可以扩展gofix以支持对自己的API进行更改。gofix是一个简单的由插件驱动的程序,插件系统叫作fixes,其中每个插件(一个fix)处理一个特定的API更改。现在,编写一个新的修复程序需要对go/ast语法树进行一些扫描和重写,通常与API更改的复杂程度成比例。如果你想探索一下,netdialFixosopenFixhttpserverFixreflectFix都是说明性的例子,它们的复杂度依次增高。

当然,我们自己也编写Go代码,我们的代码和你的代码一样受到这些API更改的影响。通常,我们在更改API的同时编写gofix支持,然后使用gofix重写主(main)源代码树分支中的用法。我们使用gofix来更新其他Go代码库和我们的个人项目。当需要针对新的Go版本进行构建时,我们甚至会使用gofix来更新谷歌内部的源代码树。

例如,gofix可以重写fmt/print.go中的代码片段:

switch f := value.(type) {
case *reflect.BoolValue:
    p.fmtBool(f.Get(), verb, field)
case *reflect.IntValue:
    p.fmtInt64(f.Get(), verb, field)
// ...
case reflect.ArrayOrSliceValue:
    // Byte slices are special.
    if f.Type().(reflect.ArrayOrSliceType).Elem().Kind() == reflect.Uint8 {
        // ...
    }
// ...
}

为使用新的反射API:

switch f := value; f.Kind() {
case reflect.Bool:
    p.fmtBool(f.Bool(), verb, field)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
    p.fmtInt64(f.Int(), verb, field)
// ...
case reflect.Array, reflect.Slice:
    // Byte slices are special.
    if f.Type().Elem().Kind() == reflect.Uint8 {
        // ...
    }
// ...
}

上面几乎每一行都有细微的变化。要重写的地方虽然有很多,但几乎完全是机械的,这正是计算机擅长做的事情。

Gofix之所以成为可能,是因为Go的标准库支持将Go源文件解析为语法树,也支持将这些语法树打印回Go源代码。重要的是,Go打印库(printer)以官方推荐的格式打印程序源码(通常通过gofmt工具强制执行),允许gofix对Go程序进行机械地更改,而不会导错误的格式更改。事实上,创建gofmt的一个关键动机可能仅次于避免关于括号应该放在何处的争论,那就是让重写Go程序源码的工具变得简单,无论是创建还是使用,例如gofix。

Gofix已经变得不可或缺。特别是,如果没有自动转换,最近对反射API的更改将是不受欢迎的,并且难以修改之前程序源码里的反射API。Gofix使我们能够修复错误或完全重新思考API,而无需担心转换现有代码所需的成本。我们希望你能发现gofix这个工具是有用和方便的。

使用Gofmt工具格式化Go代码

本文翻译自《go fmt your code》。

Andrew Gerrand 2013/01/23

介绍

Gofmt是一个用来自动格式化Go源代码的工具。

Gofmt的代码:

  • 更易于编写:在编写时永远不要担心小的格式问题,
  • 更容易阅读:当所有代码看起来都一样时,你不需要在心里把别人的格式风格转换成你能理解的东西。
  • 更易于维护:对源码的细节更改不会导致对文件格式的不相关更改;diffs(差异)只显示真正的变化。
  • 无争议:永远不会再争论空格或括号位置这些问题了!

格式化你的代码

我们最近对野外(非官方)的Go软件包进行了一项调查,发现大约70%的软件包是根据gofmt规则格式化的。这比预期的要多,感谢所有使用gofmt的人,但如果能继续缩小差距就太好了。

要格式化Go代码,可以直接使用gofmt工具:

gofmt -w yourcode.go

或者你也可以使用“go fmt”命令:

go fmt path/to/your/package

为了帮助你保持代码的风格规范,Go存储库包含编辑器和版本控制系统的钩子,使你可以轻松地在代码上运行gofmt。

对于Vim用户,Go的Vim插件包含了在当前缓冲区上运行gofmt的Fmt命令。

对于emacs用户,go-mode.el提供了一个保存前执行gofmt的钩子,可以通过将此行添加到.emacs文件来安装:

(add-hook 'before-save-hook #'gofmt-before-save)

对于Eclipse或SublimeText用户,GoClipseGoSublime项目为这些编辑器添加了一个gofmt功能。

对于Git爱好者来说,misc/Git/precommit脚本是一个commit之前的钩子,可以防止提交格式错误的Go代码。如果你使用Mercurial,hgstyle插件提供了一个gofmt预提交钩子。

机器修改源代码

使用机器格式化代码最大的优点之一是可以机械地转换代码格式,而不会产生与格式化无关的东西。当你使用的代码库很庞大时,机械转换是无价的,因为它比手工进行大范围的更改更加全面,也更不容易出错。事实上,当工作规模很大时(就像我们在谷歌所做的那样),手动进行代码更改通常是不现实的。

使用机器操作Go代码的最简单方法是使用gofmt命令的-r标志。标志指定代码格式的重写规则:

pattern -> replacement

其中模式(pattern)和替换(replacement)都是有效的Go表达式。在该模式中,单字符小写标识符用作匹配任意子表达式的通配符,并且这些表达式在replacement中被替换为相同的标识符。

例如,最近对Go核心的更改重写了对bytes.Compare方法的一些使用,以改为使用更高效bytes.Equal方法。仅仅使用两个gofmt命令就修改成功:

gofmt -r 'bytes.Compare(a, b) == 0 -> bytes.Equal(a, b)'
gofmt -r 'bytes.Compare(a, b) != 0 -> !bytes.Equal(a, b)'

Gofmt还使用了gofix,它可以进行任意复杂的源代码转换。在早期,当我们经常对语言和库进行破坏性的更改时,Gofix是一个非常宝贵的工具。例如,在Go 1之前,内置的error接口还不存在,约定是使用os.Error类型。当我们引入error接口时,我们提供了一个gofix模块,它重写了对os.Error及其相关助手函数的所有引用,以使用error接口和新的errors。尝试手工更改代码可能会让人望而却步,但有了标准格式的代码,准备、执行和审查这一变化就相对容易,这几乎涉及到了现有的所有Go代码。

有关gofix的更多信息,请参阅本文