如何验证业务逻辑?


让我们讨论经典的 3 层架构,我们在其中与与数据库交互的 Web API 进行前端通信。让我们看看数据处理管道可能出错的地方:

  1. 前端没有验证,或者它没有检查所有条件。我们不能假设我们会完美无缺并且可以标准化一切。我们在开发管道中的元素越多,我们的同事或我们忽略它们的可能性就越大。
  2. API改变了它的业务逻辑,前端还不知道。
  3. 前端验证无法验证某些条件。例如,产品是否仍有库存,或者用户电子邮件是否是唯一的。查询后端来检查这些条件是不够的。
  4. 我们的 API 是公开的。大多数 API 使用基于文本的表示形式来表示它们收到的消息。这意味着任何人都可以手工制作 JSON、XML 或纯文本并发送任何数据。这意味着,例如空请求、错误消息格式(XML 而不是 JSON)、未提供所需数据、无效数据格式(字符串而不是数字、数组而不是单个值等)。
  5. 有人可能故意执行恶意操作,例如发送无效请求以破坏我们的系统或通过数据抓取窃取数据。任何人都可以用棍子戳我们的 API,试图通过分析响应来寻找漏洞并提取数据。

基本上,任何事情都可能发生。如果我们不形成隔离墙,我们可能会遇到真正的麻烦。

数据库也可以是这样。而这甚至是危险的,因为我们认为这是我们的数据,我们可以完全控制。这可能让我们措手不及。数据结构和它的意义都随着软件的寿命而演变。有些字段成为必需的,有些变得过时,有些则被放弃。我们将无法从一开始就提供最终的解决方案。不是说我们有一个大的泥球,我们通过数据库整合多个模块/服务的情况。

拥抱外部世界可能是邪恶的,或者只是与我们预期的不同,这是 "端口与适配器 "的基础,所以需要六边形架构。

业务验证大致流程如下:

1. 把API请求类作为使用原始类型的普通对象。
我假设我可以得到任何东西,空,无效的格式,一切都可能是错误的。我试着去解析,不要验证。这样一个类的任务是将请求中的数据转化为该类的实例。我通常把这样一个类直接放在API项目中。它在C中可能看起来如下

public record AddProductRequest(
    Guid? ProductId,
    int? Quantity
);

2.在解析了请求之后,我把它映射到真正的合同(例如命令或查询)。
这个合同已经来自领域模块。这就是信任元素的作用。我可以信任这段代码,因为我在我的代码中进行实例化,另外我还负责定义如何进行实例化。我通常会创建一个静态的工厂方法,并且毫不吝啬地在这里使用值对象来执行语义验证。映射代码可能看起来像。

 var command = AddProduct.From(
    id,
    ProductItem.From(
        request?.ProductItem?.ProductId,
        request?.ProductItem?.Quantity
    )
);

类型定义:

public record AddProduct(
    Guid CartId,
    ProductItem ProductItem
)
{
    public static AddProduct Create(Guid cartId, ProductItem productItem)
    {
        if (cartId == Guid.Empty)
            throw new ArgumentOutOfRangeException(nameof(cartId));

        return new AddProduct(cartId, productItem);
    }
}

public record ProductItem
{
    public Guid ProductId { get; }

    public int Quantity { get; }

    private ProductItem(Guid productId, int quantity)
    {
        ProductId = productId;
        Quantity = quantity;
    }

    public static ProductItem From(Guid? productId, int? quantity)
    {
        if (!productId.HasValue)
            throw new ArgumentNullException(nameof(productId));

        return quantity switch
        {
            null => throw new ArgumentNullException(nameof(quantity)),
            <= 0 => throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity has to be a positive number"),
            _ => new ProductItem(productId.Value, quantity.Value)
        };
    }
   
// (...)
}

和所有的事情一样,这种模式也有它的名字--智能构造器。它来自函数式编程,但正如你所看到的,即使在命令式世界中,它也有很大的意义。

在创建一个命令或查询实例后,我们知道它是正确的。所谓正确,我的意思是它满足了基本的假设,比如:所有的必填字段都有指定的值,字段有正确的类型,验证如开始日期早于结束日期,等等。关键是不要在这里做复杂的领域逻辑验证,而是要做语义验证。

你可能还注意到,我使用了记录类型。这意味着这些类的实例将是不可改变的。现在大多数语言都允许定义这样的结构,例如,Java也有记录,TypesScript有只读类型,函数式语言默认也有。为什么它如此重要?

由于不可变性,我们对我们的对象有了更多的信任。我们知道没有人会通过做意外的牛仔式更新来改变它们。我们可以把它们作为参数传递;它们将永远是我们创建的样子。

3.正确的领域验证应该在业务逻辑中完成。
这就是为什么我喜欢CQRS。由于CQRS,我们知道一个特定的处理程序将执行该命令。业务逻辑将被路由到一个特定的函数或聚合方法。如果我们要改变规则,我们不必用不稳定的眼光来看待整个代码。例如,在命令中验证结束日期是否晚于开始日期是值得的,但我建议在业务逻辑中检查日期是否大于今天的日期。

public class ShoppingCart: Aggregate
{
    private Guid ClientId { get; private set; }

    private ShoppingCartStatus Status { get; private set; }

    private List<PricedProductItem> ProductItems { get; private set; } = new ();

    public void AddProduct(
        IProductPriceCalculator productPriceCalculator,
        ProductItem productItem)
    {
        if(Status != ShoppingCartStatus.Pending)
            throw new InvalidOperationException($"Adding product for the cart in '{Status}' status is not allowed.");

        var pricedProductItem = productPriceCalculator.Calculate(productItem).Single();

        var newProductItem = @event.ProductItem;

        var existingProductItem = FindProductItemMatchingWith(newProductItem);

        if (existingProductItem is null)
        {
            ProductItems.Add(newProductItem);
            return;
        }

        ProductItems.Replace(
            existingProductItem,
            existingProductItem.MergeWith(newProductItem)
        );
    }

总结一下:

  • 我们增加了信任和我们代码的安全性。
  • 我们使领域代码的变化与API的变化无关。
  • 我们可以逐一切断边缘场景:反序列化、类型的语义验证和业务验证。
  • 由于提高了可维护性和认知负荷,我们更容易知道什么、哪里和如何改变。
  • 我们减少了所需的测试数量。

当然,所有这些都需要一致性,但一旦我们建立了它,并在我们的类型上仔细工作,每一个新类型都会变得更容易。