Go 基础体系 · 第 85/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 通用工具封装:Options、分页、错误码、重试与包边界

本文以 Go 1.26.4 为基准,仅使用标准库完成核心示例。通用工具用于稳定重复语义:构造参数校验、分页一致性、错误跨层转换、重试预算以及可测试的时间与随机数。

utilscommonshared 往往会变成依赖中心。本文从 API、生命周期、并发、错误和安全角度明确第三方库、内部能力包与业务代码的分界。

1. 工具包首先是依赖方向

包名应表达能力,如 paginationretryhttperrclock,而不是表达“放杂物”。能力包不能反向导入订单、用户或具体 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. 领域错误与错误码不是一回事

领域层返回可匹配的错误值或自定义类型,例如 ErrOrderNotFoundConflictError。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 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。