这一章沿用一份价格表:book 为 2100 分,pen 为 300 分。先让程序查到价格,再加入替换数据源、辨认错误和取消等待的能力。金额用整数分表示。
四个目录保留各自的完整版本。先运行当前版本,再比较相邻两个版本的接口和调用位置。
01:建立一条能看清的调用路径
需求:给出商品名,返回价格。此时价格都在进程内的一张表中。
learning/go-patterns/stages/01-sync/main.go · L1–L33 · 打开完整源码
// Stage 01 keeps the price source concrete so the call path is easy to see.
// Run it with: go run .
package main
import "fmt"
// Price stores money in cents. An integer keeps arithmetic predictable.
type Price struct {
Item string
Cents int
}
// Catalog is the only data source needed at this stage.
type Catalog map[string]Price
// Service owns the operation exposed to the caller.
type Service struct {
catalog Catalog
}
func NewService(catalog Catalog) Service {
return Service{catalog: catalog}
}
// Quote performs one synchronous lookup: the caller waits for the answer.
func (s Service) Quote(item string) (Price, error) {
price, ok := s.catalog[item]
if !ok {
return Price{}, fmt.Errorf("item %q is not in the catalog", item)
}
return price, nil
}
调用从 main → NewService → Quote → catalog[item] 走过去。main 负责组装数据,Service 保存后续要用的表,Quote 执行一次查询并返回结果。
你可以把 Service 对应到 Java/C# 中保存依赖的对象。这里构造函数返回一个小 struct 值,方法也按值接收。struct 中的 map 仍引用同一份表。调用方与 Service 都能通过自己的引用看到修改,因此本例约定建好目录后只读。
这一阶段没有创建需要显式关闭的外部资源。以后接入连接池时,再决定由谁创建、复用和关闭它。
cd learning/go-patterns
go run ./stages/01-sync
go test ./stages/01-sync
改动练习: 加一个 notebook 商品。你只需改组装处的数据,查询流程继续使用原来的代码。
02:把数据源作为依赖传入
新需求:测试要能指定返回值,将来还可能从远端取价。Service 只需要一次查询能力,于是这一侧定义 PriceSource。
learning/go-patterns/stages/02-dependencies/main.go · L1–L41 · 打开完整源码
// Stage 02 keeps the service independent from a particular price source.
// Run it with: go run .
package main
import "fmt"
type Price struct {
Item string
Cents int
}
// PriceSource is defined by the service that consumes it. The interface is
// deliberately small: this service only needs one lookup operation.
type PriceSource interface {
Lookup(item string) (Price, error)
}
// Catalog is one production implementation of PriceSource.
type Catalog map[string]Price
func (c Catalog) Lookup(item string) (Price, error) {
price, ok := c[item]
if !ok {
return Price{}, fmt.Errorf("item %q is not in the catalog", item)
}
return price, nil
}
type Service struct {
source PriceSource
}
func NewService(source PriceSource) Service {
return Service{source: source}
}
// Quote depends on behavior, not on Catalog's representation.
func (s Service) Quote(item string) (Price, error) {
return s.source.Lookup(item)
}
现在调用链变成 Service.Quote → PriceSource.Lookup → 具体实现。Catalog 提供同名、同签名的方法后,就可以传入构造函数。
测试里可以放一个很小的替身:实现 Lookup,记录收到的商品名,并返回预先设置的价格或错误。这样能单独检查 Service 是否把请求正确交给依赖。可直接打开本阶段测试看实现。
这和手动依赖注入很接近。这里暂时只需要一个构造函数。等到很多对象有重复的组装逻辑时,再考虑更统一的组装方式。
选择理由: 接口围绕消费者实际调用的方法设计,可以减轻实现方和测试替身的负担。只有一个稳定实现、也没有替换需求时,具体类型往往更省事。
03:让调用方能判断失败原因
新需求:界面需要区别“商品不存在”和“数据源暂时失败”。错误文字里还需要保留是哪次报价失败。
learning/go-patterns/stages/03-errors-tests/main.go · L1–L48 · 打开完整源码
// Stage 03 adds stable error identity and tests the wrapped error chain.
// Run it with: go run .
package main
import (
"errors"
"fmt"
)
var ErrUnknownItem = errors.New("unknown item")
var ErrSourceUnavailable = errors.New("price source unavailable")
type Price struct {
Item string
Cents int
}
type PriceSource interface {
Lookup(item string) (Price, error)
}
type Catalog map[string]Price
func (c Catalog) Lookup(item string) (Price, error) {
price, ok := c[item]
if !ok {
return Price{}, fmt.Errorf("%w: %q", ErrUnknownItem, item)
}
return price, nil
}
type Service struct {
source PriceSource
}
func NewService(source PriceSource) Service {
return Service{source: source}
}
// Quote adds operation and input context while %w preserves the cause.
func (s Service) Quote(item string) (Price, error) {
price, err := s.source.Lookup(item)
if err != nil {
return Price{}, fmt.Errorf("quote %q: %w", item, err)
}
return price, nil
}
这里把错误分成两种信息:
ErrUnknownItem等变量提供稳定的错误身份。fmt.Errorf加上商品名和当前操作,帮助诊断。
%w 保留内部错误。调用方使用 errors.Is 沿包装链检查身份,因此增加上下文后,判断逻辑仍然成立。
测试应同时检查成功值、缺失商品和依赖失败。对错误身份用 errors.Is;对需要展示的文字再单独检查。完整测试见本阶段测试。
记录日志也需要分工。本例下层返回错误,最外面的 main 决定如何打印。如果每层都打印同一个错误,排查时会看到很多重复记录。
改动练习: 在 Service 和 Source 之间再包一层操作说明。原来的 errors.Is 判断应继续通过。
04:让请求可以停止等待
新需求:数据源可能变慢,调用方需要设置截止时间。ctx 从 Quote 一路传到 Lookup。
learning/go-patterns/stages/04-context/main.go · L1–L73 · 打开完整源码
// Stage 04 carries context through the service and source so waiting can stop.
// Run it with: go run .
package main
import (
"context"
"errors"
"fmt"
"time"
)
var ErrUnknownItem = errors.New("unknown item")
type Price struct {
Item string
Cents int
}
type PriceSource interface {
Lookup(context.Context, string) (Price, error)
}
type Catalog map[string]Price
type DelayedCatalog struct {
prices Catalog
delay time.Duration
}
func NewDelayedCatalog(prices Catalog, delay time.Duration) DelayedCatalog {
return DelayedCatalog{prices: prices, delay: delay}
}
// Lookup waits for the source delay, but also wakes when the caller cancels.
func (c DelayedCatalog) Lookup(ctx context.Context, item string) (Price, error) {
if err := ctx.Err(); err != nil {
return Price{}, err
}
price, ok := c.prices[item]
if !ok {
return Price{}, fmt.Errorf("%w: %q", ErrUnknownItem, item)
}
if c.delay <= 0 {
return price, nil
}
timer := time.NewTimer(c.delay)
defer timer.Stop()
select {
case <-timer.C:
return price, nil
case <-ctx.Done():
return Price{}, ctx.Err()
}
}
type Service struct {
source PriceSource
}
func NewService(source PriceSource) Service {
return Service{source: source}
}
func (s Service) Quote(ctx context.Context, item string) (Price, error) {
price, err := s.source.Lookup(ctx, item)
if err != nil {
return Price{}, fmt.Errorf("quote %q: %w", item, err)
}
return price, nil
}
阅读时按顺序连起来:
- 调用方创建有截止时间的 context。
- Quote 把它传给数据源。
- 数据源在 timer 与
ctx.Done()之间等待。 - 成功就返回价格,取消就返回 context 的错误。
defer timer.Stop()和调用方的defer cancel()负责各自创建的对象。
这里用 timer 演示可取消等待。接入真实 HTTP 或数据库时,应把 ctx 交给相应的请求 API。普通函数即使接收了 ctx,也仍需在自己的等待或操作中使用它。
如果完成和取消恰好同时就绪,select 可以选中其中任意一个分支。业务若需要更严格的截止时间语义,应在接口约定与测试中明确。
资源归属: 请求 context 由调用方按次创建;Service 保存可复用的数据源。本例返回的是一个很小的 Service 值,如果以后加入 mutex 等不可随意复制的状态,应重新审视返回方式与接收者。
四步放在一起看
- 01 把查询流程写通。
- 02 把数据源变成可替换依赖。
- 03 让错误兼顾判断与诊断。
- 04 把取消传到真正等待的位置。
下一章增加运费查询,再扩展到批量报价。你将看到 goroutine、结果存储、并发上限和收尾怎样配合。