大家好,我是十三!欢迎来到十三Tech。

去年给一个 HTTP 服务加可观测性——要在每个请求前后打印日志、记录 metrics、做链路追踪、加 CORS 头。最初想直接改业务 handler,结果发现四十多个 handler 都要改,工作量巨大。后来用 func(next http.Handler) http.Handler 这种中间件写法,四个装饰器叠在一起,业务 handler 一行没动。

这就是装饰器模式——不修改原对象,在运行时给它叠加新行为。但很多人把装饰器和代理混为一谈,因为代码长得几乎一样。这一篇就聊聊装饰器的标志特征是"调用方主动叠加",以及为什么 Go 社区把它玩成了洋葱模型。

装饰器的现实类比:咖啡加料,组合自由

一、装饰器在解决什么——不是"加一层"

教科书定义:动态地给对象添加额外职责,比继承更灵活。这话在 Java 时代有意义——继承爆炸时需要装饰器替代。但在 Go 里,没有继承这回事——组合是天然的,那装饰器还有什么价值?

装饰器的真实价值有两个:调用方主动控制叠加顺序运行时按需组合

"主动控制叠加顺序"是装饰器区别于代理的核心。装饰器是调用方明确知道自己包装了什么——logging(auth(rate(handler))) 这个表达式里,调用方主动决定了顺序:最外层是 logging、最内层是 rate。"运行时按需组合"是装饰器区别于继承/编译期组合的核心——同一个 handler,dev 环境只包 logging,prod 环境包 logging+auth+rate+tracing,全靠配置驱动。

判断口诀:调用方主动决定包装顺序 → 装饰器;调用方不知道自己被代理 → 代理

二、Go 里装饰器怎么写——函数式洋葱

Go 没有继承层次,但有一个等价的"接口同型组合"——装饰器函数返回和入参相同的类型。最经典的就是 func(http.Handler) http.Handler

装饰器 UML 结构:Decorator 与 Component 同接口

type Handler interface { ServeHTTP(w ResponseWriter, r *Request) }

func Logging(next Handler) Handler {
    return HandlerFunc(func(w ResponseWriter, r *Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func Auth(next Handler) Handler {
    return HandlerFunc(func(w ResponseWriter, r *Request) {
        if !checkToken(r) { w.WriteHeader(401); return }
        next.ServeHTTP(w, r)
    })
}

调用方装配:

h := Logging(Auth(RateLimit(limiter, bizHandler)))

这段代码的精髓是——Logging 入参 Handler、出参 Handler,所以可以无限嵌套。每个装饰器都可以在"调用 next 之前"和"next 返回之后"做事情——这就是洋葱模型:请求从外到内逐层进入,响应从内到外逐层返回。

写装饰器有个工程坑:装饰器之间不应假设顺序。如果你写了一个 Logging 装饰器,它假设外面一定有 Auth——一旦 Auth 被去掉,Logging 拿不到用户信息就崩。装饰器应该独立——拿不到用户信息就记为 unknown,不能 panic。

装饰器洋葱模型:请求外到内、响应内到外

三、什么时候别用装饰器

装饰器不是所有"加一层"场景都适用,至少有三个反模式。

反模式一:装饰器超过 5 层嵌套。十层洋葱模型在调试时是噩梦——一个 panic 堆栈十层装饰器,定位真正出问题的层要花十分钟。这种时候该考虑的不是再加装饰器,而是把某些装饰器合并或下沉到基础设施层(比如 service mesh)。

反模式二:装饰器里写业务逻辑。装饰器只做横切关注点——日志、监控、限流、鉴权、CORS。如果装饰器里写了"判断用户等级""计算折扣",它已经退化成了业务层,该拆出去。

反模式三:装饰器和代理混用导致意图模糊。如果一个"装饰器"内部持有了"代理"才有的状态(比如缓存、连接池),它的意图已经偏移到了代理。控制访问是代理,叠加行为是装饰器——一旦混淆,新人接手代码会困惑"这个包装到底是干嘛的"。

装饰器适用决策:动态 vs 静态、行为 vs 控制

Go 标准库里装饰器最经典的地方是 io.Reader——bufio.NewReader(os.File) 返回 *bufio.Readergzip.NewReader(bufio.Reader) 又返回 *gzip.Reader。每一层都实现了 io.Reader,每一层都给底层加一点能力(缓冲、解压、解码)。看一段典型的 IO 处理代码:gzip.NewReader(bufio.NewReader(file))——三层装饰,调用方决定顺序,每一层都透明。这就是装饰器在标准库里的教科书用法。

回头看那个 HTTP 服务——值得用装饰器吗?值得,四十多个 handler、四个横切关注点、dev/prod 环境差异,正是装饰器的甜区。但如果只有一两个 handler 要加日志,直接在 handler 里写 log.Printf 就行,装饰器反而是过度设计。

装饰器真正教会我的,不是"动态叠加功能",而是区分"横切关注点"和"业务关注点"——横切的用装饰器统一处理,业务的留在 handler 里显式写。这个区分比模式本身重要得多。

下一篇讲代理模式,它和装饰器长得最像但意图完全不同。


关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。

联系方式:569893882@qq.com GitHub:@TriTechAI