需要预先了解:


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 初始化顺序

一个程序的初始化顺序是:

  1. 被导入的包先初始化,按依赖关系拓扑排序;
  2. 每个包内:先初始化包级变量(按依赖顺序,不是书写顺序),再依次执行 init() 函数(可以有多个,按出现顺序);
  3. 最后执行 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())
}

组织代码时的几条惯例:

  1. 按功能分目录,而不是按类型分(不要建 models/、utils/ 这种大杂烩目录);
  2. 包名就是使用时的前缀,尽量短,避免 util、common 这类含义模糊的名字;
  3. 对外暴露的东西放在包的顶层,实现细节可以放进 internal/;
  4. 避免包之间循环导入——Go 直接禁止循环依赖,这是编译期错误。

1.10 练习

  1. 建一个模块,包含两个包 calc 和 main,在 calc 里提供导出和未导出的函数,验证 main 只能调用导出的那个。
  2. 写两个包,各自有 init() 和包级变量,观察 go run 输出的初始化顺序,并解释为什么是这个顺序。
  3. 用 go mod init 建一个模块,go get 一个第三方包,运行 go mod tidy 后对比 go.mod 与 go.sum 的变化。