PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
GO PATTERNS / 02

同步服务的四个版本

从查价到依赖注入、测试和 context。

本章目录

这一章沿用一份价格表: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
}

阅读时按顺序连起来:

  1. 调用方创建有截止时间的 context。
  2. Quote 把它传给数据源。
  3. 数据源在 timer 与 ctx.Done() 之间等待。
  4. 成功就返回价格,取消就返回 context 的错误。
  5. defer timer.Stop() 和调用方的 defer cancel() 负责各自创建的对象。

这里用 timer 演示可取消等待。接入真实 HTTP 或数据库时,应把 ctx 交给相应的请求 API。普通函数即使接收了 ctx,也仍需在自己的等待或操作中使用它。

如果完成和取消恰好同时就绪,select 可以选中其中任意一个分支。业务若需要更严格的截止时间语义,应在接口约定与测试中明确。

资源归属: 请求 context 由调用方按次创建;Service 保存可复用的数据源。本例返回的是一个很小的 Service 值,如果以后加入 mutex 等不可随意复制的状态,应重新审视返回方式与接收者。

四步放在一起看

  • 01 把查询流程写通。
  • 02 把数据源变成可替换依赖。
  • 03 让错误兼顾判断与诊断。
  • 04 把取消传到真正等待的位置。

下一章增加运费查询,再扩展到批量报价。你将看到 goroutine、结果存储、并发上限和收尾怎样配合。