00 中的计数函数与 main 同属一个包。把函数移到 stats/ 后,CLI 的输入和输出没有变,却必须回答三个不同的问题:编译器该把哪几份源码合成一个包,main 用什么路径找到计数函数,以及从新目录取到工程时还需要哪些外部依赖。把 package、module 和依赖混成一个词,会误以为“多一个目录就得下载一个模块”。

这次变更只移动原有函数和对应测试,不引入接口或第二套服务。整个系列仍只有 examples/go-language/ 一个累计工程;本章素材包保存此时完整的工程与 00、01 的原始证据,00 的原版另有独立快照。系列导航列明各篇验收状态。

一个目录改变了什么

count.go 从工程根目录移到 stats/count.go,包声明由 package main 改为 package stats,调用方改用该包导出的函数:

1
2
3
4
5
6
7
package stats

import "strings"

func CountWords(text string) int {
return len(strings.Fields(text))
}

计数规则没有变化:按 strings.Fields 所认定的空白分字段,Go 写 Go 仍应得到 3。CountWords 的首字母大写使包外代码可以引用这个名字。main.go 里增加 import "example.com/go-language-task/stats",调用写作 stats.CountWords(*text)。若只改目录、不改包声明或调用表达式,00 直接同包调用的写法就不再能成立。

Go 的 package 划定源码编译和命名空间的边界。一个目录中参与普通构建的 Go 文件需要相容的包声明;代码在调用处用包名 stats 限定 CountWords。module 则由 go.mod 声明为 example.com/go-language-task,给本工程的包路径提供共同前缀,并管理模块级依赖与版本。这里选 example.com 作为教学路径,没有发表这个 module,更没有向外部仓库索取一个叫 stats 的模块。

1
2
3
4
5
6
7
go.mod: module example.com/go-language-task
|
+-- 工程根目录:package main
| import "example.com/go-language-task/stats"
|
+-- stats/:package stats
CountWords(由 main 调用)

这两个目录是两个 package,但都属于一个 module。main.go 与 main_test.go 留在根目录,stats/count.go 与 stats/count_test.go 留在统计包。测试拆分让计数行为的断言靠近它所属的包;入口测试继续检查纯空白、未知参数等 CLI 契约。此时不需要新建一个专门的 go.mod:那会凭空制造版本与发布边界,而当前业务只要求拆分代码职责。

从干净目录构建

实验将 go.mod、根目录源码和 stats/ 复制到 /tmp/go-language-01-clean-PplCQR,不依赖仓库外的同名包、IDE 项目模型或未提交的 workspace。使用 00 冻结的 Go 1.27.1,并设置 GOTOOLCHAIN=local、GOWORK=off、GOMAXPROCS=2、CGO_ENABLED=0;其中 GOWORK=off 是为了排除其他 workspace 文件暗中帮助导入。命令和原始双流、退出码见包内 evidence/01/。

1
2
3
4
5
6
go version
go list ./...
go list -m all
go test -count=1 -v ./...
go build -trimpath -o /tmp/go-language-01-clean-PplCQR/taskstats .
/tmp/go-language-01-clean-PplCQR/taskstats --text 'Go 写 Go'

go list ./... 本次实测打印两行:example.com/go-language-task 与 example.com/go-language-task/stats;这是匹配当前模块中的两个 package。go list -m all 只打印 example.com/go-language-task,因为仍只有一个 module,也没有第三方依赖。go test 分别对根包和统计包打印 ok,原有七个子测试通过;构建和运行退出 0,CLI 标准输出仍是 words=3。同一份干净副本对纯空白输入退出 2,未写入成功输出。

go version -m 对独立构建的可执行文件记录 Go 1.27.1、主模块 (devel)、-trimpath=true 与 CGO_ENABLED=0,但没有仓库 VCS 修订字段:副本里没有 .git。这不是“产物按位可复现”的证明,只表明此处无需原工作树的 Git 元数据也能完成构建。go test -count=1 不使用缓存结果,但仅覆盖本次提供的输入;00 的其他平台与 race 场景仍未运行。

错误的导入路径会怎样

在另一个隔离副本中,仅把 main.go 的导入改为 example.com/go-language-task/missing,保留 stats/ 包并设置 GOPROXY=off,再执行 go test -count=1 ./...。本次退出 1;原始标准错误包括:

1
2
main.go:10:2: no required module provides package example.com/go-language-task/missing; to add it:
go get example.com/go-language-task/missing

stats 包自己的测试仍能通过,根目录的程序却无法编译。错误指出的是调用方想导入的路径在当前模块和已选择的依赖里都找不到,并非 strings.Fields 算错。工具文本里出现 go get 是建议,不是已执行的命令;这次没有运行 go get、没有下载外部模块,恢复正确的内部路径即可。完整失败输出与唯一改动 missing-import.diff 留在 evidence/01/,错误副本不在累计工程里。

由此可分清三类计数:go list ./... 列出匹配的包;go list -m all 列出选择的模块;构建所得二进制是程序产物,不是“第三个 module”。将来 SQL 驱动真正成为外部依赖时,需要另冻版本、来源和 go.sum,不能把此处没有外部依赖的结果沿用到 25。

素材与验收

独立交付的压缩包 SHA-256 是 54990e12643523f2943b161b87952a113d2d731da83e7c3628ad0d07769f80f3;旁边的校验文件可由 sha256sum -c 核查。包内 RUN.md 给出从解压目录启动的命令,VERSIONS.md 固定 Go patch、二进制校验值和源码 tag/SHA;证据包含 00 与 01 两章。当前环境已重新解包,实际跑出两个 package、一个 module、测试和构建均退出 0;有效请求退出 0、纯空白退出 2。相应输出保存在素材目录 unpack-* 中,而不是拿原工程测试代替归档测试。

练习一:新增 stats/ 后,go list -m all 会不会打印两个模块?答案:不会,当前实际只打印 example.com/go-language-task;第二个包路径出现在 go list ./...。包的数量不决定 module 数量。

练习二:如果 main 把内部导入路径拼错,为什么不应该直接按编译提示执行 go get?答案:当前模块中的真实包叫 example.com/go-language-task/stats,不存在的 missing 并非缺了一个可下载依赖;先修正路径。失败实验已在隔离副本中实测,但没有替读者验证任意第三方 module 的下载情况。

下一篇在这个工程中增加任务配置,分别检查零值、显式配置和变量遮蔽造成的错误。只有当错误配置在计数之前被拒绝,才继续把同一入口扩为批量执行。

参考资料

  • Go 模块参考与go.mod 指令:module path 和选择依赖的规则;实际教学版本 go1.27.1 的 go help modules 输出留在 evidence/01/doc-modules.*。
  • Go 语言规范:Package clause及Exported identifiers:包声明与包外可见性;本章以 Go 1.27.1 工具链自带的 doc/go_spec.html 对照。
  • Go 1.27.1 go help packages:./... 是包模式,不是模块数;实际 go list 命令及输出见本章原始日志。