雜談:軟體設計中 Cross-Cutting Concerns 的重要性

雜談:軟體設計中 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 圖(二)、以 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 跨領域關注的設計並不是那麼困難,對嗎。

留言

這個網誌中的熱門文章

軟體工程師 - 成長的 10 個階段

常見的程式碼壞味道(Code Smell or Bad Smell)

什麼是 gRPC ?