[AOP]Implementation AOP by Castle.Windsor
前言
最近剛好公司的 training 在提 Decorator Pattern ,而每個月一次的讀書會最近也剛好提到了 IoC 與 AOP ,雖然這個題材在之前已經寫過幾篇使用 Spring.NET 來實作 AOP ,但蠻多朋友反應對 Spring.NET 不熟,而且 Spring.Net 太大一包,對團隊開發或既有系統的改變太大。也的確在業界使用 .NET 開發的公司,DI/AOP framework 採 Spring.NET 的機會應該算是頗低。
因此,怎麼用比較單純的例子,以及輕量化的 library 來說明與實作 AOP ,就是這篇文章的重點。
原本想用 Autofac 當例子,但就我所知,許多大包的 IoC/AOP framework 骨子裡都是使用 Castle Project 裡面的 Dynamic Proxy ,因此決定從最根本挖掘起,練習只用 Castle 來做 AOP。
這篇文章會先從個簡單的訂單資料更新範例開始,介紹 Decorator 的實作,以及對應的問題,進而透過 AOP 的攔截器(interceptor)來解決。這篇不會介紹到 Decorator 與 AOP 的概念,有興趣的讀者可以自行參閱先前的文章:
- [.NET]重構之路系列v12 –用 Decorator Pattern 讓職責分離與組合
- [Design Pattern]Decorator Pattern with IComparer - 先比這個,再比那個
- Spring.NET AOP 系列
文章大綱
- 原始範例
- 使用 Decorator 加上 Log 功能
- 使用工廠模式動態決定是否加上 Log
- 套用 DI framework ,改寫工廠實作內容
- Decorator 可能碰到的問題
- AOP - 實作攔截器,透過 attribute(或稱 annotation)加上 Log 功能
- 針對特訂規則(如 type 或 method 符合條件)才加上攔截器
原始範例
首先在一個 Console Project 建立一個 Order 的類別,上面有 Update 與 Delete 的方法。在 Main() 裡面呼叫 Order 的 Update() 與 Delete() 。如下所示:
class Program
{
static void Main(string[] args)
{
Order order = new Order();
order.Update("91", "Joey");
order.Delete("92");
}
}
public class Order
{
public int Update(string id, string name)
{
Console.WriteLine("Update order, id={0}, name={1}", id, name);
return 1;
}
public void Delete(string id)
{
Console.WriteLine("Delete order, id={0}", id);
}
}
需求異動-希望在 Update() 與 Delete() 之後加上 Log
常見的需求,希望 Order 呼叫 Update() 做完之後,要能記錄 log , Delete() 也是如此。而記錄 log 的動作,與 Order 的商業邏輯(屬於 service 的部分)沒有關係,屬於 concern 的部分。而在開發中希望遵守單一職責原則(SRP)以及關注點分離(SoC)。
因此,常見的作法便是套用 Decorator Pattern 來分離紀錄 log 與 Order 本身的職責。程式碼修改後如下:
internal class Program
{
private static void Main(string[] args)
{
//Order order = new Order();
IOrder order = new LogOrder(new Order());
order.Update("91", "Joey");
order.Delete("92");
}
}
public class LogOrder : IOrder
{
private IOrder _order;
public LogOrder(IOrder order)
{
this._order = order;
}
public int Update(string id, string name)
{
Console.WriteLine("== update log is starting ==");
var result = this._order.Update(id, name);
Console.WriteLine("== update log is stopping ==");
Console.WriteLine();
return result;
}
public void Delete(string id)
{
Console.WriteLine("== delete log is starting ==");
this._order.Delete(id);
Console.WriteLine("== delete log is stopping ==");
Console.WriteLine();
}
}
public interface IOrder
{
int Update(string id, string name);
void Delete(string id);
}
public class Order : IOrder
{
public int Update(string id, string name)
{
Console.WriteLine("Update order, id={0}, name={1}", id, name);
return 1;
}
public void Delete(string id)
{
Console.WriteLine("Delete order, id={0}", id);
}
}
程式碼說明:
- 新增一個 IOrder 的 interface, 讓 context 相依於 interface。並讓 Order 實作 IOrder 。
- 新增一個 LogOrder 的 class 來裝飾 Order ,並實作 IOrder ,於 Update() 與 Delete() 中,記錄 log 。
- Context 端,也就是 Main() 中,原本直接 new Order() 的部分,改為初始化 LogOrder ,並傳入 Order 的 instance, 代表用 Log 裝飾這個 Order 物件。
這樣一來就能避免, Log 的功能或程式碼,弄髒了 Order 的商業邏輯。執行結果如下:
需求異動-動態決定要不要加上 Log
這個需求很簡單,先把 IOrder 的生成方式封裝到簡單工廠中,如果需要加入 Log, 則用裝飾者的方式,若不需要,則直接回傳原本的 Order 。程式碼如下:
internal class Program
{
private static void Main(string[] args)
{
//IOrder order = new LogOrder(new Order());
IOrder order = Factory.GetOrderInstance();
order.Update("91", "Joey");
order.Delete("92");
IOrder order2 = Factory.GetOrderInstance();
order2.Update("91", "Joey");
order2.Delete("92");
}
}
internal class Factory
{
internal static IOrder GetOrderInstance()
{
Console.WriteLine("請輸入true或false,決定是否啟用Log");
var isLogEnabled = Boolean.Parse(Console.ReadLine());
if (isLogEnabled)
{
return new LogOrder(new Order());
}
else
{
return new Order();
}
}
}
public class LogOrder : IOrder
{
private IOrder _order;
public LogOrder(IOrder order)
{
this._order = order;
}
public int Update(string id, string name)
{
Console.WriteLine("== update log is starting ==");
var result = this._order.Update(id, name);
Console.WriteLine("== update log is stopping ==");
Console.WriteLine();
return result;
}
public void Delete(string id)
{
Console.WriteLine("== delete log is starting ==");
this._order.Delete(id);
Console.WriteLine("== delete log is stopping ==");
Console.WriteLine();
}
}
public interface IOrder
{
int Update(string id, string name);
void Delete(string id);
}
public class Order : IOrder
{
public int Update(string id, string name)
{
Console.WriteLine("Update order, id={0}, name={1}", id, name);
return 1;
}
public void Delete(string id)
{
Console.WriteLine("Delete order, id={0}", id);
}
}
第一個輸入 true ,第二個輸入 false ,執行結果如下:
讀者可以想見,如果把這個 flag 放到 config 或 DB 中,就可以動態的決定目前的 process 是否要對 Order 的 Update() 與 Delete() 動作加上 Log ,而不需要修改到程式碼。
套用 DI framework 決定生成 Decorator 的方式
接下來介紹一下,如果引入 DI framework ,生成物件的方式會如何改變。
首先先透過 NuGet 安裝「Castle.Windsor」。
接著加入 Container 的初始化與註冊的類別,將工廠裡面的方法改從 Container 取得物件。程式碼如下所示:
internal class Program
{
private static void Main(string[] args)
{
// 加上 IoC container 註冊與初始化
CastleConfig.Initialized();
IOrder order = Factory.GetOrderInstance();
order.Update("91", "Joey");
order.Delete("92");
IOrder order2 = Factory.GetOrderInstance();
order2.Update("91", "Joey");
order2.Delete("92");
}
}
internal static class CastleConfig
{
public static IWindsorContainer Container;
internal static void Initialized()
{
Container = new WindsorContainer();
Container.Register(
Component.For<IOrder>()
.ImplementedBy<Order>().LifestyleTransient());
Container.Register(
Component.For<IOrder>()
.Instance(new LogOrder(Container.Resolve<IOrder>())).Named("logOrder").LifestyleTransient());
}
}
internal class Factory
{
internal static IOrder GetOrderInstance()
{
Console.WriteLine("請輸入true或false,決定是否啟用Log");
var isLogEnabled = Boolean.Parse(Console.ReadLine());
if (isLogEnabled)
{
//return new LogOrder(new Order());
return CastleConfig.Container.Resolve<IOrder>("logOrder");
}
else
{
//return new Order();
return CastleConfig.Container.Resolve<IOrder>();
}
}
}
Container 的註冊沒有什麼特別的,第一段是註冊預設碰到在生成物件的過程,若要取得 IOrder 的 instance ,預設回傳 Order 。而第二段是先透過 key: “logOrder”來註冊,當欲取得 IOrder 的實體,傳入 key 為 logOrder 時,則回傳 LogOrder 的 Decorator 。
跑起來的結果跟上面沒有引入 IoC 的例子一模一樣,而我們異動的部分只有兩個:
- 初始化 DI container 的動作。(只需要加一次,之後都不需要再改變)
- 修改工廠取得物件的方式。
Context 端的流程完全不需要異動,這符合了開放封閉原則(OCP)。
需求異動-除了 Order 以外,假設 Customer 物件也要記錄 Log
通常記錄 log 的需求,不會只有 Order 物件需要記錄 log ,如果現在多了一個 Customer 的物件,呼叫其 Contact() 也需要記錄 log 時,該怎麼辦?
因為 Decorator 需要實作 interface, 而 Order 與 Customer 的 interface 不會相同,那該怎麼辦?為每一個需要裝飾 Log 的物件,加上對應的 Decorator 。土法煉鋼的程式碼如下:
internal class Program
{
private static void Main(string[] args)
{
// 加上 IoC container 註冊與初始化
CastleConfig.Initialized();
IOrder order = Factory.GetOrderInstance();
order.Update("91", "Joey");
order.Delete("92");
ICustomer customer = Factory.GetCustomerInstance();
customer.Contact();
}
}
public class LogCustomer : ICustomer
{
private ICustomer _customer;
public LogCustomer(ICustomer customer)
{
this._customer = customer;
}
public void Contact()
{
Console.WriteLine("== Contact log is starting ==");
this._customer.Contact();
Console.WriteLine("== Contact log is stopping ==");
Console.WriteLine();
}
}
public interface ICustomer
{
void Contact();
}
public class Customer : ICustomer
{
public void Contact()
{
Console.WriteLine("contact customer...");
}
}
internal static class CastleConfig
{
public static IWindsorContainer Container;
internal static void Initialized()
{
Container = new WindsorContainer();
// 透過 key 來決定取回的 IOrder 物件為何
Container.Register(
Component.For<IOrder>()
.ImplementedBy<Order>().LifestyleTransient());
Container.Register(
Component.For<IOrder>()
.Instance(new LogOrder(Container.Resolve<IOrder>())).Named("logOrder").LifestyleTransient());
// 可以透過註冊的順序,直接決定 LogCustomer Decorator 的生成方式
Container.Register(
Component.For<ICustomer>()
.ImplementedBy<LogCustomer>().LifestyleTransient());
Container.Register(
Component.For<ICustomer>()
.ImplementedBy<Customer>().LifestyleTransient());
}
}
internal class Factory
{
internal static IOrder GetOrderInstance()
{
Console.WriteLine("請輸入true或false,決定是否啟用Log");
var isLogEnabled = Boolean.Parse(Console.ReadLine());
if (isLogEnabled)
{
//return new LogOrder(new Order());
return CastleConfig.Container.Resolve<IOrder>("logOrder");
}
else
{
//return new Order();
return CastleConfig.Container.Resolve<IOrder>();
}
}
internal static ICustomer GetCustomerInstance()
{
// 直接回傳Log裝飾過的Customer
return CastleConfig.Container.Resolve<ICustomer>();
}
}
程式碼說明如下:
- 一樣新增一個 ICustomer 介面,一個 Customer 類別,一個 LogCustomer 類別。透過 Decorator Pattern 來滿足需求。
- Container 註冊的部分,展現了另外一種方式,當呼叫端要取得 ICustomer 物件,我們希望都是回傳 Decorator 時,可以直接先註冊 LogCustomer ,再註冊 Customer 給 ICustomer ,這樣一來取得的順序就會是:要取得 ICustomer ,發現要取得 LogCustomer ,在取得 LogCustomer 發現還要傳入 ICustomer ,則以 Customer 傳入。有點類似
new LogCusomter(new Customer())
的味道。
回到原本提的問題,Log 這個 concern 可能存在很多地方都要使用,可不可以不要讓 Log 被 Decorator 的介面綁住。
所以,要透過 AOP framework 的 interceptor 來設計。
AOP-攔截器的實作
首先設計一個 LogInterceptor 類別,並實作 Castle.DynamicProxy.IInterceptor
介面。程式碼如下:
internal class LogInterceptor : IInterceptor
{
public void Intercept(IInvocation invocation)
{
var typeName = invocation.TargetType.FullName;
var methodName = invocation.Method.Name;
Console.WriteLine("== {0}.{1} log is starting ==", typeName, methodName);
foreach (var arg in invocation.Arguments)
{
Console.WriteLine("argument:{0}", arg);
}
invocation.Proceed();
Console.WriteLine("return value:{0}", invocation.ReturnValue ?? "null");
Console.WriteLine("== {0}.{1} log is stopping ==", typeName, methodName);
Console.WriteLine();
}
}
IInterceptor
介面只有一個 Intercept
的方法,而傳入的參數就是在呼叫時,把所有要攔截的目標與方法抽象成 IInvocation
介面。上面有幾個常用的關鍵:
invocation.TargetType
:也就是攔截的目標型別。invocation.Method
:也就是這次攔截到的方法。invocation.Arguments
:呼叫目標物件的方法,所傳入的參數。invocation.Proceed()
:實際呼叫目標物件的方法。invocation.ReturnValue
:實際目標方法的回傳值,可於攔截器中重新設定想要回傳的值。
接著只需要在 Container 註冊這個攔截器的型別,最後,在要攔截的物件類別上,加載 Interceptor
Attribute 即可。
Container 註冊的程式碼如下所示:
internal static class CastleConfig
{
public static IWindsorContainer Container;
internal static void Initialized()
{
Container = new WindsorContainer();
// 註冊攔截器的型別與物件供 Interceptor attribute 使用
Container.Register(
Component.For<LogInterceptor>()
.ImplementedBy<LogInterceptor>().LifestyleTransient());
Container.Register(
Component.For<IOrder>()
.ImplementedBy<Order>().LifestyleTransient());
Container.Register(
Component.For<ICustomer>()
.ImplementedBy<Customer>().LifestyleTransient());
}
}
程式碼說明:
- Container 註冊,註冊 LogInterceptor 的型別對應。
- 其他的註冊則採預設的 Order 對應 IOrder ,Customer 對應 ICustomer 。
Customer 與 Order 的類別如下:
[Interceptor(typeof(LogInterceptor))]
public class Customer : ICustomer
{
public void Contact()
{
Console.WriteLine("contact customer...");
}
}
[Interceptor(typeof(LogInterceptor))]
public class Order : IOrder
{
public int Update(string id, string name)
{
Console.WriteLine("Update order, id={0}, name={1}", id, name);
return 1;
}
public void Delete(string id)
{
Console.WriteLine("Delete order, id={0}", id);
}
}
假設現在先不動態決定要不要加入 Log 的功能,而是只要有標記 Interceptor
Attribute,就要加上 Log ,則工廠類別也可以最單純化地取回介面所對應的實體物件執行個體,程式碼如下:
internal class Factory
{
internal static IOrder GetOrderInstance()
{
//Console.WriteLine("請輸入true或false,決定是否啟用Log");
//var isLogEnabled = Boolean.Parse(Console.ReadLine());
//if (isLogEnabled)
//{
// //return CastleConfig.Container.Resolve<IOrder>("logOrder");
// return CastleConfig.Container.Resolve<IOrder>();
//}
//else
//{
// return CastleConfig.Container.Resolve<IOrder>();
//}
return CastleConfig.Container.Resolve<IOrder>();
}
internal static ICustomer GetCustomerInstance()
{
return CastleConfig.Container.Resolve<ICustomer>();
}
}
Main() 方法完全不需要改變,程式碼與執行結果如下:
private static void Main(string[] args)
{
// 加上 IoC container 註冊與初始化
CastleConfig.Initialized();
IOrder order = Factory.GetOrderInstance();
order.Update("91", "Joey");
order.Delete("92");
ICustomer customer = Factory.GetCustomerInstance();
customer.Contact();
}
可以看到,只需要在類別上加上 [Interceptor(typeof(LogIntercepter))]
就可以為該類別上所有方法加上攔截器。在 runtime 取回對應物件時,AOP framework 偵測到該物件有標記 Interceptor
Attribute 時,就會動態加載攔截器上去,這也就是 Dynamic Decorator 的效果。
這樣就可以避免為了要用 Decorator 而撰寫多份的 LogDecorator 物件,反而導致違反了 DRY 原則的壞味道。
如何根據條件決定要不要加載攔截器
這邊的條件列出兩種,讓讀者知道 AOP framework 可以做到什麼樣的地步。
- 原本的工廠,要能動態決定回來的 Order 要不要有 Log 攔截器。
- 當方法名稱中含有 Update 的字眼時,才加載 Log 攔截器。
第一個條件的部分,只需要在 Container 註冊時,額外註冊一個有加掛攔截器的 key 即可。 Container 程式碼修改如下:
internal static class CastleConfig
{
public static IWindsorContainer Container;
internal static void Initialized()
{
Container = new WindsorContainer();
// 註冊攔截器的型別與物件供 Interceptor attribute 使用
Container.Register(
Component.For<LogInterceptor>()
.ImplementedBy<LogInterceptor>().LifestyleTransient());
Container.Register(
Component.For<IOrder>()
.ImplementedBy<Order>().LifestyleTransient());
// 額外註冊有加載攔截器的Order
Container.Register(
Component.For<IOrder>()
.ImplementedBy<Order>().LifestyleTransient().Named("logOrder")
.Interceptors(InterceptorReference.ForType<LogInterceptor>()).Anywhere);
Container.Register(
Component.For<ICustomer>()
.ImplementedBy<Customer>().LifestyleTransient()
.Interceptors(InterceptorReference.ForType<LogInterceptor>()).Anywhere);
}
}
工廠修改如下:
internal class Factory
{
internal static IOrder GetOrderInstance()
{
Console.WriteLine("請輸入true或false,決定是否啟用Log");
var isLogEnabled = Boolean.Parse(Console.ReadLine());
if (isLogEnabled)
{
return CastleConfig.Container.Resolve<IOrder>("logOrder");
}
else
{
return CastleConfig.Container.Resolve<IOrder>();
}
}
internal static ICustomer GetCustomerInstance()
{
return CastleConfig.Container.Resolve<ICustomer>();
}
}
接下來 Main() 方法回到最原本取兩個 Order 物件的情況,程式碼與執行結果如下:
internal class Program
{
private static void Main(string[] args)
{
// 加上 IoC container 註冊與初始化
CastleConfig.Initialized();
IOrder order = Factory.GetOrderInstance();
order.Update("91", "Joey");
order.Delete("92");
IOrder order2 = Factory.GetOrderInstance();
order2.Update("91", "Joey");
order2.Delete("92");
Console.WriteLine();
ICustomer customer = Factory.GetCustomerInstance();
customer.Contact();
}
}
記得要把 Order 與 Customer 上的 InterceptorAttribute 移除
第二個條件則是,只針對方法名字有 Update 字眼的進行攔截,這比較麻煩一些些,但卻很能代表 AOP 的強大彈性之處。
一樣,只需要修改 Container 註冊的部分,就能讓這些需求都滿足。
只需修改 Container 註冊的部分,這是一個很重要很重要的關鍵,因為這樣才完全地滿足了開放封閉原則(OCP),動態的組裝各個僅擁有單一職責的物件,卻能滿足使用端動態的需求。
Container 修改後的程式碼如下:
internal static class CastleConfig
{
public static IWindsorContainer Container;
internal static void Initialized()
{
Container = new WindsorContainer();
// 註冊攔截器的型別與物件供 Interceptor attribute 使用
Container.Register(
Component.For<LogInterceptor>()
.ImplementedBy<LogInterceptor>().LifestyleTransient());
Container.Register(
Component.For<IOrder>()
.ImplementedBy<Order>().LifestyleTransient());
// 額外註冊有加載攔截器的Order
//Container.Register(
// Component.For<IOrder>()
// .ImplementedBy<Order>().LifestyleTransient().Named("logOrder")
// .Interceptors(InterceptorReference.ForType<LogInterceptor>()).Anywhere);
Container.Register(
Component.For<IOrder>()
.ImplementedBy<Order>().LifestyleTransient().Named("logOrder")
.Interceptors(InterceptorReference.ForType<LogInterceptor>()).Anywhere
.SelectInterceptorsWith(new OnlyUpdateMethodBeSelected()));
Container.Register(
Component.For<ICustomer>()
.ImplementedBy<Customer>().LifestyleTransient()
.Interceptors(InterceptorReference.ForType<LogInterceptor>()).Anywhere);
}
}
internal class OnlyUpdateMethodBeSelected : IInterceptorSelector
{
public IInterceptor[] SelectInterceptors(Type type, System.Reflection.MethodInfo method, IInterceptor[] interceptors)
{
if (method.Name.Contains("Update"))
{
return interceptors;
}
else
{
return Enumerable.Empty<IInterceptor>().ToArray();
}
}
}
程式碼說明:
- 在原本註冊 Order 要加載攔截器的部分,加上一個
SelectedInterceptorsWith()
,代表只有哪一些符合條件的攔截器要被加載上去,這時傳入的參數型別為IInterceptorSelector
。 - 自訂一個 OnlyUpdateMethodBeSelected 的類別實作
IInterceptorSelector
介面。 - 當方法名字有包含 Update 時,則把上面所有攔截器都加上去。否則,則卸載所有攔截器。
執行結果可以發現,只有 Update() 方法被加載 Log 的攔截器了,或是說,只有 Update() 方法時,會讓某些攔截器生效。如下:
這邊只是個簡單的範例,讀者們可以看到 IInterceptorSelector
會傳入 type 與 method ,也就代表開發人員能從 type 與 method 上透過 reflection 做相當相當多的事情,傳入的 interceptors 也可以透過 LINQ 去判斷什麼條件下,希望加載什麼 interceptors 。
結論
這篇文章雖然略過了基礎概念不提,但還是完整的從最原始的需求,以及需求的變化,一路帶到 Design Patterns 的應用,以及 DI/AOP framework 可以協助我們解決什麼樣的問題。
以下則摘要幾個重點:
- 大部分的 AOP framework 都 base on DI framework 。如果你的團隊跟系統還沒導入 DI framework ,那麼建議讀者最好可以先實作工廠模式,讓所有生成物件的部分封裝起來,未來只要搭配策略模式(Strategy Pattern)與多型的概念,就能把實作面的商業邏輯與 context 流程分開。屆時只需要把工廠內的實作部分,接上 DI framework 即可。而一旦有 DI framework 的情況,AOP 就能滿足你絕大部分關注點分離(SoC)的開發與彈性的需求。
- 不管需求怎麼異動,只要原本的 context 流程沒改,商業邏輯沒變,那我們希望就只需要動到工廠內部或是 Container 註冊的部分,即可滿足新的需求。這也是為什麼開放封閉原則是 SOLID 原則中的總綱。
- 原本可能弄髒商業邏輯或重複開發的 concern 模組,例如:權限檢查、Log紀錄、交易控管、例外處理等等,都可以透過攔截器的方式來設計,並可以在線上環境動態的決定是否加載,以滿足特定的需求。
最後有兩個附註說明,第一,DI framework 註冊與取用物件的部分,也可以透過 XML/Config 方式來設計。第二,看起來 Castle 雖然輕量化,但還是有一些地方用起來不夠順手,這就是為什麼會有額外許多 DI framework ,如 Autofac ,要基於 Castle project 再往上加上很多包裝,因為可以讓開發人員更簡易、好懂、彈性的操作 DI 與 AOP 的設計。
Sample Code 下載: DecoratorToAop.zip
blog 與課程更新內容,請前往新站位置:http://tdd.best/