← [ BACK_TO_NOTES ]

Go API 设计与代码约定

导出与命名、接受接口返回具体类型、错误与上下文、包边界与最小公开面等 Go 公共 API 设计约定。

#concept #growing #dev #go

[!info] related notes

Go API 设计与代码约定

本篇讨论 Go 包与库的调用接口;HTTP 资源路径、状态码和请求验证分别见 go-routing-patterns、go-http-handler-service-repository、go-validation。

这个概念为什么会出现

语言允许你导出任何大写标识符,但不等于应该全部导出。库与服务的长期成本主要花在:

  • 名字是否直白、可预测
  • 依赖方向是否稳定
  • 错误是否可判断(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

以上 SQLRepo 是固定返回值的占位实现,不实际访问 DB 或响应取消;真实实现须把 ctx 传给支持取消的操作。示意组装:

// 用例侧只依赖 Repository;单测塞 stub,生产塞 &SQLRepo{}
var repo Repository = &SQLRepo{}
u, err := repo.Find(ctx, "42")

结合场景再看关注点

  1. 命名:包名 user 参与调用处语义。
  2. ctx:可取消/超时的边界从 API 签名开始。
  3. 具体返回 + 使用方小接口:可测且不锁死实现。
  4. 导出面最小化:内部字段/helper 保持未导出。

核心概念与准确模型

1. 包是 API 的第一边界

包名出现在调用处:

user.Find(...)   // 好:包名参与语义
util.Do(...)    // 差:看不出领域

约定:

  • 短、小写且有语义;单数不是硬性要求,标准库也有 bytes、strings、errors
  • 避免 util、common、misc、base 万能包
  • 避免与标准库顶层名无谓冲突(谨慎用 io、http 作自己的包名)
  • 包名不要重复戳类型名:user.User 可接受;user.UserDataInfo 噪音大

参考:Package names

2. 导出面最小化

级别机制
标识符大写导出 / 小写包私有
包路径internal/ 目录限制导入方
文档导出符号需要清晰 Godoc

原则:

  1. 默认不导出
  2. 只有跨包需要的才导出
  3. 导出即承诺:变更成本上升

见 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

原因:

  1. 调用方拿到具体类型可调用新增导出方法,无需改接口
  2. 接口由消费方定义更贴合真实需求
  3. 返回接口隐藏实现,并限制静态可见的方法集;为导出接口增方法可能破坏外部实现方。返回具体类型也会让其公开行为成为兼容性承诺

例外:

  • 需要隐藏实现防误用(返回 io.Reader 而非内部缓冲)
  • 工厂必须在多种实现间切换且调用方只依赖行为

5. context 作为首参

func (r *SQLRepo) Find(ctx context.Context, id string) (User, error)

约定:

  • ctx 在参数列表第一位(方法接收者之后)
  • 不把 context 存在结构体长期字段(请求级 ctx 除外的生命周期讨论)
  • 下游调用传递同一 ctx 或其派生
  • 禁止在库里 context.Background() 吞掉取消——除非是真正的进程级任务且文档说明

见 go-context、context package。

6. 错误是 API 的一部分

好的错误 API:

  1. 需要调用方程序化识别的错误,提供 errors.Is / errors.As 合同
  2. 有意选择暴露哪些根因:%w 让调用方可 Is/As 检查,也使底层错误成为 API 承诺;不应无条件暴露驱动细节
  3. 对用户/调用方有行动信息
  4. 不把敏感数据无控打进错误
var ErrNotFound = errors.New("user: not found")

func Find(...) (User, error) {
	// ...
	return User{}, fmt.Errorf("find id %s: %w", id, ErrNotFound)
}

避免:

  • 需要调用方程序化分支时,却只提供易变字符串;只需报告失败的 API 不必为每种错误都导出哨兵
  • 导出不稳定的错误字符串当契约却又经常改文案
  • 在错误里塞巨大 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
}

适用:可选参数多,希望新增可选项时保留已有调用代码的源码兼容性;这不保证 Go 包的二进制兼容。上例未校验负超时,真实构造器应按合同校验配置并在需要时返回 error。 过度:只有 1~2 个参数时,直接字段/参数更清晰。

Rob Pike / Dave Cheney 等关于 functional options 的讨论是社区背景;可读性优先。

8. 零值可用性

Go 喜爱“零值就可用”:

  • var s sync.Mutex
  • var b bytes.Buffer
  • http.Client{} 可用

设计类型时问:

零值是否安全?是否表示“未初始化必须 New”?

若必须 New,应:

  • 未导出字段可阻止外部直接改内部状态,但导出的 struct 仍能被声明为零值,不能强制调用 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 只限制 reslice/append 可见范围,已有元素仍共享。需隔离时复制;元素含 map、slice 或指针时,浅拷贝仍可能共享内部对象

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限制导入方范围,不等于取消兼容责任按目录树限定包可见性,不依赖是否 monorepo

应用代码仍应避免“随手导出”,否则包循环与测试痛苦会累积。

边界与限制

  1. 惯例不是规范全文:团队可有本地规则,但偏离要一致且写清。
  2. 生成代码:protobuf 等命名不完美,可用包装层。
  3. 性能 API:有时返回接口或 []byte 复用缓冲,需额外文档(所有权)。
  4. 泛型:约束与类型参数命名也要克制(T、K、V 常见)。见 go-generics。
  5. 上下文超时默认值:库是否设置默认 timeout 有争议——常留给调用方。
  6. 错误即控制流过深:仍要避免用 panic 当 API。
  7. 反射/any 边界:序列化边界不可避免;领域核心保持强类型。
  8. 不要为了“可测试”污染导出面:优先小接口注入。

常见误区

[!warning] 误区 1:全部导出“以备后用” 公开面只增难减。

[!warning] 误区 2:包名 util 成为依赖黑洞与循环依赖温床。

[!warning] 误区 3:返回 interface{} 图省事 丢掉编译期检查,错误推迟到运行时。

[!warning] 误区 4:提供方预定义巨大 Repository 接口 实现与 mock 双输。

[!warning] 误区 5:忽略 context 无法取消,级联超时失控。

[!warning] 误区 6:需要机器判断的错误只给文案 为稳定分支提供 Is/As 合同;%w 包装哪些底层错误也应经过设计。

[!warning] 误区 7:结构体字段全导出当配置 调用方可绕过构造时校验或之后修改字段。配置 struct 可以公开,但使用方应明确校验时机与是否复制配置。

[!warning] 误区 8:API 文档写“详见源码” 源码会变;导出行为应在 Godoc 说清。

工程实践

设计检查清单

  1. 包名是否出现在读起来通顺的句子里?
  2. 每个导出符号是否有调用方与稳定职责?
  3. 参数是否接受最小接口?
  4. 返回是否具体类型?
  5. 需要取消的操作是否接收并传播 ctx?需程序化处理的错误是否可判定?
  6. 零值语义?并发语义?
  7. 破坏性变更是否有版本与迁移方案?internal 只限制导入范围,仍需维护其调用方。

Code Review 焦点

  • 新导出符号的必要性
  • 错误与 ctx 是否贯穿
  • 命名是否与标准库和谐
  • 测试是否不依赖脆 mock

与静态分析

staticcheck、golint 历史规则、revive 等会抓部分风格;但 API 设计判断仍靠人。见 go-static-analysis。

推荐阅读顺序

  1. Effective Go
  2. Code Review Comments
  3. Package names
  4. Go Blog — Context
  5. Go Blog — Working with Errors / Go 1.13 errors
  6. 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. 零值可用为何是优点?

答案
  1. 便于增加方法且不破坏调用方;接口由需要的一方定义更贴合。
  2. 小写:包内私有符号;internal:包路径级限制谁能导入。
  3. 切断调用方取消/超时信号,可能导致泄漏与失控耗时。
  4. 已有实现类型不再满足接口,编译失败。
  5. 语义空洞、依赖聚集、命名冲突与循环依赖风险。
  6. 降低初始化仪式,标准模式清晰,少 nil 指针状态。

依据与延伸

创建于 2026/7/14 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本