.Net Core 環境下構建強大且易用的規則引擎

本文源碼: https://github.com/jonechenug/ZHS.Nrules.Sample

1. 引言

1.1 爲什麼需要規則引擎

在業務的早期時代,也許使用硬編碼或者邏輯判斷就可以滿足要求。但隨着業務的發展,越來越多的問題會暴露出來:

  • 邏輯複雜度帶來的編碼挑戰,需求變更時改變邏輯可能會引起災難
  • 重複性的需求必須可重用,否則必須重複性編碼
  • 運行期間無法即時修改規則,但重新部署可能會帶來其他問題
  • 上線前的測試變得繁瑣且不可控,必須花大量的人力和時間去測試

這些困境在『 小明歷險記:規則引擎 drools 教程一』 一文中可以體會一番,一開始只是簡單的根據購物金額來發放積分,運行期間又要更改爲更多的規則層次,如果不及時引入對應的規範化處理機制,開發人員將慢慢墜入無止盡的業務深淵。對此,聰明的做法是在系統中引入規則引擎,對業務操作員要提供儘量簡單的操作頁面來配置規則,規則引擎和配置儘量不要耦合到一塊。

1.2 .Net Core 環境下的選擇 -- Nrules

目前最流行的規則引擎應該是Drools, 用 Java 語言編寫的開放源碼規則引擎,使用 Rete 算法對所編寫的規則求值,其操作流程如下:

Drools 操作流程

對於 .Net 應用來說,可以通過 Kie 組件提供的 Rest 接口調用規則引擎運算。然而其過於龐大,僅僅只是需要規則引擎計算核心的部分。對此,查找了 .Net 中開源的規則引擎,發現只有同樣實現 Rete 算法的 Nrules 滿足要求(支持 .Net Core,運行時加載規則引擎)。

注:本文參考借鑑了美團技術團隊 從 0 到 1:構建強大且易用的規則引擎 一文的設計思路,對 Drools 從入門到放棄。

2. Nrules 實戰 -- 電商促銷活動規則引擎設計

2.1 瞭解 Nrules

NRules 是基於 Rete 匹配算法的.NET 生產規則引擎,基於.NET Standard ,支持 4.5+ 的應用,提供 流式聲明規則運行時構建規則專門的規則語言(開發中,不推薦使用到生產,基於.Net 4.5 而不是 .NETStandard )。
其計算機制也與其他規則引擎大同小異:
計算機制

2.2 設計規則配置

前文提到 對業務操作員要提供儘量簡單的操作頁面來配置規則 ,所以我們定義促銷活動的規則配置就要儘量簡單。

業務操作員眼中的規則

在設計模型時,我們必須先參考現實生活中遇到的電商促銷活動,大致可以想到有這麼幾種活動類型:滿減促銷、單品促銷、套裝促銷、贈品促銷、滿贈促銷、多買優惠促銷、定金促銷等。
在這裏,我選擇對多買優惠促銷做分析,多買促銷優惠即所謂的階梯打折,如買一件9折,買兩件8折,其模型大致如下:

    public class LadderDiscountPromotion
    {
        public List<LadderDiscountRuleItem> Rules { get; set; }
        public string Name { get; set; }
        public DateTime StarTime { get; set; }
        public DateTime EndTime { get; set; }
        public PromotionState State { get; set; }
        public List<string> ProductIdRanges { get; set; }
        public bool IsSingle { get; set; }
        public string Id { get; set; }
    }

    public class LadderDiscountRuleItem
    {
        /// <summary>
        /// 數量
        /// </summary>
        public Int32 Quantity { get; set; }

        /// <summary>
        /// 打折的百分比
        /// </summary>
        public Decimal DiscountOff { get; set; }
    }

這裏爲了簡化設計,設計的模型並不會去約束平臺、活動範圍、會員等級等,僅僅約束了使用的產品 id 範圍。爲了匹配現實中可能出現的組合優惠(類似滿減活動後還可以使用優惠券等)現象和相反的獨斥現象(如該商品參與xx活動後不支持X券),設置了一個字段來判斷是否可以組合優惠,也可以理解爲所有活動都爲組合優惠,只是有些組合優惠只有一個促銷活動。

注:想了解更多關於電商促銷系統設計可參考腦圖

2.3 規則配置轉換

爲了實現 規則引擎和配置儘量不要耦合到一塊,必須有中間層對規則配置進行轉換爲 Nrules 能夠接受的規則描述。聯繫前文的計算機制,我們可以得到這樣一個描述模型:

    public class RuleDefinition
    {
        /// <summary>
        /// 規則的名稱
        /// </summary>
        public String Name { get; set; }
        /// <summary>
        /// 約束條件
        /// </summary>
        public List<LambdaExpression> Conditions { get; set; }
        /// <summary>
        ///  執行行動
        /// </summary>
        public  List<LambdaExpression> Actions { get; set; }
    }

由於 Nrules 支持流式聲明,所以約束條件和產生的結果都可以用 LambdaExpression 表達式實現。現在我們需要把階梯打折的配置轉換成規則描述,那我們需要先分析一下。假設滿一件9折,滿兩件8折,滿三件7折,那我們可以將其分解爲:

  • 大於等於三件打 7 折
  • 大於等於兩件且小於三件打 8 折
  • 大於等於一件且小於兩件 9 折

基於此分析,我們可以看出,只有第一個最多的數量規則是不一樣的,其他規則都是比前一個規則的數量小且大於等於當前規則的數量,那麼我們可以這樣轉換我們的規則配置:

List<RuleDefinition> BuildLadderDiscountDefinition(LadderDiscountPromotion promotion)
        {
            var ruleDefinitions = new List<RuleDefinition>();
            //按影響的數量倒敘
            var ruleLimits = promotion.Rules.OrderByDescending(r => r.Quantity).ToList();
            var currentIndex = 0;
            var previousLimit = ruleLimits.FirstOrDefault();
            foreach (var current in ruleLimits)
            {
                //約束表達式
                var conditions = new List<LambdaExpression>();
                var actions = new List<LambdaExpression>();
                if (currentIndex == 0)
                {
                    Expression<Func<Order, bool>> conditionPart =
                        o => o.GetRangesTotalCount(promotion.ProductIdRanges) >= current.Quantity;
                    conditions.Add(conditionPart);
                }
                else
                {
                    var limit = previousLimit;
                    Expression<Func<Order, bool>> conditionPart = o =>
                        o.GetRangesTotalCount(promotion.ProductIdRanges) >= current.Quantity
                        && o.GetRangesTotalCount(promotion.ProductIdRanges) < limit.Quantity;
                    conditions.Add(conditionPart);
                }
                currentIndex = currentIndex + 1;

                //觸發的行爲表達式
                Expression<Action<Order>> actionPart =
                    o => o.DiscountOrderItems(promotion.ProductIdRanges, current.DiscountOff, promotion.Name, promotion.Id);
                actions.Add(actionPart);

                // 增加描述
                ruleDefinitions.Add(new RuleDefinition
                {
                    Actions = actions,
                    Conditions = conditions,
                    Name = promotion.Name
                });
                previousLimit = current;
            }
            return ruleDefinitions;
        }

2.4 生成規則集合

在 Nrules 的 wiki 中,爲了實現運行時加載規則引擎,我們需要引入實現 IRuleRepository ,所以我們需要將描述模型轉換成 Nrules 中的 RuleSet

    public class ExecuterRepository : IRuleRepository, IExecuterRepository
    {
        private readonly IRuleSet _ruleSet;
        public ExecuterRepository()
        {
            _ruleSet = new RuleSet("default");
        }

        public IEnumerable<IRuleSet> GetRuleSets()
        {
            //合併
            var sets = new List<IRuleSet>();
            sets.Add(_ruleSet);
            return sets;
        }

        public void AddRule(RuleDefinition definition)
        {
            var builder = new RuleBuilder();
            builder.Name(definition.Name);
            foreach (var condition in definition.Conditions)
            {
                ParsePattern(builder, condition);
            }
            foreach (var action in definition.Actions)
            {
                var param = action.Parameters.FirstOrDefault();
                var obj = GetObject(param.Type);
                builder.RightHandSide().Action(ParseAction(obj, action, param.Name));
            }
            _ruleSet.Add(new[] { builder.Build() });
        }

        PatternBuilder ParsePattern(RuleBuilder builder, LambdaExpression condition)
        {
            var parameter = condition.Parameters.FirstOrDefault();
            var type = parameter.Type;
            var customerPattern = builder.LeftHandSide().Pattern(type, parameter.Name);
            customerPattern.Condition(condition);
            return customerPattern;
        }

        LambdaExpression ParseAction<TEntity>(TEntity entity, LambdaExpression action, String param) where TEntity : class, new()
        {
            return NRulesHelper.AddContext(action as Expression<Action<TEntity>>);
        }

    }

2.5 執行規則引擎

做了轉換處理僅僅是第一步,我們還必須創建一個規則引擎的處理會話,並把相關的事實對象(fact)傳遞到會話,執行觸發的代碼,相關對象發生了變化,其簡單代碼如下:

var repository = new ExecuterRepository();
//加載規則
repository.AddRule(new RuleDefinition());
repository.LoadRules();
// 生成規則
ISessionFactory factory = repository.Compile();
// 創建會話
ISession session = factory.CreateSession();
// 加載事實對象
session.Insert(new Order());
// 執行
session.Fire();

2.6 應用場景示例

我們假設有這麼一個應用入口:傳入一個購物車(這裏等價於訂單)id,獲取其可以參加的促銷活動,返回對應活動優惠後的結果,並按總價的最低依次升序,那麼可以這麼寫:

       public IEnumerable<AllPromotionForOrderOutput> AllPromotionForOrder([FromQuery]String id)
        {
            var result = new List<AllPromotionForOrderOutput>();
            var order = _orderService.Get(id) ?? throw new ArgumentNullException("_orderService.Get(id)");
            var promotionGroup = _promotionService.GetActiveGroup();
            var orderjson = JsonConvert.SerializeObject(order);
            foreach (var promotions in promotionGroup)
            {
                var tempOrder = JsonConvert.DeserializeObject<Order>(orderjson);
                var ruleEngineService = HttpContext.RequestServices.GetService(typeof(RuleEngineService)) as RuleEngineService;
                ruleEngineService.AddAssembly(typeof(OrderRemarkRule).Assembly);
                ruleEngineService.ExecutePromotion(promotions, new List<object>
                {
                    tempOrder
                });
                result.Add(new AllPromotionForOrderOutput(tempOrder));
            }
            return result.OrderBy(i => i.Order.GetTotalPrice());
        }

假設這麼一個購物車id,買一件時最優惠是參加 A 活動,買兩件時最優惠是參加 B 和 C 活動,那麼其效果圖可能如下:

不同的條件對規則的影響

3. 結語

本文只是對規則引擎及 Nrules 的簡單介紹及應用,過程中隱藏了很多細節。在體會到規則引擎的強大的同時,還必須指出其侷限性,規則引擎同樣不是銀彈,必須結合實際出發。

擴展閱讀:Martin Fowler:應該使用規則引擎嗎?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章