wip-7.4
zero7.4 Handling conditional logic in controllers
7.4 处理控制器中的条件逻辑
Handling conditional logic and simultaneously maintaining the domain layer free of out-of-process collaborators is often tricky and involves trade-offs. In this section, I’ll show what those trade-offs are and how to decide which of them to choose in your own project.
处理条件逻辑,同时又让领域层保持不含进程外协作者,通常很棘手,并且涉及取舍。本节中,我会说明这些取舍是什么,以及如何在你自己的项目中决定选择哪一种。
The separation between business logic and orchestration works best when a business operation has three distinct stages:
当一个业务操作具有三个清晰阶段时,业务逻辑与编排之间的分离效果最好:
- Retrieving data from storage
从存储中检索数据。 - Executing business logic
执行业务逻辑。 - Persisting data back to the storage (figure 7.10)
把数据持久化回存储(图 7.10)。
There are a lot of situations where these stages aren’t as clearcut, though. As we discussed in chapter 6, you might need to query additional data from an out-of-process dependency based on an intermediate result of the decision-making process (figure 7.11). Writing to the out-of-process dependency often depends on that result, too.
不过,在很多情况下,这些阶段并没有这么清晰。正如第 6 章讨论过的,你可能需要根据决策过程中的中间结果,从进程外依赖查询额外数据(图 7.11)。写入进程外依赖通常也取决于这个结果。
As also discussed in the previous chapter, you have three options in such a situation:
同样如上一章所讨论的,在这种情况下你有三个选择:
- Push all external reads and writes to the edges anyway. This approach preserves the read-decide-act structure but concedes performance: the controller will call out-of-process dependencies even when there’s no need for that.
无论如何都把所有外部读写推到边缘。这个方法保留了“读取—决策—执行”结构,但牺牲了性能:即使没有必要,控制器也会调用进程外依赖。 - Inject the out-of-process dependencies into the domain model and allow the business logic to directly decide when to call those dependencies.
把进程外依赖注入领域模型,让业务逻辑直接决定何时调用这些依赖。 - Split the decision-making process into more granular steps and have the controller act on each of those steps separately.
把决策过程拆分为更细粒度的步骤,并让控制器分别对每个步骤采取行动。
The challenge is to balance the following three attributes:
挑战在于平衡以下三个属性:
- Domain model testability, which is a function of the number and type of collaborators in domain classes
领域模型可测试性,它是领域类中协作者数量和类型的函数。 - Controller simplicity, which depends on the presence of decision-making (branching) points in the controller
控制器简单性,它取决于控制器中是否存在决策(分支)点。 - Performance, as defined by the number of calls to out-of-process dependencies
性能,它由对进程外依赖的调用次数定义。
Each option only gives you two out of the three attributes (figure 7.12):
每个选项都只能让你在三个属性中获得两个(图 7.12):
- Pushing all external reads and writes to the edges of a business operation—Preserves controller simplicity and keeps the domain model isolated from out-of-process dependencies (thus allowing it to remain testable) but concedes performance.
把所有外部读写推到业务操作边缘——保留控制器简单性,并让领域模型与进程外依赖隔离(因此保持可测试),但牺牲性能。 - Injecting out-of-process dependencies into the domain model—Keeps performance and the controller’s simplicity intact but damages domain model testability.
把进程外依赖注入领域模型——保持性能和控制器简单性,但损害领域模型可测试性。 - Splitting the decision-making process into more granular steps—Helps with both performance and domain model testability but concedes controller simplicity. You’ll need to introduce decision-making points in the controller in order to manage these granular steps.
把决策过程拆分为更细粒度的步骤——有助于性能和领域模型可测试性,但牺牲控制器简单性。你需要在控制器中引入决策点,才能管理这些细粒度步骤。
In most software projects, performance is important, so the first approach (pushing external reads and writes to the edges of a business operation) is out of the question. The second option (injecting out-of-process dependencies into the domain model) brings most of your code into the overcomplicated quadrant on the types-of-code diagram. This is exactly what we refactored the initial CRM implementation away from. I recommend that you avoid this approach: such code no longer preserves the separation between business logic and communication with out-of-process dependencies and thus becomes much harder to test and maintain.
在大多数软件项目中,性能很重要,因此第一种方法(把外部读写推到业务操作边缘)通常不可取。第二种选择(把进程外依赖注入领域模型)会把大部分代码带入代码类型图中的过度复杂象限。这正是我们把初始 CRM 实现重构掉的原因。我建议你避免这种方法:这类代码不再保持业务逻辑与进程外依赖通信之间的分离,因此会变得更难测试和维护。
That leaves you with the third option: splitting the decision-making process into smaller steps. With this approach, you will have to make your controllers more complex, which will also push them closer to the overcomplicated quadrant. But there are ways to mitigate this problem. Although you will rarely be able to factor all the complexity out of controllers as we did previously in the sample project, you can keep that complexity manageable.
剩下的就是第三种选择:把决策过程拆分为更小的步骤。使用这种方法,你必须让控制器更复杂,这也会把它们推向过度复杂象限。但有一些方法可以缓解这个问题。虽然你很少能像前面示例项目那样,把控制器中的所有复杂度都提取出去,但你可以让这些复杂度保持可管理。
7.4.1 Using the CanExecute/Execute pattern
7.4.1 使用 CanExecute/Execute 模式
The first way to mitigate the growth of the controllers’ complexity is to use the CanExecute/Execute pattern, which helps avoid leaking of business logic from the domain model to controllers. This pattern is best explained with an example, so let’s expand on our sample project.
缓解控制器复杂度增长的第一种方法,是使用 CanExecute/Execute 模式,它有助于避免业务逻辑从领域模型泄漏到控制器。这个模式最适合通过示例解释,所以我们继续扩展示例项目。
Let’s say that a user can change their email only until they confirm it. If a user tries to change the email after the confirmation, they should be shown an error message. To accommodate this new requirement, we’ll add a new property to the User class.
假设用户只能在确认邮箱之前修改邮箱。如果用户在确认之后尝试修改邮箱,系统应该向他们显示错误消息。为了适应这个新需求,我们会给 User 类添加一个新属性。
There are two options for where to put this check. First, you could put it in User’s ChangeEmail method:
关于把这个检查放在哪里,有两个选择。首先,你可以把它放进 User 的 ChangeEmail 方法:
public string ChangeEmail(string newEmail, Company company)
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
/* the rest of the method */
}Then you could make the controller either return an error or incur all necessary side effects, depending on this method’s output.
然后你可以让控制器根据这个方法的输出,要么返回错误,要么产生所有必要的副作用。
This implementation keeps the controller free of decision-making, but it does so at the expense of a performance drawback. The Company instance is retrieved from the database unconditionally, even when the email is confirmed and thus can’t be changed. This is an example of pushing all external reads and writes to the edges of a business operation.
这个实现让控制器不包含决策逻辑,但代价是性能缺陷。即使邮箱已经确认、因此无法修改,Company 实例仍然会无条件地从数据库中检索出来。这就是把所有外部读写推到业务操作边缘的例子。
NOTE I don’t consider the new if statement analyzing the error string an increase in complexity because it belongs to the acting phase; it’s not part of the decision-making process. All the decisions are made by the User class, and the controller merely acts on those decisions.
注意 我不认为这个分析错误字符串的新 if 语句增加了复杂度,因为它属于执行阶段;它不是决策过程的一部分。所有决策都由 User 类做出,控制器只是根据这些决策采取行动。
The second option is to move the check for IsEmailConfirmed from User to the controller.
第二种选择是把对 IsEmailConfirmed 的检查从 User 移到控制器。
With this implementation, the performance stays intact: the Company instance is retrieved from the database only after it is certain that the email can be changed. But now the decision-making process is split into two parts:
使用这个实现,性能保持不变:只有在确定邮箱可以修改之后,才会从数据库中检索 Company 实例。但现在决策过程被拆成了两部分:
- Whether to proceed with the change of email (performed by the controller)
是否继续修改邮箱(由控制器执行)。 - What to do during that change (performed by User)
修改过程中要做什么(由 User 执行)。
Now it’s also possible to change the email without verifying the IsEmailConfirmed flag first, which diminishes the domain model’s encapsulation. Such fragmentation hinders the separation between business logic and orchestration and moves the controller closer to the overcomplicated danger zone.
现在,也可以在不先验证 IsEmailConfirmed 标志的情况下修改邮箱,这削弱了领域模型的封装性。这种碎片化妨碍了业务逻辑与编排之间的分离,并把控制器推近过度复杂的危险区域。
To prevent this fragmentation, you can introduce a new method in User, CanChangeEmail(), and make its successful execution a precondition for changing an email. The modified version in the following listing follows the CanExecute/Execute pattern.
为了防止这种碎片化,你可以在 User 中引入一个新方法 CanChangeEmail(),并把它成功执行作为修改邮箱的前置条件。下面代码清单中的修改版本遵循 CanExecute/Execute 模式。
This approach provides two important benefits:
这种方法提供两个重要好处:
- The controller no longer needs to know anything about the process of changing emails. All it needs to do is call the CanChangeEmail() method to see if the operation can be done. Notice that this method can contain multiple validations, all encapsulated away from the controller.
控制器不再需要了解修改邮箱过程中的任何细节。它只需要调用 CanChangeEmail() 方法,查看操作是否可以执行。注意,这个方法可以包含多个校验,并且这些校验都封装在控制器之外。 - The additional precondition in ChangeEmail() guarantees that the email won’t ever be changed without checking for the confirmation first.
ChangeEmail() 中额外的前置条件保证了邮箱永远不会在未先检查确认状态的情况下被修改。
This pattern helps you to consolidate all decisions in the domain layer. The controller no longer has an option not to check for the email confirmation, which essentially eliminates the new decision-making point from that controller. Thus, although the controller still contains the if statement calling CanChangeEmail(), you don’t need to test that if statement. Unit testing the precondition in the User class itself is enough.
这个模式帮助你把所有决策集中到领域层。控制器不再有“不检查邮箱确认状态”的选择,这本质上消除了控制器中的新决策点。因此,虽然控制器仍然包含调用 CanChangeEmail() 的 if 语句,你也不需要测试这个 if 语句。对 User 类自身的前置条件进行单元测试就足够了。
NOTE For simplicity’s sake, I’m using a string to denote an error. In a real-world project, you may want to introduce a custom Result class to indicate the success or failure of an operation.
注意 为简单起见,我使用字符串表示错误。在真实项目中,你可能会想引入一个自定义 Result 类,用来表示操作成功或失败。
7.4.2 Using domain events to track changes in the domain model
7.4.2 使用领域事件跟踪领域模型中的变化
It’s sometimes hard to deduct what steps led the domain model to the current state. Still, it might be important to know these steps because you need to inform external systems about what exactly has happened in your application. Putting this responsibility on the controllers would make them more complicated. To avoid that, you can track important changes in the domain model and then convert those changes into calls to out-of-process dependencies after the business operation is complete. Domain events help you implement such tracking.
有时很难推断出哪些步骤让领域模型进入了当前状态。但了解这些步骤可能很重要,因为你需要告知外部系统应用程序中究竟发生了什么。把这个职责放到控制器上会让控制器变得更复杂。为了避免这一点,你可以在领域模型中跟踪重要变化,然后在业务操作完成之后,把这些变化转换为对进程外依赖的调用。领域事件可以帮助你实现这种跟踪。
DEFINITION A domain event describes an event in the application that is meaningful to domain experts. The meaningfulness for domain experts is what differentiates domain events from regular events (such as button clicks). Domain events are often used to inform external applications about important changes that have happened in your system.
定义 领域事件描述的是应用程序中对领域专家有意义的事件。对领域专家有意义,正是领域事件区别于普通事件(例如按钮点击)的地方。领域事件常用于告知外部应用:你的系统中已经发生了重要变化。
Our CRM has a tracking requirement, too: it has to notify external systems about changed user emails by sending messages to the message bus. The current implementation has a flaw in the notification functionality: it sends messages even when the email is not changed, as shown in the following listing.
我们的 CRM 也有一个跟踪需求:它必须通过向消息总线发送消息,通知外部系统用户邮箱发生了变化。当前实现的通知功能有一个缺陷:即使邮箱没有变化,它也会发送消息,如下面代码清单所示。
You could resolve this bug by moving the check for email sameness to the controller, but then again, there are issues with the business logic fragmentation. And you can’t put this check to CanChangeEmail() because the application shouldn’t return an error if the new email is the same as the old one.
你可以通过把“邮箱是否相同”的检查移到控制器中来解决这个 bug,但这样又会出现业务逻辑碎片化的问题。而且你不能把这个检查放进 CanChangeEmail(),因为如果新邮箱与旧邮箱相同,应用程序不应该返回错误。
Note that this particular check probably doesn’t introduce too much business logic fragmentation, so I personally wouldn’t consider the controller overcomplicated if it contained that check. But you may find yourself in a more difficult situation in which it’s hard to prevent your application from making unnecessary calls to out-of-process dependencies without passing those dependencies to the domain model, thus overcomplicating that domain model. The only way to prevent such overcomplication is the use of domain events.
注意,这个特定检查可能不会引入太多业务逻辑碎片化,所以如果控制器包含这个检查,我个人并不会认为它过度复杂。但你可能会遇到更困难的情况:如果不把进程外依赖传给领域模型,就很难阻止应用程序对进程外依赖进行不必要调用,而一旦传入,又会让领域模型过度复杂。防止这种过度复杂的唯一方式,就是使用领域事件。
From an implementation standpoint, a domain event is a class that contains data needed to notify external systems. In our specific example, it is the user’s ID and email:
从实现角度看,领域事件是一个包含通知外部系统所需数据的类。在我们的具体示例中,这些数据是用户 ID 和邮箱:
public class EmailChangedEvent
{
public int UserId { get; }
public string NewEmail { get; }
}NOTE Domain events should always be named in the past tense because they represent things that already happened. Domain events are values—they are immutable and interchangeable.
注意 领域事件应该始终使用过去时命名,因为它们代表已经发生的事情。领域事件是值——它们是不可变且可互换的。
User will have a collection of such events to which it will add a new element when the email changes. This is how its ChangeEmail() method looks after the refactoring.
User 会拥有一个这样的事件集合;当邮箱发生变化时,它会向集合中添加一个新元素。重构后,它的 ChangeEmail() 方法如下所示。
The controller then will convert the events into messages on the bus.
然后,控制器会把这些事件转换为消息总线上的消息。
Notice that the Company and User instances are still persisted in the database unconditionally: the persistence logic doesn’t depend on domain events. This is due to the difference between changes in the database and messages in the bus.
注意,Company 和 User 实例仍然会无条件持久化到数据库:持久化逻辑不依赖领域事件。这是因为数据库中的变化和消息总线中的消息之间存在差异。
Assuming that no application has access to the database other than the CRM, communications with that database are not part of the CRM’s observable behavior—they are implementation details. As long as the final state of the database is correct, it doesn’t matter how many calls your application makes to that database. On the other hand, communications with the message bus are part of the application’s observable behavior. In order to maintain the contract with external systems, the CRM should put messages on the bus only when the email changes.
假设除了 CRM 之外,没有其他应用可以访问数据库,那么与该数据库的通信就不是 CRM 的可观察行为——它们是实现细节。只要数据库的最终状态正确,应用程序向数据库发起多少次调用并不重要。另一方面,与消息总线的通信是应用程序可观察行为的一部分。为了维持与外部系统的契约,CRM 只有在邮箱发生变化时才应该把消息放到总线上。
There are performance implications to persisting data in the database unconditionally, but they are relatively insignificant. The chances that after all the validations the new email is the same as the old one are quite small. The use of an ORM can also help. Most ORMs won’t make a round trip to the database if there are no changes to the object state.
无条件把数据持久化到数据库确实有性能影响,但这种影响相对较小。在所有校验之后,新邮箱仍然与旧邮箱相同的概率很低。使用 ORM 也会有所帮助。大多数 ORM 在对象状态没有变化时,不会往返访问数据库。
You can generalize the solution with domain events: extract a DomainEvent base class and introduce a base class for all domain classes, which would contain a collection of such events: List<DomainEvent> events. You can also write a separate event dispatcher instead of dispatching domain events manually in controllers. Finally, in larger projects, you might need a mechanism for merging domain events before dispatching them. That topic is outside the scope of this book, though. You can read about it in my article “Merging domain events before dispatching” at http://mng.bz/YeVe.
你可以把基于领域事件的解决方案泛化:提取一个 DomainEvent 基类,并为所有领域类引入一个基类,其中包含这类事件的集合:List<DomainEvent> events。你也可以编写一个单独的事件分发器,而不是在控制器中手动分发领域事件。最后,在更大的项目中,你可能需要一种在分发前合并领域事件的机制。不过,这个主题超出了本书范围。你可以阅读我的文章 “Merging domain events before dispatching”:http://mng.bz/YeVe。
Domain events remove the decision-making responsibility from the controller and put that responsibility into the domain model, thus simplifying unit testing communications with external systems. Instead of verifying the controller itself and using mocks to substitute out-of-process dependencies, you can test the domain event creation directly in unit tests, as shown next.
领域事件把决策职责从控制器中移除,并放入领域模型,从而简化了对外部系统通信的单元测试。你不必验证控制器本身,也不必使用 mock 替代进程外依赖,而是可以在单元测试中直接测试领域事件的创建,如下所示。
Of course, you’ll still need to test the controller to make sure it does the orchestration correctly, but doing so requires a much smaller set of tests. That’s the topic of the next chapter.
当然,你仍然需要测试控制器,以确保它正确完成编排,但这样做只需要小得多的一组测试。这就是下一章的主题。