Go API 设计与代码约定

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

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go API 设计与代码约定

这个概念为什么会出现

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

  • 名字是否直白、可预测
  • 依赖方向是否稳定
  • 错误是否可判断(Is/As
  • 接口是否放对位置
  • 兼容性是否可演进
  • 上下文取消能否贯穿

Go 社区通过 Code Review CommentsEffective GoPackage namesGo 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")

结合场景再看关注点

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

核心概念与准确模型

1. 包是 API 的第一边界

包名出现在调用处:

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

约定:

  • 短、小写、单数概念(httpusersql
  • 避免 utilcommonmiscbase 万能包
  • 避免与标准库顶层名无谓冲突(谨慎用 iohttp 作自己的包名)
  • 包名不要重复戳类型名:user.User 可接受;user.UserDataInfo 噪音大

参考:Package names

2. 导出面最小化

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

原则:

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

go-packages-and-visibilitygo-internal-packages

3. 命名:一致性高于机智

  • 函数名:动词或动词短语;避免无信息 DoHandleData
  • Get 前缀:社区常避免无意义 Getlen 风格);Get 在 RPC/HTTP 语义里另说
  • 接口:单方法多 Xxxer;多方法用业务名
  • 错误变量:ErrNotFoundErrInvalid
  • 缩写:IDURLHTTP 保持一致大小写风格(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-contextcontext package

6. 错误是 API 的一部分

好的错误 API:

  1. errors.Is / errors.As
  2. 不丢根因(%w 包装)
  3. 对用户/调用方有行动信息
  4. 不把敏感数据无控打进错误
var ErrNotFound = errors.New("user: not found")

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

避免:

  • 只返回字符串无哨兵、无类型
  • 导出不稳定的错误字符串当契约却又经常改文案
  • 在错误里塞巨大 payload

go-error-handlinggo-error-wrappingerrors

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.Mutex
  • var b bytes.Buffer
  • http.Client{} 可用

设计类型时问:

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

若必须 New,应:

  • 未导出字段
  • 方法检查未初始化并返回错误或 panic(选一种并文档)

9. 指针 vs 值接收者(API 层)

对导出类型:

  • 需要突变或含 mutex → 指针
  • 小不变配置 → 值可能更好
  • 同一类型方法接收者一致性很重要

go-method-sets-and-receiversgo-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-modulesModule version numbering

15. 分层:库 API vs 服务应用代码

公开库公司内部服务
稳定性极高中(仍要纪律)
接口位置更谨慎使用方定义很常见
internal应用 interne 边界monorepo 强依赖

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

边界与限制

  1. 惯例不是规范全文:团队可有本地规则,但偏离要一致且写清。
  2. 生成代码:protobuf 等命名不完美,可用包装层。
  3. 性能 API:有时返回接口或 []byte 复用缓冲,需额外文档(所有权)。
  4. 泛型:约束与类型参数命名也要克制(TKV 常见)。见 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:错误只 fmt.Errorf 字符串,无法 Is 调用方只能比字符串。

[!warning] 误区 7:结构体字段全导出当配置 无法校验不变量,也难演进。

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

工程实践

设计检查清单

  1. 包名是否出现在读起来通顺的句子里?
  2. 导出符号能否删一半?
  3. 参数是否接受最小接口?
  4. 返回是否具体类型?
  5. 是否有 ctx?错误是否可判定?
  6. 零值语义?并发语义?
  7. 破坏性变更是否被 semver/internal 隔离?

Code Review 焦点

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

与静态分析

staticcheckgolint 历史规则、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-repositorygo-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/7/15