Go 模块是一个 go.mod 文件管着的一棵包树。弄懂导入路径、导出名和 internal 目录,再用表格驱动测试、子测试、示例和基准测试来测代码。
本系列到目前为止,每个程序都放在一个叫 main.go 的文件里。真正的 Go 代码不会一直这么小。你会把它拆成多个包,包放在一个模块里,模块再配上由 go test 运行的测试。
本文搭建一个小模块:一个统计单词的程序,包含一个库、一个私有的辅助包、一个命令和若干测试,并逐个讲解每个文件。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。展示的代码直接取自模块里的文件,这些文件都通过了 go vet、go test 和 go test -race。
包是一个目录,模块是一棵目录树
Go 包就是一个目录里的所有 .go 文件,模块则是一棵由包组成的目录树,顶层有一个 go.mod 文件。本文用到的整个模块如下:
11-wordcount/
├── go.mod
├── wordcount.go
├── wordcount_test.go
├── example_test.go
├── internal/
│ └── tokenize/
│ ├── tokenize.go
│ └── tokenize_test.go
└── cmd/
└── wc/
├── main.go
└── main_test.go
这里一共三个包。顶层的文件组成 wordcount 包,也就是库。internal/tokenize 把文本切成单词。cmd/wc 是一个使用这个库的小程序。
同一个目录里的每个文件,第一行都必须写同一个包名,一个目录也只放一个包。唯一的例外是测试文件,后面会讲到。
go.mod 文件只有两行:
module example.com/wordcount
go 1.26
module 这一行写的是模块路径。它是模块内每个导入路径的前缀。在后面接上包所在的目录,就得到这个包的导入路径。go list 会把它们列出来:
$ go list ./...
example.com/wordcount
example.com/wordcount/cmd/wc
example.com/wordcount/internal/tokenize
./... 的意思是“当前目录及其下面的所有目录”。go test、go vet 和 go build 也会用到它。
所以导入路径不是文件名,也不是磁盘上的路径。它是模块路径加上目录路径。包名,也就是 package 后面那个词,通常是这条路径的最后一段,也是你在点号前面写的那个名字:tokenize.Words、wordcount.Count。
导出名是唯一的入口
一个 Go 包里的代码,只能使用另一个包里首字母大写的名字。第一部分讲 strings.ToUpper 时你已经见过这条规则。你自己写的包也一样。下面是这个库:
// Package wordcount counts words in text.
package wordcount
import (
"cmp"
"slices"
"strings"
"example.com/wordcount/internal/tokenize"
)
// Pair is one word and how many times it appeared.
type Pair struct {
Word string
Count int
}
// Count returns how many times each word appears in text.
// Words are compared without regard to case.
func Count(text string) map[string]int {
counts := map[string]int{}
for _, w := range tokenize.Words(text) {
counts[normalize(w)]++
}
return counts
}
// Top returns the n most frequent words, most frequent first.
// Words with the same count are sorted alphabetically.
func Top(counts map[string]int, n int) []Pair {
pairs := make([]Pair, 0, len(counts))
for w, c := range counts {
pairs = append(pairs, Pair{Word: w, Count: c})
}
slices.SortFunc(pairs, func(a, b Pair) int {
if c := cmp.Compare(b.Count, a.Count); c != 0 {
return c
}
return cmp.Compare(a.Word, b.Word)
})
return pairs[:min(n, len(pairs))]
}
// normalize is unexported: only code in package wordcount can call it.
func normalize(word string) string {
return strings.ToLower(word)
}
Pair、Count 和 Top 首字母大写,所以是导出的。字段 Word 和 Count 也是。normalize 首字母小写,所以只有 wordcount 包里的代码能调用它。Count 可以随便调用它,因为两者在同一个包里。
Top 里次数相同时按字母顺序排,这是故意的。map 的遍历顺序每次运行都会变,而返回结果顺序随机的函数很难测试。
为了亲眼看到这条规则生效,我在一个位于包外的测试文件里加了一个示例函数,调用 wordcount.normalize("Go")。go test 拒绝构建:
$ go test .
# example.com/wordcount_test [example.com/wordcount.test]
./example_test.go:27:24: undefined: wordcount.normalize
FAIL example.com/wordcount [build failed]
注意这条消息说的是什么。它没说“normalize 是私有的”,而是说 undefined。在包外面看,未导出的名字根本不存在。
正因为如此,改动未导出的名字是安全的。你可以给 normalize 改名、改参数,甚至删掉,包外的代码都不会坏,因为它们根本碰不到它。
internal/:只有本模块能导入的包
名为 internal 的目录,让它下面的包只能被以 internal 的父目录为根的代码导入。这里 internal 位于模块顶层,所以模块里的每个包都能导入 tokenize,模块外的代码都不能:
// Package tokenize splits text into words.
package tokenize
import (
"strings"
"unicode"
)
// Words splits text into words. A word is a run of letters, digits and
// apostrophes. Apostrophes at either end of a word are dropped.
func Words(text string) []string {
fields := strings.FieldsFunc(text, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\''
})
words := fields[:0]
for _, f := range fields {
if w := strings.Trim(f, "'"); w != "" {
words = append(words, w)
}
}
return words
}
Words 是导出的,所以 wordcount 包可以调用 tokenize.Words。但它只在本模块内导出。
为了验证,我另建了一个模块 example.com/other,同时导入这两个包:
$ go build .
package example.com/other
main.go:7:2: use of internal package example.com/wordcount/internal/tokenize not allowed
导入 example.com/wordcount 本身没问题。对 internal/tokenize 的导入则被 go 命令拒绝了,编译器连一行代码都还没看。
这样你就有了一个地方,放那些想在自己的几个包之间共享、却不想对外承诺的代码。如果 tokenize 是普通包,别人就可能导入它,你一改 Words,他们的代码就坏了。放在 internal 下面,你想什么时候改就什么时候改。
用十岁孩子能懂的话说
把模块想象成一栋房子,每个包是里面的一个房间。
房间里名字小写的东西只待在这个房间里。别的房间的人碰不到,连看都看不见。
名字大写的东西放在房间门口,朝着走廊。谁走到门口都能用。这就是“导出”的意思。
internal 房间是家庭房。它们的门也朝着走廊,但大门口挂着一块牌子:“仅限家人”。住在这栋房子里的人可以进去。别家来的客人在大门口就被拦下了。
准确的说法
在包的顶层声明的名字,如果首字符是大写字母,就是导出的。只有导出的名字才能在另一个包里引用。这由编译器检查。
包含 internal 这一段的导入路径,只能被路径以 internal 前面那部分开头的包导入。example.com/wordcount/internal/tokenize 可以被 example.com/wordcount 及其下面的任何包导入。这由 go 命令在解析导入时检查。
两条规则叠加生效。internal 包里的名字,既要首字母大写,又要由允许的包来导入。
这个比喻的局限:真实房子里的房间是并排的。Go 的规则跟着目录树走。往下三层的 internal 目录只放行它上一级目录里的包,所以“家人”可以只是房子的一小部分,而不是整栋房子。
使用这个库的命令
文件写着 package main 的目录会构建出一个程序。在模块内,它像导入其他包一样,用导入路径导入这个库。把命令放在 cmd/<name> 下是常见的布局,不是硬性规定:
// Command wc prints the most frequent words read from standard input.
package main
import (
"fmt"
"io"
"log"
"os"
"example.com/wordcount"
)
func main() {
if err := run(os.Stdin, os.Stdout, 3); err != nil {
log.Fatal(err)
}
}
// run reads all of r and writes the n most frequent words to w.
func run(r io.Reader, w io.Writer, n int) error {
text, err := io.ReadAll(r)
if err != nil {
return err
}
counts := wordcount.Count(string(text))
for _, p := range wordcount.Top(counts, n) {
fmt.Fprintf(w, "%-8s %d\n", p.Word, p.Count)
}
return nil
}
main 几乎什么都不做。活儿在 run 里,它接收 io.Reader 和 io.Writer,而不是直接用 os.Stdin 和 os.Stdout。这样很容易测试,后面你会看到。
把讲 map 那一部分里的句子喂给它:
$ printf 'the cat sat on the mat and the cat slept' | go run ./cmd/wc
the 3
cat 2
and 1
go run ./cmd/wc 指定的是包所在的目录。你没在 go.mod 里写任何东西,导入就能用,因为 example.com/wordcount 就在你所在的模块里。
别人的代码:go get、go mod tidy 和 go.sum
本系列只用标准库,所以这个模块没有依赖。但在真实项目里你还是会加依赖,步骤很简单。
先在代码里为要用的包加上 import,再运行 go mod tidy。它读取模块里的每个导入,下载提供缺失包的模块,并为每个模块在 go.mod 里写一行 require。它还会删掉不再被导入的 require 行。想要某个特定版本时,go get example.com/some/module@v1.2.3 可以直接添加。
在这个模块上,go mod tidy 什么都不输出,也什么都不改,因为没有要加或要删的东西。提交之前还是运行一下。这是让 go.mod 保持真实的最快办法。
模块第一次有了依赖时,会多出第二个文件:go.sum。它记录构建用到的每个模块版本的加密哈希。之后任何人在任何机器上下载这个版本,go 命令都会拿它和哈希比对,内容变了就停下。把 go.sum 和 go.mod 一起提交,不要手动编辑它。
第一个测试
Go 测试写在文件名以 _test.go 结尾的文件里,和被测代码放在同一个目录。测试是一个名字以 Test 开头、接收 *testing.T 的函数:
package tokenize
import (
"slices"
"testing"
)
func TestWords(t *testing.T) {
got := Words("Don't panic -- 'quoted' words, 42 times!")
want := []string{"Don't", "panic", "quoted", "words", "42", "times"}
if !slices.Equal(got, want) {
t.Errorf("Words() = %q, want %q", got, want)
}
}
没有断言库。你自己比较值,不对就调用 t.Errorf。消息遵循 Go 的一个习惯:说清楚调用了什么、得到了什么、想要的是什么。
go build 会忽略 _test.go 文件,所以测试永远不会进到你的程序里。go test 把它们和包一起编译,并运行每个 Test 函数。go test ./... 对模块里的每个包都这样做:
$ go test ./...
ok example.com/wordcount 0.005s
ok example.com/wordcount/cmd/wc 0.004s
ok example.com/wordcount/internal/tokenize 0.004s
每行末尾的耗时每次运行都不一样。什么都不改再跑一次,每行就会显示 (cached)。go test 会记住通过的结果,直到代码或测试有变化。如果无论如何都想重新跑测试,加上 -count=1。
表格驱动测试和子测试
表格驱动测试把用例放进一个结构体切片,对每个用例执行同样的检查。这是 Go 代码里最常见的测试写法:
package wordcount
import (
"maps"
"testing"
)
func TestCount(t *testing.T) {
tests := []struct {
name string
text string
want map[string]int
}{
{"empty", "", map[string]int{}},
{"one word", "go", map[string]int{"go": 1}},
{"mixed case", "Go go GO", map[string]int{"go": 3}},
{"punctuation", "stop. Stop, stop!", map[string]int{"stop": 3}},
{"apostrophes", "don't 'quote' me", map[string]int{"don't": 1, "quote": 1, "me": 1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Count(tt.text)
if !maps.Equal(got, tt.want) {
t.Errorf("Count(%q) = %v, want %v", tt.text, got, tt.want)
}
})
}
}
加一个用例,就是在表格里加一行。t.Run 把每个用例作为一个有名字的子测试运行,每个子测试有自己的 t。失败的用例会报出自己的名字,其他用例照常运行。
这个文件写的是 package wordcount,和被测代码一样。这让测试待在房间里面,所以它也能测试未导出的名字。下面就是 TestNormalize 的全部内容:
func TestNormalize(t *testing.T) {
if got := normalize("HeLLo"); got != "hello" {
t.Errorf("normalize(%q) = %q, want %q", "HeLLo", got, "hello")
}
}
-v 会在运行时列出每个测试和子测试:
$ go test -v -run TestCount .
=== RUN TestCount
=== RUN TestCount/empty
=== RUN TestCount/one_word
=== RUN TestCount/mixed_case
=== RUN TestCount/punctuation
=== RUN TestCount/apostrophes
--- PASS: TestCount (0.00s)
--- PASS: TestCount/empty (0.00s)
--- PASS: TestCount/one_word (0.00s)
--- PASS: TestCount/mixed_case (0.00s)
--- PASS: TestCount/punctuation (0.00s)
--- PASS: TestCount/apostrophes (0.00s)
PASS
最后和前面一样,还有一行带耗时的 ok。
-run 接收一个正则表达式,只运行名字匹配的测试。模式里的斜杠可以深入到子测试。用例名里有空格,Go 把空格换成了下划线。两种写法都可以。-run 'TestCount/mixed case' 和 -run TestCount/mixed_case 选中的是同一个子测试,因为模式也会做同样的替换:
$ go test -v -run 'TestCount/mixed case' .
=== RUN TestCount
=== RUN TestCount/mixed_case
--- PASS: TestCount (0.00s)
--- PASS: TestCount/mixed_case (0.00s)
PASS
t.Errorf、t.Fatalf 和 t.Helper
t.Errorf 把测试标记为失败,然后继续执行;t.Fatalf 把测试标记为失败,并立刻停止这个测试。检查 Top 结果的辅助函数两个都用到了:
// assertPairs fails the test if got and want differ.
func assertPairs(t *testing.T, got, want []Pair) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %d pairs, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("pair %d = %v, want %v", i, got[i], want[i])
}
}
}
func TestTop(t *testing.T) {
counts := map[string]int{"a": 1, "b": 3, "c": 3, "d": 2}
assertPairs(t, Top(counts, 2), []Pair{{"b", 3}, {"c", 3}})
assertPairs(t, Top(counts, 10), []Pair{{"b", 3}, {"c", 3}, {"d", 2}, {"a", 1}})
assertPairs(t, Top(counts, 0), []Pair{})
}
如果长度不同,下面的循环会越过切片末尾去取索引,引发 panic。所以这个检查用 t.Fatalf。如果长度一致,每一个错的键值对都值得看到,所以循环里用 t.Errorf。
为了看出区别,我把第二次调用的期望改成三个键值对,把第三次调用的期望改成一个。结果只报告了一个失败:
$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
wordcount_test.go:53: got 4 pairs, want 3: [{b 3} {c 3} {d 2} {a 1}]
FAIL
t.Fatalf 在第 53 行停止了 TestTop,所以第 54 行那个被改坏的检查根本没运行。t.Fatalf 停止的是当前测试或子测试,不是整次运行。其他测试照常运行。
行号是 t.Helper() 的功劳。它把 assertPairs 标记为辅助函数,所以失败时报告的是调用它的那一行。恢复测试后,我把第 52 行第一次调用里的期望顺序对调了:
$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
wordcount_test.go:52: pair 0 = {b 3}, want {c 3}
wordcount_test.go:52: pair 1 = {c 3}, want {b 3}
FAIL
然后把 t.Helper() 注释掉,再跑一次:
$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
wordcount_test.go:44: pair 0 = {b 3}, want {c 3}
wordcount_test.go:44: pair 1 = {c 3}, want {b 3}
FAIL
第 44 行是辅助函数里的 t.Errorf。三次调用报的都是这一行,所以看不出是哪次调用失败了。在每个测试辅助函数的第一行写上 t.Helper()。
测试命令
package main 也可以有测试,而 run 函数让这件事变得很容易。测试给它一个字符串 reader,再收集它写出的内容:
package main
import (
"strings"
"testing"
)
func TestRun(t *testing.T) {
in := strings.NewReader("one two two three three three")
var out strings.Builder
if err := run(in, &out, 2); err != nil {
t.Fatalf("run: %v", err)
}
want := "three 3\ntwo 2\n"
if got := out.String(); got != want {
t.Errorf("run wrote %q, want %q", got, want)
}
}
strings.Builder 是一个 io.Writer,所以它可以代替 os.Stdout。让 main 保持极简,把 reader 和 writer 传来传去,Go 程序就是这样做到不启动真实进程也能测试的。
示例函数是会被检查的文档
示例函数以 Example 开头,不接收参数,结尾有一条 // Output: 注释。go test 会运行它,并把它输出的内容和这条注释比较:
package wordcount_test
import (
"fmt"
"example.com/wordcount"
)
func ExampleCount() {
counts := wordcount.Count("The cat saw the other cat.")
fmt.Println(counts)
// Output: map[cat:2 other:1 saw:1 the:2]
}
func ExampleTop() {
counts := wordcount.Count("to be or not to be, that is the question")
for _, p := range wordcount.Top(counts, 3) {
fmt.Println(p.Word, p.Count)
}
// Output:
// be 2
// to 2
// is 1
}
这个文件写的是 package wordcount_test,带 _test 后缀。这是一个目录里可以有两个包名的唯一情况。_test 包单独编译,像任何外部代码一样导入 wordcount。所以示例只用导出的名字,正和读你文档的人一样。前面调用 normalize 构建失败的,也正是这个文件。
在这里对 map 用 fmt.Println 是安全的,因为 fmt 打印之前会先给 map 的键排序。
为了看到检查生效,我把 ExampleCount 的期望输出改成了错的:
$ go test -run ExampleCount .
--- FAIL: ExampleCount (0.00s)
got:
map[cat:2 other:1 saw:1 the:2]
want:
map[cat:2 other:1 saw:1 The:1 the:1]
FAIL
这就是示例的意义。go doc 和 pkg.go.dev 会把 ExampleCount 显示在 Count 旁边,作为用法文档;而文档一旦不再正确,go test 马上失败。没有 // Output: 注释的示例会被编译,但不会运行。
用 b.Loop 写基准测试
基准测试是一个名字以 Benchmark 开头、接收 *testing.B 的函数。它写在同一个测试文件里:
var sample = "the quick brown fox jumps over the lazy dog and the dog sleeps"
func BenchmarkCount(b *testing.B) {
for b.Loop() {
Count(sample)
}
}
for b.Loop() 会按 testing 包的需要反复运行循环体,直到测量结果稳定。它在 Go 1.24 中引入。旧代码用的是 for i := 0; i < b.N; i++,现在依然能用。如今更好的选择是 b.Loop:循环前的准备代码不计入耗时,而且编译器不会把你忽略了结果的调用优化掉。
除非你主动要求,go test 不会运行基准测试:
$ go test -bench=. -run='^$' .
-bench=. 运行所有基准测试,-run='^$' 不匹配任何测试名,所以普通测试都会跳过。输出开头几行是操作系统、CPU 架构、包名和 CPU 型号。接下来每个基准测试一行:名字后面带 CPU 数量作为后缀,比如 BenchmarkCount-12,然后是循环运行的次数,以及以 ns/op 为单位的每次平均耗时。加上 -benchmem,这一行还会显示每次运行分配的字节数和分配次数。
我没有贴出具体数字,因为它们取决于机器,每次运行也会变。基准测试结果只在同一台机器上、多跑几次之后再比较。
go test 内部会运行 go vet
go test 在运行任何测试之前,会先对包执行一组 go vet 检查,vet 发现问题就构建失败。为了看到这一点,我把 TestNormalize 改成把字符串传给 %d 动词:
$ go test .
# example.com/wordcount
# [example.com/wordcount]
./wordcount_test.go:32:29: (*testing.common).Errorf format %d has arg got of wrong type string
FAIL example.com/wordcount [build failed]
一个测试都没跑。同样的错误放在普通程序里,只会在运行时打印一条乱掉的消息。在测试里,它在任何代码运行之前就把你拦下了。go test 只运行 vet 检查中的一部分,也就是那些几乎不会误报的,所以你自己也要运行 go vet ./...。
go test -race ./... 在构建测试时打开竞态检测器。这个模块没有 goroutine,所以测试能通过,也没什么可查的。到讲 goroutine 的那一部分,它就必不可少了。
要点
- 包是一个目录里的
.go文件。模块是一棵包树,带一个go.mod。导入路径是模块路径加上目录路径。 - 首字母大写就导出名字。在包外面,未导出的名字是
undefined。 internal/下的包,只能从包含这个internal目录的那棵树内部导入。go mod tidy让go.mod和你的导入保持一致。go.sum记录依赖的哈希。两个都要提交。- 测试写在
_test.go文件里,形如func TestX(t *testing.T)。用表格和t.Run,想继续用t.Errorf,想停下用t.Fatalf,辅助函数里用t.Helper。 go test ./...运行全部测试,-run挑选测试,-v列出测试,vet 检查最先运行。带// Output:的示例会被检查,基准测试用for b.Loop()。
在 Go 里,目录决定包,首字母决定谁能看到名字,
go test把两者都检查一遍。