《Go 语言编程入门》7.1 包的声明、导入与可见性

本节把 TaskAPI 从单文件拆成 internal/task、internal/store、cmd/taskapi 三个包:先讲清 package 声明与目录的对应关系、import 路径的解析与分组,再说明标识符大小写如何决定导出可见性、internal/ 目录为何能挡住模块外的引用,最后给出包命名规范与循环导入的排查思路。读完你能为自己的项目划出清晰的包边界。

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/
复用别的项目无法导入 maininternal/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、paginationtask_store、TaskStore
短、语义明确task、storetaskapiutils
避免与标准库重名mytaskerrors、json
避免复数tasktasks

还有一条硬约束: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 小结与常见坑

拆包时最常踩的坑:

  1. 目录名与包名不一致。internal/store/ 里写了 package memstore,编译能过,但导入方必须写 store.MemStore 之外的奇怪别名,可读性立刻崩坏。
  2. 忘了导出。把 NewMemStore 写成 newMemStore,外部包 undefined。
  3. 把测试包当普通包。xxx_test 只能用于 _test.go 文件,不能出现在生产代码里。
  4. 循环导入。先画依赖方向再动手,依赖必须是单向的树。

下一节我们要让 go.mod 真正发挥作用:如何声明依赖、go.sum 校验什么、Go 的**最小版本选择(MVS)**在多个模块要求同一依赖时如何裁决。

阅读导航:上一节:6.3 errors.Is/As 与自定义错误 · 下一节:7.2 go.mod/go.sum 与最小版本选择 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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