写在前面
需求来了:给所有数据库操作的仓储加日志、加缓存、加重试。怎么办?
新手通常去改 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 整合:
Scrutor 的 Decorate<T, TDeco>() 一行组装 - vs 代理/适配器:装饰器加功能、代理加管控、适配器转接口
- 典型场景:横切关注点(日志/缓存/重试/指标)
下一篇是本系列的重头——责任链模式,看 ASP.NET Core 中间件管道是怎么把它用到极致的。