设计模式实战(五):装饰器模式——透明地叠加功能

写在前面

需求来了:给所有数据库操作的仓储加日志加缓存加重试。怎么办?

新手通常去改 OrderRepository 的源码——把日志、缓存、重试塞进每个方法。这一塞就糟了:核心逻辑被淹没,加一个仓储要重抄一遍,改日志格式要动所有仓储。这篇讲装饰器模式——不动原类,把功能一层层"套"上去。


一、问题:继承爆炸

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
想给 IRepository 加:日志、缓存、重试,三种功能自由组合。
  用继承怎么做?

    Repository
      ├── LoggedRepository
      ├── CachedRepository
      └── RetryRepository
           ├── LoggedRetryRepository
           ├── CachedLoggedRepository
           ├── CachedRetryRepository
           └── CachedLoggedRetryRepository ... (组合爆炸)

  病:
  ✗ N 个功能 → 2^N 个子类,指数爆炸
  ✗ 功能组合写死在类型里,运行时不能换
  ✗ 违反"组合优于继承"

  病根:把"叠加功能"当成"特化",硬走继承。

二、装饰器:同接口 + 包裹一个同接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
意图:动态地给对象添加职责,不改其接口。
     装饰器和被装饰者实现同一个接口,内部持有一个被装饰者。

  结构:
    IComponent          ← 共同接口
    ConcreteComponent   ← 被装饰的原始对象
    Decorator           ← 实现同接口,内部持有 IComponent(可嵌套)

  关键:装饰器"是一个" IComponent,又"持有一个" IComponent。
       所以可以无限套娃:A 装 B,B 装 C,对外都是 IComponent。
       调用时,每层装饰器先做自己的事,再转给内层。

三、手写:给仓储叠加日志/缓存/重试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 1. 共同接口
public interface IOrderRepository
{
    Task<Order?> Find(Guid id);
}

// 2. 原始实现(只管数据库,啥装饰都不带)
public class SqlOrderRepository : IOrderRepository
{
    public Task<Order?> Find(Guid id) { /* 查库 */ }
}

// 3. 装饰器基类:持有内部 IOrderRepository,方法默认转发
public class OrderRepoDecorator(IOrderRepository _inner) : IOrderRepository
{
    public virtual Task<Order?> Find(Guid id) => _inner.Find(id);
}

// 4. 具体装饰器:加日志
public class LoggingDecorator(IOrderRepository inner, ILogger log)
    : OrderRepoDecorator(inner)
{
    public override async Task<Order?> Find(Guid id)
    {
        log.LogInformation("Find {Id}", id);
        var result = await base.Find(id);   // 调内层
        log.LogInformation("Result: {Found}", result is not null);
        return result;
    }
}

// 5. 具体装饰器:加缓存
public class CachingDecorator(IOrderRepository inner, IMemoryCache cache)
    : OrderRepoDecorator(inner)
{
    public override Task<Order?> Find(Guid id)
        => cache.GetOrCreateAsync(id, _ => base.Find(id));
}
1
2
3
4
5
6
7
8
// 用:按需套,顺序自由
IOrderRepository repo =
    new LoggingDecorator(           // 最外层:记日志
        new CachingDecorator(        // 中间层:查缓存
            new SqlOrderRepository() // 最内层:真查库
        ), logger);
// 调用方只认 IOrderRepository,不知道套了几层
await repo.Find(id);
1
2
3
4
5
对比继承爆炸:
  ✓ 加功能 = 写一个装饰器类,不是 2^N 子类
  ✓ 运行时自由组合顺序(先缓存还是先日志,拼装时决定)
  ✓ 原始类(SqlOrderRepository)纯净,只管核心职责
  ✓ 每个 Decorator 单一职责(开闭、SRP 都满足)

四、.NET 的教科书案例:Stream

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Stream 全家桶是装饰器的经典:

  FileStream fs = File.OpenRead("a.txt");
  var buffered = new BufferedStream(fs);        // 套一层缓冲
  var gzip     = new GZipStream(buffered, ...); // 再套一层压缩
  var reader   = new CryptoStream(gzip, ...);   // 再套一层加密

  FileStream / BufferedStream / GZipStream / CryptoStream
  都继承自 Stream(同接口),每个包一个 Stream(持有一个)。
   你可以任意组合:加密 + 压缩 + 缓冲,顺序自由。
   不需要"加密缓冲流""压缩加密流"这种组合类。

  认出这个套路,装饰器就懂了一半。

五、用 DI 让装饰器自动组装

手写套娃链(new LoggingDecorator(new CachingDecorator(...)))很烦。Scrutor 库给 .NET DI 加了装饰器支持,一行注册:

1
2
3
4
5
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.Decorate<IOrderRepository, CachingDecorator>();   // 套缓存
services.Decorate<IOrderRepository, LoggingDecorator>();   // 再套日志

// 解析 IOrderRepository 时,容器自动按注册顺序套好装饰器返回
1
2
3
4
5
6
搭配 DI:
  ✓ 声明式组装,注册顺序即装饰顺序
  ✓ 装饰器自身也能注入依赖(ILogger、IMemoryCache)
  ✓ 切换/增减装饰器 = 改注册,不改业务代码

  这就是 ASP.NET Core 里做横切关注点(日志/缓存/重试/指标)的标准姿势。

六、装饰器 vs 代理 vs 适配器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
三个结构型模式都"包一个对象",容易混:

  装饰器(Decorator):同接口,加职责
    → 重点:给对象增加功能(日志/缓存/重试)
    → 接口不变、行为增强

  代理(Proxy):同接口,控制访问
    → 重点:管"能不能访问/什么时候访问"(延迟加载、权限、远程代理)
    → 接口不变、加"管控",不是加业务功能

  适配器(Adapter):转换接口
    → 重点:把不兼容的接口转成期望的(A 接口 → B 接口)
    → 接口变了(这才是关键区别)

  一句话:装饰器加功能,代理加管控,适配器转接口。

七、何时用 / 何时别用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
该用装饰器:
  ✓ 横切关注点(日志、缓存、重试、指标、鉴权、校验)
  ✓ 想给对象叠加功能,但不改原类
  ✓ 功能要可组合、运行时配置顺序

别用:
  ✗ 功能和对象强绑定、永远不变 → 直接写进类更简单
  ✗ 只有一层、不会扩展 → 别为了模式而模式
  ✗ 需要"按条件选不同实现" → 那是策略/工厂的活

  信号:当你发现自己在 N 个类的每个方法里
       重复写"记日志 + try 重试 + 记指标" —— 就该上装饰器。

八、小结

  • 问题:用继承叠加功能 → 子类组合爆炸
  • 装饰器:同接口 + 内部持有一个同接口,可无限套娃
  • 手写:装饰器基类转发 + 子类 override 加料,链式拼装
  • .NET 经典Stream(FileStream/Buffered/GZip/Crypto)全家桶
  • DI 整合ScrutorDecorate<T, TDeco>() 一行组装
  • vs 代理/适配器:装饰器加功能、代理加管控、适配器转接口
  • 典型场景:横切关注点(日志/缓存/重试/指标)

下一篇是本系列的重头——责任链模式,看 ASP.NET Core 中间件管道是怎么把它用到极致的。