规则引擎模式 - upperdine


作为专业或有抱负的软件工程师,我们通常的任务是将业务规则转化为计算机可以理解的东西。我们使用类对问题域进行建模,并编写业务逻辑以反映存在于代码库之外的现实世界规则。当这些业务规则在现实世界中发生变化时,它们也必须在代表它们的代码中发生变化,这就是我们领域真正复杂的地方。
 
定义好的代码
软件工程师的普遍看法是,好的代码是可以轻松更改的代码。另一个是人们应该努力编写代码,就好像下一个编写代码的人将是一个凶残的精神病患者。两者都是基于相同原则的不同观点。
如果您只编写永远不会更改的代码,那么您不妨停止阅读此处,因为这篇文章不适合您。对于我们这些在规则不断发展的业务中工作的人,我希望您考虑以下代码:

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 语句
  • 经常更新
  • 负责提供返回值

使您的代码更易于使用意味着您可以以更少的摩擦来更改您的代码,而更少的摩擦意味着您能够更快地交付,这有时可能是业务成功或失败之间的区别。