使用DDD规格Specification模式构建数据驱动规则引擎 - jonblankenship


当面临确定对象是否满足一组特定条件的任务时,规格/规范模式(Specification pattern)可能是开发人员工具箱中必不可少的工具。当与组合模式结合使用时,组合规范成为一种强大的工具,可以解决任何复杂的业务规则,同时确保可维护性,健壮性和可测试性。在本文中,我们将看到如何在.NET应用程序中使用组合规格/规范模式来构建数据驱动的规则引擎。
在我的库存警报项目中,用户为应该连续评估的给定库存配置标准,并在满足条件时向用户发出通知。
我希望用户不仅可以设置单个价格触发条件,还可以指定多种类型的条件,并使用布尔逻辑将它们组合起来以形成复杂的规则。
例如,当JNJ满足以下条件时,可能希望收到股息增长投资者的通知:

  • 股息> 2.5%并且
  • 支付率<50%AND
  • (市盈率<20 或价格<130)

另一位投资者可能希望使用一套完全不同的标准来提醒他们关注目标即将超出范围。
那么,我们如何以一种干净,可配置和可测试的方式完成此任务?

规范模式
过去,规范模式一直是我工具箱中的一个有价值的工具,它是当今摆在我们面前的工作的完美工具:构建数据驱动的规则引擎。
该模式由领域驱动设计领域的作者埃里克·埃文斯(Eric Evans)提出:解决软件核心问题的复杂性,并且是软件开发领域驱动设计(DDD)方法之父。Evans和Martin Fowler撰写了有关规范的白皮书,非常值得一读,其中涉及规范的用途,规范的类型以及规范的后果。
维基百科文章的规范模式为我们提供了一个很好的定义(以及一些C#示例代码)入手:
在计算机编程,所述规范图案是特定的设计模式,由此业务规则可以通过链接业务规则一起使用布尔逻辑来重新组合。
Evans和Fowler确定了规范模式适用的核心问题:

  • 选择:您需要根据一些条件选择对象的子集,并在不同时间刷新选择
  • 验证:您需要检查仅将合适的对象用于特定目的
  • 按订单构建:您需要描述一个对象可以做什么,而无需解释该对象如何执行操作的详细信息,而是以一种可以构建候选人的方式来满足要求。

这些问题中的每一个的解决方案是建立一个规范,该规范定义对象满足该规范必须满足的条件。通常,规范具有IsSatisfied(candidate)将候选对象作为参数的方法。该方法返回true或false,取决于候选对象是否满足规范的标准。
在本文中,我们将重点介绍一种特殊的规范类型(组合规范),以构建我们的规则引擎。这种规范的名称源于以下事实:它是另一种常用设计模式的实现:复合模式。

组合Compose模式
复合/组合模式是在“四人帮”(GoF)的开创性工作“ 设计模式:可重用的面向对象软件的元素”中引入的二十三种软件设计模式之一。作者将模式分为三类之一:创造型,结构型和行为型。复合模式是一种结构模式,这意味着它是一种模式,它描述实体之间的相互关系,目的是简化系统的结构。
组合模式描述了一组实体,这些实体可以组合成树状结构,其中各个部分与整个结构具有相同的界面,从而允许客户与各个部分和整体进行交互。这就是复合模式的优势所在。通过统一处理复合材料及其组件,客户可以避免在叶节点和分支之间进行区分,从而降低了复杂性并降低了出错的可能性。
此外,通过将组件结构化为可重组的原始对象的组合,我们可以获得代码重用的好处,因为我们能够利用现有组件来构建其他组合。在实践规则引擎的代码时,我们将在实践中看到这一点。

规范构建块
因此,让我们从抽象转向具体,然后看一些代码。在开始考虑需要实施的特定于域的规则之前,我们首先需要一些构建块。
首先,我们需要一个接口来与整个复合规范及其单个组件规范进行交互。所以这是我们的ISpecification接口:

public interface ISpecification<in TCandidate>
{
    bool IsSatisfiedBy(TCandidate candidate);
}

这是一个非常简单的接口,由一个方法组成,该方法IsSatisfiedBy(TCandidate candidate)根据传递给它的候选对象是否满足给定的规范返回true或false。
type参数TCandidate指定规范将要评估的对象的类型。对于复合规范,传递给根节点的候选对象的类型将传递给子节点,因此对于构成复合规范的所有单个规范,期望的类型都是相同的。

接下来,我们有一个抽象类CompositeSpecification,它将作为复合规范中任何分支(非叶)节点的基类:

public abstract class CompositeSpecification<TCandidate> : ISpecification<TCandidate>
{
    protected readonly List<ISpecification<TCandidate>> _childSpecifications = new List<ISpecification<TCandidate>>();

    public void AddChildSpecification(ISpecification<TCandidate> childSpecification)
    {
        _childSpecifications.Add(childSpecification);
    }

    public abstract bool IsSatisfiedBy(TCandidate candidate);

    public IReadOnlyCollection<ISpecification<TCandidate>> Children => _childSpecifications.AsReadOnly();
}

CompositeSpecification此处实现的主要行为是节点子规范的管理。它处理子规范向复合规范的添加,并将子规范公开为可以遍历的只读集合。

现在介绍布尔规范模式,分支(非叶子节点)是表示连接1..n其他规范的布尔运算的规范,它们从派生CompositeSpecification。对于我们的初始实现,我们有AND和OR规范(短路)。
AndSpecification:

public class AndSpecification<TCandidate> : CompositeSpecification<TCandidate>
{
    public override bool IsSatisfiedBy(TCandidate candidate)
    {
        if (!_childSpecifications.Any()) return false;

        foreach (var s in _childSpecifications)
        {
            if (!s.IsSatisfiedBy(candidate)) return false;
        }

        return true;
    }
}

OrSpecification:

public class OrSpecification<TCandidate> : CompositeSpecification<TCandidate>
{
    public override bool IsSatisfiedBy(TCandidate candidate)
    {
        if (!_childSpecifications.Any()) return false;

        foreach (var s in _childSpecifications)
        {
            if (s.IsSatisfiedBy(candidate)) return true;
        }

        return false;
    }
}

当然,可以很容易地实现其他布尔运算符,例如NOT和XOR,但是到目前为止,这是我到目前为止对我的应用程序唯一需要的两个,它们足以演示模式。

在继续介绍域规范之前,让我们简要讨论一下单元测试。规范模式的吸引人的特征之一是,由于围绕单个逻辑小块的清晰边界,可以轻松地对规范进行单元测试。(点击标题见原文)

特定领域规范(Domain-Specific Specifications)
既然我们已经具备了构建任何规范所需的构建块,那么我们就可以查看构建特定于我们领域的规范所需的内容:库存警报。当我们从讨论布尔规范过渡到讨论领域特定规范时,我们的重点是从复合规范的分支(非叶子)节点转移到叶子节点。
1.价格规格
我们的规范必须测试的主要标准之一是,当给出新的报价时,新价格是否超过一定水平。为此,我们将创建一个PriceSpecification知道警报条件的警报标准,该警报标准指定了重要的价格水平,并将根据新股票报价是否违反该水平返回true或false:

public class PriceSpecification : ISpecification<AlertEvaluationMessage>
{
    private readonly AlertCriteria _alertCriteria;

    public PriceSpecification(AlertCriteria alertCriteria)
    {
        _alertCriteria = alertCriteria ?? throw new ArgumentNullException(nameof(alertCriteria));
    }

    public bool IsSatisfiedBy(AlertEvaluationMessage candidate)
    {
        if (_alertCriteria.Operator == CriteriaOperator.GreaterThan)
        {
            return candidate.LastPrice > _alertCriteria.Level &&
                candidate.PreviousLastPrice <= _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.GreaterThanOrEqualTo)
        {
            return candidate.LastPrice >= _alertCriteria.Level &&
                   candidate.PreviousLastPrice < _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.Equals)
        {
            return candidate.LastPrice == _alertCriteria.Level &&
                   candidate.PreviousLastPrice != _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.LessThanOrEqualTo)
        {
            return candidate.LastPrice <= _alertCriteria.Level &&
                   candidate.PreviousLastPrice > _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.LessThan)
        {
            return candidate.LastPrice < _alertCriteria.Level &&
                   candidate.PreviousLastPrice >= _alertCriteria.Level;
        }

        return false;
    }
}

2.价格规范
我们的规范必须测试的主要标准之一是,当给出新的报价时,新价格是否超过一定水平。为此,我们将创建一个PriceSpecification知道警报条件的警报标准,该警报标准指定了重要的价格水平,并将根据新股票报价是否违反该水平返回true或false:

public class PriceSpecification : ISpecification<AlertEvaluationMessage>
{
    private readonly AlertCriteria _alertCriteria;

    public PriceSpecification(AlertCriteria alertCriteria)
    {
        _alertCriteria = alertCriteria ?? throw new ArgumentNullException(nameof(alertCriteria));
    }

    public bool IsSatisfiedBy(AlertEvaluationMessage candidate)
    {
        if (_alertCriteria.Operator == CriteriaOperator.GreaterThan)
        {
            return candidate.LastPrice > _alertCriteria.Level &&
                candidate.PreviousLastPrice <= _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.GreaterThanOrEqualTo)
        {
            return candidate.LastPrice >= _alertCriteria.Level &&
                   candidate.PreviousLastPrice < _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.Equals)
        {
            return candidate.LastPrice == _alertCriteria.Level &&
                   candidate.PreviousLastPrice != _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.LessThanOrEqualTo)
        {
            return candidate.LastPrice <= _alertCriteria.Level &&
                   candidate.PreviousLastPrice > _alertCriteria.Level;
        }

        if (_alertCriteria.Operator == CriteriaOperator.LessThan)
        {
            return candidate.LastPrice < _alertCriteria.Level &&
                   candidate.PreviousLastPrice >= _alertCriteria.Level;
        }

        return false;
    }
}

3.AlertCriteria
该规范在构建时已传递给它一个域模型:AlertCriteria。现在,我们只看一些AlertCriteria与该特定规范相关的属性:

public class AlertCriteria
{
    // snip

    public CriteriaType Type { get; set; }

    public CriteriaOperator Operator { get; set; }

    public decimal? Level { get; set; }

   
// snip
}

Type指定CriteriaType我们正在评估的警报。可能的值Composite,Price,DailyPercentageGainLoss,并有可能会更多。 Operator指定CriteriaOperator适用于我们正在评估的特定方案的。最后,如果需要触发价格警报,则需要知道应在哪个级别上触发该警报,该警报由Level属性指定。有了这三个数据,我们就需要了解创建一个规范,该规范表示给定股票价格大于或等于150美元时的价格警报。
该AlertCriteria域对象将最终来自于数据库,是数据模型,这将使这是一个数据驱动的规则引擎的重要组成部分。我们将对其进行更详细的研究。

3.AlertEvaluationMessage
我们所需的下一个对象PriceSpecification是AlertEvaluationMessage,这是我们的规范旨在评估的候选对象的类型。在我们的示例中,AlertEvaluationMessage表示新的报价(Message因在这种特殊情况下被从消息队列中拉出而命名)。

public class AlertEvaluationMessage
{
    public Guid AlertDefinitionId { get; set; }

    public decimal LastPrice { get; set; }

    public decimal PreviousLastPrice { get; set; }

    public decimal OpenPrice { get; set; }
}

与PriceSpecification相关的是LastPrice和PreviousLastPrice,以此我们可以确定价格是否已超过某个价格水平。
现在我们有了所需的信息,我们可以评估PriceSpecification中AlertCriteria 是否满足AlertEvaluationMessage ,这是通过PriceSpecification中的 IsSatisfiedBy(AlertEvaluationMessage candidate)方法实现。

更多点击标题见原文