因此我们需要在防御性编程后再添加一个try/catch块包裹其他所有的代码,如下:
public void Accrue(RentalAgreement agreement)
{
//防御性编程
if (agreement==null)
{
throw new Exception(“agreement为null!”);
}
//日志
Console.WriteLine(“Accrue:{0}”,DateTime.Now);
Console.WriteLine(“Customer:{0}”,agreement.Customer.Id);
Console.WriteLine(“Vehicle:{0}”,agreement.Vehicle.Id);
try
{
using (var ts = new TransactionScope())//开始一个新事务
{
var retries = 3;//重试事务3次
var succeeded = false;
while (!succeeded)//一直循环,直到成功
{
try
{
var rentalTimeSpan = agreement.EndDate.Subtract(agreement.StartDate);
var numberOfDays = (int)rentalTimeSpan.TotalDays;
var pointsPerDay = 1;
if (agreement.Vehicle.Size >= Size.Luxury)
{
pointsPerDay = 2;
}
var points = numberOfDays * pointsPerDay;
//调用数据服务存储客户获得的积分
_loyaltyDataService.AddPoints(agreement.Customer.Id, points);
ts.Complete();//调用Complete方法表明事务成功提交
succeeded = true;//成功后设置为true,确保最后一次循环迭代
Console.WriteLine(“Accrue Complete:{0}”, DateTime.Now);//这句移入try里
}
catch
{
if (retries >= 0)
{
retries–;//直到尝试完次数时才重抛异常
}
else
{
throw;//没有调用Complete方法,事务会回滚
}
} } } } catch (Exception ex) { if (!ExceptionHelper.Handle(ex))//如果没有处理异常,继续重抛 { throw ex; } }}
ExceptionHelper是自定义的异常处理帮助类,覆盖了个别异常的处理,如果是没有覆盖的异常,我们可能需要记录日志,并告诉客户出现了什么异常。相似地,Redeem方法也要做相同的处理,此处省略。
此时,我们已经实现了所有非功能需求:logging,防御性编程,事务,重试,和异常处理。将这些处理横切关注点的代码添加到原始的Accrue和Redeem方法中使得它们膨胀成巨大的方法。现在代码可以去生产环境(或更可能去QA/预发布环境),但是这代码太糟糕了!
你可能在想这个描述有点过了,并不是所有的横切关注点都是必须的,是的,你可能大多数情况只需要一两个横切关注点,一些关注点可以移到数据层或UI层。但这里要说明的道理是横切关注点可以使你的代码变杂乱,使得代码更难阅读、维护和调试。
不使用AOP重构
是时候整理下代码了,因为Accrue和Redeem方法中有很多重复代码,我们可以把这些代码放到它们自己的类或方法中。一种选择是将所有的非功能关注点重构到静态方法中,这是个馊主意,因为这会将业务逻辑紧耦合到非功能关注点代码中,虽然使方法看上去更短更可读了,但仍然留下了方法做的事情太多的问题。你也可以使用DI策略,将所有的logging,防御性编程和其他服务传给LoyaltyAccrualService和LoyaltyRedemptionService的构造函数:
public class LoyalRedemptionServiceRefactored:ILoyaltyRedemptionService
{
private readonly ILoyaltyDataService _loyaltyDataService;
private readonly IExceptionHandler _exceptionHandler;//异常处理接口
private readonly ITransactionManager _transactionManager;//事务管理者
public LoyalRedemptionServiceRefactored(ILoyaltyDataService loyaltyDataService, IExceptionHandler exceptionHandler, ITransactionManager transactionManager) { _loyaltyDataService = loyaltyDataService; _exceptionHandler = exceptionHandler;//通过依赖注入传入 _transactionManager = transactionManager; } public void Redeem(Invoice invoice, int numberOfDays) { //防御性编程 if (invoice==null) { throw new Exception("Invoice为null了!"); } if (numberOfDays<=0) { throw new Exception("numberOfDays不能小于1!"); } //logging Console.WriteLine("Redeem: {0}", DateTime.Now); Console.WriteLine("Invoice: {0}", invoice.Id); _exceptionHandler.Wrapper(() => { _transactionManager.Wrapper(() => { var pointsPerDay = 10; if (invoice.Vehicle.Size>=Size.Luxury) { pointsPerDay = 15; } var totalPoints = numberOfDays*pointsPerDay; _loyaltyDataService.SubstractPoints(invoice.Customer.Id,totalPoints); invoice.Discount = numberOfDays*invoice.CostPerDay; // logging Console.WriteLine("Redeem complete: {0}",DateTime.Now); }); }); }}
上面是重构过的版本,IExceptionHandler等的代码没有贴出来,请查看源码,这个版本比之前的好多了。我将异常处理代码和事务/重试代码分别放到了IExceptionHandler和ITransactionManager中,这种设计有它的优势,一是它把那些代码段放到了他们自己的类中,以后可以重用;二是通过减少了横切关注点的噪音使得代码阅读更容易。
当然,Accrue方法也可以重构成这样,此处略过。重构之后,代码和最原始的状态差不多了。但是构造函数好像太庞大了,也就是依赖太多了,实际上,这里可以优化一下,往下看。
Code Smells【代码异味】
代码异味是一个俚语,本质上它不是bug,但它暗示了可能会存在一个问题。就像冰箱里的难闻气味表明背后有腐烂的肉一样,代码异味可能指示了当前的设计不太好,应该被重构。详细了解代码意味,可以点击阅读。
我们可以将异常处理和事务管理合并成一个服务,如下:
public interface ITransactionManager2
{
void Wrapper(Action method);
}
public class TransactionManager2 : ITransactionManager2
{
public void Wrapper(Action method)
{
using (var ts=new TransactionScope())
{
var retires = 3;
var succeeded = false;
while (!succeeded)
{
try
{
method();
ts.Complete();
succeeded = true;
}
catch (Exception ex)
{
if (retires >= 0)
retires–;
else
{
if (!ExceptionHelper.Handle(ex))
throw;
}
}
}
}
}
}
处理注入依赖过多的另一种方法是将所有的服务移到一个聚合服务或者门面服务(即,使用门面模式将所有的小服务组合成一个服务来组织这些小服务),我们这个例子中,TransactionManager和ExceptionHandler服务是独立的,但是可以使用第三个门面类来组织它们的使用。
门面模式 The Facade Pattern
门面模式为更大的或者更复杂的代码段提供了一个简化接口,比如,一个提供了许多方法和选项的服务类可以放到一个门面接口中,这样就可以通过限制选项或者提供简化方法的子集来降低复杂度。
public interface ITransactionFacade
{
void Wrapper(Action method);
}
public class TransactionFacade : ITransactionFacade
{
private readonly ITransactionManager _transactionManager;
private readonly IExceptionHandler _exceptionHandler;
public TransactionFacade(ITransactionManager transactionManager, IExceptionHandler exceptionHandler) { _transactionManager = transactionManager; _exceptionHandler = exceptionHandler; } public void Wrapper(Action method) { _exceptionHandler.Wrapper(()=> _transactionManager.Wrapper(method) ); }}
这样修改后,Accrual和Redemption服务方法中的Wrapper样板代码就减少了很多,更干净了。但是还存在防御编程和logging的问题。
使用装饰器模式重构
不使用AOP重构代码的另一种方式是使用装饰器模式或代理器模式。剧透一下:装饰器/代理器模式只是AOP的一种简单形式。
试想,如果有一种方法可以将上面所有的方法合起来成为一种方法,使得代码回到最初始状态(只有业务逻辑),那将是最好的了。那就读起来最简单,有最少的构造函数注入的服务。当业务逻辑变化时,我们也不必担心忘记或忽略了这些横切关注点,从而减少了变更的代价。
变更的代价
软件工程中不变的东西就是变化,需求变了,业务规则变了,技术变了。业务逻辑或需求的任何变更对处理原始版本的业务逻辑都是挑战性的(在代码重构之前)。
需求变更
因为许多原因,需求会变更。需求一开始可能是很模糊的,但是随着软件开始成型,就会变得更加具体。项目经理等人就会改变想法,对他们来说看似很小的变化,可能在代码中意味着很大的不同。
虽然我们都知道需求会变是个真理,并且也已经反复见证了,但仍然在犯一个错,那就是编码时好像什么都不会改变。作为一个好的开发者,不仅要接受需求的变化,还要期待需求变化。
项目的大小确实很重要,如果你是一个人编写一个简单的软件(比如一个具有两三个表单和许多静态内容的网站),那么变更的代价可能很低,因为改动的地方很少。
方法签名变更
给方法添加或移除参数就会导致方法签名变更。如果移除了一个参数,就必须移除该参数的防御性编程,否则,项目编译不通过。如果修改了一个参数的类型,那么防御性编程边界情况也会改变。更危险的是,如果添加了一个参数,就必须添加该参数的防御性编程,不幸的似乎,编译器不会帮你做这个,自己必须要记得做这件事。
看一下之前的Accrue方法,签名改变的地方会立即影响防御编程和日志记录,如下:
public void Accrue(RentalAgreement agreement) {
// defensive programming
if(agreement == null) throw new ArgumentNullException(“agreement”);
// logging
Console.WriteLine(“Accrue: {0}”, DateTime.Now);
Console.WriteLine(“Customer: {0}”, agreement.Customer.Id);
Console.WriteLine(“Vehicle: {0}”, agreement.Vehicle.Id);
// … snip …
// logging
Console.WriteLine(“Accrue complete: {0}”, DateTime.Now);
}
如果参数名从agreement变成rentalAgreement,那么必须记得更改ArgumentNullException的构造函数的字符串参数。如果方法名本身变了,也必须更改logging中记录的字符串方法名。虽然有很多重构工具可以辅助,如Resharp,但是其他的还要依赖你自己和团队的警惕。
团队开发
一个人开发就算了。假设有个新的需求,ILoyaltyAccureService接口需要添加一个新的方法,也许这个任务会派给其他队友,并且这个队友实现了业务逻辑并完成了任务。不幸地是,这个队友忘记了使用TransactionFacade的Wrapper方法,他的代码通过了UT,然后交给了QA。如果这是一个敏捷项目,这也许不是大问题:QA会捕捉到这个问题,并立即把这个问题报告给你。在一个瀑布项目中,QA可能在几个月之后才会发现这个bug。几个月后,你可能也不记得造成这个bug的原因了。就好像你是团队中的新员工一样。
最糟糕的情况:它可能通过了QA,假设的异常或重试条件不是必要的或者没有被注意到,这样,代码就没有经过防御性编程、logging、事务等等进入了生产环境,这样迟早出问题!
使用AOP重构
再次重构代码,这次使用AOP,使用NuGet添加Postsharp到项目CarRental.Core中,关于如何添加,请查看上一篇文章。
开发简单、独立的logging
先来重构一个简单的横切关注点:logging。当方法调用时,会记录方法名和时间戳。创建一个日志切面类,继承自OnMethodBoundaryAspect,它允许我们在方法的边界插入代码:
[Serializable]
public class LoggingAspect:OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs args)
{
Console.WriteLine(“{0}:{1}”,args.Method.Name,DateTime.Now);
}
public override void OnSuccess(MethodExecutionArgs args) { Console.WriteLine("{0} complete:{1}",args.Method.Name,DateTime.Now); }}
注意,我们可以通过MethodExecutionArgs参数获得方法名,因此,这个切面可以c重复使用,可给Accure和Redeem方法使用:
public class LoyaltyAccrualService:ILoyaltyAccrualService
{
[LoggingAspect]
public void Accrue(RentalAgreement agreement)
{
//…
}
}
public class LoyalRedemptionService:ILoyaltyRedemptionService
{
[LoggingAspect]
public void Redeem(Invoice invoice, int numberOfDays)
{
//…
}
}
现在就可以从这些方法中移除logging代码了。除此之外,我们还没有打印传入参数的Id,比如Customer.Id。有了Postsharp,我们可以取到所有的传入参数,但为了取到Id,必须还得做点事情。
public override void OnEntry(MethodExecutionArgs args)
{
Console.WriteLine(“{0}:{1}”,args.Method.Name,DateTime.Now);
foreach (var argument in args.Arguments)//遍历方法的参数
{
if (argument.GetType()==typeof(RentalAgreement))
{
Console.WriteLine(“Customer:{0}”, ((RentalAgreement)argument).Customer.Id);
Console.WriteLine(“Vehicle:{0}”, ((RentalAgreement)argument).Vehicle.Id);
}
if (argument.GetType()==typeof(Invoice))
{
Console.WriteLine(“Invoice:{0}”,((Invoice)argument).Id);
}
}
}
就这个例子来说,这样没问题了,但是对于一个大一点的应用,可能会有几十个甚至几百个不同的类型,如果需求是记录实体Id和信息,那么可以在实体上使用一个公共接口(或基类)。比如,如果Invoice和RentalAgreement都实现了ILoggable接口,该接口具有一个方法string LogInfo(),代码可以这样写:
public override void OnEntry(MethodExecutionArgs args)
{
Console.WriteLine(“{0}:{1}”,args.Method.Name,DateTime.Now);
foreach (var argument in args.Arguments)//遍历方法的参数
{
if (argument!=null)
{
if (typeof(ILoggable).IsAssignableFrom(argument.GetType()))
{
Console.WriteLine((ILoggable)argument.LogInfo());
}
}
} }现在Accure和Redeem方法开始收缩了,因为我们将logging功能移到了它自己的类日志切面中去了。
重构防御性编程
下面还是使用OnMethodBoundaryAspect基类重构防御性编程,确保没有参数为null,以及所有的int参数不为0或负数:
[Serializable]
public class DefensiveProgramming:OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs args)
{
var parameters = args.Method.GetParameters();//获取形参
var arguments = args.Arguments;//获取实参
for (int i = 0; i < arguments.Count; i++)
{
if (arguments[i]==null)
{
throw new ArgumentNullException(parameters[i].Name);
}
if (arguments[i] is int&&(int)arguments[i]<=0)
{
throw new ArgumentException(“参数非法”,parameters[i].Name);
}
}
}
}
首先检查实参是否为null,之后再判断参数是否是整型,并且是否合法。如果不处理这些事情,非法值会使得程序崩溃,但这里处理之后我们可以看到崩溃的确定原因(ArgumentNullException或ArgumentException 的异常信息)。
同时,这个类没有直接耦合任何参数类型或服务类,这意味着可以重复使用在多个服务中。
[LoggingAspect]
[DefensiveProgramming]
public void Accrue(RentalAgreement agreement)
{
//…略
}
[LoggingAspect]
[DefensiveProgramming]
public void Redeem(Invoice invoice, int numberOfDays)