iT邦幫忙

0

深入淺出設計守則1

深入淺出設計守則1

客戶需求

小明的公司提供一套肯特機系統, 客戶希望系統能夠提供消費上限機制, 客戶希望系統能夠提供至少三種設定, 設定規則如下:

  • 1天內最多消費上限 (例如1000 元, 或是無上限)
  • 7天內最多消費上限 (例如6000 元, 或是無上限)
  • 30天內最多消費上限 (例如20000 元, 或是無上限)

客戶設定儲存之後,

  • 24小時內不能修改設定,
    但是可以把消費上限金額往下調整(例如1 天消費上限金額1000 元往下調為900元), 或者是原本是無上限金額調整為有上限金額.
  • 超過24 小時之後, 可以自由調整各種消費上限金額

開始實作

於是小明按照需求設計了一個儲存設定的物件

public class ChargeLimitConfig
{
   public ChargeLimitConfig(int limit1Day, int limit7Day, int limit30Day, DateTime lastModified)
   {
      Day1Limit = new DepositLimit(limit1Day);
      Day7Limit = new DepositLimit(limit7Day);
      Day30Limit = new DepositLimit(limit30Day);
      LastModified = lastModified;
   }

   public DateTime LastModified { get; }
   public DepositLimit Day1Limit { get; }
   public DepositLimit Day7Limit { get; }
   public DepositLimit Day30Limit { get; }
}

然後針對"消費金額" 做了一個物件,

public class ChargeLimit
{
   public ChargeLimit(int amount)
   {
      Amount = amount;
   }

   private bool IsUnlimited => Amount == 0;
   private bool Islimited => Amount > 0;
   private int Amount { get; }

   public bool IsMoreThan(ChargeLimit other)
   {
      return IsIncreaseMoreFrom(other) || ChangeFromLimitedToUnlimited(other);
   }

   private bool ChangeFromLimitedToUnlimited(ChargeLimit other)
   {
      return (IsUnlimited && other.Islimited);
   }

   private bool IsIncreaseMoreFrom(ChargeLimit other)
   {
      return Islimited && other.Islimited && Amount > other.Amount;
   }
}

特別注意的地方是,
裡面程式碼將上限金額為0 時, 表示為無上限

接著小明寫了一個檢查設定儲存驗證物件, 企圖用Validate 方法來驗證是否允許客戶修改設定內容

public class ChargeLimitUpdateValidator
{
   private readonly ChargeLimitConfig _newConfig;
   private readonly ChargeLimitConfig _oldConfig;

   public ChargeLimitUpdateValidator(ChargeLimitConfig newConfig, ChargeLimitConfig oldConfig)
   {
      _newConfig = newConfig;
      _oldConfig = oldConfig;
   }

   public bool Validate()
   {
      if ((IncreaseDay1LimitAmount() || IncreaseDay7LimitAmount() || IncreaseDay30LimitAmount())
            && ModifiedWithinRestrictionTimespan()) return false;
      return true;
   }

   private bool IncreaseDay1LimitAmount()
   {
      return _newConfig.Day1Limit.IsMoreThan(_oldConfig.Day1Limit);
   }

   private bool IncreaseDay7LimitAmount()
   {
      return _newConfig.Day7Limit.IsMoreThan(_oldConfig.Day7Limit);
   }

   private bool IncreaseDay30LimitAmount()
   {
      return _newConfig.Day30Limit.IsMoreThan(_oldConfig.Day30Limit);
   }

   private bool ModifiedWithinRestrictionTimespan()
   {
      return _newConfig.LastModified - _oldConfig.LastModified <
               new TimeSpan(24, 0, 0);
   }
}

在系統中, 小明就用下面程式碼來檢查是否可以讓客戶更動設定

var updateValidator = new ChargeUpdateValidator(newConfig, oldConfig);
if( updateValidator.Validate() ){
   //Save newConfig to Database
}

可以改進的地方

觀察上述的程式碼, 可以發現到

  • 商業邏輯規則(客戶設定修改儲存限制), 散落在ChargeLimit 物件和ChargeLimitUpdateValidator 物件. 如果要新增規則, 就很難維護修改.

  • 有重複的程式碼

IncreaseDay1LimitAmount()
IncreaseDay7LimitAmount()
IncreaseDay30LimitAmount()
  • 如果增加新的天數消費上限, 例如(15天內的消費金額),
    要修改的地方太多.

設計守則
找出程式碼可能更動的地方, 把它們獨立出來,
不要和不太改動的地方放在一起.

從上述的需求可以看出可能更動的地方

  • N天內的上限金額設定(1天,7天,30天)
  • 商業邏輯規則--消費金額上限修改規則
    • 24小時內的規則1 金額大小只能往下修
    • 24小時內的規則2 無上限金額可以改成有上限金額
    • 超過24小時的規則

如果你有大量的資料型別的資料, 就考慮可能將資料組織起來,形成一個物件類.

故看到這些1 天,7 天,30 天這些設定的資料, 就該考慮把這些設定資料集合起來變成物件類.

集合資料的方法有很多種, 資料結構可以用陣列也可以利用集合.
在這案例, 可以考慮用Dictionary

public class ChargeLimit
{
   public int PeriodDays { get; set; }
   public int Amount { get; set; }
}

public class ChargeLimitsConfig
{
   public Dictionary<int, ChargeLimit> PeriodDayLimits { get; set; }
   public DateTime LastModifiedTime { get; set; }
}

接下來看ChargeLimitUpdateValidator::Validate() 這個函數, 這個函數需要用數條規則來驗證使用者的參數內容(ChargeLimitsConfig)是否允許被修改

public class ChargeLimitUpdateValidator
{
   public bool Validate(ChargeLimitsConfig oldConfig, ChargeLimitsConfig newConfig)
   {
      //這裡需要實作數條規則來驗證
   }
}

設計守則 當看到數條規則的時候, 我們應該試著考慮設計一個驗證模式(Validation Pattern)

設計驗證應該滿足下面條件

  • 聲明(Declarative) - 我想要有一個驗證器(validator)能夠方便給開發人員去執行.
  • 只有一個單點故障 - 我不想要維護許多變數(variables)而只是為了檢查輸入是否滿足規則.
  • 容易擴展(Extendable) - 最後我希望增加規則的時候, 開發人員能夠容易新增.

所以我們要建立驗證介面, 這個驗證方法需要"舊的ChargeLimit", "修改時間", "新的ChargeLimit", 以及"新的修改時間"四個參數

public interface IChargeLimitUpdateRule
{
   bool Validate(ChargeLimit oldLimit, DateTime lastModifyiedTime, ChargeLimit newLimit, DateTime modifyTime);
}

設計守則 函數(function)的參數應該盡可能地很少, 3個或更多參數對於一個函數來說太多了

所以我們要建立物件來包裝這四個參數

public class ValidateChargeLimitArgs
{
  public ChargeLimit OldLimit { get; set; }   
  public DateTime LastModifiedTime { get; set; }
  public ChargeLimit NewLimit { get; set; }
  public DateTime ModifyTime { get; set; }
}

經過上面的重構, IChargeLimitUpdateRule 宣告如下

public interface IChargeLimitUpdateRule
{
   void Handle(ValidateChargeLimitArgs args);
}

設計守則 規則實作 - 只要發現不符合規則, 我們就直接丟例外就好

首先設計規則1是 -- 不允許金額增加

public class CannotIncreaseRule :  IChargeLimitUpdateRule
{
   public void Handle(ValidateChargeLimitArgs args)
   {
      if (!args.OldLimit.IsUnlimit && !args.NewLimit.IsUnlimit)
		{
         if (args.OldChargeLimitSetting.Amount < args.NewChargeLimitSetting.Amount)
         {
            throw new ValidationException();
         }
      }
   }
}

接著設計規則2, 不允許有上限金額改成無上限金額

public class CannotLimitToUnlimitRule :  IChargeLimitUpdateRule
{
   public void Handle(ValidateChargeLimitArgs args)
   {
      if (args.OldLimit.Islimit && args.NewLimit.IsUnlimit )
      {
         throw new ValidationException();
      }
   }
}

接著設計規則3, 24小時內要套用規則1 和規則2 , 這時候...

設計守則 多用組合, 少用繼承.

原因如下

  • 某些語言不支援多繼承 - 這個限制導致只能其繼承一個基類. 如果想賦予一個類多個功能, 選擇只有兩個: 介面和組合.
  • 組合讓測試更容易 - 單元測試的時候, 我們需要mock 資料. 使用繼承時, 我們不得不mock 基類. 而使用組合, 則簡單很多, 我們可以通過注入不同的實例(instance)來方便的完成mock .
  • 繼承不利於封裝 - 在繼承中, 如果子類依賴父類的行為, 子類將變得脆弱. 因為一旦父類行為發生變化, 子類也將受到影響.

故我們設計如下

public class In24HrRule : IChargeLimitUpdateRule
{
   public void Handle(ValidateChargeLimitArgs args)
   {
      if (!TimeHelper.IsIn24Hr(args.LastModifiedTime, args.ModifyTime))
      {
         return;
      }

      var rule1 = new CannotIncreaseRule();
      rule1.Handle(args);

      var rule2 = new CannotLimitToUnlimitRule();
      rule2.Handle(args);
   }
}

回到ChargeLimitUpdateValidator::Validate() 的地方, 開始建立規則驗證實例並呼叫

public class ChargeLimitUpdateValidator
{
   public bool Validate(ChargeLimitsConfig oldConfig, ChargeLimitsConfig newConfig)
   {
      var rule = new In24HrRule();
      rule.Handle(xxx);
   }
}

我們需要取得所有的使用者新舊設定資料, 故我們實作方法來做這件事情

private static IEnumerable<ValidateChargeLimitArgs> GetAllChargeLimits(ChargeLimitsConfig oldConfig, ChargeLimitsConfig newConfig)
{
   var q1 = from tb1 in oldConfig.PeriodDayLimits.Values
            join tb2 in newConfig.PeriodDayLimits.Values on tb1.PeriodDays equals tb2.PeriodDays
            select new ValidateChargeLimitArgs()
            {
               OldLimit = tb1,
               LastModifiedTime = oldConfig.LastModifiedTime,
               NewLimit = tb2,
               ModifyTime = DateTime.Now
            };
   return q1;
}

取得所有使用者新舊設定資料之後, 並且一個一個餵給規則去檢查,
然後用try...catch 方式檢查規則是否有丟出驗證例外, 如果有例外錯誤, 就回傳false

public bool Validate(ChargeLimitsConfig oldConfig, ChargeLimitsConfig newConfig)
{
	var allChargeLimits = GetAllChargeLimits(oldConfig, newConfig);

   var rule = new In24HrRule();
   try
   {
      foreach (var item in allChargeLimits)
      {
         rule.Handle(item);
      }
      return true;
   }
   catch(ValidationException)
   {
      return false;
   }
}

為了程式碼更乾淨一點, 我們可以把try...catch 抽取出去變成一個方法

private static bool HandleAllChargeLimitsByRule(IEnumerable<ValidateChargeLimitArgs> chargeLimits, IChainOfResponsibilityHandler<ValidateChargeLimitArgs> rule)
{
   try
   {
      foreach (var limit in chargeLimits)
      {
         rule.Handle(limit);
      }
      return true;
   }
   catch
   {
      return false;
   }
}

最後我們的驗證器方法變成如下

public bool Validate(ChargeLimitsConfig oldConfig, ChargeLimitsConfig newConfig)
{
   var allChargeLimits = GetAllChargeLimits(oldConfig, newConfig);

   var rule = new In24HrRule();

   return HandleAllChargeLimitsByRule(allChargeLimits, rule);
}

這樣一來程式碼不就看起來清晰漂亮了嗎?

接下來如果想要加更多的規則, 我們更可以用責任鏈模式(Chain Of Responsibility Pattern)來設計規則.

責任鏈模式的特色 - 當這個物件沒有要處理或是處理完的時候, 能夠將這個請求(request) 傳遞給下一個物件繼續處理.

責任鏈物件的建構元(constructor) 通常都會有一個參數(下一個處理物件是誰)

public class MyHandler : IChainOfResponsibilityHandler
{
   IChainOfResponsibilityHandler _nextHandler;

   public MyHandler(IChainOfResponsibilityHandler nextHandler)
   {
      //儲存下一個處理物件
      _nextHandler = nextHandler;
   }

   public void Handle(object request)
   {
      //處理完我的事情之後
      ...

      //將這個請求(request) 傳遞給下一個處理物件繼續處理
      _nextHandler?.Handle(request);
   }
}

所以CannotIncreaseRule 規則的程式碼就會修改為如下

public class CannotIncreaseRule : IChargeLimitUpdateRule
{
   IChargeLimitUpdateRule _nextHandler;

   public CannotIncreaseRule(IChargeLimitUpdateRule nextHandler)
   {
      _nextHandler = nextHandler;
   }

   public void Handle(ValidateChargeLimitArgs args)
   {
      if (!args.OldLimit.IsUnlimit && !args.NewLimit.IsUnlimit)
		{
         if (args.OldChargeLimitSetting.Amount < args.NewChargeLimitSetting.Amount)
         {
            throw new ValidationException();
         }
      }
      _nextHandler?.Handle(args);
   }
}

另一條規則也修改如下

public class CannotLimitToUnlimitRule :  IChargeLimitUpdateRule
{
   IChargeLimitUpdateRule _nextHandler;

   public CannotLimitToUnlimitRule(IChargeLimitUpdateRule nextHandler)
   {
      _nextHandler = nextHandler;
   }

   public void Handle(ValidateChargeLimitArgs args)
   {
      if (args.OldLimit.Islimit && args.NewLimit.IsUnlimit )
      {
         throw new ValidationException();
      }
      _nextHandler?.Handle(args);
   }
}

然後在程式中使用這2 個規則的時候, 就可以這樣使用

var rules = new CannotIncreaseRule(new CannotLimitToUnlimitRule());

rules.Handle(request);

到這裡, 你會發現....

設計守則 不要有重複的程式碼

剛剛兩個規則中你可以發現有重複的程式碼(duplicate code).

設計守則 應當減少巢狀式的寫法

另外你發現初始化那一串物件的地方, 也是一種壞味道(巢狀式的寫法). 我們也能夠用其他方式來封裝.

所以我們把重複的程式碼抽出來變成另一個類. 然後新增一個方法SetNext 來指定下一個處理物件.

public abstract class BaseRule : IChargeLimitUpdateRule
{
   IChargeLimitUpdateRule _nextHandler;

   public IChargeLimitUpdateRule SetNext(IChargeLimitUpdateRule handler)
   {
      this._nextHandler = handler;
      return this._nextHandler;
   }

   public virtual void Handle(ValidateChargeLimitArgs args) {
      _nextHandler?.Handle(args); 
   }
}

然後把剛剛兩個規則改寫為如下

public class CannotIncreaseRule : BaseRule, IChargeLimitUpdateRule
{
   public override void Handle(ValidateChargeLimitArgs args)
   {
      if (args.OldChargeLimitSetting.Amount < args.NewChargeLimitSetting.Amount)
      {
         throw new ValidationException();
      }
      base.Handle(args);
   }
}

另外一個規則也改寫如下

public class CannotLimitToUnlimitRule : BaseRule, IChargeLimitUpdateRule
{
   public override void Handle(ValidateChargeLimitArgs args)
   {
      if (args.OldLimit.Islimit && args.NewLimit.IsUnlimit )
      {
         throw new ValidationException();
      }
      base.Handle(args);
   }
}

接著寫一個初始化一串Chain 的輔助方法

public static class ChainOfResponsibility {
   public static IChargeLimitUpdateRule Chain(params IChargeLimitUpdateRule[] handlers) 
   {
      var first = handlers.First();
      var chain = first;
      foreach (var handler in handlers.Skip(1))
      {
         chain = chain.SetNext(handler);
      }
      return first;
   }
}

同樣地在程式中初始化Chain 這2 個規則以上的時候, 就可以這樣使用

var rules = ChainOfResponsibility.Chain(
   new CannotIncreaseRule(),
   new CannotLimitToUnlimitRule(),
   new xxxRule1(),
   new xxxRule2()
);

rules.Handle(request);

這樣一來就可以打破巢狀式的初始化寫法


尚未有邦友留言

立即登入留言