Skip to content
February 15, 2013 / windperson

Strategy Pattern解決複雜邏輯判斷

假設有個會員制的網站,現在有個需求:根據會員的不同,在登入時提供不同的“招呼語”。

  1. 根據會員男女性別的不同,會顯示Mr.或Ms.的尊稱。
  2. 根據會員種類的不同,顯示的招呼語也不同:
    • Normal資格的顯示”Welcome + 尊稱 + FullName”
    • Premium資格的顯示”Hello + 尊稱 + FirstName”
    • VIP資格的顯示”Dear + 尊稱 + FirstName”
  3. 會員年齡滿18但不到20歲的,登入時不論會員資格,都一律顯示”Hi + 尊稱 + FullName”
  4. 其餘情況不必顯示招呼語。

一般直覺地來說就會直接硬幹,一個method就搞定的程式碼如下:

public static string GetWelcomeText(UserData user)
{
    string ret = String.Empty;
    DateTime now = DateTime.Now;
    if (user.Birth.AddYears(18) <= now
        && user.Birth.AddYears(20) > now)
    {
        if (user.Gender == Gender.Male
            && user.Type != AccountType.Unknwon)
            ret = "Hi Mr." + user.FullName();
        else if (user.Gender == Gender.Female
            && user.Type != AccountType.Unknwon)
            ret = "Hi Ms." + user.FullName();
    }
    else
    {
        switch (user.Type)
        {
            case AccountType.Normal:
                if (user.Gender == Gender.Male)
                    ret = "Welcome Mr." + user.FullName();
                else if (user.Gender == Gender.Female)
                    ret = "Welcome Ms." + user.FullName();
                break;
            case AccountType.Premium:
                if (user.Gender == Gender.Male)
                    ret = "Hello Mr." + user.FirstName;
                else if (user.Gender == Gender.Female)
                    ret = "Hello Ms." + user.FirstName;
                break;
            case AccountType.VIP:
                if (user.Gender == Gender.Male)
                    ret = "Dear Mr." + user.FirstName;
                else if (user.Gender == Gender.Female)
                    ret = "Dear Ms." + user.FirstName;
                break;
            default:
                ret = "";
                break;
        }
    }
    return ret;
}

這樣的程式碼,優缺點如下:
優點

  • 語法簡單易寫平鋪直述。
  • 單一程式碼檔案即可寫完。

缺點

  • 程式碼都在單一類別內實作,這個類別的程式碼變很大很多行。
  • 程式碼中用來判斷輸入是那個狀況的多層巢狀判斷邏輯會隨著需求變更而越變越複雜,到最後沒人看得懂這整個method內判斷情況的邏輯規則。
  • 因多層巢狀判斷容易造成有重複的程式碼在這整串邏輯之中,或者為了避免重複,可怕並且不該存在的“goto”敘述就出現了。
  • 程式若在這個多層巢狀邏輯內發出exception時,在非偵錯版本,沒有顯示程式碼行號的log內容中,會很難判斷是在哪一段發生錯誤。
  • 日後若因需求變更而得變更其中的某個分支判斷邏輯時,很難保證不會影響到其他的分支。
  • 因為全部結構都位於單一個類別的一個method內,無法將該任務分派給多人以便同時開發加快進度。
  • 該method若要撰寫單元測試(unit test)的測試案例(test case)時,需測試到所有分支邏輯,會造成該測試案例異常龐大不易維護且測試的執行變慢。

這個時候用Strategy Pattern來重構程式碼,套用該模式之前,必須要做的預先檢查項目如下:

  1. 釐清情況是該情況時需要進行的事
    也就是看懂到底這一整坨『spaghetti』判斷邏輯總共會有哪幾種情況,以及這些情況下各會做怎樣的資料處理流程。
  2. 找出如何判定情況是屬於該分類的資料:
    也就是找出前一個步驟所找到的那幾種情況中,條件成立的判斷依據各是什麼。
  3. 找出需要回傳給Client端的輸出結果資料,讓Context這個類別能夠有辦法轉遞這些運算結果給呼叫的Client端。

重構後的程式碼,首先是AbstractStrategy類別:

abstract class AbstractStrategy
{
    public abstract bool IsThisKind(UserData user);

    public abstract string WelcomeStr(UserData user);

    protected const int LegalCivilLawAge = 20;
    protected const int LegalCrimeLawAge = 18;
    protected bool IsOlderThan(UserData user, int year)
    {
        if (year <= 0)
        { throw new ArgumentOutOfRangeException("year"); }
        return user.Birth.AddYears(year) <= DateTime.Now;
    }
}

然後繼承自AbstractStrategy,產生
NormalMaleUserStrategy, NormalFemaleUserStrategy,
PremiumMaleUserStrategy ,PremiumFemaleUserStrategy,
VIPMaleUserStrategy, VIPFemaleUserStrategy,
BetweenCivilCrimeLawAgeMaleStrategy, BetweenCivilCrimeLawAgeFemaleStrategy
這幾個實體Strategy類別,下面列出其中幾個類別的程式碼:

NormalMaleUserStrategy.cs:

class NormalMaleUserStrategy : AbstractStrategy
{
    public override bool IsThisKind(UserData user)
    {
        return user.Type == AccountType.Normal 
               && user.Gender == Gender.Male
               && IsOlderThan(user, LegalCivilLawAge);
    }

    public override string WelcomeStr(UserData user)
    {
        return "Welcome Mr." + user.FullName();
    }
}

VIPFemaleUserStrategy.cs:

class VIPFemaleUserStrategy : AbstractStrategy
{
    public override bool IsThisKind(UserData user)
    {
        return user.Type == AccountType.VIP
               && user.Gender == Gender.Female
               && IsOlderThan(user, LegalCivilLawAge);
    }

    public override string WelcomeStr(UserData user)
    {
        return "Dear Ms." + user.FirstName;
    }
}

BetweenCivilCrimeLawAgeMaleStrategy.cs:

class BetweenCivilCrimeLawAgeMaleStrategy : AbstractStrategy
{
    public override bool IsThisKind(UserData user)
    {
        return user.Gender == Gender.Male
               && IsOlderThan(user, LegalCrimeLawAge)
               && !IsOlderThan(user, LegalCivilLawAge);
    }

    public override string WelcomeStr(UserData user)
    {
        return "Hi Mr." + user.FullName();
    }
}

其他的實體Strategy類別程式碼也只是稍微修改在bool IsThisKind(UserData user)函式的回傳Logical AND條件中正向表列屬於該種資格的判斷條件,以及string WelcomeStr(UserData user)所回傳的內容不同,就不再列出。

最後,是統合全部Strategy類別的Context類別程式碼:

class Context
{
    private readonly List<AbstractStrategy> _strategies = new List<AbstractStrategy>();

    public Context()
    {
        _strategies.Add(new NormalMaleUserStrategy());
        _strategies.Add(new NormalFemaleUserStrategy());
        _strategies.Add(new PremiumMaleUserStrategy());
        _strategies.Add(new PremiumFemaleUserStrategy());
        _strategies.Add(new VIPMaleUserStrategy());
        _strategies.Add(new VIPFemaleUserStrategy());
        _strategies.Add(new BetweenCivilCrimeLawAgeMaleStrategy());
        _strategies.Add(new BetweenCivilCrimeLawAgeFemaleStrategy());
    }

    public string GetWelcomeText(UserData user)
    {
        foreach (var strategy in _strategies)
        {
            if (strategy.IsThisKind(user))
            {
                return strategy.WelcomeStr(user);
            }
        }
        return string.Empty;
    }
}

這樣在使用時,只要先建立Context類別的物件,然後呼叫context.GetWelcomeText(UserData user)這個方法就可以取得正確的招呼語字串。

使用Strategy Pattern重構後的優缺點為:
優點

  • 將原本複雜的分支流程結構簡化,原本要巢狀階層式的循序判斷邏輯,轉化成同等地位的Strategy物件和其各自對應的確認是否為所屬情況的規則。
    (注:由於本例子中每個Strategy要做的『演算法』WelcomeStr(UserData user)邏輯很簡單,所以就和判斷所屬規則的方法IsThisKind(UserData user)寫在同一個類別,即所屬的實體Strategy類別中)
  • 若日後有新增的情況時,也只需要增加新的實體Strategy類別和修改Context類別的constructor程式碼上加入新增的Strategy物件即可。
  • 修改某一實體Strategy的類別時,因為已經拆分開了,不會影響到原本其他Strategy類別的功能。
  • 易於分配工作:原本只寫在同一個類別內同一個方法的情況拆成Context類別和眾多個實體Strategy類別,可以很容易將工作分派給多個developer共同開發以加快進度,且因為是一個個分開來的Strategy類別,減少原始碼控管時需要合併多人工作成果時所帶來的風險。
  • 易於測試:

    1. 可對每個Strategy類別提供的方法個別進行單元測試以確保每個Strategy類別的運作結果是正確的,減少單一測試案例寫很龐大的可能。
    2. 針對Context類別,可撰寫測試用的”假(Mock)”Strategy類別載入進Context以進行測試,不會造成實際的實體Strategy類別所執行的演算法中,影響到不可復原資料或是難以重複測試的情形;也不必得等到所有的實體Strategy類別都完成沒問題了,才能對Context類別進行單元測試。
  • 在只有產生exception stack trace而沒有印出偵錯版才會有程式碼行號的log訊息時,會比較容易找出有問題的程式碼位置。

缺點

  • 程式碼檔案的數量和類別的數量增多,如果沒有在專案層級組織好這些檔案,很容易掉東掉西。
  • 需知道策略模式這種Design Pattern的開發者才會看懂這樣組織的程式結構。
  • 增加了執行環境內額外的運算資源消耗:

    1. 額外的Strategy類別載入和物件生成需要消耗額外CPU運算和記憶體資源。
    2. Strategy類別的物件和Context類別的物件需要額外的一些程式邏輯和資料成員變數來傳遞最後運算結果,一樣也是導致需要消耗額外CPU運算和記憶體資源。
      (注:由於本例只是回傳字串故影響不大,倘若回傳的結果是需要儲存大量資料的Value Object,就有可能會因為這樣而比原本的更吃CPU和記憶體資源)
    3. 目前Context類別程式碼內,在根據輸入來判斷使用哪種Strategy以便呼叫WelcomeStr()方法產生結果的邏輯,在Strategy數量如果非常多的情況下,效能會變的比直接用一堆condition statement (if else, switch, goto…) hard code的硬幹方法來得慢很多。
      此缺點的彌補方法有幾個:

      • 將這群Strategy物件存在Hashmap內改寫成用Hash key查找的方式。
      • 將判斷是否採用某Strategy的邏輯拆開到別的類別之中。
        (例如使用Factory Pattern來產生Strategy物件的Factory類別內)
      • 增加幾個前置判斷條件來預先篩選需要進行情況判斷的Strategy物件數目。
Advertisements

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: