八、将领域逻辑提取到事务中

在上一章中,我们将所有 SQL 语句提取到一层网关对象中。这封装了应用程序和数据库之间的交互。

然而,我们通常需要对来自和返回数据库的数据应用一些业务或领域逻辑。逻辑可以包括数据验证、为表示或计算目的添加或修改值、将更简单的记录收集到更复杂的记录中、使用数据执行相关操作等。该领域逻辑通常嵌入到页面脚本中,使得该逻辑难以重用和测试。

本章介绍一种将域行为提取到单独层的方法。在许多方面,本章构成了本书的核心:之前的一切都引导我们关注遗留应用程序的核心问题,之后的一切都将引导我们进入这个核心功能之上和周围的各个层。

领域还是模型?

遗留应用程序中的领域逻辑是模型视图控制器的模型部分。然而,遗留代码库不太可能有单独的实体对象来提供业务领域的完整模型。因此,在本章中,我们将从领域逻辑而不是模型逻辑的角度进行讨论。如果我们足够幸运,已经有了独立的模型对象,那就更好了。

嵌入式领域逻辑

尽管我们提取了 SQL 语句,但页面脚本和类可能正在处理结果并执行与检索到的数据相关的其他操作。这些操作和动作是领域逻辑的核心,它目前与其他非域关注点一起嵌入。

通过检查附录 B中的代码、网关前的代码附录 C中的代码、网关后的代码之间的差异,我们可以看到一个从嵌入式 SQL 到使用网关类的进展示例。代码太长,无法在此处显示。我们要注意的是,即使在提取了嵌入的 SQL 语句之后,代码仍然在将结果呈现给用户之前,对传入和传出的数据进行大量工作。

将领域逻辑嵌入到页面脚本中会使隔离测试该逻辑变得非常困难。我们也不能轻易地重用它。如果我们想在如何使用域实体(在本例中是一系列文章)时搜索重复和重复,我们需要检查整个应用程序中的每个页面脚本。

这里的解决方案是将领域逻辑提取到一个或多个类中,这样我们就可以独立于任何特定的页面脚本来测试它们。然后我们可以实例化领域逻辑类,并在我们喜欢的任何页面脚本中使用它们。

在应用该解决方案之前,我们需要确定如何为领域逻辑构造目标类。

领域逻辑模式

Martin Fowler 的企业应用程序架构模式PoEAA)列举了四种领域逻辑模式:

  • 事务脚本:它主要将【域】逻辑组织为单个过程,直接调用数据库或通过瘦数据库包装器进行调用。每个事务都有自己的事务脚本,尽管公共子任务可以分解为子流程。”
  • 领域模型:它创建了一个互联对象的网络,其中每个对象代表一些有意义的个人,无论是大到公司还是小到订单上的一行。
  • 表模块:它以数据库中每个表一个类的形式组织领域逻辑,一个类的单个实例包含作用于数据的各种过程,如果您有多个订单,一个域模型每个订单将有一个订单对象,而一个表模块将有一个对象处理所有订单。
  • 服务层:它从接口客户端层的角度定义应用程序的边界及其可用操作集。它封装了应用程序的业务逻辑,在其操作的实现中控制事务并协调响应。

我强烈建议购买纸质版的 PoEAA,并完整阅读模式描述和示例。这本书绝对是专业程序员必备的参考书。我发现自己每周(有时更频繁地)都在查阅它,它总是提供清晰和洞察力。

现在摆在我们面前的选择是:考虑到遗留应用程序的现有结构,以下哪种模式最适合当前的体系结构?

此时,我们将忽略服务层,因为它意味着我们的遗留应用程序中可能不存在的复杂程度。我们同样会忽略域模型,因为它意味着一组精心设计的封装行为的业务实体对象。如果遗留应用程序已经实现了其中一种模式,那么就更好了。否则,就剩下表模块和事务脚本模式了。

在上一章中,当我们将 SQL 语句提取到Gateway类时,这些Gateway类很可能遵循表数据网关模式,特别是如果它们足够简单,每个Gateway类只与一个表交互的话。这使得表模块模式似乎非常适合我们的领域逻辑。

但是,不太可能每个剩余的带有嵌入式领域逻辑的页面脚本或类一次与单个表交互。更常见的是,遗留应用程序在单个类或脚本中跨多个表进行许多交互。因此,在提取领域逻辑时,我们将首先使用事务脚本模式。

事务脚本无疑是一种简单的模式。使用它,我们从页面脚本中提取领域逻辑,并将其转储到一个基本完整的类方法中。我们对逻辑进行修改只是为了让数据进出类方法,这样原始代码仍然可以正常运行。

尽管我们可能希望得到比事务脚本更复杂的东西,但我们必须记住,我们的目标之一是避免过于剧烈地更改现有逻辑。我们正在重构,而不是重写。我们现在想要的是移动代码,以便能够对其进行适当的测试和重用。因此,事务脚本可能是包装遗留领域逻辑的最佳方式,因为它是存在的,而不是我们想要的。

一旦我们将领域逻辑提取到它自己的层中,我们就能够更清楚地看到该逻辑,并且不那么分心。在这一点上,如果确实需要,我们可以开始计划将域层重构为更复杂的东西。例如,我们可以构建一个使用表模块或域模型来协调各种域交互的服务层。服务层提供给页面脚本的接口可能与事务脚本接口完全不同,尽管底层架构可能已经完全改变。但这是另一天的任务。

活动记录呢?

RubyonRails 以使用活动记录模式而闻名,许多 PHP 开发人员都喜欢这种数据库交互。它肯定有它的长处。然而,Fowler 将活动记录分类为数据源体系结构模式,而不是领域逻辑模式,因此我们在这里不讨论它。

提取过程

在本书中描述的重构过程中,提取领域逻辑将是最困难、最耗时、最面向细节的。这是一件非常困难的事情,需要非常小心和关注。领域逻辑是我们遗留应用程序的核心,我们需要确保只提取正确的部分。这意味着成功完全取决于我们对现有遗留应用程序的熟悉程度和能力。

幸运的是,我们之前在使遗留代码库现代化方面的练习使我们对整个应用程序有了一个广泛的了解,并对我们必须提取和重构的特定部分有了深入的了解。这应该使我们有信心成功地完成这项任务。这是一项要求很高但最终令人满意的活动。

总的来说,我们的工作如下:

  1. 在整个代码库中搜索存在于Transactions类之外的Gateway类的用法。
  2. 在我们找到Gateway用法的地方,检查Gateway操作周围的逻辑,以发现该逻辑的哪些部分与应用程序的域行为相关。
  3. 将相关领域逻辑提取到与域元素相关的一个或多个Transactions类中,并修改原始代码以使用Transactions类而不是嵌入式领域逻辑。
  4. 抽查确保原始代码仍然正常工作,并根据需要修改提取的逻辑以确保正确操作。
  5. 为提取的Transactions逻辑编写测试,与测试代码一起细化,直到通过测试。
  6. 当所有原始测试和新测试通过时,提交代码和测试,推送到公共存储库,并通知 QA。
  7. 再次搜索Gateway类的用法,继续提取领域逻辑,直到Gateway用法只存在于Transactions中。

搜索网关的用途

与前面的章节一样,我们使用项目范围内的搜索工具查找我们在何处创建Gateway类的新实例:

搜索:

new .*Gateway

新的Gateway实例可以直接在页面脚本中使用,在这种情况下,我们找到了一些用于提取领域逻辑的候选代码。如果Gateway实例被注入到一个类中,我们现在需要深入该类以找到Gateway的使用位置。围绕该用法的代码将是我们提取领域逻辑的候选代码。

发现并提取相关领域逻辑

提示

在将逻辑提取到类方法时,我们应该仔细学习前面章节中关于依赖项注入的所有经验教训。这意味着:不使用全局变量,用Request对象替换超全局变量,不在Factory类之外使用new关键字,以及(当然)根据需要通过构造函数注入对象。

在找到某个使用Gateway的候选代码后,我们需要检查这些和其他操作的Gateway用法周围的代码:

  • 数据的规范化、筛选、清理和验证
  • 数据的计算、修改、创建和操作
  • 使用数据的连续或并发操作和操作
  • 保留这些操作和操作的成功/失败/警告/通知消息
  • 为以后的输入和输出保留值和变量

这些和其他逻辑片段可能与域相关。

为了成功地将领域逻辑提取到一个或多个Transactions类和方法,我们必须执行以下活动和其他活动:

  • 分解或将提取的领域逻辑重新组织为支持方法
  • 分解或重新组织原始代码以围绕新的Transactions调用
  • 保留、返回或报告原始代码所需的数据
  • 在原始代码中添加、更改或删除与提取的领域逻辑相关的变量
  • Transactions类和方法创建和注入依赖项

发现和提取最好被认为是一种学习练习。像这样分离遗留应用程序是了解应用程序是如何构造的一种方法。因此,我们不应该害怕多次尝试提取。如果我们的第一次尝试失败了,结果很糟糕,或者结果很差,那么我们不应该因为放弃工作重新开始而感到内疚,因为我们已经学到了更多关于什么有效,什么无效的知识。就我自己而言,在工作完成到令我满意的程度之前,我经常在提取领域逻辑时经过两三次。这就是修订控制系统让我们的生活变得更加轻松的地方;我们可以零零碎碎地工作,只有当我们对结果感到满意时才做出承诺,如果我们需要从头开始,就回到早期阶段。

示例提取

通过示例,回想我们在附录 B中开始的代码,网关前的代码。在本章前面,我们提到我们已经将嵌入式 SQL 语句提取到ArticlesGateway类中,最后得到了附录 C中的代码,网关后的代码。现在我们从这里转到附录 D事务脚本后的代码,在这里我们将领域逻辑提取到一个ArticleTransactions类中。

提取的领域逻辑在其完整的形式中并不显得特别复杂,但实际工作证明是相当详细的。查看附录 C网关后代码,并与附录 D事务脚本后代码进行比较。除此之外,我们还应发现以下几点:

  • 我们发现在页面脚本中执行了两个单独的事务:一个提交新文章,另一个更新现有文章。反过来,这些操作都需要对数据库中用户的信用计数进行操作,以及各种数据规范化和支持操作。
  • 我们将相关的领域逻辑提取到一个ArticleTransactions类和两个独立的方法中,一个用于创建,一个用于更新。我们将ArticleTransactions方法命名为正在执行的领域逻辑,而不是底层技术操作的实现。
  • 输入过滤已被封装为ArticleTransactions类中的一个支持方法,以便跨两个事务方法重用。
  • 新的ArticleTransactions类接收ArticlesGatewayUsersGateway依赖项来管理数据库交互,而不是直接进行 SQL 调用。
  • 几个仅与领域逻辑相关的变量已从页面脚本中删除,并作为属性放入Transactions类中。
  • 原始页面脚本中的代码已大大减少。它现在本质上是一种对象创建和注入机制,将用户输入传递到域层,并将数据返回到以后的输出。
  • 由于领域逻辑现在已封装,原始代码在整个事务中修改时无法再看到$failure变量。该代码现在必须从ArticleTransactions类中获取故障信息,以便以后演示。

在提取之后,我们有一个classes/目录结构,如下所示。这是我们将 SQL 提取到Gateway类时使用面向域的类结构的结果:

/path/to/app/classes/
1 Domain/
2 Articles/
3 ArticlesGateway.php
4 ArticleTransactions.php
5 Users/
6 UsersGateway.php

这不一定是我们的最终重构。ArticleTransactions的进一步修改仍有可能。例如,与其注入一个UsersGateway,不如将与用户相关的各种领域逻辑提取到一个UserTransactions类中,然后注入该类。Transactions方法之间仍有大量重复。在Transactions方法中,我们还需要更好的错误检查和条件报告。这些重构和其他重构都是次要的,只有在领域逻辑的主要提取之后,它们才会更加引人注目,也更容易处理。

抽查剩余原代码

一旦我们从原始代码中提取了一个或多个事务,我们需要确保在使用事务而不是嵌入式领域逻辑时,原始代码能够正常工作。与前面一样,我们通过运行我们预先存在的特性测试来实现这一点。如果我们没有特性测试,我们必须浏览或以其他方式调用更改的代码。如果这些测试失败,我们将感到高兴!我们发现提取有缺陷,我们有机会在部署到生产之前修复它。如果“测试”通过,我们同样感到高兴,并继续前进。

对提取的事务进行写测试

我们现在知道原始代码与新提取的事务逻辑一起工作。然而,新的类和方法需要它们自己的测试集。与提取领域逻辑相关的所有其他内容一样,编写这些测试可能会非常详细和苛刻。逻辑可能很复杂,有很多分支和循环。我们不应该让这阻止我们进行测试。至少,我们需要编写涵盖领域逻辑主要情况的测试。

如有必要,我们可以重构提取的逻辑,以分离出本身更容易测试的方法。分解提取的逻辑将使我们更容易看到流程并找到重复的逻辑元素。但是,我们必须记住,我们的目标是维护现有的行为,而不是更改遗留应用程序呈现的行为。

提示

有关如何使提取的逻辑更易于测试的见解和技术,请参见重构http://refactoring.com/ Martin Fowler 等人的,以及有效处理遗留代码https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/01311 迈克尔·费瑟斯。

再次抽查、提交、推送、通知 QA

最后,由于我们对提取的事务逻辑的测试和相关重构可能引入了一些意外的更改,因此我们使用我们的特性测试或通过调用相关代码再次抽查原始代码。如果这些失败了,我们将欢欣鼓舞!我们发现我们的更改没有我们想象的那么好,我们有机会在代码和测试离我们太远之前纠正它们。

当原始代码测试和提取的事务测试都通过时,我们再次欢欣鼓舞!我们现在可以提交所有新的工作,将其推送到中央存储库,并通知 QA 我们的现代化代码已准备好供他们审查。

做。。。虽然

通过在事务类之外寻找另一个网关用法,我们重新开始提取过程。我们继续提取和测试,直到所有的网关调用都发生在事务类中。

常见问题

我们在谈论 SQL 事务吗?

术语事务脚本指的是架构模式,并不意味着领域逻辑必须包装在 SQL 事务中。这两种观点很容易混淆。

话虽如此,记住 SQL 事务可能有助于我们提取领域逻辑。一个有用的经验法则是,领域逻辑的各个部分应该根据它们在单个 SQL 事务中的适合程度进行划分。假设的事务将作为一个原子整体提交或回滚。

这种目的的单一性将帮助我们确定我们的领域逻辑的边界在哪里。我们实际上并没有添加 SQL 事务,只是用这些术语思考可以让我们对领域逻辑的边界有一些了解。

重复领域逻辑呢?

当我们将 SQL 语句提取到Gateway类时,我们有时会发现类似但不完全相同的查询。我们必须确定是否有办法将它们组合成一个单一的方法。

以同样的方式,我们可能会发现遗留领域逻辑的某些部分已被复制并粘贴到两个或多个位置。当我们发现这些问题时,我们与Gateway类有着同样的问题。这些逻辑片段是否足够相似,可以组合成一个方法,或者它们必须是不同的方法(或者甚至是完全不同的Transactions

答案是这要看情况而定。在某些情况下,重复的代码显然是其他地方逻辑的副本,这意味着我们可以重用现有的Transactions方法。如果没有,我们需要提取到一个新的Transactions类或方法。

也有一条中间路径,其中领域逻辑作为一个整体是不同的,但是在不同的Transactions之间有相同的逻辑支持元素。在这些情况下,我们可以将支持逻辑重构为抽象基础Transactions类上的方法,然后从中扩展新的Transactions。或者,我们可以将逻辑提取到支持类并将其注入到我们的Transactions中。

打印和回显是领域逻辑的一部分吗?

我们的Transactions类不应该使用printecho。领域逻辑应该只返回或保留数据。

当我们在领域逻辑的中间发现输出生成时,我们应该提取该部分,使其位于领域逻辑之外。一般来说,这意味着在Transactions类中收集输出,然后返回或通过单独的方法使其可用。为表示层保留输出生成。

事务可以是类而不是方法吗?

在示例中,我们将事务显示为与特定域实体相关的方法集合,例如ArticleTransactions。与该实体相关的领域逻辑的每个部分都包装在一个类方法中。

然而,将领域逻辑分解为每个事务一个类的结构也是合理的。事实上,有些事务可能非常复杂,它们确实需要自己的独立类。使用单个类来表示单个领域逻辑事务没有错。

例如,早期的ArticleTransactions类可能被拆分为一个带有支持方法的抽象基类,以及两个具体的类,分别用于提取的领域逻辑片段。每个具体类都扩展了AbstractArticleTransaction,如下所示:

classes/
1 Domain/
2 Articles/
3 ArticlesGateway.php
4 Transaction/
5 AbstractArticleTransaction.php
6 SubmitNewArticleTransaction.php
7 UpdateExistingArticleTransaction.php
8 Users/
9 UsersGateway.php

如果我们使用每个事务一个类的方法,那么我们如何命名单个事务类上的主方法,即实际执行事务的方法?如果对于遗留代码库中已经存在的主要方法有一个通用约定,那么我们应该遵守该约定。否则,我们需要选择一个一致的方法名。就个人而言,我喜欢为此选择__invoke()魔术方法,但您可能希望使用exec()或其他适当的术语来表示我们正在执行或以其他方式执行事务。

网关类中的领域逻辑是什么?

当我们将 SQL 语句提取到Gateway类中时,可能是我们将一些领域逻辑移到了它们中,而不是将该逻辑保留在其原始位置。在重构工作的早期阶段,很容易将域级输入过滤(确保数据符合特定于域的状态)与数据库级过滤(确保数据可以安全地与数据库一起使用)混淆。

现在我们可以更容易地区分两者之间的区别。如果我们发现我们的Gateway类中存在域级逻辑,我们可能应该将其提取到Transactions类中。我们还需要确保更新相关测试。

非域类中嵌入的领域逻辑是什么?

本章中的示例展示了嵌入在页面脚本中的领域逻辑。我们也很可能在类中嵌入了领域逻辑。如果可以合理地将该类视为域的一部分,并且只包含与域相关的逻辑,但不以域命名,那么将该类移动到域命名空间中可能是明智的。

否则,如果该类具有除领域逻辑以外的任何职责,我们可以按照从页面脚本提取逻辑的相同方式继续从中提取领域逻辑。提取之后,原始类将需要将相关的Transactions类作为依赖项注入。然后,原始类应该根据需要调用Transactions

回顾和下一步

此时,我们已经将遗留代码库的核心(位于应用程序中心的领域逻辑)提取到它自己的独立可测试层。这是我们现代化进程中要求最高的步骤,但它非常值得我们花费时间。我们并没有对领域逻辑本身进行太多的修改或改进。我们所做的任何更改都足以将数据输入到新的Transactions类中,然后再次输出以供以后使用。

在很多方面,我们所做的只是改变逻辑,使其能够独立寻址。尽管领域逻辑本身可能仍然存在许多问题,但这些问题现在是可测试问题。我们可以根据需要继续添加测试,以探索领域逻辑中的边缘情况。如果我们需要添加新的领域逻辑,我们可以创建或修改Transactions类和方法来封装和测试该逻辑。

将领域逻辑提取到其自身层的过程,为进一步的域模型的迭代重构提供了良好的基础。如果我们选择继续这样做,重构将引导我们为应用程序领域逻辑找到更合适的体系结构。但是,该体系结构将取决于应用程序。有关为我们的应用程序开发良好领域模型的更多信息,请阅读领域驱动设计https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215 作者:埃里克·埃文斯。

通过将领域逻辑提取到它自己的层,我们可以继续进入现代化进程的下一阶段。在这一点上,我们的原始代码中只剩下一些关注点。在这些问题中,我们接下来将讨论表示层。