AspectF 声明式添加 Aspect 以实现更清晰可维护的代码






4.97/5 (60投票s)
AspectF 是一种实现面向切面编程风格编码的简单方法,它能让你的代码更加整洁、更易于维护。
引言
AspectF
是一种为代码添加切面的流畅而简单的方式。如果你熟悉面向切面编程(AOP),你就会知道它能让你的代码更整洁、更易于维护。但 AOP 需要第三方框架或像 IL 操作这样的硬核 .NET 功能才能实现。尽管其好处远大于复杂性,我仍在寻找一种更简单的方式来进行 AOP 风格的编码,以便保持代码的整洁和简单,将切面从核心逻辑中分离出去。这就诞生了 AspectF
。
代码可在 Google Code 上获取。
背景 - 面向切面编程 (AOP)
切面是你在项目中不同部分时常编写的通用功能。它可能是在代码中处理异常的某种特定方式,或是记录方法调用,或是计时方法执行,或是重试某些方法等等。如果你不使用任何面向切面编程框架来实现这些,那么你就是在整个项目中重复编写大量相似的代码,这会让你的代码难以维护。例如,假设你有一个业务层,其中的方法需要被记录日志、错误需要以特定方式处理、执行需要计时、数据库操作需要重试等等。于是,你写出了这样的代码:
public bool InsertCustomer(string firstName, string lastName, int age,
Dictionary<string, string> attributes)
{
if (string.IsNullOrEmpty(firstName))
throw new ApplicationException("first name cannot be empty");
if (string.IsNullOrEmpty(lastName))
throw new ApplicationException("last name cannot be empty");
if (age < 0)
throw new ApplicationException("Age must be non-zero");
if (null == attributes)
throw new ApplicationException("Attributes must not be null");
// Log customer inserts and time the execution
Logger.Writer.WriteLine("Inserting customer data...");
DateTime start = DateTime.Now;
try
{
CustomerData data = new CustomerData();
bool result = data.Insert(firstName, lastName, age, attributes);
if (result == true)
{
Logger.Writer.Write("Successfully inserted customer data in "
+ (DateTime.Now-start).TotalSeconds + " seconds");
}
return result;
}
catch (Exception x)
{
// Try once more, may be it was a network blip or some temporary downtime
try
{
CustomerData data = new CustomerData();
if (result == true)
{
Logger.Writer.Write("Successfully inserted customer data in "
+ (DateTime.Now-start).TotalSeconds + " seconds");
}
return result;
}
catch
{
// Failed on retry, safe to assume permanent failure.
// Log the exceptions produced
Exception current = x;
int indent = 0;
while (current != null)
{
string message = new string(Enumerable.Repeat('\t', indent).ToArray())
+ current.Message;
Debug.WriteLine(message);
Logger.Writer.WriteLine(message);
current = current.InnerException;
indent++;
}
Debug.WriteLine(x.StackTrace);
Logger.Writer.WriteLine(x.StackTrace);
return false;
}
}
}
在这里你可以看到,真正实现业务逻辑的两行代码——通过调用名为 CustomerData
的类来插入一个客户——几乎被所有你必须在业务层中实现的关注点(日志、重试、异常处理、计时)所淹没。如今的业务层中充斥着验证、错误处理、缓存、日志记录、计时、审计、重试、依赖解析等各种逻辑。一个项目越成熟,进入你代码库的关注点就越多。因此,你不断地复制粘贴样板代码,然后在那堆样板代码的某个角落里写下那一点点真正的业务逻辑。更糟糕的是,你必须为每个业务层方法都这样做。假设现在你想要在业务层中添加一个 UpdateCustomer
方法。你就必须再次复制所有这些关注点,然后把那两行真正的代码放在样板代码的某个地方。
想象一下这样的场景:你需要对整个项目中处理错误的方式进行修改。你必须逐一检查你编写的数百个业务层函数并进行修改。再比如,你需要改变计时执行的方式,你又得再次检查数百个函数来完成这个任务。
面向切面编程解决了这些挑战。当你使用 AOP 时,你可以用一种更酷的方式来实现:
[EnsureNonNullParameters]
[Log]
[TimeExecution]
[RetryOnceOnFailure]
public void InsertCustomerTheCoolway(string firstName, string lastName, int age,
Dictionary<string, string> attributes)
{
CustomerData data = new CustomerData();
data.Insert(firstName, lastName, age, attributes);
}
在这里,你已经将像日志、计时、重试、验证这些通用性的东西——它们被正式称为“关注点”——完全从你的实际代码中分离出来了。这个方法变得简洁明了,直奔主题。所有的关注点都已从函数代码中移除,并通过特性(Attribute)添加到函数上。这里的每个特性都代表一个切面。例如,你只需添加 Log 特性,就可以为任何函数添加日志记录切面。无论你使用哪个 AOP 框架,该框架都会确保这些切面在构建时或运行时被“织入”到代码中。
有些 AOP 框架允许你在编译时通过后期构建事件和 IL 操作来织入切面,例如 PostSharp;有些则在运行时通过 DynamicProxy 来实现;还有一些需要你的类继承自 ContextBoundObject
,以便利用 C# 内置功能来支持切面。所有这些方法都有一定的入门门槛,你需要证明使用某个外部库的合理性,进行足够的性能测试以确保这些库具有良好的伸缩性等等。而你所需要的,或许只是一种非常简单的方式来实现“关注点分离”,而不一定是功能完备的面向切面编程。记住,这里的目的是分离关注点,并保持代码的优美和整洁。
AspectF 如何让这一切变得极其简单
那么,让我来展示一种简单的方法,它能实现关注点分离,使用标准的 C# 代码,没有特性或 IL 操作之类的“黑魔法”,只需简单地调用类和委托,就能以一种可重用和可维护的方式实现优雅的关注点分离。最棒的是,它非常轻量,只有一个小小的类。
public void InsertCustomerTheEasyWay(string firstName, string lastName, int age,
Dictionary<string, string> attributes)
{
AspectF.Define
.Log(Logger.Writer, "Inserting customer the easy way")
.HowLong(Logger.Writer, "Starting customer insert",
"Inserted customer in {1} seconds")
.Retry()
.Do(() =>
{
CustomerData data = new CustomerData();
data.Insert(firstName, lastName, age, attributes);
});
}
让我们看看它与其他常见 AOP 框架的区别:
- 你不是在方法外部定义切面,而是在方法内部立即定义它们。
- 你不是将切面定义为类,而是将它们定义为方法。
现在来看看它的优势:
- 不需要任何黑魔法(特性、
ContextBoundObject
、后期构建事件、IL 操作、DynamicProxy
)。 - 没有因黑魔法带来的性能开销。
- 可以按你喜欢的方式精确排序切面。例如,你可以只记录一次日志但重试多次,或者通过将 Retry 调用在调用链中上移,你也可以让日志记录本身也支持重试。明白了吗?不明白的话,请继续阅读。
- 你可以向切面传递参数、局部变量等。这是其他任何框架都无法做到的。
- 它不是一个功能完备的框架,只是一个名为
AspectF
的简单类。 - 你可以在代码的任何地方定义切面。例如,你可以用切面包裹一个
for
循环。
让我们看看用这种方法构建切面是多么简单。切面被定义为扩展方法。AspectExtensions
类包含所有预置的切面,如 Log
、Retry
、TrapLog
、TrapLogThrow
等。例如,Retry
的工作原理如下:
[DebuggerStepThrough]
public static AspectF Retry(this AspectF aspects)
{
return aspects.Combine((work) =>
Retry(1000, 1, (error) => DoNothing(error), DoNothing, work));
}
[DebuggerStepThrough]
public static void Retry(int retryDuration, int retryCount,
Action<Exception> errorHandler, Action retryFailed, Action work)
{
do
{
try
{
work();
}
catch (Exception x)
{
errorHandler(x);
System.Threading.Thread.Sleep(retryDuration);
}
} while (retryCount-- > 0);
retryFailed();
}
这个切面会在出现异常时,按你指定的次数重复调用你的代码。用 Retry 切面来包裹数据库、文件 IO、网络 IO、Web 服务调用非常方便,因为这些操作可能会因为各种暂时的基础设施问题而失败,有时重试一次就能解决问题。我习惯于总是对数据库的插入、更新、删除操作,以及调用 Web 服务方法和处理文件时进行重试。这为我避免了许多生产环境中的问题。
它的工作原理是,它创建了一个委托(Delegate
)的组合。最终结果类似于以下代码:
Log(() =>
{
HowLong(() =>
{
Retry(() =>
{
Do(() =>
{
CustomerData data = new CustomerData();
data.Insert(firstName, lastName, age, attributes);
});
});
});
});
AspectF
类不过是实现这种代码组合的一种流畅方式。
下面是你如何创建自己的切面。首先,为 AspectF
类创建一个扩展方法。假设我们正在创建 Log
切面。
[DebuggerStepThrough]
public static AspectF Log(this AspectF aspect, TextWriter logWriter,
string beforeMessage, string afterMessage)
{
return aspect.Combine((work) =>
{
logWriter.Write(DateTime.Now.ToUniversalTime().ToString());
logWriter.Write('\t');
logWriter.Write(beforeMessage);
logWriter.Write(Environment.NewLine);
work();
logWriter.Write(DateTime.Now.ToUniversalTime().ToString());
logWriter.Write('\t');
logWriter.Write(afterMessage);
logWriter.Write(Environment.NewLine);
});
}
你调用 AspectF
的 Combine
函数来组合一个委托,这个委托随后会被放入委托链中,当你最后调用 Do
方法时,这个链条就会被触发。
public class AspectF
{
/// <summary>
/// Chain of aspects to invoke
/// </summary>
public Action<Action> Chain = null;
/// <summary>
/// Create a composition of function e.g. f(g(x))
/// </summary>
/// <param name="newAspectDelegate">A delegate that offers an aspect's behavior.
/// It's added into the aspect chain</param>
/// <returns></returns>
[DebuggerStepThrough]
public AspectF Combine(Action<Action> newAspectDelegate)
{
if (this.Chain == null)
{
this.Chain = newAspectDelegate;
}
else
{
Action<Action> existingChain = this.Chain;
Action<Action> callAnother = (work) =>
existingChain(() => newAspectDelegate(work));
this.Chain = callAnother;
}
return this;
}
在这里,Combine
函数接收由切面扩展方法(如 Log
)传递的委托,然后将其包裹在先前添加的切面之内,这样第一个切面就会调用第二个,第二个调用第三个,最后一个切面则调用你希望在切面内执行的真实代码。
Do
/Return
函数负责执行最终的调用。
/// <summary>
/// Execute your real code applying the aspects over it
/// </summary>
/// <param name="work">The actual code that needs to be run</param>
[DebuggerStepThrough]
public void Do(Action work)
{
if (this.Chain == null)
{
work();
}
else
{
this.Chain(work);
}
}
就是这样,你现在有了一种极其简单的方式来分离关注点,并能使用基本的 C# 语言特性来享受 AOP 风格的编程。
AspectF
类还附带了其他几个方便的切面。例如,MustBeNonNull
切面可以用来确保传入的参数不为 null。下面是一个单元测试,展示了它的用法:
[Fact]
public void TestMustBeNonNullWithValidParameters()
{
bool result = false;
Assert.DoesNotThrow(delegate
{
AspectF.Define
.MustBeNonNull(1, DateTime.Now, string.Empty, "Hello", new object())
.Do(delegate
{
result = true;
});
});
Assert.True(result, "Assert.MustBeNonNull did not call the function
although all parameters were non-null");
}
类似地,还有一个 MustBeNonDefault
,它检查传入的参数值是否为默认值,例如,对于数字来说是 0。
[Fact]
public void TestMustBeNonDefaultWithInvalidParameters()
{
bool result = false;
Assert.Throws(typeof(ArgumentException), delegate
{
AspectF.Define
.MustBeNonDefault<int>(default(int))
.MustBeNonDefault<DateTime>(default(DateTime))
.MustBeNonDefault<string>(default(string), "Hello")
.Do(() =>
{
result = true;
});
Assert.True(result, "Assert.MustBeNonDefault must
not call the function when there's a null parameter");
});
}
还有一个 Delay
切面,它会在一定的延迟后执行代码。这对于限制对某些 Web 服务的调用频率非常有用。
[Fact]
public void TestDelay()
{
DateTime start = DateTime.Now;
DateTime end = DateTime.Now;
AspectF.Define.Delay(5000).Do(() => { end = DateTime.Now; });
TimeSpan delay = end - start;
Assert.InRange<double>(delay.TotalSeconds, 4.9d, 5.1d);
}
另一个有用的切面是 RunAsync
,它会异步调用代码。有时你的 Web 服务方法需要立即返回,并在后台异步执行主要任务。你只需用 RunAsync
包裹方法的代码,其中的代码就会异步运行。
[Fact]
public void TestAspectAsync()
{
bool callExecutedImmediately = false;
bool callbackFired = false;
AspectF.Define.RunAsync().Do(() =>
{
callbackFired = true;
Assert.True(callExecutedImmediately,
"Aspect.RunAsync Call did not execute asynchronously");
});
callExecutedImmediately = true;
// wait until the async function completes
while (!callbackFired) Thread.Sleep(100);
bool callCompleted = false;
bool callReturnedImmediately = false;
AspectF.Define.RunAsync(() =>
Assert.True(callCompleted,
"Aspect.RunAsync Callback did not fire
after the call has completed properly"))
.Do(() =>
{
callCompleted = true;
Assert.True(callReturnedImmediately,
"Aspect.RunAsync call did not run asynchronously");
});
callReturnedImmediately = true;
while (!callCompleted) Thread.Sleep(100);
}
另一个方便的切面是 TrapLog
,它会捕获并记录抛出的异常,但不会让异常继续传播。如果你希望异常在被记录后再次抛出以便继续传播,你可以使用 TrapLogThrow
。
[Fact]
public void TestTrapLog()
{
StringBuilder buffer = new StringBuilder();
StringWriter writer = new StringWriter(buffer);
Assert.DoesNotThrow(() =>
{
AspectF.Define.TrapLog(writer).Do(() =>
{
throw new ApplicationException("Parent Exception",
new ApplicationException("Child Exception",
new ApplicationException("Grandchild Exception")));
});
});
string logOutput = buffer.ToString();
Assert.True(logOutput.Contains("Parent Exception"));
Assert.True(logOutput.Contains("Child Exception"));
Assert.True(logOutput.Contains("Grandchild Exception"));
}
所以,你现在拥有了一种优美简洁的方式来实现关注点分离,并且无需任何重量级的库就能体验到面向切面编程的乐趣!
结论
AOP 的纯粹主义者看到这里肯定要火冒三丈了——“你这个笨蛋,这根本就不是 AOP!” 嗯,AOP 的核心在于关注点分离和横切。确实,AspectF
并没有展示一种实现横切的方法,但它确实展示了一种实现关注点分离的方式。仅仅因为 AOP 展示了一种在方法边界之外编写代码的方式,并不意味着你不能在方法边界之内做同样的事情。例如,如果在 C# 中你必须在方法体内声明特性(Attribute),那么使用特性来实现 AOP 与使用 AspectF
的差别就很小了。AspectF
的主要目标是——不依赖外部库或非标准的 .NET 功能,以最小的努力创建新的切面,并利用强类型在应用切面时提供构建时检查等特性。