本篇目錄
本系列的源碼本人已托管於Coding上:點擊查看。
本系列的實驗環境:VS 2013 Update 5(建議最好使用集成了Nuget的VS版本,VS Express版也夠用),安裝了PostSharp。
這篇博客覆蓋的內容包括:
- 為項目創建需求
- 從零編寫代碼來滿足需求
- 不使用AOP重構凌亂的代碼
- 使用AOP來重構代碼
這一節會構建一個汽車租賃系統,先是給定業務需求,然后逐漸地添加代碼來滿足那些需求。
一開始不使用任何AOP,從零開始敲代碼。業務需求是最重要的,因此我們先做需求,一旦滿足了業務邏輯,然后再覆蓋非功能需求。最后,盡可能地簡化並重構代碼,不使用AOP來重構橫切關注點。
這些都完成之后,就會轉向一個應用生命周期的長尾階段。軟件很少是長期不變的:新的功能需求和新發現的bugs。很少有軟件的開發階段會比生產階段長,這就意味着大多數軟件的生命周期是維護階段。一個維護困難或昂貴的應用會導致高代價或者低品質(或兩者都有),最終形成一個大泥球。
然后,會使用PostSharp重構代碼,將各自的橫切關注點分離到它們自己的類中。一旦重構完成,你就會看到使用AOP的好處,特別是添加更多功能時。
開始一個新項目
時間:現在
地點:你公司(汽車租賃服務相關)的研發部的辦公室
人物:你的技術團隊或者只有你自己
背景:啟動一個新的項目,高大上一點,叫做客戶忠誠度系統,low一點,叫做客戶積分程序。目的是為了增加銷售,獎勵那些經常購買服務的客戶。比如,客戶今天租賃了一輛車,那么他就會獲得積分,積分累積多了之后,以后可以用於抵消一部分租賃費用或其他費用。
假設有一個基本的三層架構,如下圖。我們會從應用到這個積分系統的核心業務邏輯層着手編寫代碼,持久化層會跟蹤客戶的忠誠度積分,業務邏輯層供所有的UI層使用:網站,APP和店員使用的桌面端。
這一篇,我們主要看一下中間一層的業務邏輯層。我們可以假設持久化層已經實現了,還要假設一旦業務邏輯實現了,UI也就實現了。
業務需求
項目經理和利益相關人(比如銷售和市場)確定了下圖的業務需求,你已經確定了兩個主要的需求集:累積積分和使用累積的積分 兌換獎勵。
現在的業務需求就是:客戶每租一天普通型車輛,累積一積分,豪華型或者大型車輛,每天兩積分。這些積分會在他們支付之后並返還了車以后會增加到他們的賬戶中。一旦客戶累積了10積分,那么就可以使用這些積分兌換獎勵了,具體兌換規則見上圖。
這就是所有業務規則,但是在實現之前還是得和銷售和市場確定好:因為他們將來肯定還會更改或者添加一些東西。
必要的非功能需求
在給項目經理估算時間和花銷之前,你有自己必須要解決的技術關注點。
第一,需要記錄日志。如果客戶的積分累積得不對(累積少了),那么他們會生氣的,因此必須確保記錄了業務邏輯處理的一切(尤其是起初階段)。
第二,因為業務邏輯代碼會被多個UI應用使用,要確保傳入業務層的數據是合法的,你的隊友可能會在UI里寫入一些集成代碼,因此,必須編寫防御性代碼來檢查無意義的邊緣情況和參數。
第三,還是因為業務邏輯代碼會被多個UI應用使用,這些UI可能會使用不同類型的連接(緩慢的移動手機的連接,國外瀏覽器訪問等等),你需要采用事務和重試邏輯來確保維護數據集成以及給用戶提供一個愉快的體驗。
最后,總有意外會發生,你可能不知道此時你會使用何種類型的持久化,所以需要某種方法處理異常(很可能是記錄日志)。
沒有AOP的生活
將評估提交給項目經理之后,所有的批准和文件也已經簽署了,現在就可以開始了。
新建一個解決方案,名叫CarRental,並創建一個類庫項目存放業務邏輯,取名CarRental.Core
編寫業務邏輯
創建一個累積積分的接口,代碼如下:
public interface ILoyaltyAccrualService
{
void Accrue(RentalAgreement agreement);
}
RentalAgreement
是該積分系統領域公用的一個實體類,因此按理說它應該在一個不同的程序集,但這里為了演示,我創建了一個Entities
的文件夾,存放所有的實體。
public class RentalAgreement
{
public Guid Id { get; set; }
public Customer Customer { get; set; }
public Vehicle Vehicle { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; }
public string DriversLicense { get; set; }
public DateTime DateOfBirth { get; set; }
}
public class Vehicle
{
public Guid Id { get; set; }
public string Make { get; set; }
public string Model { get; set; }
public Size Size { get; set; }
public string Vin { get; set; }
}
public enum Size
{
Compact=0,
Midsize,
FullSize,
Luxury,
Truck,
SUV
}
再回頭看ILoyaltyAccrualService
接口,該接口有一個使用了這些實體的Accure
方法,用來為客戶累積積分。下面是該接口的實現,它會依賴一個持久化數據的服務。Accure
方法會包含了計算協議中天數和這些天共累積多少積分的業務邏輯,並將這些積分數量存儲到數據庫中。
public class LoyaltyAccrualService:ILoyaltyAccrualService
{
private readonly ILoyaltyDataService _loyaltyDataService;
public LoyaltyAccrualService(ILoyaltyDataService loyaltyDataService)
{
_loyaltyDataService = loyaltyDataService;//數據服務必須在該對象初始化時傳入該對象
}
/// <summary>
/// 該方法包含了積分系統累積客戶積分的邏輯和規則
/// </summary>
/// <param name="agreement">租賃協議實體</param>
public void Accrue(RentalAgreement agreement)
{
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);
}
}
ILoyaltyDataService
只有兩個方法:
public interface ILoyaltyDataService
{
void AddPoints(Guid customerId,int points);
void SubstractPoints(Guid customerId, int points);
}
ILoyaltyDataService
作為數據庫接口,會通過DI的方式傳入到業務層的構造函數。因為我們現在只集中在業務邏輯層,所以我們在數據服務層只是簡單地打印一些東西就好了,FakeLoyaltyDataService
實現了ILoyaltyDataService
如下:
public class FakeLoyalDataService:ILoyaltyDataService
{
public void AddPoints(Guid customerId, int points)
{
Console.WriteLine("客戶{0}增加了{1}積分",customerId,points);
}
public void SubstractPoints(Guid customerId, int points)
{
Console.WriteLine("客戶{0}減少了{1}積分", customerId, points);
}
}
到這里,已經完成了累積積分的業務邏輯!現在回到客戶關心的問題上,如何兌換積分?創建一個接口ILoyaltyRedemptionService
:
public interface ILoyaltyRedemptionService
{
void Redeem(Invoice invoice, int numberOfDays);
}
/// <summary>
/// 發票實體
/// </summary>
public class Invoice
{
public Guid Id { get; set; }
public Customer Customer { get; set; }
public Vehicle Vehicle { get; set; }
public int CostPerDay { get; set; }
public decimal Discount { get; set; }
}
兌換積分是基於客戶租賃的車型和兌換的天數從客戶的賬戶中減去積分,並填充發票中的折扣金額。代碼如下:
public class LoyalRedemptionService:ILoyaltyRedemptionService
{
private readonly ILoyaltyDataService _loyaltyDataService;
public LoyalRedemptionService(ILoyaltyDataService loyaltyDataService)
{
_loyaltyDataService = loyaltyDataService;
}
public void Redeem(Invoice invoice, int numberOfDays)
{
var pointsPerDay = 10;
if (invoice.Vehicle.Size>=Size.Luxury)
{
pointsPerDay = 15;
}
var totalPoints = pointsPerDay*numberOfDays;
invoice.Discount = numberOfDays*invoice.CostPerDay;
_loyaltyDataService.SubstractPoints(invoice.Customer.Id,totalPoints);
}
}
測試業務邏輯
下面創建一個控制台UI模擬業務邏輯的使用:
class Program
{
static void Main(string[] args)
{
SimulateAddingPoints();//模擬累積
Console.WriteLine("***************");
SimulateRemovingPoints();//模擬兌換
Console.Read();
}
/// <summary>
/// 模擬累積積分
/// </summary>
static void SimulateAddingPoints()
{
var dataService=new FakeLoyalDataService();//這里使用的數據庫服務是偽造的
var service=new LoyaltyAccrualService(dataService);
var agreement=new RentalAgreement
{
Customer = new Customer
{
Id = Guid.NewGuid(),
Name = "tkb至簡",
DateOfBirth = new DateTime(2000,1,1),
DriversLicense = "123456"
},
Vehicle = new Vehicle
{
Id = Guid.NewGuid(),
Make = "Ford",
Model = "金牛座",
Size = Size.Compact,
Vin = "浙-ABC123"
},
StartDate = DateTime.Now.AddDays(-3),
EndDate = DateTime.Now
};
service.Accrue(agreement);
}
/// <summary>
/// 模擬兌換積分
/// </summary>
static void SimulateRemovingPoints()
{
var dataService = new FakeLoyalDataService();
var service = new LoyalRedemptionService(dataService);
var invoice = new Invoice
{
Customer = new Customer
{
Id = Guid.NewGuid(),
Name = "Farb",
DateOfBirth = new DateTime(1999, 1, 1),
DriversLicense = "abcdef"
},
Vehicle = new Vehicle
{
Id = Guid.NewGuid(),
Make = "奧迪",
Model = "Q7",
Size = Size.Compact,
Vin = "浙-DEF123"
},
CostPerDay = 100m,
Id = Guid.NewGuid()
};
service.Redeem(invoice,3);//這里兌換3天
}
}
運行程序,偽造的數據服務會在控制台上打印一些東西,結果如下:
現在,業務邏輯完成了,代碼很干凈,分離地也很好,很容易閱讀和維護,但是這代碼還不能進入生產環境,因為有各種各樣可能會出錯的事情發生,因此下面着手新功能的需求開發。
添加日志
雖然審計積分事務還不是一個需求,但是為了安全起見,最好還是記錄每個請求,至少是為了QA(質量保證)的目的。在生產環境,可能會限制或減少日志,但是現在我們要放一些簡單的日志幫助開發者重現QA找到的bugs。
現在,當累積積分和兌換積分時,添加日志,其余代碼和之前的一樣。
/// <summary>
/// 該方法包含了積分系統累積客戶積分的邏輯和規則
/// </summary>
/// <param name="agreement">租賃協議實體</param>
public void Accrue(RentalAgreement agreement)
{
Console.WriteLine("Accrue:{0}",DateTime.Now);
Console.WriteLine("Customer:{0}",agreement.Customer.Id);
Console.WriteLine("Vehicle:{0}",agreement.Vehicle.Id);
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);
Console.WriteLine("Accrue Complete:{0}",DateTime.Now);
}
public void Redeem(Invoice invoice, int numberOfDays)
{
Console.WriteLine("Redeem:{0}",DateTime.Now);
Console.WriteLine("Invoice:{0}",invoice.Id);
var pointsPerDay = 10;
if (invoice.Vehicle.Size>=Size.Luxury)
{
pointsPerDay = 15;
}
var totalPoints = pointsPerDay*numberOfDays;
invoice.Discount = numberOfDays*invoice.CostPerDay;
_loyaltyDataService.SubstractPoints(invoice.Customer.Id,totalPoints);
Console.WriteLine("Redeem Complete:{0}",DateTime.Now);
}
現在還不是很糟糕,只不過在每個實現中添加了幾行代碼而已。咱們繼續往下走!
防御性編程
因為我們的業務邏輯沒有對傳入的參數進行控制,因此必須要檢查一下是否是最壞的情景。比如,如果Accrue
方法傳入一個null會怎樣?我們的業務邏輯不能處理這個,所以會拋異常,但我們希望它能調用我們的API處理這個異常,如果處理不了,就提醒UI開發者或QA發生了一些錯誤的東西。這種哲學就叫防御性編程,只是為了減少危險場景的風險。
下面我們使用防御性編程檢查傳入參數為null的無效場景:
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);
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);
Console.WriteLine("Accrue Complete:{0}",DateTime.Now);
}
我們也可以檢查RentalAgreement
的屬性,但現在上面的就足夠了。Redeem
的實現也有相同的問題,numberOfDays
參數的值不能小於1,Invoice
參數也不能為null,因此也必須使用防御性編程:
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);
var pointsPerDay = 10;
if (invoice.Vehicle.Size>=Size.Luxury)
{
pointsPerDay = 15;
}
var totalPoints = pointsPerDay*numberOfDays;
invoice.Discount = numberOfDays*invoice.CostPerDay;
_loyaltyDataService.SubstractPoints(invoice.Customer.Id,totalPoints);
Console.WriteLine("Redeem Complete:{0}",DateTime.Now);
}
現在我們的代碼開始變得具有防御性了,如果在核心邏輯的控制之外發生了錯誤,也不會影響到我們了。
在添加了日志和防御性代碼之后,Accrue
和Redeem
方法開始變得有點長了,也有點重復,但繼續看一下事務和重試邏輯。
使用事務和重試
如果我們使用了不止一個數據層操作,為了使這些操作具有原子性,那么事務是必須的。也就是說,我們想要所有的數據層調用都成功(提交),要么都失敗(回滾)。假設,我們可以將事務放到業務邏輯層。
假設底層的數據層會使用和.NET內置的事務類TransactionScope
兼容的技術,結合try/catch
塊,我們可以給Accrue
方法添加事務代碼:
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);
using (var ts=new TransactionScope())//開始一個新事務
{
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方法表明事務成功提交
}
catch (Exception ex)
{
throw;//沒有調用Complete方法,事務會回滾
}
}
Console.WriteLine("Accrue Complete:{0}",DateTime.Now);
}
記住,只有調用了事務的Complete
方法,事務才會提交,否則就會回滾。如果拋出了異常,這里我們只是重新拋出,相似地,也可以在Redeem
方法中使用TransactionScope
,這里不再貼了,請自行看源碼。
上面的代碼開始變長、變丑了,原始的業務邏輯代碼周圍包了很多和橫切關注點有關的代碼塊:logging,防御性編程和事務代碼。
但是我們還沒做完,假設底層的數據持久層偶爾會出現高流量,可能就會導致某些請求失敗(比如,拋出超時異常)。如果是那種情況,執行幾次重試會保持程序平滑運行(盡管在高流量期間有點慢)。通過在事務中放一個循環,每次事務回滾時,我們就增加重試次數,一旦重試次數達到限制值,我們就不管了,如下:
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);
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方法,事務會回滾
}
}
}
}
}
相似地,我們也要在Redeem
方法中添加,這里不做了,省略。問題越來越明顯了,橫切關注點基本上占據了這個方法的一半代碼。但是我們還沒有做完,我們需要討論一下異常處理。
處理異常
前面不是添加了try/catch
了么?難道還不夠?也許!比如,服務器離線了,重試次數到達限制了,異常還是會重拋出去,如果是這種情況,我們就需要在程序崩潰前處理這個異常。
因此我們需要在防御性編程后再添加一個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)
{
//...
}
防御性編程切面
這里寫的防御性編程切面可能不是編寫通用切面的最佳實踐,在C#中,我們可以直接在每個參數上放置特性,因此可以這樣替代前面那種方法。實際上,Nuget和github上有專門的類庫NullGuard,一個Fody版本的,一個PostSharp版本的,大家可以去學習一下。
到這里,需要說明一下了,.Net中的特性沒有一定的順序,也就是說,上面的代碼里,[LoggingAspect]
特性在[DefensiveProgramming]
的上面,不是意味着[LoggingAspect]
優先應用,兩者的影響和順序無關,怎么放都可以。
有了防御性編程切面之后,服務代碼又簡化了,代碼可讀性又提高了,下一步來重構事務管理代碼。
為事務和重試創建切面
要重構事務管理代碼,這次不使用OnMethodBoundaryAspect
,而是使用MethodInterceptionAspect
,它不是在方法的邊界插入代碼,而是會攔截任何該方法的調用。攔截切面會在攔截到方法調用時執行切面代碼,之后再執行攔截到的方法;而邊界切面會在方法執行前后運行切面代碼。
[Serializable]
public class TransactionManagement : MethodInterceptionAspect
{
public override void OnInvoke(MethodInterceptionArgs args)
{
using (var ts = new TransactionScope())
{
var retries = 3;//重試3次
var succeeded = false;
while (!succeeded)
{
try
{
args.Proceed();//繼續執行攔截的方法
ts.Complete();//事務完成
succeeded = true;
}
catch (Exception ex)
{
if (retries >= 0)
retries--;
else
throw ex;
}
}
}
}
}
這個切面例子的代碼和業務邏輯中的代碼基本一樣,除了使用args.Proceed()
方法替換了業務邏輯代碼。Proceed()
方法意思就是繼續執行攔截到的方法。通過上面的代碼,我們的代碼又簡化了,下面記得給服務方法添加特性,並將業務代碼從事務中移除:
[LoggingAspect]
[DefensiveProgramming]
[TransactionManagement]
public void Accrue(RentalAgreement agreement)
{
//...略
}
[LoggingAspect]
[DefensiveProgramming]
[TransactionManagement]
public void Redeem(Invoice invoice, int numberOfDays)
{
//...
}
為了說明事務切面能正常工作,可以在OnInvoke
內部前后添加Console.WriteLine("{0}方法開始/結束:{1}", args.Method.Name,DateTime.Now);
,打印出來看一下。
重構異常處理切面
異常處理切面需要使用OnMethodBoundaryAspect
,或者可以使用OnExceptionAspect
,無論使用哪一種,樣子都是差不多的。
[Serializable]
public class MyExceptionAspect:OnExceptionAspect
{
public override void OnException(MethodExecutionArgs args)
{
if (ExceptionHelper.Handle(args.Exception))
{
args.FlowBehavior=FlowBehavior.Continue;
}
}
}
ExceptionHelper
是我自己定義的異常處理靜態類,這里出現了一個新玩意FlowBehavior
,它指定了當切面執行完之后,接下來怎么辦!這里設置了Continue
,也就是說,如果異常處理完了,程序繼續執行,否則,默認的FlowBehavior
是 RethrowException
,這樣的話,切面就沒效果了,異常又再次拋出來了。
移除異常處理的代碼,加上異常處理切面特性,至此,所有的橫切關注點就重構完了。下面完整地看一下成品:
[LoggingAspect]
[DefensiveProgramming]
[TransactionManagement]
[MyExceptionAspect]
public void Accrue(RentalAgreement agreement)
{
var rentalTime = agreement.EndDate.Subtract(agreement.StartDate);
var days = (int) Math.Floor(rentalTime.TotalDays);
var pointsPerDay = 1;
if (agreement.Vehicle.Size>=Size.Luxury)
{
pointsPerDay = 2;
}
var totalPoints = days*pointsPerDay;
_loyaltyDataService.AddPoints(agreement.Customer.Id,totalPoints);
}
[LoggingAspect]
[DefensiveProgramming]
[TransactionManagement]
[MyExceptionAspect]
public void Redeem(Invoice invoice, int numberOfDays)
{
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;
}
可以看到,這樣的代碼看着很不錯吧?又回到了之前最開始的代碼,只有業務邏輯的單一職責狀態,所有的橫切關注點都放到了它們各自的類中去了。代碼非常容易閱讀。
再來看看使用AOP的優點:
- 更改方便。如果更改了方法的方法名或參數名,切面會自動處理。切面不會關心業務邏輯是否發生變化(比如每天積分的變化),業務邏輯也不會關心你是否從
Console
切換到了log4Net或NLog
,除非你想使用TransactionScope
之外的東西處理事務或者需要改變重試次數的最大值。 - 可以將這些切面重復給每個服務的各個方法使用,而不是不使用AOP時,每次都要復制粘貼相似的代碼。
- 可以在整個類、命名空間或程序集使用多廣播切面,而不用在每個方法上這樣寫。
小結
這篇的目的一是演示一下橫切關注點可以使你得代碼臟亂差,常規的OOP和使用好設計模式在許多情況下可以幫助重構代碼,但是很多情況還是會讓你的代碼和橫切關注點緊耦合。即使你的代碼遵守了SPR和DI,代碼也會相互糾纏,錯亂或重復。
二來是說明一下變更的代價是和你的代碼多么靈活、可讀和模塊化是相關的。即使已經重構的很好了,仍能在傳統的OOP中中發現一些不容易解耦的橫切關注點。
三是演示一下AOP工具(如PostSharp)如何讓你對橫切關注點進行解耦。使用AOP重構的版本,所有的橫切關注點都有它自己的類,服務類減少到只有業務邏輯和執行業務邏輯。
本篇只是使用AOP的熱身,如果這是你初次接觸AOP(不太可能),那么你已經走上了構建更好、更靈活、更容易閱讀和維護的軟件之路。