7.1 包的声明、导入与可见性
前六章我们把 TaskAPI 的所有代码堆在 main 包里:一个 main.go 里既有领域类型、又有内存存储、还有命令分发。这在教学阶段没问题,但一旦要写测试、要复用存储、要换实现,单文件就会立刻变成绊脚石——你没法从测试包访问 main 里的东西,也没法阻止别人依赖你的内部实现。
本节把 TaskAPI 拆成三个包:领域类型落到
internal/task,存储抽象与内存实现落到internal/store,命令行入口留在cmd/taskapi。这是全卷第一次出现「多包项目」,后面第 8 章的测试、第 9 章的泛型都会建立在这套骨架上。
7.1.1 拆包前后的目录对照
先看结果。拆包之后,TaskAPI 的目录长这样:
taskapi/
├── go.mod # module taskapi
├── cmd/
│ └── taskapi/
│ └── main.go # package main,可执行入口
└── internal/
├── task/
│ └── task.go # package task,领域类型
└── store/
├── store.go # package store,接口与错误
└── memstore.go # package store,内存实现
拆包不是「为了好看」,而是为了三个具体收益:
| 维度 | 单文件 main 包 | 拆包之后 |
|---|---|---|
| 测试 | 只能写 main_test.go,难以单独测存储 | 每个包可独立 go test ./internal/store/ |
| 复用 | 别的项目无法导入 main | internal/task 可在模块内自由复用 |
| 边界 | 实现细节全暴露 | internal/ 挡住模块外引用 |
| 编译 | 改一行全量重编 | 只重编受影响的包 |
关键规则只有一条:目录名就是包名,目录路径就是导入路径。internal/store/ 里的文件,第一行写 package store,别人用 taskapi/internal/store 导入。
7.1.2 package 声明与文件组织
一个目录下的所有 .go 文件必须声明同一个包名(测试包 xxx_test 是唯一例外,第 8 章再讲)。internal/task/task.go 的开头是这样:
// Package task 定义 TaskAPI 的核心领域类型。
package task
import "time"
// Task 表示一条待办任务。
type Task struct {
ID int64
Title string
Done bool
Created time.Time
}
// New 构造一条未完成的任务。
func New(id int64, title string) Task {
return Task{ID: id, Title: title, Created: time.Now()}
}
// Complete 把任务标记为完成,返回修改后的副本。
func (t Task) Complete() Task {
t.Done = true
return t
}
这里有三个容易被忽略的细节:
- 包注释写在
package之上,以Package xxx开头。go doc会把它当成包文档;缺了它,golint与staticcheck会报警告。 - 同目录文件可以随意拆分。把
Complete挪到internal/task/methods.go里,只要还是package task,对外毫无区别。文件划分按可读性来,不按语义边界来。 - 不要用
package task1之类的命名,也不要为了「好读」把包名写成task_pkg。Go 的惯例是短、全小写、无下划线、无驼峰。
7.1.3 import:路径、分组与别名
internal/store/store.go 需要同时引用标准库和本模块的另一个包:
// Package store 提供 TaskAPI 的任务持久化抽象。
package store
import (
"errors"
"taskapi/internal/task"
)
// ErrNotFound 表示目标任务不存在。
var ErrNotFound = errors.New("task not found")
// TaskStore 抽象任务的存取。
type TaskStore interface {
Add(t task.Task) (int64, error)
Get(id int64) (task.Task, error)
List() []task.Task
}
import 的写法有几条约定:
- 分组:标准库一组,第三方一组,本模块一组,组间空一行。
gofmt不会替你分组,但goimports会。 - 导入路径是「模块路径 + 子目录」,不是文件系统相对路径。模块名是
taskapi,所以这里写taskapi/internal/task。 - 引用方式是「包名.标识符」,用的是包名不是路径末段。路径末段通常等于包名,但可以不等,例如
gopkg.in/yaml.v3的包名其实是yaml。
偶尔会用到两种特殊导入:
import (
_ "embed" // 副作用导入:只执行 init,不直接引用
. "math" // 点导入:把导出名引入当前命名空间
)
两者都应当克制使用。空白导入主要出现在注册驱动(如 _ "database/sql/driver" 的各类实现)或 //go:embed 时;点导入会让「这个名字从哪来」变得不可追溯,本卷一律不用。
7.1.4 可见性:首字母大小写决定一切
Go 没有 public/private/protected 关键字。导出的唯一判据是标识符首字母是否为大写字母:
| 写法 | 可见范围 | 例子 |
|---|---|---|
Task、New、ErrNotFound | 任何导入该包的地方 | 对外 API |
task、maxTitleRunes、newID | 仅包内可见 | 内部实现 |
MemStore 的 nextID 字段 | 仅 store 包内 | 封装状态 |
这条规则同时适用于类型、函数、方法、字段、常量、变量。注意方法名的大小写同样重要:小写方法在包外根本调不到,也就无法满足包外定义的接口。
拆包时最容易犯的错,是把原来在 main 里的小写标识符直接搬过去,结果新包外部的代码编译不过。memstore.go 里 nextID 就是刻意的包内字段:
package store
import (
"sync"
"taskapi/internal/task"
)
// MemStore 是基于内存的 TaskStore 实现。
type MemStore struct {
mu sync.RWMutex
nextID int64
data map[int64]task.Task
}
// NewMemStore 返回一个空的内存存储。
func NewMemStore() *MemStore {
return &MemStore{data: make(map[int64]task.Task)}
}
// Add 追加一条任务并返回其 ID。
func (s *MemStore) Add(t task.Task) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
t.ID = s.nextID
s.data[t.ID] = t
return t.ID, nil
}
// Get 按 ID 查询任务。
func (s *MemStore) Get(id int64) (task.Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.data[id]
if !ok {
return task.Task{}, ErrNotFound
}
return t, nil
}
// List 返回全部任务。
func (s *MemStore) List() []task.Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]task.Task, 0, len(s.data))
for _, t := range s.data {
out = append(out, t)
}
return out
}
外部只能通过 NewMemStore() 拿到 *MemStore,无法直接读写 data 或 nextID。这就是「字段小写」带来的封装。
7.1.5 internal/ 目录:模块内的私有树
internal 是 Go 工具链的特殊目录名。规则是:internal 目录下的包,只允许其「父目录所辖子树」内的代码导入。
对 taskapi/internal/task 来说,父目录是模块根 taskapi,所以整个 taskapi 模块都能导入它,但模块外的代码不能。实测一下,在模块外新建一个 outside 模块尝试导入:
$ GOTOOLCHAIN=go1.27.0 go build .
package outside
main.go:6:2: use of internal package taskapi/internal/task not allowed
报错是编译期的,不是运行期。这正是我们要的效果:internal/ 把「实现」和「契约」分开——对外暴露的 API 放在 pkg/ 或模块根,不想被别人依赖的实现放进 internal/。本卷的 TaskAPI 全部实现都放 internal/,因为它本来就不打算被外部模块导入。
7.1.6 包命名与循环导入
包名规范总结成一张表:
| 规则 | 正例 | 反例 |
|---|---|---|
| 全小写、无分隔 | store、pagination | task_store、TaskStore |
| 短、语义明确 | task、store | taskapiutils |
| 避免与标准库重名 | mytask | errors、json |
| 避免复数 | task | tasks |
还有一条硬约束:Go 禁止循环导入。store 导入 task,那么 task 就不能反过来导入 store,否则 go build 会直接报 import cycle not allowed。出现循环导入时,正确的做法不是加 interface 硬凑,而是把共享的抽象下沉到第三个更基础的包。这也是为什么拆包时我们把 TaskStore 接口放在 store 而不是 task——接口应当靠近使用方。
7.1.7 把入口装起来
cmd/taskapi/main.go 是唯一留在 package main 的文件,它负责把各包拼起来:
package main
import (
"fmt"
"taskapi/internal/store"
"taskapi/internal/task"
)
func main() {
var st store.TaskStore = store.NewMemStore()
id, _ := st.Add(task.New(0, "写第 7 章"))
fmt.Printf("added id=%d\n", id)
got, err := st.Get(id)
fmt.Println(got.Title, err)
fmt.Println("total:", len(st.List()))
}
实跑输出:
added id=1
写第 7 章 <nil>
total: 1
注意 main 里用的是 var st store.TaskStore = ... 这行显式接口赋值——它是第 5 章接口思想的延续:入口只依赖抽象,将来换成数据库实现时,main 之外的所有代码都不用改。
7.1.8 小结与常见坑
拆包时最常踩的坑:
- 目录名与包名不一致。
internal/store/里写了package memstore,编译能过,但导入方必须写store.MemStore之外的奇怪别名,可读性立刻崩坏。 - 忘了导出。把
NewMemStore写成newMemStore,外部包undefined。 - 把测试包当普通包。
xxx_test只能用于_test.go文件,不能出现在生产代码里。 - 循环导入。先画依赖方向再动手,依赖必须是单向的树。
下一节我们要让 go.mod 真正发挥作用:如何声明依赖、go.sum 校验什么、Go 的**最小版本选择(MVS)**在多个模块要求同一依赖时如何裁决。
阅读导航:上一节:6.3 errors.Is/As 与自定义错误 · 下一节:7.2 go.mod/go.sum 与最小版本选择 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。