第4章 在业务逻辑中使用 EF Core

发布时间 2024-01-05 16:08:18作者: 生活的倒影

本章涵盖

  • 了解业务逻辑及其对 EF Core 的使用
  • 从简单到复杂的三种业务逻辑
  • 审查每种类型的业务逻辑,包括优缺点
  • 添加一个步骤,用于在将数据写入数据库之前验证数据
  • 使用事务以菊花链方式连接代码串行

实际应用进程旨在提供一组服务,从在计算机上保存简单的事物列表到类似管理核反应堆一样复杂的事物列表。每个现实世界的问题都有一组规则,通常称为业务规则,或者称为更通用的名称域规则(本书使用业务规则)。

为实现业务规则而编写的代码称为业务逻辑或域逻辑。由于业务规则可能很复杂,因此您编写的业务逻辑也可能很复杂。想想在线订购商品时应该完成的所有检查和步骤。

业务逻辑的范围可以从简单的状态检查到海量人工智能 (AI) 代码,但在几乎所有情况下,业务逻辑都需要访问数据库。尽管第 2 章和第 3 章中的所有方法都起作用,但在业务逻辑中应用这些 EF Core 命令的方式可能略有不同,这就是我编写本章的原因。

本章介绍一种处理业务逻辑的模式,该模式将一些复杂性划分为减少开发人员的负担。你还将学习几种编写使用 EF Core 访问数据库的不同类型的业务逻辑的技术。这些技术包括使用软件类进行验证,以及标准化业务逻辑的接口以简化前端代码。总体目标是帮助您快速编写准确、易于理解且性能良好的业务逻辑。

4.1 在开始编码之前要问的问题和需要做出的决定

第 2 章和第 3 章中的 CRUD 代码在数据进出数据库时对其进行了调整和转换。其中一些代码很复杂,我向您展示了查询对象模式,以使大型查询更易于管理。同样,业务逻辑的范围可以从简单到复杂。

定义:在本章中,我使用术语“业务规则”来表示一些需要实现的逻辑的人类可读陈述,例如“一本书的价格不能为负数”。我还使用术语“业务逻辑”,它是实现应用进程中特定功能所需的所有业务规则的代码。

在开始处理业务逻辑之前,应该考虑一些问题的答案:

  • 您了解正在实现的功能的业务规则吗?
  • 业务规则是否有意义,还是不完整?
  • 是否有任何需要涵盖的边缘情况或例外情况?
  • 如何证明您的实现符合业务规则?
  • 如果业务规则发生变化,更改代码的难易程度如何?

4.1.1 业务逻辑代码的三个复杂程序

当您掌握了需要实现的业务规则时,您应该对业务逻辑的复杂程度有所了解。大多数规则都很容易编写,但有些规则会非常复杂。诀窍是快速实现简单的业务逻辑,但对更复杂的业务逻辑使用更结构化的方法。

根据我的经验,我创建了一个包含三个业务逻辑复杂度级别的列表,每个级别都有不同的模式:验证、简单和复杂。

以下三个部分介绍了这三个复杂程度,以及它们将如何影响您编写的代码。但请注意,这三种模式并不是严格的规则。某些业务规则可能很简单,但你可能会决定使用更复杂的模式,因为它更容易进行单元测试。不过,此列表对于讨论可用于编写业务逻辑的类型和模式非常有用。

用于检查用于更改实体类的数据的验证代码

当您使用 CUD(创建、更新和删除)代码时,如第 3 章所示,您可能需要检查数据是否在特定范围内。例如,Review 的 NumStars 属性必须在 0 到 5 的范围内。这种测试称为验证。对我来说,验证是调用代码业务逻辑而不是 CRUD 代码的起点。

这种类型的业务逻辑很常见;你到处都能看到它(参见第 4.2 节之前的侧边栏“是否所有业务逻辑代码都存在于特定的业务逻辑层中"?)最简单的验证业务逻辑通常使用 if-then 语句来测试数据值,但一组称为“数据注释”的有用属性可以自动执行您需要编写的一些验证代码。(稍后,您将在第 4.7.1 节中看到数据注释。

但是有很多级别的验证,从简单的范围检查到通过某种检查服务验证一个人的驾驶执照是否有效,这使得定义业务逻辑的起始级别更加困难。但正如我在开头所说,这些级别是指导方针,“检查人员的驾驶执照”验证示例会将该代码提升到业务逻辑的下一个级别。

简单的业务逻辑(即,很少或没有分支且易于理解)

下一种类型是很少或没有分支的业务逻辑,即很少或没有 if-then 分支语句,并且没有调用其他业务代码。代码很容易理解,因为您可以阅读它并查看必须按顺序执行的每个步骤。一个很好的例子是创建一本书及其作者的代码——它需要代码来创建这本书,然后查找或创建作者,最后添加 BookAuthor 链接实体类。代码很简单,没有分支,但仍然需要很多行代码才能与作者一起创建一本书。

我总是惊讶于在实际应用进程中有这幺多这样的“简单”业务逻辑;通常,我发现很多管理功能都属于这一类。因此,使用简单的模式来构建和检查这种类型的业务逻辑对于快速构建代码至关重要。

复杂的业务逻辑(即需要认真努力才能正确编写的代码)

我把最难写成复杂的业务逻辑称为复杂逻辑。这个术语没有一个很好的定义,但对于这种类型的代码,你需要认真思考这个问题,然后才能实现它。下面引用了一本关于编写业务逻辑的权威书籍,它描述了编写复杂业务代码的挑战:

软件的核心是它能够为用户解决与领域(业务)相关的问题。所有其他功能,尽管它们可能至关重要,但都支持这一基本目的。当领域复杂时,这是一项艰巨的任务,需要有才华和技能的人集中精力。 ——Eric Evans,Domain-Driven Design

这种类型的业务逻辑非常复杂,因此我开发了一种结构化方法,将业务逻辑与数据库和前端隔离开来。这样一来,我就可以专注于纯粹的业务问题——关注点分离原则的另一种应用(我在第 5.5.2 节中详细讨论)。

是否所有业务逻辑代码都位于特定的业务逻辑层中?

不。在实际应用中,尤其是与人交互的应用进程,您希望用户体验尽可能好。因此,一些业务逻辑存在于表示层中。

进入表示层的明显逻辑是验证业务逻辑,因为越早向用户提供反馈越好。大多数前端系统都具有内置功能,便于验证错误并向用户提供良好的反馈。

另一个领域是具有许多步骤的业务逻辑。通常,当复杂的业务逻辑流在向导中显示为一系列页面或步骤时,对用户来说更好。

即使在应用进程的后端,我也将业务逻辑分布在我的图书应用进程中的多个层(即项目)。我将在本章中解释我如何以及为什幺这样做。

4.2 复杂业务逻辑示例:处理图书订单

我从复杂的业务逻辑开始,因为该逻辑将向您介绍一种强大的业务处理方法——这种方法取自 Eric Evan 的《领域驱动设计》一书,我在上一节中引用了该书。不过,首先,请看一下您希望在图书应用进程中实现的复杂业务功能。您将构建的示例是处理用户的图书订单。图 4.1 显示了图书应用进程的结帐页面。你将实现在用户单击“购买”按钮时运行的代码。

注意:您可以通过从关联的 Git 存储库下载图书应用代码并在本地运行它来尝试结帐过程。图书应用进程使用 HTTP cookie 来保存您的购物篮和您的身份(这使您不必登录)。不需要钱;正如条款和条件文本所说,您实际上不会购买一本书。

图 4.1 图书应用进程的结帐页面。当用户单击某本书旁边的“购买图书”按钮时,应用会将该图书添加到其购物篮中,然后显示“结帐”页面,其中显示用户购物车中的所有书籍。单击“购买”按钮将调用创建订单的业务逻辑,即我们将要编写的代码。

4.3 使用设计模式实现复杂的业务逻辑

在开始编写代码来处理订单之前,请查看可帮助您编写、测试和性能优化业务逻辑的模式。该模式基于 Eric Evans 阐述的域驱动设计 (DDD) 概念,但业务逻辑代码不在实体类中。此模式称为事务脚本或业务逻辑的过程模式,因为代码包含在独立方法中。

此过程模式易于理解,并使用已看到的基本 EF Core 命令。但许多人认为过程方法是一种 DDD 反模式,称为贫血领域模型(见 http://mng.bz/nM7g)。稍后,在本书的第 3 部分中,您将把这种方法扩展到完全的 DDD 设计。

本节和第 13 章介绍了我对 Evans 的 DDD 方法的解释,以及将 DDD 与 EF 应用的许多其他方法。虽然我提供了我的方法,希望能对你有所帮助,但不要害怕寻找其他方法。

4.3.1 构建使用 EF Core 的业务逻辑的五条准则

以下列表说明了构成本章将使用的业务逻辑模式的五个准则。大多数模式来自 DDD 概念,但有些是编写大量复杂业务逻辑并看到需要改进的地方的结果:

  1. 业务逻辑首先调用如何定义数据库结构。因为您试图解决的问题(埃文斯称之为领域模型)是问题的核心,所以逻辑应该定义整个应用进程的设计方式。因此,您尝试使数据库结构和实体类尽可能匹配您的业务逻辑数据需求。
  2. 业务逻辑不应受到干扰。编写业务逻辑本身就足够困难,因此您将其与除实体类之外的所有其他应用进程层隔离。当您编写业务逻辑时,您必须只考虑您要解决的业务问题。您将调整数据以供呈现的任务留给应用进程中的服务层。
  3. 业务逻辑应该认为它正在处理内存中的数据。埃文斯教我编写业务逻辑,就像数据在内存中一样。当然,您需要一些加载和保存部分,但对于业务逻辑的核心,请将数据(尽可能多)视为正常的内存中类或集合。
  4. 将数据库访问代码隔离到一个单独的项目中。该规则源于编写具有复杂定价和交付规则的电子商务应用进程。之前,我在业务逻辑中直接使用EF,但我发现它很难维护,而且很难调优。相反,您应该使用另一个项目(业务逻辑的配套项目)来保存所有数据库访问代码。
  5. 业务逻辑不应直接调用 EF Core 的 SaveChanges。您应该在服务层(或自定义库)中有一个类,其任务是运行业务逻辑。如果没有错误,此类将调用 SaveChanges。该规则的主要原因是可以控制是否写出数据,但它还有其他好处,我将在 4.4.5 节中介绍。

图 4.2 显示了您将创建的应用进程结构,以帮助您在实现业务逻辑时应用这些准则。在本例中,您将向第 2 章中描述的原始图书应用进程结构添加两个新项目:

  • 纯业务逻辑项目,它包含业务逻辑类,这些类处理由配套业务数据库访问方法提供的内存中数据。
  • 业务数据库访问项目,它为每个需要数据库访问的纯业务逻辑类提供一个伴随类。每个伴随类都使纯业务逻辑类认为它正在处理内存中的数据集。

图 4.2 有 5 个数字,并附有注释,与 5 个准则相匹配。

图 4.2 Book App 中的项目,其中包含两个用于处理复杂业务逻辑的新项目。“纯业务逻辑”项目包含独立的业务逻辑,它认为它正在处理内存中的类集。“业务数据库访问”项目提供了一个接口,纯业务逻辑可以使用该接口来访问数据库。服务层的工作是调整来自 ASP.NET Core 应用进程的数据,以它希望数据的形式发送到纯业务逻辑,并在业务逻辑未报告任何错误时调用最终的 SaveChanges 进行保存。

您将在与第 4.3.1 节中列出的五个准则匹配的部分中实现代码。最后,你将看到如何从 Book App 正在使用的 ASP.NET Core 应用进程中调用此组合代码。

4.4.1 准则 1:业务逻辑在定义数据库结构时首先需要调用

该指南指出,数据库的设计应遵循业务需求 —— 在本例中,由六条业务规则表示。这些规则中只有三个与数据库设计相关:

  • 订单必须至少包括一本书(暗示可以有更多)。
  • 必须将书籍的价格复制到订单中,因为价格可能会在以后发生变化。
  • 订单必须记住订购书籍的人。

这三个规则规定了一个 Order 实体类,该类具有 LineItem 实体类的集合,即一对多关系。Order 实体类包含有关下订单的人员的信息,每个 LineItem 实体类都包含对预订订单、数量和价格的引用。

图 4.3 显示了这两个表(LineItem 和 Orders)在数据库中的样子。为了使图像更易于理解,我显示了每个 LineItem 行引用的 Books 表(灰色)。

图 4.3 添加了新的 LineItem 和 Orders 表,以允许接受书籍的订单。每次购买都有一个“订单”行,订单中的每本书都有一个 lineItem 行。

注意:Orders 表名称是复数形式,因为您已将 DbSet Orders 属性添加到应用进程的 DbContext,并且默认情况下,EF Core 使用属性名称 Orders 作为表名称。您尚未为 LineItem 实体类添加属性,因为它是通过 Order 的关系链接访问的。在这种情况下,默认情况下,EF Core 使用类名 LineItem 作为表名。您可以将表名设置为特定名称;参见第 7.11.1 节。

4.4.2 准则 2:业务逻辑需要专注于逻辑处理

现在,您处于业务逻辑代码的核心,此处的代码将完成大部分工作。这段代码将是你编写的实现中最难的部分,但你想通过切断任何干扰来帮助自己。这样,您就可以专注于问题。

为此,请仅参考系统的其他两个部分编写纯业务代码:图 4.3 中所示的实体类(Order、LineItem 和 Book)和将处理所有数据库访问的伴随类。即使将范围最小化,您仍然会将工作分解为几个部分。

检查错误并将其反馈给用户:验证

业务规则包含多个检查,例如“必须勾选条款和条件框”。规则还说,您需要向用户提供良好的反馈,以便他们能够解决任何问题并完成购买。这些类型的检查(称为验证)在整个应用进程中很常见。

有两种主要方法可以处理将错误传递回更高级别。一种是在发生错误时抛出异常,另一种是通过状态接口将错误传回调用方。每个选项都有自己的优点和缺点。此示例使用第二种方法:将某种形式的状态类中的错误传递回更高级别进行检查。

为了提供帮助,您将创建一个名为 BizActionErrors 的小型抽象类,如清单 4.1 所示。此类为所有业务逻辑提供通用的错误处理接口。该类包含一个名为 AddError 的 C# 方法,业务逻辑可以调用该方法来添加错误,以及一个名为 Errors 的不可变列表(无法更改的列表),该列表包含运行业务逻辑时发现的所有验证错误。

您将使用一个名为 ValidationResult 的类来存储每个错误,因为它是返回错误的标准方法,其中包含有关错误相关确切属性的可选附加信息。使用 ValidationResult 类而不是简单的字符串适合本章后面将添加的另一种验证方法。

清单 4.1 为业务逻辑提供错误处理的抽象基类

public abstract class BizActionErrors  // 为业务逻辑提供错误处理的抽象类
{
    private readonly List<ValidationResult> _errors = new List<ValidationResult>();  // 私下保存验证错误列表
    public IImmutableList<ValidationResult> Errors => _errors.ToImmutableList();  // 提供公开的、不可变的错误列表
    public bool HasErrors => _errors.Any();   // 创建一个bool HasErrors,使检查错误更容易
    protected void AddError(string errorMessage, params string[] propertyNames)  // 允许将简单错误消息或具有与其链接的属性的错误消息添加到错误列表中
    {
        _errors.Add( new ValidationResult (errorMessage, propertyNames));
    }
}  // 验证结果包含错误消息,并且可能包含它链接到的属性的空列表

使用此抽象类意味着业务逻辑更易于编写,并且所有业务逻辑都具有一致的错误处理方式。另一个优点是,您可以更改内部处理错误的方式,而无需更改任何业务逻辑代码。

处理订单的业务逻辑会进行大量验证,这是订单的典型特征,因为它通常涉及金钱。其他业务逻辑可能不执行任何验证,但基类 BizActionErrors 将自动返回 false 的 HasErrors,这意味着所有业务逻辑都可以以相同的方式处理。

4.4.3 准则 3:业务逻辑应该认为它正在处理内存中的数据

现在,您将从主类 PlaceOrderAction 开始,它包含纯业务逻辑。此类依赖于伴随类 PlaceOrderDbAccess 将数据呈现为内存中集(在本例中为字典),并将创建的订单写入数据库。尽管您没有尝试从纯业务逻辑中隐藏数据库,但您确实希望它像数据是普通的 .NET 类一样工作。

清单 4.2 显示了 PlaceOrderAction 类,该类继承了抽象类 BizActionErrors 来处理向用户返回错误消息。它还使用配套的 PlaceOrderDbAccess 类提供的两种方法:

  • FindBooksByIdsWithPriceOffers —— 获取 BookId 列表并返回一个字典,其中 BookId 为键,Book 实体类为值,以及任何关联的 PriceOffers
  • Add —— 将 Order 实体类及其 LineItem 集合添加到数据库中

清单 4.2 带有构建新订单业务逻辑的 PlaceOrderAction 类

public class PlaceOrderAction : 
    BizActionErrors,   // BizActionErrors 类为业务逻辑提供错误处理。
    IBizAction<PlaceOrderInDto,Order>    // IBizAction 接口使业务逻辑符合标准接口。
{
    private readonly IPlaceOrderDbAccess _dbAccess;
 
    public PlaceOrderAction(IPlaceOrderDbAccess dbAccess)  // PlaceOrderAction 使用 PlaceOrderDbAccess 类来处理数据库访问。
    {
        _dbAccess = dbAccess;
    }
    
    public Order Action(PlaceOrderInDto dto)  // 此方法由 BizRunner 调用以执行此业务逻辑。
    {
        // 一些基本验证
        if (!dto.AcceptTAndCs)
        {
            AddError("You must accept the T&Cs to place an order.");
            return null;
        }
        if (!dto.LineItems.Any())
        {
            AddError("No items in your basket."); return null;
        }

        // PlaceOrderDbAccess 类查找所有已购买的书籍,并带有可选的 PriceOffers。
        var booksDict = _dbAccess.FindBooksByIdsWithPriceOffers(dto.LineItems.Select(x => x.BookId));
        
        // 使用 FormLineItemsWithError 检查创建 LineItems 创建订单
        var order = new Order
        {
            CustomerId = dto.UserId, 
            LineItems = FormLineItemsWithErrorChecking (dto.LineItems, booksDict)
        };

        if (!HasErrors) _dbAccess.Add(order);  // 只有在没有错误的情况下才将订单添加到数据库中
        return HasErrors ? null : order;  // 如果有错误,返回 null;否则,返回订单
    }

    // 此私有方法处理为订购的每本书创建每个 LineItem。
    private List<LineItem> FormLineItemsWithErrorChecking (IEnumerable<OrderLineItem> lineItems, IDictionary<int,Book> booksDict)
    {
        var result = new List<LineItem>(); 
        var i = 1;

        foreach (var lineItem in lineItems)  // 浏览此人订购的每种图书类型
        {
            // 将缺书视为系统错误并抛出异常
            if (!booksDict.ContainsKey(lineItem.BookId))
                throw new InvalidOperationException ("An order failed because book, " + $"id = {lineItem.BookId} was missing.");

            var book = booksDict[lineItem.BookId]; 
            var bookPrice = book.Promotion?.NewPrice ?? book.Price;   // 计算订单时的价格
            if (bookPrice <= 0)  // 更多验证,检查图书是否可以销售
                AddError($"Sorry, the book '{book.Title}' is not for sale."); 
            else
            {
                // 有效,所以添加到订单中
                result.Add(new LineItem  // 一切正常,因此使用详细信息创建 LineItem 实体类。
                {
                    BookPrice = bookPrice, 
                    ChosenBook = book, 
                    LineNum = (byte)(i++),
                    NumBooks = lineItem.NumBooks
                });
            }
        }
        return result;  // 返回此订单的所有 LineItems
    }
}

您会注意到,您添加了另一个验证检查,以确保用户选择的书籍仍在数据库中。此检查不在业务规则中,但可能会发生,尤其是在提供恶意输入的情况下。在这种情况下,可以区分用户可以更正的错误(由 Errors 属性返回)和系统错误(在本例中为缺少书籍),后者会引发系统应记录的异常。

您可能已经在类的顶部看到,您应用了 IBizAction 形式的接口。此接口可确保此业务逻辑类符合您在所有业务逻辑中使用的标准接口。您将在第 4.7.1 节中看到这一点,当您创建一个泛型类来运行和检查业务逻辑时。

4.4.4 准则 4:将数据库访问代码隔离到单独的项目中

我们的准则是将业务逻辑所需的所有数据库访问代码放在一个单独的伴随类中。这种技术可确保所有数据库访问都集中在一个地方,从而使测试、重构和性能调优变得更加容易。

我的博客的读者注意到的另一个好处是,如果您使用的是现有的旧数据库,则该指南会有所帮助。在这种情况下,数据库实体可能与要编写的业务逻辑不匹配。如果是这样,则可以将 BizDbAccess 方法用作适配器模式,将较旧的数据库结构转换为更易于业务逻辑处理的窗体。

定义:适配器模式将类的接口转换为客户端期望的另一个接口。此模式允许由于接口不兼容而无法协同工作的类协同工作。请参见 https:// sourcemaking.com/design_patterns/adapter

请确保纯业务逻辑、类 PlaceOrderAction 和业务数据库访问类 PlaceOrderDbAccess 位于单独的项目中。该方法允许从纯业务逻辑项目中排除任何 EF Core 库,确保所有数据库访问都是通过伴随类 PlaceOrderDbAccess 完成的。在我自己的项目中,我将实体类拆分为一个独立于 EF 代码的项目。那幺我的纯业务逻辑项目没有 Microsoft.EntityFrameworkCore NuGet 库,所以我的业务逻辑不能直接执行任何数据库命令;它必须依赖 PlaceOrderDbAccess 类进行任何数据访问。

为简单起见,示例代码将实体类与应用进程的 DbContext 保存在同一个项目中。清单 4.3 显示了我们的 PlaceOrderDbAccess 类,它实现了两种方法来提供纯业务逻辑所需的数据库访问:

  • FindBooksByIdsWithPriceOffers 方法,该方法使用任何可选的 PriceOffer 查找并加载每个 Book 实体类。
  • Add 方法,该方法将完成的 Order 实体类添加到应用进程的 DbContext 属性 Orders 中,以便在调用 EF Core 的 SaveChanges 方法后可以将其保存到数据库中。

清单 4.3 PlaceOrderDbAccess,它处理所有数据库访问

public class PlaceOrderDbAccess : IPlaceOrderDbAccess
{
    private readonly EfCoreContext _context;

    public PlaceOrderDbAccess(EfCoreContext context)  // 所有 BizDbAccess 都需要应用进程的 DbContext 才能访问数据库。
    {
        _context = context;
    }
    
    public IDictionary<int, Book> FindBooksByIdsWithPriceOffers  // 此方法查找用户要购买的所有书籍。
        (IEnumerable<int> bookIds)  // BizLogic 提供了 BookId 的集合,该集合由结帐提供。
    {
        return _context.Books
            .Where(x => bookIds.Contains(x.BookId))  // 使用 LINQ Contains 方法查找所有键,为每个 ID 查找一本书
            .Include(r => r.Promotion)  // 包括任何可选的促销活动,BizLogic需要这些促销活动来计算价格
            .ToDictionary(key => key.BookId);  // 以字典形式返回结果,以便 BizLogic 更轻松地查找它们
    }
 
    public void Add(Order newOrder)  // 此方法将新订单添加到 DbContext 的 Orders DbSet 集合中。
    {
        _context.Add(newOrder);
    }
}

PlaceOrderDbAccess 类实现一个名为 IPlaceOrderDbAccess 的接口,这是 PlaceOrderAction 类访问此类的方式。除了帮助进行依赖注入(在第 5 章中介绍)之外,在对 PlaceOrderAction 类进行单元测试时,使用接口还可以将 PlaceOrderDbAccess 类替换为测试版本(称为存根或模拟的过程)。 第 17.7 节更详细地介绍了此主题。

4.4.5 准则 5:业务逻辑不应调用 EF Core 的 SaveChanges

最终规则指出,业务逻辑不会调用 EF Core 的 SaveChanges,这将直接更新数据库。此规则有几个原因:

  • 您认为服务层是数据库访问的主要编排器:它控制着写入数据库的内容。
  • 仅当业务逻辑未返回错误时,服务层才会调用 SaveChanges。

为了帮助您运行业务逻辑,我构建了一系列简单的类,用于运行任何业务逻辑;我称这些类为 BizRunners。它们是泛型类,能够运行具有不同输入和输出类型的业务逻辑。BizRunner 的不同变体可以处理不同的输入/输出组合和异步方法(第 5 章介绍了 EF Core 的异步/await),以及一些具有额外功能的功能,即 PlaceOrderAction(在第 4.7.3 节中介绍)。

每个 BizRunner 都通过定义业务逻辑必须实现的通用接口来工作。BizLogic 项目中的类运行一个操作,该操作需要 PlaceOrderInDto 类型的单个输入参数,并返回 Order 类型的对象。因此,PlaceOrderAction 类实现以下清单中所示的接口,但其输入和输出类型 (IBizAction<PlaceOrderInDto,Order>) 。

清单 4.4 允许 BizRunner 执行业务逻辑的接口

public interface IBizAction<in TIn, out TOut>  // BizAction 使用 TIn 和 TOut 来定义 Action 方法的输入和输出。
{
    // 从业务逻辑返回错误信息
    IImmutableList<ValidationResult> Errors { get; }
    bool HasErrors { get; }

    TOut Action(TIn dto);  // BizRunner 将调用的操作
}

当您让业务逻辑类实现此接口时,BizRunner 知道如何运行该代码。BizRunner 本身很小,如下面的清单所示,它被称为 RunnerWriteDb。此 BizRunner 变体旨在处理具有输入、提供输出并写入数据库的业务逻辑。

清单 4.5 运行业务逻辑并返回结果或错误的 BizRunner

public class RunnerWriteDb<TIn, TOut>
{
    private readonly IBizAction<TIn, TOut> _actionClass; 
    private readonly EfCoreContext _context;

    // 业务逻辑中的错误信息将传递回 BizRunner 的用户。
    public IImmutableList<ValidationResult> Errors => _actionClass.Errors;
    public bool HasErrors => _actionClass.HasErrors;
 
    public RunnerWriteDb(IBizAction<TIn, TOut> actionClass, EfCoreContext context)  // 处理符合 IBizAction 接口的业务逻辑
    {
        _context = context;
        _actionClass = actionClass;
    }

    public TOut RunAction(TIn dataIn)  // 如果数据以正确的形式返回,则在服务层或表示层中调用 RunAction
    {
        var result = _actionClass.Action(dataIn);   // 运行你给它的业务逻辑
        if (!HasErrors) _context.SaveChanges();   // 如果没有错误,则调用 SaveChanges 以执行任何添加、更新或删除方法
        return result;  // 返回业务逻辑返回的结果
    }	
}	

BizRunner 模式隐藏了业务逻辑,并提供了其他类可以使用的通用接口/API。BizRunner 的调用方无需担心 EF Core,因为对 EF Core 的所有调用都在 BizDbAccess 代码或 BizRunner 中。这一事实本身就足以成为使用 BizRunner 模式的理由,但正如您稍后将看到的,此模式允许您创建其他形式的 BizRunner 来添加额外的功能。

注意:您可能希望查看我创建的名为 EfCore.GenericBizRunner 的开源库,该库提供与 BizRunner 相同的功能,但位于库中。它使用泛型类来运行业务逻辑,而无需编写额外的代码。有关详细信息,请参阅 http://mng.bz/vz7J

关于 BizRunner 的一点是,它应该是在应用进程的 DbContext 的生存期内唯一允许调用 SaveChanges 的方法。为什幺?业务逻辑不考虑数据库,因此业务逻辑随时添加或更新实体类是很正常的,以后可能会发现错误。若要阻止在发现错误之前所做的更改写入数据库,需要依赖 SaveChanges,以便在应用进程的 DbContext 的生存期内不调用 SaveChanges。

在 ASP.NET 应用进程中,控制应用进程的 DbContext 的生存期相当容易管理,因为会为每个 HTTP 请求创建应用进程的 DbContext 的新实例。在运行时间较长的应用进程中,这种情况是一个问题。过去,我通过让 BizRunner 为应用进程的 DbContext 创建一个新的隐藏实例来避免这种情况,这样我就可以确保没有其他代码会在该 DbContext 实例上调用 SaveChanges。

4.4.6 组合在一起:调用订单处理业务逻辑

现在,您已经了解了这个复杂业务逻辑模式的所有部分,可以了解如何调用此代码了。清单 4.6 显示了服务层中的 PlaceOrderService 类,它调用 BizRunner 来执行执行订单处理的 PlaceOrderAction。

注意:我使用HTTP cookie来保存用户选择他们想要购买的书籍。我把这个饼干称为篮子饼干。此 cookie 之所以有效,是因为 HTTP cookie 可以在用户的计算机上存储少量数据。我使用 ASP.NET Core 的 cookie 功能来访问用户的购物篮 cookie。有关详细信息,请参阅http://mng.bz/4ZNa

如果业务逻辑成功,则代码将清除购物篮 Cookie 并返回 Order 实体类键,以便可以向用户显示确认页。如果订单失败,它不会清除购物车 Cookie,并且会再次显示结帐页面,并显示错误消息,以便用户可以更正任何问题并重试。

清单 4.6 调用业务逻辑的 PlaceOrderService 类

public class PlaceOrderService  // 此类处理 basket cookie,其中包含用户选择的书籍。
{
    private readonly BasketCookie _basketCookie;

    private readonly RunnerWriteDb<PlaceOrderInDto, Order> _runner;   // 定义此业务逻辑的输入 PlaceOrderInDto 和输出 Order
    public IImmutableList<ValidationResult> Errors => _runner.Errors;  // 保存从业务逻辑发回的任何错误
    // 构造函数接收 cookie 输入/输出数据,以及应用进程的 DbContext。
    public PlaceOrderService( IRequestCookieCollection cookiesIn, IResponseCookies cookiesOut, EfCoreContext context)
    {
        _basketCookie = new BasketCookie( cookiesIn, cookiesOut);  // 使用 ASP.NET Core 的 cookie in/out 数据创建 BasketCookie
        // 使用要运行的业务逻辑创建 BizRunner
        _runner = new RunnerWriteDb<PlaceOrderInDto, Order>( new PlaceOrderAction(new PlaceOrderDbAccess(context)), context);
    } 
 
    public int PlaceOrder(bool acceptTAndCs)  // 此方法是在用户单击“购买”按钮时调用的方法。
    {
        var checkoutService = new CheckoutCookieService(_basketCookie.GetValue());  // CheckoutCookieService 是一个对购物篮数据进行编码/解码的类。
        // 使用 basket cookie 中所需的数据运行业务逻辑 
        var order = _runner.RunAction(new PlaceOrderInDto(acceptTAndCs, checkoutService.UserId, checkoutService.LineItems));
        if (_runner.HasErrors) return 0;   // 如果业务逻辑有错误,它会立即返回。 basket cookie 未清除。
        // 订单已成功下达,因此它清除了 basket cookie。
        checkoutService.ClearAllLineItems();
        _basketCookie.AddOrUpdateCookie(checkoutService.EncodeForCookie());
        return order.OrderId;  // 返回 OrderId,它允许 ASP.NET 向用户确认订单详细信息
    }
}

除了运行业务逻辑之外,此类还充当 Adapter 模式;它将篮子 Cookie 中的数据转换为业务逻辑接受的形式,并在成功完成后提取 Order 实体类的主键 OrderId 以发送回 ASP.NET Core 表示层。

此适配器模式角色是调用业务逻辑的代码的典型角色,因为表示层格式和业务逻辑格式之间经常发生不匹配。这种不匹配可能很小,如本例所示,但除了对业务逻辑的最简单调用外,你可能需要对所有内容进行某种形式的调整。这种情况就是为什幺我的更复杂的 EfCore.GenericBizRunner 库具有内置的适配器模式功能。

4.4.7 在图书应用进程中下订单

现在,我们已经介绍了用于处理订单的业务逻辑、BizRunner 和执行业务逻辑的 PlaceOrderService,让我们看看如何在图书应用进程的上下文中使用此逻辑。 图 4.4 显示了从用户单击“购买”按钮到运行业务逻辑并返回结果的过程。我在这里不详细介绍演示代码,因为本章是关于在业务逻辑中使用 EF Core,但我在第 5 章中介绍了其中的一些内容,该章是关于在 ASP.NET Core 应用进程中使用 EF Core。

图4.4 从用户点击购买按钮到服务层的一系列步骤,其中 BizRunner 执行业务逻辑处理订单

通过单击图 4.4 中的 Purchase 按钮,将执行 CheckoutController 中的 ASP.NET Core 操作 PlaceOrder。此操作在服务层中创建一个名为 PlaceOrderService 的类,该类包含大多数适配器模式逻辑。调用方为该类提供对 Cookie 的读/写访问权限,因为结帐数据保存在用户设备上的 HTTP Cookie 中。

您在清单 4.6 中看到了 PlaceOrderService 类。它的 PlaceOrder 方法从 HTTP cookie 中提取签出数据,并以业务逻辑所需的形式创建 DTO。然后,它调用泛型 BizRunner 来运行它需要执行的业务逻辑。当 BizRunner 从业务逻辑返回时,可以进行以下两种路由:

  • 订单已成功下达(无错误)。在本例中,PlaceOrder 方法清除了购物篮 Cookie 并返回了已下订单的 OrderId,因此 ASP.NET Core 代码可以显示包含订单摘要的确认页。
  • 订单不成功(存在错误)。在这种情况下,PlaceOrder 方法会立即返回到 ASP.NET Core 代码,该代码检测到错误,重新显示结帐页,并添加错误消息,以便用户可以更正错误并重试。

注意:您可以通过下载图书应用代码并在本地运行它来尝试结账过程以查看结果。要尝试错误路径,请不要选中条款和条件 (T&C) 框。

4.4.8 复杂业务逻辑模式的优缺点

多年来,我一直将这种模式用于复杂的业务逻辑。我认为这总体上是一个很好的方法,但它的代码量很大,我的意思是你必须编写额外的结构代码来实现它。因此,我仅将其用于复杂的业务逻辑。以下各节详细介绍了优缺点。

这种模式的优点

此模式遵循 DDD 方法,该方法受到广泛推崇和广泛使用。它使业务逻辑保持“纯净”,因为它不知道数据库,该数据库已通过提供每个业务逻辑存储库的 BizDbAccess 方法隐藏。此外,BizDbAccess 类允许在不使用数据库的情况下测试业务逻辑,因为单元测试可以提供替换类(称为存根或模拟),该类可以根据需要提供测试数据。

此模式的缺点

主要缺点是您必须编写更多代码才能将业务逻辑与数据库访问分开,这需要更多的时间和精力。如果业务逻辑很简单,或者大多数代码都在数据库上工作,那幺创建单独的类来处理数据库访问的工作是不值得的。

4.5 简单的业务逻辑示例:ChangePriceOfferService

对于我的简单业务逻辑示例,您将构建业务逻辑来处理为一本书添加或删除价格促销。此示例包含业务规则,但正如您将看到的,这些规则与大量数据库访问相关联。规则是

  • 如果图书有 PriceOffer,代码应删除当前 PriceOffer(删除价格促销)。
  • 如果图书没有 PriceOffer,我们会添加新的价格促销。
  • 如果代码添加促销价格,则 PromotionalText 不得为 null 或空。

正如您将在第 4.5.2 节中看到的,代码是业务规则和数据库访问的混合体,我将其定义为简单的业务逻辑类型。

4.5.1 简单业务逻辑的设计方法

对于简单的业务逻辑,我希望有最小的额外结构,因为我认为业务逻辑足够简单和/或与数据库访问相互链接,不需要隔离。因此,没有使用第 4.3.1 节中所述的五个准则,从而加快了代码的构建速度。缺点是业务逻辑与其他代码混合在一起,这会使业务逻辑难以理解和单元测试,这是为了加快开发速度而必须管理的权衡。

通常,我将简单业务逻辑放在服务层而不是 BizLogic 层中,因为我的简单业务逻辑需要访问应用进程的 DbContext,而 BizLogic 层不允许该访问。我通常将简单的业务逻辑与处理相同功能的 CRUD 类放在一起。在 ChangePriceOfferService 示例中,我将 ChangePriceOfferService 类与其他 CRUD 服务放在 AdminServices 文档夹中。

4.5.2 编写 ChangePriceOfferService 代码

ChangePriceOfferService 类包含两个方法:GetOriginal 方法(用于加载 PriceOffer 的简单 CRUD 命令)和 AddRemovePriceOffer 方法(用于处理 Book 的 PriceOffer 类的创建或删除)。第二种方法包含业务逻辑,如下面的清单所示。

清单 4.7 ChangePriceOfferService 中的 AddRemovePriceOffer 方法


public ValidationResult AddRemovePriceOffer(PriceOffer promotion)  //此方法删除 PriceOffer(如果存在);否则,它将添加新的 PriceOffer。
{
    //加载本书,与任何现有的促销活动 
    var book = _context.Books
        .Include(r => r.Promotion)
        .Single(k => k.BookId == promotion.BookId);

    if (book.Promotion != null)  //如果该书有现有的促销活动,则删除该促销活动
    {
        _context.Remove(book.promotion);  //删除与所选图书相关的PriceOffer条目
        _context.SaveChanges();
        return null;  //返回 null,这意味着该方法成功完成
	}
 
    if (string.IsNullOrEmpty(promotion.PromotionalText))  //验证检查。促销文本必须包含一些文本。
    {
        //返回错误消息,属性名称不正确
        return new ValidationResult( "This field cannot be empty",
            new []{ nameof(PriceOffer.PromotionalText)});
    }

    book.Promotion = promotion;  //将新的 PriceOffer 分配给所选图书
    _context.SaveChanges();   //SaveChanges 方法更新数据库。
    return null;  //添加新的促销活动已成功,因此该方法返回 null。
}

4.5.3 这种业务逻辑模式的优缺点

您编写了一些以与处理订单的更复杂业务逻辑不同的方式实现的业务逻辑,我将其描述为简单业务逻辑。简单业务逻辑和复杂业务逻辑之间的主要区别是

  • 简单的业务逻辑没有遵循第 4.3.1 节中受 DDD 启发的准则。特别是,它没有将数据库访问与业务逻辑隔离开来。
  • 简单的业务逻辑与购物篮相关的 CRUD 服务一起放置在服务层(而不是 BizLogic 层)。

这种模式有以下优点和缺点。

这种模式的优点

该模式几乎没有或没有固定结构,因此您可以用最简单的方式编写代码来归档所需的业务目标。通常,代码会比复杂的业务模式短,复杂的业务模式具有额外的类来将业务逻辑与数据库隔离。

业务逻辑也是独立的,所有代码都集中在一个地方。与复杂的业务逻辑示例不同,此业务逻辑处理所有内容。例如,它不需要 BizRunner 来执行它,因为代码会调用 SaveChanges 本身,从而更容易更改、移动和测试,因为它不依赖于其他任何东西。此外,通过将业务逻辑类放在服务层中,我可以将这些简单的业务逻辑服务与与此业务功能相关的 CRUD 服务分组到同一个文档夹中。因此,我可以快速找到功能的所有基本代码,因为复杂的业务代码在另一个项目中。

这种模式的缺点

您没有受 DDD 启发的复杂业务逻辑模式方法来指导您,因此您有责任以合理的方式设计业务逻辑。您的经验将帮助您选择最佳的使用模式并编写正确的代码。简单是这里的关键。如果代码很容易理解,那幺你就做对了;否则,代码过于复杂,需要遵循复杂的业务逻辑模式。

4.6 验证业务逻辑示例:向书籍添加评论,并带有检查

最后一个示例是第 3 章中对 CRUD 示例的升级。在该章中,您为一本书添加了评论。但该版本缺少一些重要的业务规则:

  • NumStars 属性必须介于 0 和 5 之间。
  • Comment 属性中应包含一些文本。

在本节中,您将更新 CRUD 代码以添加验证检查。下面的列表显示了改进的 AddReviewWithChecks 方法,但侧重于验证部分。

清单 4.8 改进的 CRUD 代码,添加了业务验证检查

public IStatusGeneric AddReviewWithChecks(Review review)  //此方法将评论添加到书籍中,并对数据进行验证检查。
{
    var status = new StatusGenericHandler();  //创建一个状态类来保存任何错误
    
    //如果星级在正确的范围内,则为状态添加错误
    if (review.NumStars < 0 || review.NumStars > 5) 
        status.AddError("This must be between 0 and 5.", nameof(Review.NumStars));
    
    //第二次检查确保用户提供了某种评论。
    if (string.IsNullOrWhiteSpace(review.Comment))
        status.AddError("Please provide a comment with your review.", nameof(Review.Comment));
 
    if (!status.IsValid) return status;  //如果有任何错误,该方法会立即返回这些错误。

    //为一本书添加评论的 CRUD 代码 
    var book = _context.Books
        .Include(r => r.Reviews)
        .Single(k => k.BookId == review.BookId); book.Reviews.Add(review);

    _context.SaveChanges();
    return status;  //返回状态,如果没有发现错误,则该状态有效
}

注意:清单 4.8 中使用的 IStatusGeneric 接口和 StatusGenericHandler 类来自名为 GenericServices.StatusGeneric 的 NuGet 包。该库提供了一种简单但全面的方法来返回与 .NET Core 验证方法相匹配的好/坏状态。配套的 NuGet 包名为 EfCore.GenericServices.AspNetCore,提供了将 IStatusGeneric 状态转换为 ASP.NET Core 基于 ModelState Razor 的页面或 Web API 控制器的 HTTP 返回的方法。

该方法是一种增加了业务验证的CRUD方法,是典型的此类业务逻辑。在本例中,您使用 if-then 代码来检查属性,但您也可以使用 DataAnnotations。正如我之前所说,这种类型的验证通常在前端完成,但在后端代码中重复敏感数据的验证可以使应用进程更加健壮。稍后,在第 4.7.1 节中,我将向您展示如何在将数据写入数据库之前验证数据,这为您提供了另一种选择。

4.6.1 这种业务逻辑模式的优缺点

验证业务逻辑是您在第 3 章中看到的 CRUD 服务,通过添加验证检查来增强。因此,我将验证业务逻辑类与其他 CRUD 服务一起放置在服务层中。

这种模式的优点

您已经从第 3 章了解了 CRUD 服务,因此您不需要学习其他模式——只需添加验证检查并返回状态。然而,像许多其他人一样,我认为这些验证业务逻辑类与 CRUD 服务相同,但其中有一些额外的检查。

这种模式的缺点

唯一的缺点是您需要对模式返回的状态进行一些操作,例如重新显示带有错误消息的输入表单。但这是提供额外验证而不是验证业务逻辑设计的缺点。

4.7 为业务逻辑处理添加额外功能

这种处理业务逻辑的模式可以更轻松地向业务逻辑处理添加额外的功能。在本部分中,您将添加两个功能:

  • SaveChanges 的实体类验证
  • 以菊花链方式连接一系列业务逻辑代码的事务

这些功能使用不限于业务逻辑的 EF Core 命令。这两个功能都可以在其他领域使用,因此您在开发应用进程时可能需要记住它们。

4.7.1 验证写入数据库的数据

我已经讨论过在数据到达数据库之前验证数据,但是本节将向您展示如何在写入数据库时​​添加验证。 NET 包含一个完整的生态系统来验证数据,根据某些规则检查属性的值(例如检查整数是否在 1 到 10 的范围内或字符串是否不超过 20 个字符)。微软的许多前端系统都使用这个生态系统。

EF6:如果您正在扫描 EF6.x 更改,请阅读下一段。 EF Core 的 SaveChanges 在写入数据库之前不会验证数据,但本节介绍如何将其添加回来。

在以前版本的 EF (EF6.x) 中,默认情况下,添加或更新的数据会在写入数据库之前进行验证。在 EF Core 中,其设计更加轻量级且速度更快,在向数据库添加数据或更新数据库时不会进行验证。这个想法是验证通常在前端完成,那幺为什幺要重复验证呢?

正如您所看到的,业务逻辑包含大量验证代码,将此代码移动到实体类中作为验证检查通常很有用,特别是当错误与实体类中的特定属性相关时。这个例子是将一组复杂的规则分解为几个部分的另一个例子。

清单 4.9 将检查图书是否待售的测试移到了验证代码中,而不必在业务逻辑中进行。该列表还添加了两个新的验证检查,以显示验证检查可以采用的各种形式,从而使示例更加全面。

图 4.5 显示了添加了两种验证类型的 LineItem 实体类。第一种类型是 [Range(min,max)] 属性,称为数据批注(请参见第 7.4 节),该属性被添加到 LineNum 属性中。要应用的第二种验证方法是 IValidatableObject 接口。此接口要求添加一个名为 IValidatableObject.Validate 的方法,您可以在其中编写自己的验证规则,并在违反这些规则时返回错误。

清单 4.9 应用于 LineNum 实体类的验证规则

public class LineItem : IValidatableObject  //IValidatableObject 接口添加 IValidatableObject.Validate 方法。
{
    public int LineItemId { get; set; }
    //如果 LineNum 属性不在范围内,则添加错误消息
    [Range(1,5, ErrorMessage = "This order is over the limit of 5 books.")] 
    public byte LineNum { get; set; }
    public short NumBooks { get; set; } 
    public decimal BookPrice { get; set; }
    
    // relationships
    public int OrderId { get; set; } 
    public int BookId { get; set; }
    public Book ChosenBook { get; set; }

    IEnumerable<ValidationResult> IValidatableObject.Validate (ValidationContext validationContext)  //IValidatableObject 接口需要创建此方法。
    {
        var currContext = validationContext.GetService(typeof(DbContext));  //如有必要,允许访问当前 DbContext 以获取更多信息

        //将价格检查从业务逻辑移至此验证
        if (ChosenBook.Price < 0)
            yield return new ValidationResult($"Sorry, the book '{ChosenBook.Title}' is not for sale.");
 
        if (NumBooks > 100)  //额外验证规则:超过100本书的订单需要电话订购。
            yield return new ValidationResult( "If you want to order a 100 or more books"+" please phone us on 01234-5678-90",
                new[] { nameof(NumBooks) });  //返回带有错误的属性的名称,以提供更好的错误消息
    }
}

我应该指出,在 IValidatableObject.Validate 方法中,您可以访问 LineNum 类之外的属性:ChosenBook 的 Title。ChosenBook 是一个导航属性,当调用 DetectChanges 方法时,关系修正功能(参见图 1.10 第 3 阶段)将确保 ChosenBook 属性不为 null。因此,清单 4.9 中的验证代码可以访问业务逻辑可能没有的导航属性。

注意:除了使用内置验证属性的广泛列表外,还可以通过继承自己的类上的 ValidationAttribute 类来创建自己的验证属性。有关可用的标准验证属性以及如何使用 ValidationAttribute 类的详细信息,请参见 http://mng.bz/9cec

将验证规则代码添加到 LineItem 实体类后,需要向 EF Core 的 SaveChanges 方法添加一个名为 SaveChangesWithValidation 的验证阶段。尽管放置此阶段的明显位置位于应用进程的 DbContext 内部,但您将改为创建一个扩展方法。此方法将允许在任何 DbContext 上使用 SaveChangesWithValidation,这意味着您可以复制此类并在应用进程中使用它。

下面的清单显示了这个 SaveChangesWithValidation 扩展方法,清单 4.11 显示了 SaveChangesWithValidation 调用的私有方法 ExecuteValidation 来处理验证。

清单 4.10 将 SaveChangesWithValidation 添加到应用进程的 DbContext 中

public static ImmutableList<ValidationResult>   //SaveChangesWithValidation 返回一个 ValidationResults 列表。
    SaveChangesWithValidation(this DbContext context)  //SaveChangesWithValidation 是一种扩展方法,它将 DbContext 作为其输入。
{
    var result = context.ExecuteValidation();   //ExecuteValidation 用于 SaveChangesWithChecking/Save ChangesWithCheckingAsync。
    if (result.Any()) return result;   //如果出现错误,请立即返回它们,不要调用 SaveChanges。
    context.SaveChanges();  //没有任何错误,所以我将调用 SaveChanges
    return result;  //返回空的错误集,以表示没有错误
}

清单 4.11 SaveChangesWithValidation 调用 ExecuteValidation 方法

private static ImmutableList<ValidationResult> ExecuteValidation(this DbContext context)
{
    var result = new List<ValidationResult>(); 
    
    foreach (var entry in 
        context.ChangeTracker.Entries()  //使用 EF Core 的 ChangeTracker 访问它正在跟踪的所有实体类
            .Where(e => (e.State == EntityState.Added) || (e.State == EntityState.Modified)))  //筛选将在数据库中添加或更新的实体
    {
        var entity = entry.Entity; 
        var valProvider = new
            ValidationDbContextServiceProvider(context);   //实现 IServiceProvider 接口并将 DbContext 传递给 Validate 方法
        var valContext = new
            ValidationContext(entity, valProvider, null); 
        var entityErrors = new List<ValidationResult>(); 
        if (!Validator.TryValidateObject(entity, valContext, entityErrors, true))  //Validator.TryValidateObject 是验证每个类的方法。
        {
            result.AddRange(entityErrors);  //任何错误都添加到列表中。
        }
    }	
    return result.ToImmutableList();  //返回找到的所有错误的列表(如果没有错误,则为空)
}

主代码位于 ExecuteValidation 方法中,因为需要在 SaveChangesWithValidation 的同步和异步版本中使用它。对上下文的调用。ChangeTracker.Entries 调用 DbContext 的 DetectChanges,以确保在运行验证之前找到所做的所有更改。然后,代码查看已添加或修改(更新)的所有实体,并验证它们。

我想在清单 4.11 中指出的一段代码是一个名为 ValidationDbContextServiceProvider 的类,它实现了 IServiceProvider 接口。此类在创建 ValidationContext 时使用,因此它在具有 IValidatableObject 接口的任何实体类中都可用,从而允许 Validate 方法在必要时访问当前应用进程的 DbContext。通过访问当前的 DbContext,您可以通过从数据库中获取额外信息来创建更好的错误消息。

将 SaveChangesWithValidation 方法设计为返回错误,而不是引发异常。这样做是为了适应业务逻辑,该逻辑以列表形式返回错误,而不是异常。您可以创建新的 BizRunner 变体 RunnerWriteDbWithValidation,它使用 SaveChangesWithValidation 而不是普通的 SaveChanges,并从业务逻辑返回错误或写入数据库时发现的任何验证错误。下一个列表显示了 BizRunner 类 RunnerWriteDbWithValidation。

清单 4.12 BizRunner 变体 RunnerWriteDbWithValidation

public class RunnerWriteDbWithValidation<TIn, TOut>
{
    private readonly IBizAction<TIn, TOut> _actionClass; 
    private readonly EfCoreContext _context;

    //此版本需要自己的 Errors/HasErrors 属性,因为错误来自两个源。
    public IImmutableList<ValidationResult> Errors { get; private set; }
    public bool HasErrors => Errors.Any();

    public RunnerWriteDbWithValidation( IBizAction<TIn, TOut> actionClass, EfCoreContext context)  //处理符合 IBizAction 接口的业务逻辑
    {
        _context = context;
        _actionClass = actionClass;
    }
 
    public TOut RunAction(TIn dataIn)  //调用此方法以执行业务逻辑并处理任何错误。
    {
        var result = _actionClass.Action(dataIn);   //运行我给它的业务逻辑
        Errors = _actionClass.Errors;  //业务逻辑中的任何错误都将分配给本地错误列表。
        if (!HasErrors)  //如果没有错误,调用 SaveChangesWithChecking
        {
            Errors =_context.SaveChangesWithValidation().ToImmutableList();  //任何验证错误都将分配给“错误”列表。
        }
        return result;  //返回业务逻辑返回的结果
    }
}

BizRunner 模式的这个新变体的好处是,它具有与原始的、非验证的 BizRunner 完全相同的接口。您可以将 RunnerWriteDbWithValidation 替换为原始 BizRunner,而无需更改业务逻辑或调用方法执行 BizRunner 的方式。在第 4.7.2 节中,您将生成 BizRunner 的另一个变体,它可以运行多个业务逻辑类,使它们看起来像单个业务逻辑方法。这是可能的,因为本章开头描述的业务逻辑模式。

4.7.2 使用事务以菊花链方式连接业务逻辑代码串行

正如我之前所说,业务逻辑可能会变得复杂。当涉及到设计和实现大型或复杂的业务逻辑时,您有三种选择:

  • 选项 1——编写一个可以完成所有操作的大方法。
  • 选项 2 ——编写一些较小的方法,用一个总体方法按顺序运行它们。
  • 选项 3——编写一些较小的方法,每个方法都会更新数据库,但将它们组合成一个工作单元(参见第 3.2.2 节中的侧边栏)。

选项 1 通常不是一个好主意,因为该方法很难理解和重构。如果部分业务逻辑在其他地方使用,也会出现问题,因为您可能会违反 DRY(不要重复自己)软件原则。

选项 2 可以工作,但如果后面的阶段依赖于早期阶段编写的数据库项,可能会出现问题,这可能会破坏第 1 章中提到的原子单元规则:当数据库发生多个更改时,它们都成功,或者都失败。

这将保留选项 3,这是可能的,因为 EF Core(以及大多数关系数据库)的一项功能称为事务。在第 3.2.2 节中,侧边栏“为什幺应该在更改结束时只调用一次 SaveChanges”介绍了工作单元,并展示了 SaveChanges 如何保存事务中的所有更改,以确保所有更改都已保存,或者,如果数据库拒绝了更改的任何部分,则不会将任何更改保存到数据库中。

在这种情况下,您希望将工作单元分散到几个较小的方法中;我们称它们为 Biz1、Biz2 和 Biz3。您不必更改 Biz 方法;他们仍然认为他们正在自己工作,并希望在每个 Biz 方法完成时调用 SaveChanges。但是,当您创建总体事务时,所有三个 Biz 方法及其 SaveChanges 调用将作为一个工作单元工作。因此,Biz3 中的数据库拒绝/错误将拒绝 Biz1、Biz2 和 Biz3 所做的任何数据库更改。

此数据库拒绝之所以有效,是因为当你使用 EF Core 创建显式关系数据库事务时,它会产生两种影响:

  • 对数据库的任何写入都对其他数据库用户隐藏,直到您调用事务的 Commit 方法。
  • 如果您决定不希望数据库写入(例如,因为业务逻辑有错误),则可以通过调用事务回滚命令来放弃事务中完成的所有数据库写入。

图 4.5 显示了三个独立的业务逻辑部分,每个部分都需要调用 SaveChanges 来更新数据库,但由一个名为事务性 BizRunner 的类运行。在运行每个业务逻辑后,BizRunner 将调用 SaveChanges,这意味着业务逻辑写出的任何内容现在都可以通过本地事务用于后续业务逻辑阶段。在最后阶段,业务逻辑 Biz 3 返回错误,这会导致 BizRunner 调用 RollBack 命令,该命令具有删除 Biz 1 和 Biz 2 完成的任何数据库写入的效果。

 

图 4.5 在一个事务下执行三个独立业务逻辑阶段的示例。当最后一个业务逻辑阶段返回错误时,将回滚前两个业务逻辑阶段应用的其他数据库更改。

下一个列表显示了新的事务性 BizRunner 的代码,该代码在调用任何业务逻辑之前在应用进程的 DbContext 上启动事务。

清单 4.13 RunnerTransact2WriteDb串联运行两个业务逻辑阶段

public class RunnerTransact2WriteDb<TIn, TPass, TOut>   //这三种类型是输入、从 Part1 传递到 Part2 的类和输出。
    where TOut : class  //如果存在错误,BizRunner 可以返回 null,因此它必须是一个类。
{
    //为两个业务逻辑部件定义泛型 BizAction
    private readonly IBizAction<TIn, TPass> _actionPart1;
    private readonly IBizAction<TPass, TOut> _actionPart2;
    private readonly EfCoreContext _context;

    //保存业务逻辑返回的任何错误信息
    public IImmutableList<ValidationResult> Errors { get; private set; }
    public bool HasErrors => Errors.Any();

    //构造函数同时采用业务类和应用进程 DbContext。
    public RunnerTransact2WriteDb( EfCoreContext context, IBizAction<TIn, TPass> actionPart1, IBizAction<TPass, TOut> actionPart2)
    {
        _context = context;
        _actionPart1 = actionPart1;
        _actionPart2 = actionPart2;
    }

    public TOut RunAction(TIn dataIn)
    {
        //在 using 语句中启动事务
        using (var transaction = _context.Database.BeginTransaction())
        {
            var passResult = RunPart(_actionPart1, dataIn);   //私有方法 RunPart 运行第一个业务部件。
            if (HasErrors) return null;   //如果存在错误,则返回 null。(回滚由处置处理。
            var result = RunPart(_actionPart2, passResult);  //如果业务逻辑的第一部分成功,则运行第二部分业务逻辑
            if (!HasErrors)
            {
                transaction.Commit();  //如果没有错误,则将事务提交到数据库
            }
            return result;  //返回上一个业务逻辑的结果
        }  //如果在使用结束之前未调用 commit,则 RollBack 将撤消所有更改。
    }
    //这种私有方法负责运行业务逻辑的每个部分。
    private TPartOut RunPart<TPartIn, TPartOut>( IBizAction<TPartIn, TPartOut> bizPart, TPartIn dataIn)
        where TPartOut : class
    {
        //运行业务逻辑并复制业务逻辑的错误
        var result = bizPart.Action(dataIn); 
        Errors = bizPart.Errors;  
        if (!HasErrors)
        {
            _context.SaveChanges();  //如果业务逻辑成功,调用 SaveChanges
        }
        return result;  返回它运行的业务逻辑的结果
    }
}

在 RunnerTransact2WriteDb 类中,您依次执行业务逻辑的每个部分,并在每次执行结束时执行以下操作之一:

  • 无错误——您调用 SaveChanges 将业务逻辑已运行的任何更改保存到事务中。该保存位于本地事务中,因此访问数据库的其他方法还不会看到这些更改。然后调用业务逻辑的下一部分(如果有)。
  • 有错误——您将刚刚完成的业务逻辑发现的错误复制到BizRunner错误列表中并退出BizRunner。此时,代码将跳出保存事务的 using 子句,从而导致事务被处置。由于没有调用事务 Commit,因此处置将导致事务执行其 RollBack 方法,该方法会丢弃对该事务的数据库写入。这些写入永远不会写入数据库。

如果您已运行所有业务逻辑且没有错误,则可以对事务调用 Commit 命令。此命令对数据库进行原子更新,以反映本地事务中包含的对数据库的所有更改。

4.7.3 使用RunnerTransact2WriteDb类

为了测试 RunnerTransact2WriteDb 类,您需要将之前使用的订单处理代码分为两部分:

  • PlaceOrderPart1——创建Order实体,没有LineItems
  • PlaceOrderPart2——将购买的每本书的 LineItems 添加到订单中

由 PlaceOrderPart1 类创建的实体

PlaceOrderPart1和PlaceOrderPart2基于您已经见过的PlaceOrderAction代码,因此这里不再重复业务代码。

清单 4.14 显示了 PlaceOrderService(如清单 4.6 所示)转换为使用 RunnerTransact2WriteDb BizRunner 所需的代码更改。该清单重点关注创建和运行两个阶段(Part1 和 Part2)的部分,省略了代码中未更改的部分,以便您可以轻松看到更改。

清单 4.14 PlaceOrderServiceTransact 类显示了更改的部分

//此版本的 PlaceOrderService 使用事务来执行两个业务逻辑类:PlaceOrderPart1 和 PlaceOrderPart2。
public class PlaceOrderServiceTransact
{
    //… code removed as the same as in listing 4.5

    public PlaceOrderServiceTransact( IRequestCookieCollection cookiesIn, IResponseCookies cookiesOut, EfCoreContext context)
    {
        _checkoutCookie = new CheckoutCookie( cookiesIn, cookiesOut);  //这个BizRunner处理事务中的多个业务逻辑。
        
        //BizRunner 需要输入、从 Part1 传递到 Part2 的类以及输出。
        _runner = new RunnerTransact2WriteDb
            <PlaceOrderInDto, Part1ToPart2Dto, Order>( context,  //BizRunner 需要应用进程的 DbContext。
                new PlaceOrderPart1( new PlaceOrderDbAccess(context)),   //提供业务逻辑第一部分的实例
                new PlaceOrderPart2( new PlaceOrderDbAccess(context)));  //提供业务逻辑的第二部分的实例
    }

    public int PlaceOrder(bool tsAndCsAccepted)
    {
        //… code removed as the same as in listing 4.6
    }
}

需要注意的重要一点是,业务逻辑不知道它是否在事务中运行。您可以单独使用一段业务逻辑,也可以将其用作事务的一部分。类似地,清单 4.14 显示只有基于事务的业务逻辑的调用者(我称之为 BizRunner)需要更改。使用事务可以轻松地将多个业务逻辑类组合到一个事务下,而无需更改任何业务逻辑代码。

使用此类事务的优点是,您可以拆分和/或重用部分业务逻辑,同时使这些多个业务逻辑调用看起来像一个调用一样,适用于您的应用进程,尤其是其数据库。当我需要创建并立即更新复杂的多部分实体时,我使用了这种方法。由于我需要对其他情况进行更新业务逻辑,因此我使用事务调用了 Create 业务逻辑,然后调用了 Update 业务逻辑,这节省了我的开发工作并使我的代码保持干燥。

这种方法的缺点是它增加了数据库访问的复杂性,这可能会使调试更加困难,或者使用数据库事务可能会导致性能问题。此外,请注意,如果使用 EnableRetryOnFailure 选项(请参阅第 11.8 节)在出现错误时重试访问的数据库,则需要处理对业务逻辑的可能多次调用。

总结

  • 术语“业务逻辑”描述了为实现实际业务规则而编写的代码。业务逻辑代码的范围可以从简单到复杂。
  • 根据业务逻辑的复杂性,您需要选择一种方法,在解决业务问题的难易程度与开发和测试解决方案所需的时间之间取得平衡。
  • 将业务逻辑的数据库访问部分隔离到另一个类/项目中可以使纯业务逻辑更易于编写,但开发时间更长。
  • 将功能的所有业务逻辑放在一个类中既快速又简单,但会使代码更难理解和测试。
  • 为业务逻辑创建标准化接口,使前端调用和运行业务逻辑变得更加简单。
  • 有时,将一些验证逻辑移动到实体类中并在将数据写入数据库时运行检查会更容易。
  • 对于复杂或被重用的业务逻辑,使用数据库事务来允许一系列业务逻辑部分按顺序运行可能更简单,但从数据库的角度来看,它看起来像一个原子单元。

对于熟悉 EF6.x 的读者:

  • 与 EF6.x 不同,EF Core 的 SaveChanges 方法在将数据写入数据库之前不会验证数据。但是,在 EF Core 中实现提供此功能的方法很容易。