函数装饰器模式 - 函数的复活
这是“野生的拦截器”的另一种选择。函数装饰器模式提供了一种方法,可以在不使用 IoC 框架或修改方法实现的情况下,向现有方法注入新的行为。
引言
在《野生的拦截器》这篇文章中,João Matos Silva 展示了如何使用 IoC 框架 NInject,在不修改方法实现的情况下,向现有方法注入新的行为。在本文中,我们将介绍一种老式但有益的方法来实现相同的目标。
背景
几天前,当我在审查团队成员的代码时,发现了大量样板代码,如下所示。
int retry = 0;
WebResponse r;
TRY:
try {
r = webRequest.GetResponse();
}
catch (Exception ex) {
if (++retry < 3) {
goto TRY;
}
throw;
}
还有这个
int retry = 0;
TRY:
try {
r = socket.Send(bytes);
}
catch (Exception ex) {
if (++retry < 3) {
goto TRY;
}
throw;
}
我的同事们在从错误中恢复、错误日志记录以及其他类似的事情上反复重复。我回想起几个月前收藏的一篇关于《野生的拦截器》的文章,并反复阅读它,想知道文章中介绍的 IoC 是否可以用来消除上述样板代码。
Notice由João Matos Silva撰写的文章《野生的拦截器》写得非常好,读起来很愉快。本文复用了其中的示例。建议您在继续阅读此替代方案之前先阅读它。
经过几天的沉思和向我的同事介绍后,出于以下考虑,我们没有采用 IoC 框架。
我们的目标是什么?
- 消除重复的样板代码。
- 代码职责分离。
- 灵活地将新功能注入现有方法。
- 注入方法的底层实现应保持不变。
我们要避免什么?或者是什么阻止我们采用 IoC 框架解决方案?
- 增加了代码复杂性.
- IoC 框架通常会隐藏类型的构造函数,当注入链变得复杂时,这可能会让程序员难以理解。“IoC 解析的是哪个实例?”是程序员通常会遇到的最常见问题之一。
- 如果我们尝试实例化对象,则需要付出一些努力,如果您正在尝试调用
- IoC 通常要求程序员为行为拦截器提供抽象类或接口,因此需要在项目中添加更多类型。此外,通过添加这些接口或类,一些私有范围的代码可能会意外或不可避免地暴露出来,从而违反了最小可见性设计原则。
- 使用限制:
- IoC 绑定器会影响所有通过特定接口绑定到该类型的方法。如果我们有一个包含三个方法:
M1
、M2
和M3
的类型,并且我们将在代码中使用它们。我们想将行为B1
绑定到M1
,将B1
和B2
绑定到M2
,但不对M3
绑定任何行为。之后,我们将调用绑定的M1
、绑定的M2
和未绑定的M3
。使用 IoC 框架需要编写大量代码才能实现这一点。完成后,这并不容易理解或调试。 - IoC 绑定器仅影响实例方法,而不影响**静态方法**或**私有方法**。
- IoC 绑定器会影响所有通过特定接口绑定到该类型的方法。如果我们有一个包含三个方法:
- **性能损失**:本文后面讨论的基本性能基准测试结果告诉我,不要这样做。
为了实现目标并避免问题,我向我的团队引入了这个老式、低技术含量的**函数装饰器**模式。
函数装饰器模式
函数装饰器模式可能会让你想起典型的包装对象的装饰器模式。
维基百科对装饰器模式的引用在面向对象编程中,装饰器模式(也称为 Wrapper,与适配器模式共享一个备用名称)是一种设计模式,它允许将行为添加到单个**对象**上,无论是静态地还是动态地,而不影响同一类中其他对象的行为。
函数装饰器模式与经典*装饰器模式*最大的区别在于它包装的是实例方法、静态方法或*Lambda 表达式*,并且它没有类继承链或显式接口实现。下面是一个函数装饰器的示例。它可以像下面几行一样简单
public static Func<TArg, TResult> WaitAMinute<TArg, TResult>(this Func<TArg, TResult> func) {
return (arg) => {
// added functionality
System.Threading.Thread.Sleep(TimeSpan.FromMinutes(1));
// original functionality
return func(arg);
};
}
函数装饰器是 Func
(或 Action
)的扩展方法。简而言之,它用另一个函数包装了一个函数。
函数装饰器的用法
在本节中,我将使用《野生的拦截器》中的 NativeClient
示例来演示函数装饰器如何成为 IoC 注入的替代方案。
降低故障率/提高成功率
在 Silva 的文章《野生的拦截器》中,NativeClient
非常好地展示了一个随时可能崩溃的服务,而 RetryInterceptor
通过在出现问题时重试来解决此问题。同样的模式可以用函数装饰器来实现。
为了演示结果。让我们回顾一下原始的客户端代码
const int TimesToInvoke = 1000;
static void Main(string[] args) {
var counter = new StatsCounter();
var client = new NaiveClient(counter);
counter.Stopwatch.Start();
for (var i = 0; i < TimesToInvoke; i++) {
try {
// this method fails about 3 times out of 10
client.GetMyDate(DateTime.Today.AddDays(i % 30));
counter.TotalSuccess++;
}
catch (Exception ex) {
counter.TotalError++;
}
}
counter.Stopwatch.Stop();
counter.PrintStats();
Console.WriteLine("Press any key to exit");
Console.ReadKey();
}
在上面的代码中,您无需关心 GetMyDate
的实现方式,因为我们不会触及它,只会更改调用该函数的客户端代码。请记住,它是一个有问题的函数,并且有 0.3 的概率会抛出异常。
毫无疑问,执行结果非常糟糕。运行时间很长,执行失败率高达 325/1000。这是一个可能的输出。
Execution Time: 36.0658004 seconds
Total Executions: 1000
Execution Sucess: 675
Total Sucess: 675
Execution Fail: 325
Total Fail: 325
现在,我们将引入一个新的 RetryIfFailed
动作拦截器,在失败后自动重试该动作,我们可以通过为 maxRetry
参数赋值来轻松地**在运行时配置**我们想要重试多少次。
public static Func<TArg, TResult> RetryIfFailed<TArg, TResult>
(this Func<TArg, TResult> func, int maxRetry) {
return (arg) => {
int t = 0;
do {
try {
return func(arg);
}
catch (Exception) {
if (++t > maxRetry) {
throw;
}
}
} while (true);
};
}
然后,我们将它应用于调用过程。
var counter = new StatsCounter();
var client = new NaiveClient(counter);
counter.Stopwatch.Start();
// get the method we are going to retry with
Func<DateTime, string> getMyDate = client.GetMyDate;
// intercept it with RetryIfFailed interceptor, which retries once at most
getMyDate = getMyDate.RetryIfFailed(1);
for (var i = 0; i < TimesToInvoke; i++) {
try {
// call the intercepted method instead of client.GetMyDate
getMyDate(DateTime.Today.AddDays(i % 30));
counter.TotalSuccess++;
}
catch (Exception ex) {
counter.TotalError++;
}
}
counter.Stopwatch.Stop();
上述程序取得了更好的结果。执行故障率从原始的 325/1000 降至 91/1000,这仅仅是因为我们多重试了一次。
Execution Time: 59.6016893 seconds
Total Executions: 1302
Execution Sucess: 909
Total Sucess: 909
Execution Fail: 393
Total Fail: 91
如果您对结果不满意,可以调整拦截器中的 maxRetry
参数为一个更大的值,例如 3。这是“getMyDate.RetryIfFailed(3)
”的结果,故障率降低到大约 8/1000。
Execution Time: 65.8972493 seconds
Total Executions: 1421
Execution Sucess: 992
Total Sucess: 992
Execution Fail: 429
Total Fail: 8
缓存结果
在《野生的拦截器》中,还演示了如何通过 PoorMansCacheProvider
和 CacheInterceptor
将缓存注入方法。使用**函数装饰器**模式可以达到相同的结果。
public static Func<TArg, TResult> GetOrCache<TArg, TResult, TCache>
(this Func<TArg, TResult> func, TCache cache)
where TCache : class, IDictionary<TArg, TResult> {
return (arg) => {
TResult value;
if (cache.TryGetValue(arg, out value)) {
return value;
}
value = func(arg);
cache.Add(arg, value);
return value;
};
}
我们可以轻松地将其应用于调用过程,**在** RetryIfFailed
拦截器**之后**。
var counter = new StatsCounter();
var client = new NaiveClient(counter);
// create a cache
var cache = new Dictionary<DateTime, string> ();
counter.Stopwatch.Start();
Func<DateTime, string> getMyDate = client.GetMyDate;
// apply the cache interceptor
getMyDate = getMyDate.RetryIfFailed(3).GetOrCache(cache);
for (var i = 0; i < TimesToInvoke; i++) {
try {
getMyDate(DateTime.Today.AddDays(i % 30));
counter.TotalSuccess++;
}
catch (Exception ex) {
counter.TotalError++;
}
}
counter.Stopwatch.Stop();
注意尽管
GetOrCache
添加在RetryIfFailed
**之后**,但由于它实际上是围绕原始函数进行的包装,因此将在调用RetryIfFailed
**之前**首先访问提供给GetOrCache
的缓存。简而言之,后附加的方法会先被调用。
结果可能如下所示,与《野生的拦截器》中的最终结果类似。由于缓存的作用,执行时间大大缩短,总执行故障率降低到零左右。
Execution Time: 0.981474 seconds
Total Executions: 36
Execution Sucess: 30
Total Sucess: 1000
Execution Fail: 6
Total Fail: 0
到目前为止,我们所做的仅仅是添加了两个扩展方法,并更改了调用过程中的 4 行代码。
- 无需修改底层函数的实现。
- 无需从互联网上 NuGet 任何东西。
- 无需学习任何新框架。
- 无需为对象实例化而烦恼。
- 无需考虑如何在拦截器中更改参数。
工作完成了,我们的时间也节省了。
关注点 - 性能
在性能方面,我在代码中包含了一个简单的基准测试,比较了函数装饰器和 NInject(一个 IoC 框架)拦截器的速度。
代码将调用一个 NopClient
,它什么都不做,然后比较不同类型模式花费的时间。
public class NopClient : INaiveClient {
private StatsCounter _counter;
public NopClient(StatsCounter counter) {
_counter = counter;
}
public string GetMyDate(DateTime date) {
return null;
}
}
NInject 拦截器的设置代码如下所示
public class Module : NinjectModule
{
public override void Load() {
Kernel.Bind<StatsCounter>().ToConstant(new StatsCounter());
var binding = Kernel.Bind<INaiveClient>().To<NopClient>();
binding.Intercept().With<RetryInterceptor>();
}
}
在实际开发中,我们通常会创建不可重用的客户端对象并调用它们的方法。例如,我们创建 WebRequest
对象来加载网页,或 DbConnection
对象来管理数据库。因此,在性能基准测试中,我们必须考虑对象初始化。
我们将对以下三组操作进行基准测试。
1. 硬编码过程。
for (var i = 0; i < TimesToInvoke; i++) { var nopClient = new NopClient(counter); // instantiation int t = 0; do { try { nopClient.GetMyDate(DateTime.MinValue); break; } catch (Exception) { if (++t > 3) { throw; } } } while (true); }
2. 函数装饰器。
for (var i = 0; i < TimesToInvoke; i++) {
var nopClient = new NopClient(counter); // instantiation
Func<DateTime, string> getMyDate = nopClient.GetMyDate; // get the function pointer
getMyDate = getMyDate.RetryIfFailed(3); // setup the decorator, end of instantiation part
getMyDate(DateTime.MinValue);
}
3. NInject 拦截器。
for (var i = 0; i < TimesToInvoke; i++) {
var client = kernel.Get<INaiveClient>(); // instantiation, setting up of the interceptor
client.GetMyDate(DateTime.MinValue);
}
以下是我电脑上的一次结果。尽管**函数装饰器**比硬编码慢大约 10 倍,但比**NInject 拦截器****快几百倍**。
Hard coded Execution Time: 0.0377 milliseconds Function decorator Execution Time: 0.4576 milliseconds NInject interceptor Execution Time: 109.8437 milliseconds
在某些情况下,装饰函数的实例是可重用的,因此,我们不应将实例化包含在基准测试中。将实例化部分移出循环进行基准测试后,我电脑上的结果如下所示
Hard coded Execution Time: 0.0093 milliseconds
Function decorator Execution Time: 0.1898 milliseconds
NInject interceptor Execution Time: 30.0647 milliseconds
函数装饰器与 IoC 拦截器之间的选择
注意实际上,我不太确定这一章是否应该包含在这篇文章中。由于这两种模式具有相似的效果,在写这篇文章之前,我自己已经在团队中进行了比较。我猜这对其他人可能仍然有用。
函数装饰器模式具有以下特点
- 它确实能像 IoC 那样向我们想要更改的方法注入新行为(在《野生的拦截器》中介绍):提高执行成功率、提供缓存能力和效率等等。它为封闭函数带来了新的活力。
- 底层的基本实现保持不变。它不需要您更改被注入方法中的任何一个字符。
对原始代码的更改差异
- 函数装饰器
- 不更改对象的初始化(如果需要,您仍然可以使用 IoC 框架或其他*抽象工厂*模式)。
- 不更改被装饰的对象(无需暴露接口或基类型)。
- 更改应注入新行为的方法调用部分。
- IoC 拦截器
- 更改对象初始化。
- 被装饰的对象*可能*需要公开新的接口或基类型以注入新行为。
- 不更改方法调用部分。
如果以下方面很重要,则函数装饰器模式比 IoC 拦截器模式更受青睐
- 控制类型的构造逻辑。在需要时,可以使用老式*new className(parameters)*方式实例化对象或各种*抽象工厂*模式。对象创建逻辑保持干净、简单且完全可控,无需 IoC 框架。
- 性能和可扩展性。
- 较低的学习成本(我的同事们只用了大约 10 分钟就学会了)。
- 选择性地将新行为注入现有方法。
- 保持项目类型简单,不愿手动创建新类或接口仅用于注入。
- 在注入和未注入的方法版本之间可选择地切换。
- 在运行时将新行为注入到先前已注入的方法。
- 将新行为注入到*静态*方法、*私有*方法甚至 Lambda 表达式。
- 部署简单。对第三方程序集的引用更少。
- 易于调试。
- 使用 IDE 重构构造函数。
关于函数装饰器的附注
- 它不是对象范围的。但您可以考虑将被包装的对象传递给装饰器方法,而不是被装饰的函数。
- 它不包装属性。
- 默认情况下,它不会将行为注入到类型的所有方法中。您必须手动将它们逐个应用。
致谢
- 主要的感谢应归功于文章《野生的拦截器》的作者 。本文的思想和示例都受到了他撰写的精彩演示的启发。他还给了我重写本文的深刻见解。
- 本文的读者,* wim4you*,*Mr. Javaman* 等,给予了极大的鼓励和宝贵的建议。
历史
- 2016-6-10:重写并重命名了文章。补充了致谢。
- 2016-6-4:首次发布(标题为*Action Interceptor Pattern*)