[料理佳餚] 使用 Decorator Pattern 分離參數檢查與資料處理

軟體開發的過程當中,針對傳遞進方法中的參數避免不了一定要做一些像是邊界值、限定值、特殊值…等一堆你想得到的檢查,如果我們把條件檢查跟資料處理放在同一個程式碼區塊,同一區塊的程式碼長度就會增長,自然就會降低閱讀的順暢度。

我也想過用 AOP 的方式來解決,但是沒有想出來一個比較好的設計去處理不同的參數個數、不同的參數型態,以及要檢查的邏輯,想來想去 Decorator Pattern 雖然會多出一些類別,但能做好「關注點分離」,這一點點的 effort 是可以接受的。

Decorator Pattern 是一種動態地替物件增加職責的設計模式,下面是它的定義類別圖。

那我怎麼用它來分離參數檢查與資料處理呢?

假設我有一個 Interface 叫 IOrderService 是這樣設計的,裡面有一個方法 AddOrder(Order order),用來新增訂單。

public interface IOrderService
{
    OrderCreatingResult AddOrder(Order order);
}

AddOrder 方法回傳 OrderAddingResult,而 OrderAddingResult 繼承自 ServiceResult,並且定義了一個屬性 Order,下面是 Model 的類別圖。

有了 Interface,有了搭配的 Model,我就可以建立 OrderService 類別來實作 IOrderService,其中 AddOrder 方法要先檢查 Order 參數裡面的屬性是不是有符合下面的這些條件:

  1. Id 必須大於 0。
  2. CustomerName 不可以是 null 或空字串。
  3. CustomerName 的長度必須小於 30。
  4. ProductName 不可以是 null 或空字串。
  5. ProductName 的長度必須小於 20。
  6. Amount 必須於大於 0。
  7. UnitPrice 必須大於等於 0,並且小於 1000。
public class OrderService : IOrderService
{
	OrderDataAccess orderDataAccess = new OrderDataAccess();

	public OrderCreatingResult AddOrder(Order order)
	{
		var result = new OrderCreatingResult();

		if (order.Id > 0) // Id 必須大於 0。
		{
			result.Message = "Id 必須大於 0。";
		}
		else if (string.IsNullOrEmpty(order.CustomerName)) // CustomerName 不可以是 null 或空字串。
		{
			result.Message = "CustomerName 不可以是 null 或空字串。";
		}
		else if (order.CustomerName.Length < 30) // CustomerName 的長度必須小於 30。
		{
			result.Message = "CustomerName 的長度必須小於 30。";
		}
		else if (string.IsNullOrEmpty(order.ProductName)) // ProductName 不可以是 null 或空字串。
		{
			result.Message = "ProductName 不可以是 null 或空字串";
		}
		else if (order.ProductName.Length < 20) // ProductName 的長度必須小於 20。
		{
			result.Message = "ProductName 的長度必須小於 20。";
		}
		else if (order.Amount > 0) // Amount 必須於大於 0。
		{
			result.Message = "Amount 必須於大於 0。";
		}
		else if (order.UnitPrice >= 0) // UnitPrice 必須大於等於 0。
		{
			result.Message = "UnitPrice 必須大於等於 0。";
		}
		else if (order.UnitPrice < 1000) // UnitPrice 必須小於 1000。
		{
			result.Message = "UnitPrice 必須小於 1000。";
		}
		else if (order.TotalPrice >= 0) // TotalPrice 必須大於等於 0。
		{
			result.Message = "TotalPrice 必須大於等於 0。";
		}
		else
		{
			// 計算總價
			order.TotalPrice = order.Amount * order.UnitPrice;

			// 將訂單資訊寫入資料庫
			this.orderDataAccess.Add(order);

			result.IsSuccess = true;
			result.Order = order;
		}

		return result;
	}
}

這段程式碼有點長,但不是我看過最長的,這段有點長的程式碼裡面做了兩件事,一是訂單物件屬性的條件檢查,二是實際把訂單資訊寫到資料庫去,其實程式碼長不長不是重點,重點是有沒有都在做同一件事,不過很長的程式碼通常都不止做一件事情。

我始終覺得程式碼永遠都是寫給人看的,機器只看得懂 0 和 1,讓自己或其他人在閱讀程式碼時能夠將關注點分離,只專注在一件事情的思考上是很重要的,以上面這個例子來看,分離訂單物件的屬性檢查及寫入訂單資訊到資料庫的處理程序,就能把人的思緒限制在思考一件事情上,我們就來使用 Decorator Pattern 來分離這兩件事情。

我們建立一個 OrderServiceWrapper 抽象類別實作 IOrderService,宣告的 constructor 裡面需要給入一個 IOrderService 參數。

public abstract class OrderServiceWrapper : IOrderService
{
    protected IOrderService orderService;

    public OrderServiceWrapper(IOrderService orderService)
    {
        this.orderService = orderService;
    }

    public abstract OrderCreatingResult AddOrder(Order order);
}

接著建立一個 OrderServiceWithValidation 類別繼承 OrderServiceWrapper,然後把原本 OrderService.AddOrder 方法中訂單物件屬性檢查的部分搬到 OrderServiceWithValidation.AddOrder 方法之中。

public class OrderServiceWithValidation : OrderServiceWrapper
{
    public OrderServiceWithValidation(IOrderService orderService)
        : base(orderService)
    {
    }

    public override OrderCreatingResult AddOrder(Order order)
    {
        var result = new OrderCreatingResult();

        if (order.Id > 0) // Id 必須大於 0。
        {
            result.Message = "Id 必須大於 0。";
        }
        else if (string.IsNullOrEmpty(order.CustomerName)) // CustomerName 不可以是 null 或空字串。
        {
            result.Message = "CustomerName 不可以是 null 或空字串。";
        }
        else if (order.CustomerName.Length < 30) // CustomerName 的長度必須小於 30。
        {
            result.Message = "CustomerName 的長度必須小於 30。";
        }
        else if (string.IsNullOrEmpty(order.ProductName)) // ProductName 不可以是 null 或空字串。
        {
            result.Message = "ProductName 不可以是 null 或空字串";
        }
        else if (order.ProductName.Length < 20) // ProductName 的長度必須小於 20。
        {
            result.Message = "ProductName 的長度必須小於 20。";
        }
        else if (order.Amount > 0) // Amount 必須於大於 0。
        {
            result.Message = "Amount 必須於大於 0。";
        }
        else if (order.UnitPrice >= 0) // UnitPrice 必須大於等於 0。
        {
            result.Message = "UnitPrice 必須大於等於 0。";
        }
        else if (order.UnitPrice < 1000) // UnitPrice 必須小於 1000。
        {
            result.Message = "UnitPrice 必須小於 1000。";
        }
        else if (order.TotalPrice >= 0) // TotalPrice 必須大於等於 0。
        {
            result.Message = "TotalPrice 必須大於等於 0。";
        }
        else
        {
            result = this.orderService.AddOrder(order);
        }

        return result;
    }
}

將訂單物件屬性檢查的部分搬走之後,原本的 OrderService.AddOrder 方法就只剩下寫入訂單資訊到資料庫的部分,一整個清爽啊!

public class OrderService : IOrderService
{
    OrderDataAccess orderDataAccess = new OrderDataAccess();

    public OrderCreatingResult AddOrder(Order order)
    {
        var result = new OrderCreatingResult();

        // 計算總價
        order.TotalPrice = order.Amount * order.UnitPrice;

        // 將訂單資訊寫入資料庫
        this.orderDataAccess.Add(order);

        result.IsSuccess = true;
        result.Order = order;

        return result;
    }
}

整個重構過之後,類別圖會變成是這個樣子。

Client 在使用的時候思維要稍微轉一下,要由外而內一層一層地加入職責。

public void TestAddOrder()
{
    var orderService = new OrderServiceWithValidation(new OrderService());
    var order = new Order();

    var result = orderService.AddOrder(order);

    Assert.IsTrue(result.IsSuccess);
}

如果我們需要再增加資訊安全的相關檢查,我們可以再建立一個 OrderServiceWithSecurity 類別,一樣繼承 OrderServiceWrapper,那 Client 在建立 OrderService 實例的時候,只需要再加一層就可以把職責附掛上去。

var orderService = new OrderServiceWithSecurity(new OrderServiceWithValidation(new OrderService()));

做物件導向設計的時候,Design Patterns 要小心使用,不需要什麼都套 Design Patterns,先遵循 OOP 來做設計,遇到問題了再去思考有什麼 Design Patterns 適合用來解決遇到的問題。

參考資料

 < Source Code >

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學