規則引擎模式 - upperdine

banq發表於2022-02-25

作為專業或有抱負的軟體工程師,我們通常的任務是將業務規則轉化為計算機可以理解的東西。我們使用類對問題域進行建模,並編寫業務邏輯以反映存在於程式碼庫之外的現實世界規則。當這些業務規則在現實世界中發生變化時,它們也必須在代表它們的程式碼中發生變化,這就是我們領域真正複雜的地方。

 

定義好的程式碼

軟體工程師的普遍看法是,好的程式碼是可以輕鬆更改的程式碼。另一個是人們應該努力編寫程式碼,就好像下一個編寫程式碼的人將是一個凶殘的精神病患者。兩者都是基於相同原則的不同觀點。

如果您只編寫永遠不會更改的程式碼,那麼您不妨停止閱讀此處,因為這篇文章不適合您。對於我們這些在規則不斷髮展的業務中工作的人,我希望您考慮以下程式碼:

public class ShippingCalculator
{
    public decimal Calculate(BasketDetails details)
    {
        if (details.Customer.IsOverseas)
        {
            if (details.BasketTotal >= 200)
            {
                return 0m;
            }

            return 49.99m;
        }
        else
        {
            if (details.BasketTotal >= 100)
            {
                return 0m;
            }

            return 19.99m;
        }
    }
}

這是一段相當簡單的程式碼,用於計算電子商務網站的運費。規則是:

  • 國內運費為 19.99 英鎊
  • 國際運費為 49.99 英鎊
  • 如果購物籃總額達到國內訂單 100.00 英鎊或國際訂單 200.00 英鎊的門檻,則免運費

假設該公司引入了一項新規則,在 12 月份提供半價國內運費:考慮如何更改此程式碼以適應這種情況。如果我們使用簡單的解決方案,我們可以在 if 語句的國內運輸分支中新增如下內容:

if (DateTime.Now.Month == 12)
{
    return 9.99m;
}

現在想象一下,他們也將此優惠擴充套件到國際訂單。此時程式碼變得複雜,迫切需要重構。當然,您可以使用 C# 的一些更好的功能在一定程度上對其進行清理,但僅此而已。

 

開放-封閉原則

在過去十年左右的時間裡,如果你有過一次工作面試,你可能不得不解釋一個或多個SOLID原則。其中一個不太被理解,但卻非常重要的原則是開放-封閉原則,它指出,遵守的程式碼應該對擴充套件開放,但對修改封閉。就像大多數SOLID原則一樣,這條原則是故意模糊的,以便給會議的演講者提供一些模糊的東西,但是這條原則的核心是,你應該能夠作為現有程式碼的擴充套件來增加功能,而不是取代它。

聽完這個解釋,你認為上面的程式碼是否堅持了這個原則?為了給運費計算演算法增加一個新的規則,我們不得不在已經很亂的程式碼中增加更多的程式碼,所以答案是否定的。對這段程式碼的每一次修改都有可能引入一個bug,說實話:看起來像這樣的程式碼幾乎從來沒有被單元測試覆蓋過,或者至少沒有最新的測試,所以我們不能自信地對它進行修改。

 

應用規則引擎模式

我不打算對規則引擎模式進行理論上的概述,而是試圖通過演示我們將要重構的現有實現的程式碼來解釋它。

我們首先需要一個介面來定義規則在我們系統的上下文中是什麼樣子的。

public record ShippingCalculatorRuleResult(bool Applied, decimal Shipping);
public record ShippingCalculatorRuleFailedResult() : ShippingCalculatorRuleResult(false, 0m);
public record ShippingCalculatorRuleSuccessResult(decimal Shipping) : ShippingCalculatorRuleResult(true, Shipping);

public interface IShippingCalculatorRule
{
    ShippingCalculatorRuleResult Calculate(BasketDetails basket);
}

然後,我們需要一個代表我們的規則引擎的類,它通過建構函式接收一個規則集合,並有一個方法來傳遞所有規則執行後的計算結果。

public class ShippingCalculatorRulesEngine
{
    private readonly IReadOnlyCollection<IShippingCalculatorRule> _rules;

    public ShippingCalculatorRulesEngine(IReadOnlyCollection<IShippingCalculatorRule> rules)
    {
        _rules = rules;
    }

    public decimal CalculateShipping(BasketDetails basket)
    {
        /* We want to return the lowest shipping price
            that the customer is entitled to.*/
        return _rules
            .Select(r => r.Calculate(basket))
            .Where(r => r.Applied)
            .Min(r => r.Shipping);
    }
}

現在我們來建立一些規則:

public class InternationalShippingRule : IShippingCalculatorRule
{
    public ShippingCalculatorRuleResult Calculate(BasketDetails basket)
    {
        return basket.Customer switch
        {
            { IsOverseas: true } => new ShippingCalculatorRuleSuccessResult(49.99m),
            _ => new ShippingCalculatorRuleFailedResult()
        };
    }
}

public class BasketTotalRule : IShippingCalculatorRule
{
    public ShippingCalculatorRuleResult Calculate(BasketDetails basket)
    {
        return basket switch
        {
            { Customer.IsOverseas: true, BasketTotal: >= 200.00m } => new ShippingCalculatorRuleSuccessResult(0m),
            { BasketTotal: >= 100.00m } => new ShippingCalculatorRuleSuccessResult(0m),
            _ => new ShippingCalculatorRuleFailedResult()
        };
    }
}

public class HalfPriceDecemberShippingRule : IShippingCalculatorRule
{
    public ShippingCalculatorRuleResult Calculate(BasketDetails basket)
    {
        return DateTime.Now.Month switch
        {
            12 when basket.Customer.IsOverseas => new ShippingCalculatorRuleSuccessResult(24.99m),
            12 => new ShippingCalculatorRuleSuccessResult(9.99m),
            _ => new ShippingCalculatorRuleFailedResult()
        };
    }
}

最後,我們可以在航運計算器類中使用我們的規則引擎結果。

public class ShippingCalculator
{
    private readonly ShippingCalculatorRulesEngine _shippingCalculatorRulesEngine;

    public ShippingCalculator()
    {
        /* We can use reflection to find all rules in the current project.
            I may cover other ways of doing this in a future post.*/

        var ruleType = typeof(IShippingCalculatorRule);
        IReadOnlyCollection<IShippingCalculatorRule> rules = GetType().Assembly.GetTypes()
            .Where(p => ruleType.IsAssignableFrom(p) && !p.IsInterface)
            .Select(r => Activator.CreateInstance(r) as IShippingCalculatorRule)
            .ToList()
            .AsReadOnly()!;

        _shippingCalculatorRulesEngine = new ShippingCalculatorRulesEngine(rules);
    }

    public decimal Calculate(BasketDetails basket)
    {
        return _shippingCalculatorRulesEngine.CalculateShipping(basket);
    }
}

因此,基於這段程式碼,我們在規則引擎的實現中擁有以下元件:

  • 一個所有規則都必須實現的契約
  • 規則的實現
  • 一個執行所有規則並返回編譯後的值或結果的引擎

所有其他的東西,比如結果型別,只是一個實現細節。實際上,你所需要的只是上面列出的三個元件(以及一種將規則引入規則引擎的方法),你就可以開始了。

 

新增一個新的規則

現在我們有了一個工作的規則引擎實現,我們能夠比以前更快地新增新規則。比方說,企業決定將客戶生日時的免費送貨門檻減半,我們只需新增一個新的規則實現,其餘的就都搞定了。

public class BirthdayCouponShippingRule : IShippingCalculatorRule
{
    public ShippingCalculatorRuleResult Calculate(BasketDetails basket)
    {
        if (basket.Customer.DateOfBirth != DateTime.Now.Date) 
            return new ShippingCalculatorRuleFailedResult();

        return basket switch
        {
            { Customer.IsOverseas: true, BasketTotal: >= 100.00m } => new ShippingCalculatorRuleSuccessResult(0m),
            { BasketTotal: >= 50.00m } => new ShippingCalculatorRuleSuccessResult(0m),
            _ => new ShippingCalculatorRuleFailedResult()
        };
    }
}

新增一個規則就是這麼簡單,而且由於這些規則是純函式,這使得它們在測試時絕對是一個夢想 如果你沒有掌握函數語言程式設計的理論,純函式是一個沒有副作用的函式--這意味著你可以將相同的值傳入函式一千次,得到完全相同的結果。

 

結論

通過利用規則引擎模式,我們能夠將複雜的業務流程建模為一系列規則,並顯著降低我們的圈複雜度。雖然在使用設計模式時我傾向於謹慎行事,但如果您的程式碼表現出以下特徵,我會推薦這種模式:

  • 大量巢狀的 if 語句
  • 經常更新
  • 負責提供返回值

使您的程式碼更易於使用意味著您可以以更少的摩擦來更改您的程式碼,而更少的摩擦意味著您能夠更快地交付,這有時可能是業務成功或失敗之間的區別。

相關文章