《Go 语言编程入门》1.3 Go Modules 与项目骨架

本节把 TaskAPI 从「一个文件」升级成「一个项目」:执行 go mod init taskapi 并逐行读懂 go.mod,讲清模块路径的选法与 go.sum 何时才出现;再搭出 cmd/ 与 internal/ 目录骨架,用 go build ./...、go list 验证结构,最后给出一份可直接照抄的初始化清单与 .gitignore。

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单段名,简单直接,本书采用
要发布到 GitHubgithub.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 变量、常量与基本类型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练