Go API 设计与代码约定
导出与命名、接受接口返回具体类型、错误与上下文、包边界与最小公开面等 Go 公共 API 设计约定。
[!info] 关联笔记
Go API 设计与代码约定
这个概念为什么会出现
语言允许你导出任何大写标识符,但不等于应该全部导出。库与服务的长期成本主要花在:
- 名字是否直白、可预测
- 依赖方向是否稳定
- 错误是否可判断(
Is/As) - 接口是否放对位置
- 兼容性是否可演进
- 上下文取消能否贯穿
Go 社区通过 Code Review Comments、Effective Go、Package names、Go Blog — Errors 等沉淀了一组惯例:不是编译器强制,却是可维护性的默认值。遵守它们,能让新同事与工具链“猜对”你的代码。
[!abstract] 一句话理解 小导出面、短包名、具体类型返回、接口由使用方定义、错误可
Is/As、context 作首参——用约定降低协作与演进成本。
最小对照示例
先把示例放进业务场景,再看代码:
场景:用户查询 API 要可测、可取消、可演进
账号服务对外提供「按 ID 查用户」。
若仓库包导出巨大 UserRepository 接口、返回 map[string]any、函数不接 context,调用方会:难 mock、难超时、难做兼容。
社区约定的默认形状:返回具体类型、小接口由使用方定义、ctx 作首参、包名参与语义。
package user
import "context"
// User:领域具体类型——调用方拿到结构,而不是 any/map。
type User struct {
ID string
Name string
}
// Repository:由使用方(本服务用例层)定义的依赖面。
// 教学点:接口描述“我需要什么”,不强迫 SQL 包导出巨型接口。
type Repository interface {
Find(ctx context.Context, id string) (User, error)
}
// SQLRepo:生产实现;返回 *SQLRepo / SQLRepo 具体类型给组装根。
type SQLRepo struct {
// db *sql.DB
}
// Find:ctx 首参,便于超时与取消贯穿 DB 调用。
func (r *SQLRepo) Find(ctx context.Context, id string) (User, error) {
_ = ctx // 真实项目:QueryContext(ctx, ...)
return User{ID: id, Name: "Ada"}, nil
}
// 较差对照(示意,不要学):
// - 返回 any / map[string]any 丢失结构
// - 在 SQL 包导出 20 方法巨大接口强迫实现
// - 忽略 ctx,无法取消
// - 包名 userutils、helpers
建议运行(示意组装):
// 用例侧只依赖 Repository;单测塞 stub,生产塞 &SQLRepo{}
var repo Repository = &SQLRepo{}
u, err := repo.Find(ctx, "42")
结合场景再看关注点
- 命名:包名
user参与调用处语义。 - ctx:可取消/超时的边界从 API 签名开始。
- 具体返回 + 使用方小接口:可测且不锁死实现。
- 导出面最小化:内部字段/helper 保持未导出。
核心概念与准确模型
1. 包是 API 的第一边界
包名出现在调用处:
user.Find(...) // 好:包名参与语义
util.Do(...) // 差:看不出领域
约定:
- 短、小写、单数概念(
http、user、sql) - 避免
util、common、misc、base万能包 - 避免与标准库顶层名无谓冲突(谨慎用
io、http作自己的包名) - 包名不要重复戳类型名:
user.User可接受;user.UserDataInfo噪音大
2. 导出面最小化
| 级别 | 机制 |
|---|---|
| 标识符 | 大写导出 / 小写包私有 |
| 包路径 | internal/ 目录限制导入方 |
| 文档 | 导出符号需要清晰 Godoc |
原则:
- 默认不导出
- 只有跨包需要的才导出
- 导出即承诺:变更成本上升
见 go-packages-and-visibility、go-internal-packages。
3. 命名:一致性高于机智
- 函数名:动词或动词短语;避免无信息
Do、HandleData - Get 前缀:社区常避免无意义
Get(len风格);Get在 RPC/HTTP 语义里另说 - 接口:单方法多
Xxxer;多方法用业务名 - 错误变量:
ErrNotFound、ErrInvalid - 缩写:
ID、URL、HTTP保持一致大小写风格(userID而非userId在导出字段更常见争议——团队统一即可,标准库偏URL/ID)
Code Review Comments 是权威备忘录。
4. 接受接口,返回具体类型
// 参数需要行为:接口
func NewServer(store Store, clock Clock) *Server
// 返回:具体类型,便于增加方法
func NewServer(...) *Server
原因:
- 调用方拿到具体类型可调用新增导出方法,无需改接口
- 接口由消费方定义更贴合真实需求
- 返回接口会冻结实现细节并妨碍扩展
例外:
- 需要隐藏实现防误用(返回
io.Reader而非内部缓冲) - 工厂必须在多种实现间切换且调用方只依赖行为
5. context 作为首参
func (r *SQLRepo) Find(ctx context.Context, id string) (User, error)
约定:
ctx在参数列表第一位(方法接收者之后)- 不把 context 存在结构体长期字段(请求级 ctx 除外的生命周期讨论)
- 下游调用传递同一 ctx 或其派生
- 禁止在库里
context.Background()吞掉取消——除非是真正的进程级任务且文档说明
6. 错误是 API 的一部分
好的错误 API:
- 可
errors.Is/errors.As - 不丢根因(
%w包装) - 对用户/调用方有行动信息
- 不把敏感数据无控打进错误
var ErrNotFound = errors.New("user: not found")
func Find(...) (User, error) {
// ...
return User{}, fmt.Errorf("find id %s: %w", id, ErrNotFound)
}
避免:
- 只返回字符串无哨兵、无类型
- 导出不稳定的错误字符串当契约却又经常改文案
- 在错误里塞巨大 payload
见 go-error-handling、go-error-wrapping、errors。
7. 配置与功能性选项
构造复杂类型时常见:
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 10 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}
适用:可选参数多、需保持二进制兼容扩展。
过度:只有 1~2 个参数时,直接字段/参数更清晰。
Rob Pike / Dave Cheney 等关于 functional options 的讨论是社区背景;可读性优先。
8. 零值可用性
Go 喜爱“零值就可用”:
var s sync.Mutexvar b bytes.Bufferhttp.Client{}可用
设计类型时问:
零值是否安全?是否表示“未初始化必须 New”?
若必须 New,应:
- 未导出字段
- 方法检查未初始化并返回错误或 panic(选一种并文档)
9. 指针 vs 值接收者(API 层)
对导出类型:
- 需要突变或含 mutex → 指针
- 小不变配置 → 值可能更好
- 同一类型方法接收者一致性很重要
见 go-method-sets-and-receivers、go-values-pointers-and-reference-semantics。
10. 并发承诺写进文档
导出类型必须说明:
- 是否 goroutine-safe
- 哪些方法可并行
- 零值是否可共享
http.Client 可并发复用;你的 Conn 可能不行。未写明则调用方会猜错。
11. 时间、位置、标识等敏感类型
- 时间用
time.Time(含时区意识),避免到处int64无文档 - 传
time.Duration而非裸毫秒 int(除非协议固定) - ID 类型可考虑 defined type 防混用
12. 避免在 API 中泄露内部细节
- 不要导出仅测试需要的钩子(或用
export_test.go技巧) - 不要让调用方依赖内部 map 的可变共享
- 返回 slice 时注意是否共享内部数组(复制或截断 cap)
13. Godoc 即契约
// Find returns the user with the given id.
// If no such user exists, it returns ErrNotFound.
func Find(ctx context.Context, id string) (User, error)
约定:
- 完整句开头是符号名
- 说明边界错误
- 包注释在
doc.go或包文件首
Go Doc 语法随版本增强(列表、链接等)。
14. 兼容性与版本
Modules 语义导入路径与 semver:
- 破坏性变更:v2+ 路径
- 预期:加方法到导出接口 = 破坏实现方
- 加字段到结构体:可能破坏字面量;可用未导出字段+构造
见 go-modules、Module version numbering。
15. 分层:库 API vs 服务应用代码
| 公开库 | 公司内部服务 | |
|---|---|---|
| 稳定性 | 极高 | 中(仍要纪律) |
| 接口位置 | 更谨慎 | 使用方定义很常见 |
| internal | 应用 interne 边界 | monorepo 强依赖 |
应用代码仍应避免“随手导出”,否则包循环与测试痛苦会累积。
边界与限制
- 惯例不是规范全文:团队可有本地规则,但偏离要一致且写清。
- 生成代码:protobuf 等命名不完美,可用包装层。
- 性能 API:有时返回接口或
[]byte复用缓冲,需额外文档(所有权)。 - 泛型:约束与类型参数命名也要克制(
T、K、V常见)。见 go-generics。 - 上下文超时默认值:库是否设置默认 timeout 有争议——常留给调用方。
- 错误即控制流过深:仍要避免用 panic 当 API。
- 反射/any 边界:序列化边界不可避免;领域核心保持强类型。
- 不要为了“可测试”污染导出面:优先小接口注入。
常见误区
[!warning] 误区 1:全部导出“以备后用” 公开面只增难减。
[!warning] 误区 2:包名 util 成为依赖黑洞与循环依赖温床。
[!warning] 误区 3:返回 interface{} 图省事 丢掉编译期检查,错误推迟到运行时。
[!warning] 误区 4:提供方预定义巨大 Repository 接口 实现与 mock 双输。
[!warning] 误区 5:忽略 context 无法取消,级联超时失控。
[!warning] 误区 6:错误只 fmt.Errorf 字符串,无法 Is 调用方只能比字符串。
[!warning] 误区 7:结构体字段全导出当配置 无法校验不变量,也难演进。
[!warning] 误区 8:API 文档写“详见源码” 源码会变;导出行为应在 Godoc 说清。
工程实践
设计检查清单
- 包名是否出现在读起来通顺的句子里?
- 导出符号能否删一半?
- 参数是否接受最小接口?
- 返回是否具体类型?
- 是否有 ctx?错误是否可判定?
- 零值语义?并发语义?
- 破坏性变更是否被 semver/internal 隔离?
Code Review 焦点
- 新导出符号的必要性
- 错误与 ctx 是否贯穿
- 命名是否与标准库和谐
- 测试是否不依赖脆 mock
与静态分析
staticcheck、golint 历史规则、revive 等会抓部分风格;但 API 设计判断仍靠人。见 go-static-analysis。
推荐阅读顺序
- Effective Go
- Code Review Comments
- Package names
- Go Blog — Context
- Go Blog — Working with Errors / Go 1.13 errors
- Go Proverbs(社区箴言,配合批判性吸收)
服务代码中的“薄 API”
handler 层:
- DTO 与领域模型分离(按需)
- 依赖接口注入
- 错误映射到 HTTP 状态集中管理
见 go-http-handler-service-repository、go-project-layout-and-layering。
实验
实验 A:包名朗读
对比:
util.ParseUserDateString(s)
date.Parse(s)
多写几个调用点,感受包名是否参与表达。
实验 B:返回接口 vs 结构体
返回 interface{ Find() } 的工厂 vs 返回 *SQLRepo:给结构体加方法,看调用方是否要改。
实验 C:错误 Is
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func find() error {
return fmt.Errorf("db: %w", ErrNotFound)
}
func main() {
err := find()
fmt.Println(errors.Is(err, ErrNotFound))
}
实验 D:functional options
实现 NewServer 带默认超时与 WithTimeout,用表驱动测默认与覆盖。
总结
| 问题 | 答案 |
|---|---|
| API 成本在哪? | 命名、导出面、错误、依赖方向、兼容 |
| 接口放哪? | 优先使用方;小 |
| 返回什么? | 默认具体类型 |
| ctx? | 首参贯穿 |
| 错误? | 可 Is/As,包装保留根因 |
| 包名? | 短、有领域、拒绝 util 黑洞 |
一句话收束:
导出即承诺;让调用处读起来像人话,让错误与取消成为一等设计。
自测题
1. 为什么倾向“返回 *Server 而不是 ServerInterface”?
2. internal 与小写标识符各解决什么层级的问题?
3. 库函数里调用 context.Background() 的风险?
4. 向导出接口添加方法为什么是破坏性变更?
5. 包名 utils 的典型问题?
6. 零值可用为何是优点?
答案
- 便于增加方法且不破坏调用方;接口由需要的一方定义更贴合。
- 小写:包内私有符号;internal:包路径级限制谁能导入。
- 切断调用方取消/超时信号,可能导致泄漏与失控耗时。
- 已有实现类型不再满足接口,编译失败。
- 语义空洞、依赖聚集、命名冲突与循环依赖风险。
- 降低初始化仪式,标准模式清晰,少 nil 指针状态。