包与模块:代码组织
需要预先了解:
1. 包与模块:代码组织
Go 用**包(package)组织代码,用模块(module)**管理依赖。包对应目录,目录内的所有文件共享同一个包名;模块由 go.mod 定义,记录依赖的精确版本。这一篇把可见性规则、导入方式、初始化顺序和模块机制讲清楚。
1.1 包的概念
一个目录就是一个包,目录下所有 .go 文件的 package 声明必须一致:
mathutil/
├── add.go // package mathutil
└── mul.go // package mathutil
包名不必与目录名相同,但惯例上保持一致。包名应该短小、全小写、不用下划线(mathutil 而不是 math_util)。
main 包是特殊的:它可以被编译成可执行文件,且必须包含 main 函数。其他包只能被导入。
同一个目录下不能同时存在两个包(_test.go 文件的 package foo_test 是例外,见 测试)。
1.2 可见性
标识符的可见性由首字母大小写决定,没有 public/private 关键字:
- 首字母大写:导出(exported),其他包可以访问;
- 首字母小写:未导出(unexported),只有同一个包内的代码可以访问。
package mathutil
const Pi = 3.14159 // 导出
func Sum(nums ...int) int { return 0 } // 导出
func internal() int { return 1 } // 未导出
在别的包访问未导出的 internal:
fmt.Println(mathutil.internal())
undefined: mathutil.internal
注意报错是 undefined——未导出标识符对其他包来说根本不存在,不是"没有权限"。这条规则同样作用于结构体字段和方法:结构体里的小写字段不会被其他包访问到,也不会被 encoding/json 序列化。
1.3 import
基本形式
import "fmt" // 标准库
import "example.com/demo/mathutil" // 模块内的包,路径是 module 路径 + 目录
多个导入可以写成一组:
import (
"fmt"
"example.com/demo/mathutil"
)
gofmt 会把标准库和第三方/本地包分组,中间空一行。
别名导入
包名冲突或太长时起别名:
import mu "example.com/demo/mathutil"
mu.Sum(1, 2)
空白导入
只为了让包执行 init(),不使用它的任何标识符时,用 _ 作为别名:
import _ "example.com/demo/sideeffect"
最常见的用途是注册驱动,例如 database/sql 需要驱动包在 init() 里注册自己:
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
点导入
点导入把包内的导出标识符直接引入当前作用域,不推荐使用:
import . "fmt"
Println("不用写包名")
它会让代码难以判断某个名字来自哪里,只适合少数 DSL 风格的库。
1.4 初始化顺序
一个程序的初始化顺序是:
- 被导入的包先初始化,按依赖关系拓扑排序;
- 每个包内:先初始化包级变量(按依赖顺序,不是书写顺序),再依次执行
init()函数(可以有多个,按出现顺序); - 最后执行
main包的初始化,再调用main函数。
看一个例子。main.go 导入了两个包:
import (
"fmt"
"example.com/demo/internal/secret"
"example.com/demo/mathutil"
)
mathutil 里有包级变量和两个 init:
var cached = buildCache()
func buildCache() map[int]int {
fmt.Println("mathutil 包级变量初始化")
return map[int]int{}
}
func init() {
fmt.Println("mathutil 的第一个 init")
}
func init() {
fmt.Println("mathutil 的第二个 init")
}
运行输出:
secret 的 init
mathutil 包级变量初始化
mathutil 的第一个 init
mathutil 的第二个 init
main 开始执行
6
3.14159
内部令牌
几个观察:
secret的init在mathutil之前执行,因为两个包都被main直接导入,初始化顺序按导入路径的字典序;- 包级变量
cached的初始化发生在init之前; init可以有多个,按书写顺序执行;- 所有被导入的包初始化完成后,才轮到
main。
init 函数有几个特点:不能有参数和返回值、不能被显式调用、不能被其他包引用。它的常见用途是注册驱动、校验配置、初始化全局单例。
1.5 go.mod 详解
go.mod 描述一个模块。最小形态只有两行:
module example.com/mod
go 1.27.1
module:模块路径,也是包导入路径的前缀;go:该模块使用的 Go 版本。
添加依赖后会出现 require 行:
module example.com/mod
go 1.27.1
require golang.org/x/sync v0.23.0
// indirect 注释表示这个依赖是间接依赖——当前模块没有直接导入它,而是被其他依赖需要:
require golang.org/x/sync v0.23.0 // indirect
当代码里真的导入了这个包之后,运行 go mod tidy 会去掉这个注释。
常用命令
| 命令 | 作用 |
|---|---|
go get pkg@v1.2.3 |
添加依赖或升级/降级到指定版本(不写版本则取最新) |
go get pkg@none |
移除依赖 |
go mod tidy |
根据代码补齐缺失依赖、删除未使用的依赖,并更新 go.sum |
go mod download |
下载依赖到本地缓存 |
go mod why pkg |
解释为什么需要某个依赖 |
go list -m all |
列出当前模块的所有依赖 |
go get golang.org/x/sync@latest
go: downloading golang.org/x/sync v0.23.0
go: added golang.org/x/sync v0.23.0
go.sum
go.sum 记录每个依赖模块内容的哈希值,用来校验下载的代码是否被篡改:
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
每行包含模块路径、版本、哈希算法(h1:)和哈希值,/go.mod 后缀那行校验的是 go.mod 文件本身。go.sum 应该提交到版本库,不要手工编辑。
代理
国内访问 proxy.golang.org 可能超时,可以换成镜像:
go env -w GOPROXY=https://goproxy.cn,direct
direct 表示镜像没有的模块直接去源站拉取。
1.6 语义化版本与最小版本选择
依赖版本形如 v1.2.3,含义是:
- 主版本号(major):不兼容的变更,
v2及以上需要把版本号写进导入路径(如example.com/lib/v2); - 次版本号(minor):向后兼容的新功能;
- 补丁号(patch):向后兼容的 bug 修复。
Go 的依赖解析策略叫最小版本选择(MVS,Minimal Version Selection):当多个模块要求同一个依赖时,Go 选择所有要求中版本最高的那个,并且不再向上浮动。这保证了构建的可复现性——只要 go.mod 不变,每次构建用的依赖版本都一样。
注:
v0.x.y表示尚未稳定的 API,v1之前主版本号可以任意破坏兼容性。v2及以上必须把主版本号放进模块路径,例如github.com/gin-gonic/gin/v2。
1.7 internal 包
名字叫 internal 的目录下的包,只能被以该目录的父目录为根的子树导入:
demo/
├── internal/
│ └── secret/
└── main.go
example.com/demo/main.go 可以导入 example.com/demo/internal/secret;换成另一个模块:
import "example.com/demo/internal/secret"
use of internal package example.com/demo/internal/secret not allowed
internal 是强制的可见性边界,用来放置"不想暴露给外部使用者"的实现细节。标准库大量使用它,例如 net/http/internal。
1.8 多模块工作区 go work(Go 1.18+)
同时开发多个互相依赖的模块时,每次修改都要 go get 或用 replace 指向本地路径,很麻烦。go work 提供一个工作区文件把多个模块放到一起:
go work init ./app ./lib
生成的 go.work:
go 1.27.1
use (
./app
./lib
)
在工作区目录下执行 go run/go build 时,Go 会优先使用 use 列出的本地模块。go.work 通常不提交到版本库,它只是本地开发时的便利设施。
1.9 一个多文件多包的项目示例
demo/
├── go.mod // module example.com/demo
├── main.go // package main
├── mathutil/
│ ├── add.go // package mathutil
│ └── mul.go
├── internal/
│ └── secret/
│ └── secret.go // package secret
└── sideeffect/
└── sideeffect.go // package sideeffect
main.go:
package main
import (
"fmt"
"example.com/demo/internal/secret"
"example.com/demo/mathutil"
_ "example.com/demo/sideeffect"
)
func main() {
fmt.Println(mathutil.Sum(1, 2, 3))
fmt.Println(secret.Token())
}
组织代码时的几条惯例:
- 按功能分目录,而不是按类型分(不要建
models/、utils/这种大杂烩目录); - 包名就是使用时的前缀,尽量短,避免
util、common这类含义模糊的名字; - 对外暴露的东西放在包的顶层,实现细节可以放进
internal/; - 避免包之间循环导入——Go 直接禁止循环依赖,这是编译期错误。
1.10 练习
- 建一个模块,包含两个包
calc和main,在calc里提供导出和未导出的函数,验证main只能调用导出的那个。 - 写两个包,各自有
init()和包级变量,观察go run输出的初始化顺序,并解释为什么是这个顺序。 - 用
go mod init建一个模块,go get一个第三方包,运行go mod tidy后对比go.mod与go.sum的变化。



