Go 基础体系 · 第 85/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go 通用工具封装:Options、分页、错误码、重试与包边界
本文以 Go 1.26.4 为基准,仅使用标准库完成核心示例。通用工具用于稳定重复语义:构造参数校验、分页一致性、错误跨层转换、重试预算以及可测试的时间与随机数。
utils、common、shared 往往会变成依赖中心。本文从 API、生命周期、并发、错误和安全角度明确第三方库、内部能力包与业务代码的分界。
1. 工具包首先是依赖方向
包名应表达能力,如 pagination、retry、httperr、clock,而不是表达“放杂物”。能力包不能反向导入订单、用户或具体 Web 框架,否则上层领域会通过一个看似基础的包形成环或隐藏耦合。
cmd/api -> transport/http -> application -> domain
\-> pagination / retry / clock
infrastructure/database -> domain contracts
依赖图中的下层应更稳定。HTTP 状态码属于 transport;领域错误不应携带 Gin/Echo Context。重试执行器可以接受函数,却不应知道“支付创建”是什么。只在一个业务包使用的 helper 留在该包,等出现第二个真实消费者、语义相同且错误规则一致时再提取。
2. 构造函数负责建立不变量
类型若缺少依赖就无法工作,应通过构造函数接收必需项并校验。HTTP client、随机源和时钟等依赖应显式保存。构造函数创建的资源由对象关闭;调用者传入的共享资源仍由调用者关闭。
type Client struct {
httpClient *http.Client
baseURL *url.URL
timeout time.Duration
}
func NewClient(httpClient *http.Client, rawURL string) (*Client, error) {
if httpClient == nil {
return nil, errors.New("http client must not be nil")
}
baseURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse base url: %w", err)
}
if baseURL.Scheme != "https" || baseURL.Host == "" {
return nil, errors.New("base url must be an absolute https url")
}
return &Client{
httpClient: httpClient,
baseURL: baseURL,
timeout: 2 * time.Second,
}, nil
}
3. Functional Options 的适用条件
Options 适合多个可选参数且默认值有意义的构造函数。必需依赖仍用普通参数;不要为一个稳定可选项引入整套模式。
按照规范,可用带未导出 apply 方法的接口避免任意外部实现,并让 option 可检查:
type Option interface {
apply(*clientOptions) error
}
type clientOptions struct {
timeout time.Duration
header http.Header
}
type timeoutOption time.Duration
func (o timeoutOption) apply(options *clientOptions) error {
if o <= 0 || o > 30*time.Second {
return errors.New("client timeout must be within (0s, 30s]")
}
options.timeout = time.Duration(o)
return nil
}
func WithTimeout(timeout time.Duration) Option {
return timeoutOption(timeout)
}
Option 不应启动 goroutine 或做 I/O;apply 是纯配置动作。失败返回 error,不用 panic 处理输入。
4. 默认值、重复 Option 和边界复制
先建立默认配置,再按调用顺序应用 Options,最后做一次整体校验。重复 Option 是后者覆盖、报错还是合并必须写进契约。安全敏感配置如 TLS 校验不应允许静默重复覆盖;普通 timeout 常可采用后者覆盖,但团队应保持一致。
Header、slice、map 是引用语义。WithHeader(http.Header) 若直接保存,调用方之后修改 map 会改变 Client,且并发请求可能竞态。Option 接收时深拷贝,返回快照时也复制:
type headerOption struct {
header http.Header
}
func WithHeader(header http.Header) Option {
return headerOption{header: header.Clone()}
}
func (o headerOption) apply(options *clientOptions) error {
for key := range o.header {
if strings.EqualFold(key, "Authorization") {
return errors.New("static authorization header is not allowed")
}
}
options.header = o.header.Clone()
return nil
}
认证令牌通常按请求取得并随 context 生命周期传递,避免长期留在共享 Client 或 heap dump。
5. 分页值对象与数值边界
页码分页至少包含 page、size 和最大 size。不要悄悄把负数修成 1;外部非法输入应返回可分类错误。计算 offset 时先检查乘法,避免 int 平台差异和 SQL 驱动转换溢出。数据库通常用 int64 表达 offset,但超大 offset 本身也会很慢。
type PageRequest struct {
Page int64
Size int64
}
func NewPageRequest(page, size, maxSize int64) (PageRequest, error) {
if page < 1 {
return PageRequest{}, errors.New("page must be positive")
}
if size < 1 || size > maxSize {
return PageRequest{}, fmt.Errorf("page size must be within [1, %d]", maxSize)
}
if page-1 > math.MaxInt64/size {
return PageRequest{}, errors.New("page offset overflows int64")
}
return PageRequest{Page: page, Size: size}, nil
}
func (p PageRequest) Offset() int64 {
return (p.Page - 1) * p.Size
}
最大页码也应按产品限制,避免攻击者触发数据库扫描数十亿行。列表必须有稳定排序和唯一 tie-breaker,否则翻页会重复或遗漏。
6. Page 响应中的 nil、总数与一致性
常见 Page[T] 包含 Items、Total、Page、Size。Total 查询可能昂贵,某些接口只返回 hasMore;不要用 -1 隐式代表未知而不写契约。JSON 若承诺 items 为数组,构造时规范空 slice。
type Page[T any] struct {
Items []T `json:"items"`
Total int64 `json:"total"`
Page int64 `json:"page"`
Size int64 `json:"size"`
}
func NewPage[T any](items []T, total int64, request PageRequest) (Page[T], error) {
if total < 0 || int64(len(items)) > request.Size {
return Page[T]{}, errors.New("invalid page result")
}
result := slices.Clone(items)
if result == nil {
result = make([]T, 0)
}
return Page[T]{Items: result, Total: total, Page: request.Page, Size: request.Size}, nil
}
count 和列表若分两条 SQL,在并发写入下可能来自不同快照。严格一致需要同一事务隔离级别或接受近似总数并文档化。工具类型无法替数据库决定一致性。
7. 游标分页的签名与版本
游标分页适合大数据和持续写入。游标应携带排序键与唯一键,例如 (created_at, id),查询条件必须与 ORDER BY 严格对应。游标是服务生成的不透明 token,不让客户端自由构造 SQL 字段。
Base64 不防篡改。游标影响租户、过滤或资源消耗时,用 HMAC 签名并包含版本、过期时间与查询指纹:
type cursorPayload struct {
Version int `json:"v"`
CreatedNS int64 `json:"createdNs"`
ID string `json:"id"`
ExpiresAt int64 `json:"expiresAt"`
}
func signCursor(payload []byte, key []byte) string {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write(payload)
signature := mac.Sum(nil)
data := append(bytes.Clone(payload), signature...)
return base64.RawURLEncoding.EncodeToString(data)
}
hash.Hash.Write 按契约不返回错误,但仍显式接收返回值。验证时先限制 token 长度,解码后分离固定长度签名,用 hmac.Equal 常量时间比较,再解析并校验版本、过期和字段范围。签名不加密,敏感内容不要放游标明文。
8. 领域错误与错误码不是一回事
领域层返回可匹配的错误值或自定义类型,例如 ErrOrderNotFound、ConflictError。HTTP/gRPC/消息层把它们映射为各自协议状态。若领域错误直接包含 HTTP 409,它就无法自然用于异步任务和 CLI。
错误码是对外稳定合同,错误文本用于人读和诊断,可以变化。不要让客户端解析中文/英文 message。外部响应通常包含稳定 code、用户安全 message 和 request ID;内部日志在边界记录完整 error chain。
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"requestId"`
}
func mapDomainError(err error) (int, ErrorResponse) {
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound, ErrorResponse{Code: "NOT_FOUND", Message: "resource not found"}
case errors.Is(err, context.DeadlineExceeded):
return http.StatusGatewayTimeout, ErrorResponse{Code: "TIMEOUT", Message: "request timed out"}
default:
return http.StatusInternalServerError, ErrorResponse{Code: "INTERNAL", Message: "internal error"}
}
}
生产实现还需注入 request ID,并将 context.Canceled 按服务器协议处理。内部数据库错误不能原样返回,避免泄露表名、地址和查询。
9. 重试器必须从资格与预算开始
只有临时失败且操作幂等或有幂等键时才重试。参数错误、权限拒绝、确定性冲突不重试。重试器接收 context、最大尝试、退避函数和分类器;每次调用都先检查剩余 deadline,等待必须可取消。
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
func (c RetryConfig) Validate() error {
if c.MaxAttempts < 1 || c.MaxAttempts > 10 {
return errors.New("max attempts must be within [1, 10]")
}
if c.BaseDelay <= 0 || c.MaxDelay < c.BaseDelay {
return errors.New("invalid retry delay range")
}
return nil
}
MaxAttempts 包含首次调用,避免“retry=3”究竟执行三次还是四次。外层和 SDK 不应同时重试同一操作,否则尝试次数相乘。数据库事务失败后是否能重放整个闭包,要看闭包是否包含外部副作用。
10. 退避、抖动、取消与资源释放
指数退避常为 base * 2^attempt 并封顶。直接左移可能溢出 Duration;抖动用于避免实例同步重试,随机源必须并发安全或每调用独享。
func waitRetry(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return errors.New("retry delay must be positive")
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}
每次 attempt 必须关闭响应 body、归还连接和释放令牌后再等待。把单次尝试提取为函数,让 defer 在该次返回时执行。取消后,已提交的外部写仍可能结果未知。
11. 一个可测试的 Retry API
重试工具不应吞掉最后错误,也不应自行记录业务日志。下面 API 让分类器决定是否重试,sleep 作为能力注入以便测试;生产传入前述可取消等待的包装。
type SleepFunc func(context.Context, time.Duration) error
func Retry(
ctx context.Context,
config RetryConfig,
sleep SleepFunc,
retryable func(error) bool,
operation func(context.Context) error,
) error {
if err := config.Validate(); err != nil {
return fmt.Errorf("validate retry config: %w", err)
}
if sleep == nil || retryable == nil || operation == nil {
return errors.New("retry dependencies must not be nil")
}
var lastErr error
for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
if err := context.Cause(ctx); err != nil {
return err
}
lastErr = operation(ctx)
if lastErr == nil {
return nil
}
if !retryable(lastErr) || attempt == config.MaxAttempts {
return fmt.Errorf("operation attempt %d: %w", attempt, lastErr)
}
delay := min(config.BaseDelay*time.Duration(1<<(attempt-1)), config.MaxDelay)
if err := sleep(ctx, delay); err != nil {
return fmt.Errorf("wait before retry: %w", err)
}
}
return fmt.Errorf("retry ended unexpectedly: %w", lastErr)
}
这里 MaxAttempts<=10 限制了位移;生产版本仍应安全计算乘法并加入抖动。API 暴露策略,避免工具层猜测错误类型。
12. Clock、随机源与窄接口
业务只需要“现在”时,接口只有 Now() time.Time;需要 timer 时再为该消费者定义 NewTimer 能力。不要复制整个 time 包成庞大接口。具体类型优先,只有调用方需要替换实现时定义接口。
type Clock interface {
Now() time.Time
}
type SystemClock struct{}
func (SystemClock) Now() time.Time {
return time.Now()
}
func expired(clock Clock, expiresAt time.Time) bool {
return !clock.Now().Before(expiresAt)
}
测试 fake 使用局部对象,不修改包级 timeNow。安全 token 使用 crypto/rand 并处理错误;可预测随机只用于抖动等非安全用途。
13. 后台工具的生命周期和所有权
配置 watcher、批量 flusher 等工具若启动 goroutine,构造返回的对象必须提供 Close/Shutdown,停止接纳、发送取消并等待退出。调用 cancel 不是完成;只有 Wait 返回才能关闭其依赖。不要在 init 中启动后台任务。
type Flusher struct {
cancel context.CancelFunc
done chan struct{}
}
func NewFlusher(parent context.Context, interval time.Duration) (*Flusher, error) {
if interval <= 0 {
return nil, errors.New("flush interval must be positive")
}
ctx, cancel := context.WithCancel(parent)
f := &Flusher{cancel: cancel, done: make(chan struct{})}
go f.run(ctx, interval)
return f, nil
}
func (f *Flusher) Close() {
f.cancel()
<-f.done
}
func (f *Flusher) run(ctx context.Context, interval time.Duration) {
defer close(f.done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 调用单次 flush;真实实现记录或暴露错误。
case <-ctx.Done():
return
}
}
}
14. 安全与生产诊断
工具越通用,输入面越大。分页限制 size/page/token 长度;重试限制尝试和总预算;URL helper 防控制字符与 SSRF;文件 helper 不接受未经约束的路径拼接;错误映射不暴露内部文本。通用序列化要限制深度和总字节,避免攻击者放大 CPU/内存。
指标记录分页拒绝、游标签名失败、错误码、重试结果/退避、队列和关停时长。不要附加高基数原始 ID;日志在请求或任务边界记录一次。
故障诊断从错误链和状态指标开始,再用 race、goroutine/heap profile。工具包升级前跑消费者测试,因为真正契约往往体现在调用点,而不只在工具自己的单元测试。
15. 测试、基准与抽象退出机制
表驱动测试适合同结构边界:页码 0、size 超限、offset 溢出;游标截断、签名错误、过期、版本未知;Retry 首次成功、临时失败后成功、不可重试、取消、最后失败。用 fake sleep 记录 Duration,不用真实 Sleep。
func TestNewPageRequest(t *testing.T) {
tests := []struct {
name string
page int64
size int64
}{
{name: "zero page", page: 0, size: 20},
{name: "oversized", page: 1, size: 101},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := NewPageRequest(tt.page, tt.size, 100); err == nil {
t.Error("NewPageRequest() error = nil")
}
})
}
}
gofmt -w .
go test -count=1 ./...
go test -race ./...
go test -bench=. -benchmem ./...
go vet ./...
基准只针对 profile 证明的热点。若抽象不断增加 bool、回调和特例,应拆回具体包;内部 API 也要有迁移计划。
16. 最终设计清单
- 包按能力命名,依赖从入口指向稳定能力,不让工具反向依赖业务或框架。
- 必需依赖用构造参数,可选且有默认值的配置才用 Options;apply 不做 I/O。
- 输入范围、零值、nil/空、顺序、错误匹配、并发安全和资源所有权都进入 API 契约。
- 分页有最大规模和稳定排序,游标签名、版本化并绑定查询,签名不当加密使用。
- 领域错误与传输错误码分层,底层包装,上层映射和记录,错误只处理一次。
- 重试仅覆盖幂等临时失败,服从同一 context 预算,等待可取消,每次 attempt 释放资源。
- Clock、随机源和存储接口由真实消费者定义为最小能力,不为 mock 预造大接口。
- 后台 goroutine 可停止、可等待,Close 顺序明确;共享 map/slice 在边界复制。
- 两个真实调用点和相同语义出现后才抽取;复杂度上升时允许撤销抽象。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 常用工具库:lo、mapstructure、copier 的收益与边界
- 下一篇:Go 并发工具:errgroup、singleflight、semaphore 与 ants
- 延伸:Go 项目工程化:目录、依赖注入、代码生成与质量门禁
- 延伸:Go 泛型基础:类型参数、约束、推断与适用边界
- 延伸:Go 错误处理:包装、errors.Is/As、panic 与 recover 边界
- 延伸:Go 服务韧性设计:超时、重试、限流、熔断与隔离舱
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论