每节先说明要完成的动作,再对照真实代码看几个语法怎样一起工作。可以按需阅读,源码与测试都放在独立教学 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、取消与等待。每次只增加一个要求,再观察现有代码需要怎样调整。