雜談:軟體設計中 Cross-Cutting Concerns 的重要性
雜談:軟體設計中 Cross-Cutting Concerns 的重要性
圖(一)、Cross-Cutting Concerns 跨領域關注點
前言
什麼是 Cross-Cutting Concerns 跨領域關注點?我比較少專注談論這個主題,事實上,這不是什麼很新的技巧,也不是什麼狠了不起的高端技術,只不過,我認為有些設計技巧是這樣,看似不難,不過要真的能在專案、實務上的軟體架構中均衡的實踐,那就有一定的難度。
實踐部分,常見的手法即是 AOP, Aspect Oriented Programming 後面都簡稱 AOP,而這個技巧在 .NET C# 上就有多種實踐手法,這個早在 2014 年時,我替竹科某廠所設計的 Web API Framework (net451) 的框架裡,就充分利用這個技巧來將系統服務,像是 Logger (紀錄)、Exception Handlers (例外處裡機制)、Notification/SMS (通知) 等等的實作 與 商業邏輯分開。實作方法我們稍後來說明。
什麼是 Cross-Cutting Concerns 跨領域關注點
這其實是軟體開發上,一直存在的普遍問題,這問題在傳統階層式架構上也存在,我發現訪間有些文章扯進了 Clean Architecture 整潔架構,事實上不管你使用哪一種架構,這類跨越不同層的程式碼卻還是得因為某些 None-function 的需求,得相依某些底層服務的問題,其實一直存在。
通常在設計系統時,關注點分離,讓我在實際進行開發時,只需要專注在某一個模組/Layers 而不需分散注意力在其他的模組或者不同層次的位置上,且開發完成,不需考量各層彼此如何介接,因為每一層早已經定義好彼此互通所使用的協定與溝通的方法,像是 MVC 即是類似的框架。
回到跨領域關注,在軟體的開發上,尤其在企業商業邏輯的開發上面,在程式碼的實作上,我們知道程式碼當然是越簡單、行數越少越好,再說 SRP 也告訴我們一個元件、一個 Method 最好只有一種被修改的理由越好維護,只不過實際的程式碼。為了寫 Logger,為了實作驗證等,往往一定會與底層相關套件或模組相依,總還是會有部分程式碼讓這一個被修改的理由中,相依著其他外部組件、套件,這些在 Clean Architecture 裡,應該屬於 Infrastructure Layer 的東西,往往在某一程度裡,讓你的企業商業邏輯的程式碼不太好維護。
所以,Cross-Cutting Concerns 跨領域關注點的設計手法,或者我會認為它其實是種概念,因為它並未明確告訴你該如何來實踐,因為這可能跟各個程式語言所提供的語法或功能而有所不同,但是實踐達成的目標是相同的。
以 Design Pattern 的代理模式 (Proxy Pattern) 實踐 AOP
圖(二)、以 Design Pattern 的代理模式 (Proxy Pattern) 實踐 AOP
在這個小章節聊聊一種透過代理模式 (Proxy Pattern) 來實踐的一種方法,雖然它並沒有真正解決維護的問題,不過我認為它是初步學習實踐簡單的 AOP 的一種方式。所謂的代理模式即是提供一個中介的類別,也就是說,當我們要做某件事情時,我們不直接存取該物件,而是透過代理物件來進行處裡,而 Logger 就可以在代理的物件中進行處裡。
如上圖(二)中,RentalCarService 是對車輛 Car 操作者用戶端,它要取得車輛性能數據,而這裏我們使用車輛介面 IVehicle 將其實作隔開,操作者除了不直接與 Car 溝通外,在使用 CarProxy 代理模式隔開,所以,當操作者要取得車輛性能數據時,是透過仲介者,也就是 CarProxy 來執行。
這裡,我花了幾分鐘的時間,撰寫了簡單的 C# 範例程式,底下是透過 IVehicle 車輛介面隔開的 Car 與 RentalCarService 的範例程式碼:
public class RentalCarService
{
private IVehicle _vehicle;
private IRentalCarRepository _rentalCarRepository;
public RentalCarService(IVehicle vehicle, IRentalCarRepository rentalCarRepository)
{
_vehicle = vehicle;
_rentalCarRepository = rentalCarRepository;
}
public IEnumerable<IVehicle> GetAllCars()
{
return _rentalCarRepository.GetAllCars();
}
public TimeSpan ChoiseRentalTime(DateTime start, DateTime end)
{
return _vehicle.ChoiseRentalTime(start, end);
}
public VehiclePerformance GetVehiclePerformance()
{
CarEngineeParam carEnginee = new CarEngineeParam();
return new VehiclePerformance() {ExtremeSpeed = carEnginee.GetCarSpeed(_vehicle.Run())};
}
}
public interface IVehicle
{
decimal CalculateRentalCost(int daysRented);
TimeSpan ChoiseRentalTime(DateTime start, DateTime end);
string GetVehicleType();
int Run();
}
public class Car : IVehicle
{
public decimal CalculateRentalCost(int daysRented)
{
return daysRented * 100; // 假設為美元
}
public TimeSpan ChoiseRentalTime(DateTime start, DateTime end)
{
return end - start;
}
public string GetVehicleType() => "Car";
public int Run()
{
return 240;
}
public string Model {get;set;}
}
在這段程式碼當中,RentalCarService 若要查詢車輛的性能諸元,會去叫用 Car 的 GetVehiclePerformance() 方法,而這裡依據原先的 UML 的 Class Diagrm 圖形的設計,RentalCarService 只能透過介面,眼尖的朋友應該會發現這裡其實也就是 ISP 的具體實踐,所以實作放在 Car 裡面。
但是我們現在要新增一個代理類別 CarProxy 來代理 Car,我們也已經在 RentalCarService 預留了以 DI 方式注入 CarProxy。我們 CarProxy 的程式碼如下:
public class CarProxy : IVehicle
{
private ILogger _logger;
private Car _car;
public CarProxy(ILogger logger, Car car)
{
_logger = logger;
_car = car;
}
public decimal CalculateRentalCost(int daysRented)
{
_logger.Info("執行 CalculateRentalCost...");
return _car.CalculateRentalCost(daysRented);
}
public TimeSpan ChoiseRentalTime(DateTime start, DateTime end)
{
_logger.Info("執行 ChoiseRentalTime...");
return _car.ChoiseRentalTime(start, end);
}
public string GetVehicleType()
{
return _car.GetVehicleType();
}
public int Run()
{
_logger.Info("執行 Run...");
return 240;
}
}
轉換為代理模式之後,CarProxy 代理的程式嗎也非常簡單,這裡其實也是去呼叫 Car 類別,不過在這裡我們可以增加 Logger 的處裡。
剛上面提到 RentalCarService 在用戶端會透過 DI 方式注入 CarProxy 代理類別進來,來達到類似 Car 與 Logger 紀錄作業好像脫鉤的效果。
Client 呼叫端的範例程式碼如下:
void Main()
{
// UI
DateTime start = new DateTime(2024, 10, 19);
DateTime end = new DateTime(2024, 10, 21);
IVehicle car = new Car() { Model = "頭又大" };
// Application Services Layers
RentalCarService service = new RentalCarService(
new CarProxy(new Logger(), car as Car),
new RentalCarRepository());
var rentalDate = service.ChoiseRentalTime(start, end);
rentalDate.Dump();
}
不過,這樣的 Proxy Pattern 代理模式已經讓我們在處裡 Logger 事務時,完全不需要去動到我們原本 Car 的商業邏輯,讓橫切面的事情變得簡單,模組化,也更容易地維護這段程式碼。但是,這還是有個但是..,因為代理模式還是有個缺點,我還得多維護一個類別 CarProxy,再者,透過 Proxy Pattern 其實 Car 還是與 Logger 相關作業存在一定的耦合性,且當企業商業邏輯一多的時候,我得撰寫更多的代理類別,因此徒增維護讀困難。
透過 .NET Reflection 反射來讓代理與商業邏輯完全脫鉤
約在 8 年前,我替新竹科學園區某廠所設計的 Web API Framework 框架裡,便是利用 .NET Reflection 反射機制,在 Java 則適實作 java.lang.reflect.InvocationHandler 這個介面,在 .NET 則是本身即是一個型別系統,因此在每一個物件都需要帶一個 Type 的情況下,實作反射非常的容易。
這裡,我設計一組 Business 企業物件,利用 AOP 的技巧,來讓橫切面的商業邏輯與縱向的 Logger 或 Exception Handler 等需求完全脫鉤,這樣一來,企業邏輯 Business 商業邏輯物件完全不需要撰寫任何 Logger 相關的程式碼,完全與斷開與 Logger 的耦合性,始的商業邏輯的程式碼更乾淨,這樣更完全達到我們預期想要的效果。
範例程式碼如下,如果將 Car 改由這個框架來寫會變成這樣:
public class Car : ServerComponentBase
{
[WriteLog(UseLogType.ToFileSystem)]
public decimal CalculateRentalCost(int daysRented)
{
return daysRented * 100; // 假設為美元
}
[WriteLog(UseLogType.ToFileSystem)]
public TimeSpan ChoiseRentalTime(DateTime start, DateTime end)
{
return end - start;
}
public string GetVehicleType() => "Car";
[WriteLog(UseLogType.ToFileSystem)]
public int Run()
{
return 240;
}
public string Model { get; set; }
}
程式中不難發現,幾乎所有被呼叫方法我都打上了 Attributes,而只要有打上這個自定義的 WriteLogAttribute 的方法就會自動產生呼叫記錄。要實現這個 Attribute 首先當然是從 Attribute 裡衍伸出你自己客製化的 WriteLogAttribute 類別。
這個 WriteLog 的 Attribute 程式碼我的範例程式如下:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
public class WriteLogAttribute : Attribute
{
private UseLogType _useLogType;
//private WriteLogEntry _writeLogEntry;
/// <summary>
/// Constructor for WriteLogAttribute.
/// </summary>
public WriteLogAttribute()
{
_useLogType = UseLogType.ToFileSystem;
}
/// <summary>
/// Constructor for WriteLogAttribute.
/// </summary>
/// <param name="useLogType">Log 的類型</param>
public WriteLogAttribute(UseLogType useLogType)
{
_useLogType = useLogType;
}
/// <summary>
/// 保存 Attribute 所設定的寫入的形式.
/// </summary>
public UseLogType WriteLogType
{
get
{
return _useLogType;
}
set { _useLogType = value; }
}
}
與 Java 相同的是,你要去呼叫 Car 的任何 Method 首先這個 Method 必須是 public 的,並取得 Car 的 Runtime Type 後,並呼叫 Invoke 方法,並傳入 Current 的 Car 類別的 Instance 既可做到『動態呼叫』,讓實作 Car 的類別與外界完全的脫鉤。
在 .NET 甚至可以透過動態載入 Assembly 的方式,做到真正的完全動態產生型別。而 Attributes 可以在取得 Class or Method 的 Type 時,取得這個 Method 或 Class 上是否有自定義的 Attributes。
具體的做法如下範例程式:
void Main()
{
string nameSpace = "RentalCarApplication";
string className = "Car";
string methodName = "CalculateRentalCost";
//載入指定路徑中的 Assembly.
Assembly ass = Assembly.LoadFile(Path.Combine(appPath, dllPath));
Type magicType = ass.GetType($"{nameSpace}.{className}");
object magicValue = null;
if (magicType != null)
{
WriteLogAttribute GlobalLogAttr = GetCustomAttribute<WriteLogAttribute>(magicType);
ConstructorInfo magicConstructor = magicType.GetConstructor(Type.EmptyTypes);
object magicClassObject = magicConstructor.Invoke(new object[] { });
MethodInfo magicMethod = magicType.GetMethod(methodName);
if (magicMethod != null)
{
magicValue = magicMethod.Invoke(magicClassObject, new object[] { InvokeObj });
}
}
}
public T GetCustomAttribute<T>(Type loadClass)
where T : class
{
if (loadClass.IsClass)
{
var result = from m in loadClass.GetCustomAttributes(true).AsEnumerable()
where m.GetType() == typeof(T)
select m;
return (T) result.FirstOrDefault();
}
return null; //若取不到,回傳 null 值,供呼叫端判斷使用.
}
注意:上面程式碼只是示例,可能並不完整。
上面程式碼節錄的我 API Framework 中的部分程式碼,這裡實現了動態產生型別、並透過 Method 名稱取得 MethodInfo,最後 Invoke 該 Method 並傳入參數。
利用 .NET Core 的 Middleware 來實踐 Cross-Cutting Concerns
前面提到幾種 Cross-Cutting Concerns 的觀念 與 AOP 在 .NET 的實作技巧,而在 .NET Core 透過 Middleware 的實作方式、與 ASP.NET Core 的 Pipeline 事實上,因此要實作 AOP 也有了另一種方式,我在 API Framework V2 中便大量地使用類似技巧。
舉個例子,我的 API Method 如下:
[NeedAuthorize]
[APIName("GetPersons")]
[ApiLogException]
[ApiLogonInfo]
public async Task<IEnumerable<Person>> GetPersonsAsync()
{
return await Task.FromResult(new Person[]
{
new Person()
{
ID = 1,
Name = "Gelis Wu"
}
});
}
在這個 API Method 裡,我有一個 [NeedAuthorize] 屬性,當有打上這屬性時,這個 API Method 被呼叫時就必須要通過驗證,而在 .NET Core 哩,每個呼叫 (Request Scope) 都會通過 Pipeline ,因此我先定義一個 Middleware 物件,在這裡很自然地都能使用 .NET DI Container 的好處,因此當 Pipeline 通過這個組件時,在 Invoke 方法被呼叫時,我就傳入我自定義的 IUserService 來做賦予 Token。
public class JwtMiddleware
{
private readonly RequestDelegate _next;
private readonly AppSettings _appSettings;
public JwtMiddleware(RequestDelegate next, IOptions<AppSettings> appSettings)
{
_next = next;
_appSettings = appSettings.Value;
}
/// <summary>
/// Pipelines 的觸發方法
/// </summary>
/// <param name="context"></param>
/// <param name="userService"></param>
/// <returns></returns>
public async Task Invoke(HttpContext context, IUserService userService)
{
var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last();
if (token != null)
attachUserToContext(context, userService, token);
await _next(context);
}
/// <summary>
/// 驗證 JWT Token 是否為合法的 Token,如為合法就附掛權限上去
/// </summary>
/// <param name="context"></param>
/// <param name="userService"></param>
/// <param name="token"></param>
private void attachUserToContext(HttpContext context, IUserService userService, string token)
{
// 略...
// 將原先用來產生 Claim 的欄位從 UserServices 裡撈出來,並塞入目前的工作階段身分物件裡
}
}
如何?這在 .NET Core 是不是特別容易實現,所以事實上技巧很多種,重點還是在於你的觀念的建立。在實作程式碼時,視自己的情境,使用適合自己專案的方式,實現 Cross-Cutting Concerns 跨領域關注的設計並不是那麼困難,對嗎。
留言
張貼留言