1.3 Go Modules 与项目骨架
上一节的 go build 产物里写着 mod taskapi (devel),那个 (devel) 说明我们其实还没有模块——代码能编译,只是因为标准库不需要依赖声明。但真实项目迟早要引入第三方库、要拆成多个包、要有版本号,这些都以「模块」为前提。本节就把 TaskAPI 从散落的一个文件,正式变成一个模块化的项目。
本节把 TaskAPI 推进到「有正式模块身份与目录骨架」:执行
go mod init taskapi,建出cmd/taskapi与internal/task两级目录,让go build ./...一次编译多个包。到本节结束,项目结构就定型了,后面 17 章只是往里填内容。
1.3.1 为什么需要模块
在 Go 1.11 之前,所有 Go 代码都必须放在 $GOPATH/src 下面,依赖则靠 go get 直接拉取仓库的最新代码。这套做法有三个致命问题:
- 没有版本概念:
go get拿到的是默认分支的当前状态,今天能编译的代码,明天上游一改就崩。 - 无法锁定依赖:你没法声明「我依赖的是 v1.2.3」,也没法保证同事拉到同一份代码。
- 必须待在 GOPATH 里:项目不能放在任意目录,多项目协作很别扭。
Go Modules(Go 1.11 引入,1.16 起默认开启)把「模块」作为依赖与版本的基本单位。一个模块由一个 go.mod 文件定义,它记录模块自己的路径、Go 版本要求,以及所有直接与间接依赖的精确版本。GOPATH 模式就此退出历史舞台——现在的项目可以放在磁盘上任何位置。
1.3.2 go mod init:给项目一个身份
在项目根目录执行:
GOTOOLCHAIN=go1.27.0 go mod init taskapi
go: creating new go.mod: module taskapi
如果目录里已经有 .go 文件且它们 import 了本模块内的包,go mod init 还会多打印一行提示:
go: to add module requirements and sums:
go mod tidy
这是提醒你「依赖图还没整理」,而不是错误。生成的 go.mod 只有两行有效内容:
module taskapi
go 1.27.0
逐行解释:
| 行 | 含义 |
|---|---|
module taskapi | 本模块的导入路径前缀。本模块内的包,导入路径都以它为前缀 |
go 1.27.0 | 声明本模块按 Go 1.27 的语言与标准库语义编译 |
go 1.27.0 这一行不只是「建议」,它参与工具链协商:如果别人的本机 Go 是 1.26,GOTOOLCHAIN=auto 会自动去下载 1.27 的工具链来构建你的项目。这就是 1.1 安装与工具链
里那套机制的服务对象。
想确认模块信息,用 go list -m -json:
GOTOOLCHAIN=go1.27.0 go list -m -json
{
"Path": "taskapi",
"Main": true,
"Dir": "/Users/you/taskapi",
"GoMod": "/Users/you/taskapi/go.mod",
"GoVersion": "1.27.0"
}
"Main": true 表示这是当前正在开发的模块,而非从缓存里拉的依赖。
1.3.3 模块路径怎么取
go mod init 的参数就是模块路径,它有两个作用:一是作为本模块内包的导入前缀,二是(当项目要发布时)告诉别人从哪儿拉取。选法分三种情况:
| 场景 | 模块路径 | 说明 |
|---|---|---|
| 本地练习、不发布 | taskapi | 单段名,简单直接,本书采用 |
| 要发布到 GitHub | github.com/you/taskapi | 与实际仓库地址一致,go get 才能找到 |
| 私有仓库 | git.internal.corp/taskapi | 配合 GOPRIVATE 环境变量绕过校验 |
一个常见的误解是「必须用域名前缀」。实际上只有要对外发布的模块才需要,本地项目用单段名完全没问题——Go 允许 go.mod 里出现不含点的模块路径,只是这类模块无法被 go get 远程拉取。本书的 TaskAPI 是教学项目,用 taskapi 就够了;如果将来真要开源,改 go.mod 第一行加一次全局替换即可。
1.3.4 目录骨架:cmd 与 internal
单文件项目长不大。TaskAPI 现在就要定下结构,免得后面重构:
taskapi/
├── go.mod
├── cmd/
│ └── taskapi/
│ └── main.go # 程序入口,只负责组装与启动
└── internal/
└── task/
└── task.go # 领域模型与业务逻辑
两个目录各有讲究:
cmd/taskapi/:放可执行程序的main包。一个模块可以有多个可执行程序,各自一个子目录(cmd/server、cmd/cli、cmd/migrate),这样go build ./cmd/server就能单独构建其中一个。把入口与业务逻辑分开,是 Go 项目的标准做法。internal/:Go 语言编译器强制的可见性边界。internal目录下的包只能被「以该internal的父目录为根的子树」导入,外部模块一律无法引用。换句话说,taskapi/internal/task只有taskapi自己能用,别人go get了你的模块也导不进来。这条规则由工具链保证,不需要靠文档约定。
先写 internal/task/task.go:
package task
type Task struct {
ID int64
Title string
Done bool
}
再写 cmd/taskapi/main.go:
package main
import (
"fmt"
"taskapi/internal/task"
)
func main() {
t := task.Task{ID: 1, Title: "写第一章"}
fmt.Printf("%+v\n", t)
}
注意 import "taskapi/internal/task"——导入路径是「模块路径 + 目录相对路径」,不是文件系统路径。Go 从 go.mod 的 module 行推出前缀,再拼上目录名,就能定位到包。这也意味着目录名与包名最好一致,否则读代码的人要来回对照。
1.3.5 用 go build ./… 验证骨架
./... 通配符表示「当前目录及其所有子目录下的所有包」,一条命令就能编译整个项目:
GOTOOLCHAIN=go1.27.0 go build ./...
无输出即成功。想看看到底有哪些包被识别,用 go list:
GOTOOLCHAIN=go1.27.0 go list ./...
taskapi/cmd/taskapi
taskapi/internal/task
两个包都在。想连包名一起看:
GOTOOLCHAIN=go1.27.0 go list -f '{{.ImportPath}} {{.Name}}' ./...
taskapi/cmd/taskapi main
taskapi/internal/task task
可以看到 cmd/taskapi 的包名是 main(目录名与包名不同,这是 main 包的特权),而 internal/task 的目录名与包名一致。运行入口:
GOTOOLCHAIN=go1.27.0 go run ./cmd/taskapi
{ID:1 Title:写第一章 Done:false}
%+v 把字段名也打印出来了,这是调试结构体最顺手的一招。
1.3.6 go.sum 什么时候才出现
初学者常困惑:为什么我的项目里没有 go.sum?答案很简单——go.sum 只在存在外部依赖时才生成。
ls
cmd go.mod internal
本项目到目前为止只用了标准库,所以没有 go.sum。一旦 go get 引入第三方库,go.sum 会立刻出现,里面是每个依赖模块的哈希值,用于校验下载内容没被篡改。相关命令先认识三个:
| 命令 | 作用 |
|---|---|
go mod tidy | 增删 go.mod 里的依赖,使其与代码实际 import 一致 |
go mod verify | 校验缓存里依赖的哈希与 go.sum 是否匹配 |
go mod graph | 打印依赖图 |
现在跑一下前两个:
GOTOOLCHAIN=go1.27.0 go mod tidy
GOTOOLCHAIN=go1.27.0 go mod verify
all modules verified
go mod graph 此时输出的是工具链自身的依赖:
GOTOOLCHAIN=go1.27.0 go mod graph
taskapi go@1.27.0
go@1.27.0 toolchain@go1.27.0
第一行表示 taskapi 要求 Go 1.27.0;第二行是工具链自动下载机制留下的记录。等第 14 章引入数据库驱动后,这张图才会真正长出第三方节点。
1.3.7 初始化清单与 .gitignore
一个新建的 Go 项目,在提交第一版代码前建议核对这张清单:
-
go.mod存在,module行是期望的路径,go行是目标版本 - 目录结构是
cmd/<binary>/+internal/,业务逻辑不在main包里 -
go build ./...与go vet ./...均无输出 -
gofmt -l .无输出 -
.gitignore忽略了编译产物
.gitignore 至少要写这些:
# 编译产物(go build 默认输出名 = 目录名)
/taskapi
# 测试与覆盖率产物
*.test
*.out
# 编辑器
.idea/
.vscode/
注意不要把 go.sum 加进 .gitignore。go.sum 必须提交,它是构建可复现与依赖防篡改的保证;把它忽略掉是新手常见错误,会导致别人拉下代码后校验失败。go.mod 与 go.sum 都应入库。
小结
- 模块是 Go 依赖与版本的基本单位,由
go.mod定义;GOPATH模式已退出历史。 go mod init taskapi生成go.mod,其中module行是导入前缀,go 1.27.0行参与工具链协商。- 模块路径只有在要发布时才需要域名前缀;本地项目用单段名完全合法。
cmd/<binary>/放main包,一个模块可有多个可执行程序;internal/是编译器强制的可见性边界,外部模块无法导入。- 导入路径 = 模块路径 + 目录相对路径,与文件系统路径无关。
go build ./...编译全项目,go list ./...列出所有包;%+v是打印结构体的常用动词。go.sum只在有外部依赖时生成,且必须提交;go mod tidy维护依赖,go mod verify校验哈希。
第一章到此结束:环境就绪、程序能跑、项目骨架成型。从下一章起我们开始写真正的业务代码——2.1 变量、常量与基本类型
会为 TaskAPI 定义第一个数据结构 Task,并讲清 Go 的类型系统与零值哲学。想复习骨架的搭建过程,回看 1.2 第一个程序与 go run/build
。
阅读导航:上一节:1.2 第一个程序与 go run/build · 下一节:2.1 变量、常量与基本类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。