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

八种常见组合

把构造函数、接口、错误、清理和并发放进小例子。

本章目录

每节先说明要完成的动作,再对照真实代码看几个语法怎样一起工作。可以按需阅读,源码与测试都放在独立教学 module 中。

从 learning/go-patterns 执行 go test ./patterns/... 可运行这八组测试。想只研究其中一组,就把路径缩到对应目录。

1. 构造函数与可选配置

看到 NewServer(WithHost(...), WithPort(...)) 时,可以把它读成:建立默认配置,再依次应用修改,最后创建对象。

learning/go-patterns/patterns/construction/construction.go · L1–L64 · 打开完整源码

// Package construction demonstrates functional options for optional configuration.
package construction

import (
	"net"
	"strconv"
)

// Config contains the settings used by Server.
type Config struct {
	Host   string
	Port   int
	Secure bool
}

// Option changes one part of a Server configuration.
type Option func(*Config)

// WithHost configures the listening host.
func WithHost(host string) Option {
	return func(config *Config) {
		config.Host = host
	}
}

// WithPort configures the listening port.
func WithPort(port int) Option {
	return func(config *Config) {
		config.Port = port
	}
}

// WithSecure configures whether the server uses a secure transport.
func WithSecure(secure bool) Option {
	return func(config *Config) {
		config.Secure = secure
	}
}

// Server is a configured endpoint. Its configuration is immutable after construction.
type Server struct {
	config Config
}

// NewServer constructs a Server with useful defaults and optional overrides.
func NewServer(options ...Option) *Server {
	config := Config{Host: "127.0.0.1", Port: 8080}
	for _, option := range options {
		if option != nil {
			option(&config)
		}
	}
	return &Server{config: config}
}

// Config returns a copy of the server configuration.
func (s *Server) Config() Config {
	return s.config
}

// Address returns the host:port endpoint, including correct IPv6 formatting.
func (s *Server) Address() string {
	return net.JoinHostPort(s.config.Host, strconv.Itoa(s.config.Port))
}

Option 是函数类型。WithPort 返回一个闭包,保存调用方提供的端口;构造函数稍后把配置指针交给它。这里组合了函数返回函数、闭包和指针修改。

这类 functional options 适合具有合理默认值、可选项逐渐增加的 API。几个必填参数直接写在构造函数签名里通常更清楚。相互关联的配置也可以集中放进 Config struct,让校验规则更容易找到。

这里的 Server 演示配置组装和地址格式化;实际启动网络服务时,还需校验端口等配置。

本例选项按传入顺序应用,因此重复设置同一个字段时,后一次覆盖前一次。Server 保存完成后的配置,Config() 返回值副本。示例配置只有标量字段,复制行为也很容易判断。若未来加入 map 或 slice,还要决定如何处理共享数据。

对照 Java/C# 的 builder:这里用函数表达每个配置动作。你可以在测试中改变选项顺序,观察最后的配置。

2. 小接口、函数适配器与测试替身

PriceOf 和 Total 只使用 Catalog 的查询方法。接口放在这一侧,说明消费者需要什么能力。

learning/go-patterns/patterns/interfaces/interfaces.go · L1–L52 · 打开完整源码

// Package interfaces demonstrates consumer-defined, minimal interfaces.
package interfaces

// Product is the value returned by a Catalog.
type Product struct {
	Name       string
	PriceCents int
}

// Catalog is the only behavior needed by the pricing functions.
type Catalog interface {
	Find(name string) (Product, bool)
}

// CatalogFunc adapts a function to Catalog, avoiding a one-method struct in tests
// and small integrations.
type CatalogFunc func(name string) (Product, bool)

// Find implements Catalog.
func (f CatalogFunc) Find(name string) (Product, bool) {
	return f(name)
}

// PriceOf looks up one product through the smallest useful interface.
func PriceOf(catalog Catalog, name string) (int, bool) {
	if catalog == nil {
		return 0, false
	}
	product, ok := catalog.Find(name)
	if !ok {
		return 0, false
	}
	return product.PriceCents, true
}

// Total adds the prices of all requested products. It returns no partial total
// when any product is missing.
func Total(catalog Catalog, names []string) (int, bool) {
	if catalog == nil {
		return 0, false
	}

	total := 0
	for _, name := range names {
		product, ok := catalog.Find(name)
		if !ok {
			return 0, false
		}
		total += product.PriceCents
	}
	return total, true
}

CatalogFunc 把一个普通函数适配成接口:先定义命名函数类型,再为它写 Find 方法,方法内部调用这个函数值。测试因此能直接传入一个闭包,按测试需要返回商品、记录参数或模拟失败。

这与 C# 的委托和 Java 的函数式接口很接近。Go 中需要把函数值接到现有接口上时,这个小适配器经常很好用。

已有合适的标准库接口时,可以直接复用,例如资源示例中的 io.ReadCloser。

接口返回 Product 值,调用方根据 bool 判断是否找到。更复杂的数据源如果需要区分网络错误和缺失商品,应把这些失败情况设计进返回值。

练习:给替身增加调用计数,确认 Total 对每个名称查了几次。随后思考重复商品名应重复查询还是去重,答案取决于接口是否承诺稳定结果。

3. 错误身份、错误细节与包装

Lookup 找不到商品时创建 NotFoundError,保存失败的 key。这个类型通过 Unwrap 暴露 ErrNotFound。Load 再补一层操作说明。

learning/go-patterns/patterns/errors/errors.go · L1–L44 · 打开完整源码

// Package errors demonstrates sentinel categories, wrapping, and typed details.
package errors

import (
	"errors"
	"fmt"
)

// ErrNotFound is the stable category callers can inspect with errors.Is.
var ErrNotFound = errors.New("item not found")

// NotFoundError carries the key that was not present while remaining in the
// ErrNotFound category.
type NotFoundError struct {
	Key string
}

// Error implements error.
func (e *NotFoundError) Error() string {
	return fmt.Sprintf("item %q not found", e.Key)
}

// Unwrap makes errors.Is and errors.As work through wrapping layers.
func (e *NotFoundError) Unwrap() error {
	return ErrNotFound
}

// Lookup finds a value or returns a typed, classifiable not-found error.
func Lookup(items map[string]string, key string) (string, error) {
	value, ok := items[key]
	if !ok {
		return "", &NotFoundError{Key: key}
	}
	return value, nil
}

// Load adds operation context while preserving the underlying error identity.
func Load(items map[string]string, key string) (string, error) {
	value, err := Lookup(items, key)
	if err != nil {
		return "", fmt.Errorf("load %q: %w", key, err)
	}
	return value, nil
}

调用方有两种常见判断:errors.Is(err, ErrNotFound) 判断错误类别,errors.As(err, &detail) 取出带字段的具体错误。外层可以因此选择状态码、展示信息或执行恢复逻辑。

包装错误时保留能帮助定位问题的操作名和参数。%w 让调用方仍能继续判断原因。错误信息可能写入日志,敏感参数需要先处理。

和 Java/C# 的异常处理相比,Go 的常规失败沿返回值传递。调用链里的每一层都需要决定:直接返回、补充上下文,还是在此处恢复。通常由真正负责响应或日志的边界记录一次失败。

练习:再加一层包装,运行原来的 Is/As 测试。接着把最内层错误换成另一种失败,检查调用方是否还能正确分支。

4. 获取资源、defer 与关闭错误

ReadOwned 通过传入的 open 函数获取 reader,负责在本次调用结束前关闭它。调用方可以传入打开文件的函数,测试可以传入记录 Close 是否发生的替身。

learning/go-patterns/patterns/ownership/resource.go · L1–L19 · 打开完整源码

package ownership

import (
	"errors"
	"io"
)

// ReadOwned opens one reader, reads its contents, and closes it before returning.
// The returned error preserves both a read failure and a close failure.
func ReadOwned(open func() (io.ReadCloser, error)) (data []byte, err error) {
	reader, err := open()
	if err != nil {
		return nil, err
	}
	defer func() {
		err = errors.Join(err, reader.Close())
	}()
	return io.ReadAll(reader)
}

这里的组合稍密,按执行顺序读:先获取资源;成功后登记清理;执行读取;给命名返回值 data 和 err 赋值;运行 defer;最后把结果交回调用方。

延后执行的闭包可以修改命名返回值 err,因此读取失败与关闭失败都能通过 errors.Join 保留下来。测试用 errors.Is 检查两个原因。明确的 return io.ReadAll(reader) 仍会先把返回值写入 data 和 err,再执行 defer。

这个写法适合函数拥有资源的完整生命周期。若 reader 由调用方创建并计划继续使用,应在 API 中明确关闭责任。不要在一个长循环中持续登记大量 defer,必要时把单次操作提成小函数,让资源每轮及时关闭。

与 Python with、C# using 对照时,要留意 defer 的作用域覆盖整个所在函数。

另一个所有权例子:把队列内容交出去

learning/go-patterns/patterns/ownership/ownership.go · L1–L36 · 打开完整源码

// Package ownership demonstrates a single-owner mutable queue and an explicit
// move-like Drain operation.
package ownership

// Queue is intended to be used by one goroutine at a time. Its zero value is
// ready to use; use shared-map (or another synchronization boundary) when
// multiple goroutines need shared mutable state.
type Queue struct {
	items []string
}

// NewQueue creates a queue with an optional initial capacity.
func NewQueue(capacity int) *Queue {
	if capacity < 0 {
		capacity = 0
	}
	return &Queue{items: make([]string, 0, capacity)}
}

// Push adds an item to the queue owned by q.
func (q *Queue) Push(item string) {
	q.items = append(q.items, item)
}

// Len reports the number of queued items.
func (q *Queue) Len() int {
	return len(q.items)
}

// Drain transfers the backing slice to the caller and leaves q empty. After
// this call the returned slice is the caller's mutable data, not queue state.
func (q *Queue) Drain() []string {
	items := q.items
	q.items = nil
	return items
}

Drain 返回当前 slice,并让队列停止持有它。调用方接手这批元素,下一次 Push 使用新的存储。这种转移需要遵守使用约定:Queue 的访问由一个调用方顺序负责;若多个 goroutine 同时访问,还需增加同步。Go 的类型系统不会自动执行这里的所有权约定。

5. 函数、闭包与中间件链

Handler 表示最终处理函数。Middleware 接收一个 Handler,再返回一个 Handler。返回的闭包可以在调用 next 前处理请求,也可以在它返回后处理响应。

learning/go-patterns/patterns/middleware/middleware.go · L1–L59 · 打开完整源码

// Package middleware demonstrates function-based handler composition.
package middleware

import "maps"

// Request is the input passed through a handler chain.
type Request struct {
	Path string
}

// Response is the value returned by a handler chain.
type Response struct {
	Status  int
	Body    string
	Headers map[string]string
}

// Handler handles a request.
type Handler func(Request) Response

// Middleware wraps a Handler with cross-cutting behavior.
type Middleware func(Handler) Handler

// Chain applies middleware so the first item is the outermost wrapper.
func Chain(endpoint Handler, middleware ...Middleware) Handler {
	for i := len(middleware) - 1; i >= 0; i-- {
		if middleware[i] != nil {
			endpoint = middleware[i](endpoint)
		}
	}
	return endpoint
}

// PrefixBody adds a deterministic prefix to a successful or failed response.
func PrefixBody(prefix string) Middleware {
	return func(next Handler) Handler {
		return func(request Request) Response {
			response := next(request)
			response.Body = prefix + ":" + response.Body
			return response
		}
	}
}

// AddHeader adds or replaces one response header while preserving headers set
// by the endpoint and inner middleware.
func AddHeader(name, value string) Middleware {
	return func(next Handler) Handler {
		return func(request Request) Response {
			response := next(request)
			response.Headers = maps.Clone(response.Headers)
			if response.Headers == nil {
				response.Headers = make(map[string]string)
			}
			response.Headers[name] = value
			return response
		}
	}
}

Chain(endpoint, A, B) 从后向前构造,得到 A(B(endpoint))。运行时从 A 进入,再到 B 和 endpoint;响应返回时依次经过 B 和 A。

这里要区分组装时机和请求时机。Chain 通常在初始化时运行,构造出的闭包随后处理多次请求。被闭包捕获的可变状态也会跨请求存在,是否需要同步取决于它如何被访问。

HTTP 日志、认证、响应头和计时经常使用这个结构。本例响应里的 Headers 是 map,AddHeader 在修改前复制它,避免把对当前响应的修改带给共享旧值。

练习:在两层包装前后记录名称,先预测顺序,再运行测试。想加入计时,起止时间应该分别放在调用 next 的两侧。

6. map、mutex 与快照

Store 把 map 和保护它的 RWMutex 放在一起。访问者通过方法读取和写入,因此每条访问路径都能遵守同一份锁约定。

learning/go-patterns/patterns/shared-map/shared_map.go · L1–L53 · 打开完整源码

// Package sharedmap demonstrates protecting a map behind a mutex and returning
// snapshots instead of exposing shared mutable state.
package sharedmap

import "sync"

// Store is safe for concurrent use. Do not copy a Store after first use.
type Store struct {
	mu     sync.RWMutex
	values map[string]int
}

// NewStore creates an initialized Store.
func NewStore() *Store {
	return &Store{values: make(map[string]int)}
}

// Set associates value with key.
func (s *Store) Set(key string, value int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.values == nil {
		s.values = make(map[string]int)
	}
	s.values[key] = value
}

// Get returns the value for key, if present.
func (s *Store) Get(key string) (int, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	value, ok := s.values[key]
	return value, ok
}

// Len returns the number of stored keys.
func (s *Store) Len() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return len(s.values)
}

// Snapshot returns a copy that the caller may mutate independently.
func (s *Store) Snapshot() map[string]int {
	s.mu.RLock()
	defer s.mu.RUnlock()

	snapshot := make(map[string]int, len(s.values))
	for key, value := range s.values {
		snapshot[key] = value
	}
	return snapshot
}

Set 修改数据,用独占锁。Get 和 Len 只读,用读锁。Snapshot 在读锁期间复制 map,调用方随后可以独立遍历或修改这份副本。

锁的范围应覆盖需要保持一致的操作。本例临界区很短。若后面要调用用户提供的函数或执行网络请求,应先考虑能否在锁外完成,避免一个慢操作拖住全部访问者。

RWMutex 在读多写少时可能有用,实际收益要结合负载判断。使用普通 Mutex 往往已经足够。含锁的 Store 通过指针使用,使用后应避免复制整个 struct。

练习:修改 Snapshot 返回的 map,再查询 Store,原数据应保持不变。另运行 race 检查并发读写测试。

7. slice、复制与返回值

NewBatch 用 slices.Clone 保存输入,Items 再复制后返回。这样,调用方修改原来的 slice 或取回的 slice,都不会改写 Batch 保存的元素。

learning/go-patterns/patterns/slice-boundary/slice_boundary.go · L1–L24 · 打开完整源码

// Package sliceboundary demonstrates defensive copying at slice boundaries.
package sliceboundary

import "slices"

// Batch owns its items. The backing array is never exposed directly.
type Batch struct {
	items []string
}

// NewBatch copies items so later caller mutations cannot change the Batch.
func NewBatch(items []string) Batch {
	return Batch{items: slices.Clone(items)}
}

// Len reports the number of items.
func (b Batch) Len() int {
	return len(b.items)
}

// Items returns a copy so caller mutations cannot change the Batch.
func (b Batch) Items() []string {
	return slices.Clone(b.items)
}

你可以按数据经过的路径看两次复制:传入时隔开调用方和 Batch,传出时隔开 Batch 和接收者。额外分配换来了更容易解释的修改关系。

本例元素是 string。若元素包含指针、map 或其他 slice,复制元素后仍可能共享它们引用的数据,需要继续考虑这层共享是否符合接口约定。

在内部同步流程中,如果明确约定某段数据只读,可以考虑共享底层数组以减少复制。跨组件公开返回数据时,修改权和有效期最好写清楚。

练习:分别修改输入、Items 返回值和第二次 Items 返回值,观察哪些对象保持不变。

8. context、select 与停止处理

Process 在 jobs 和 ctx.Done 之间等待。有任务就处理,输入关闭或请求取消后结束,并返回已经完成的结果。

learning/go-patterns/patterns/cancellation/cancellation.go · L1–L32 · 打开完整源码

// Package cancellation demonstrates context cancellation in a channel loop.
package cancellation

import "context"

// Process consumes jobs until the channel closes or ctx is canceled. Results
// completed before cancellation are returned together with ctx.Err(). The
// callback runs synchronously, so there is no worker goroutine to leak.
func Process(ctx context.Context, jobs <-chan int, fn func(int) int) ([]int, error) {
	var results []int
	for {
		// Checking before select makes an already-canceled context deterministic
		// even when jobs is also ready.
		if err := ctx.Err(); err != nil {
			return results, err
		}

		select {
		case <-ctx.Done():
			return results, ctx.Err()
		case job, ok := <-jobs:
			if !ok {
				return results, nil
			}
			// The callback may cancel ctx. Do not start another job after that.
			if err := ctx.Err(); err != nil {
				return results, err
			}
			results = append(results, fn(job))
		}
	}
}

如果两个分支同时就绪,select 可以选中任意一个。发出取消后,程序还可能处理一个已经拿到的任务。这是接口语义需要考虑的地方。

本例回调 fn 是短小的同步计算,签名中没有 context。若把它替换成网络请求或长循环,还需要把取消传进去,并让具体操作观察信号。

context 负责传递停止请求。存在后台 goroutine 时,还要用 WaitGroup 或其他同步手段确认它们已经结束。下一章的阶段 07 会把取消与等待组合完整。

练习:让 jobs 关闭、让 ctx 取消,分别检查已完成结果和返回错误。测试应使用握手控制先后关系,超时仅用来避免测试自身一直等待。

把组合用进一个功能

同步服务四阶段把构造、接口、错误和 context 连起来;并发三阶段继续加入 goroutine、worker pool、取消与等待。每次只增加一个要求,再观察现有代码需要怎样调整。