通过在状态转换表中引入数据约束条件(如配送地址白名单),在不破坏状态机清晰结构的前提下实现条件判断,兼顾可维护性与表达能力。
我们的生命周期现在有一张显式的转换表。对于一个有效的订单,它告诉我们:
操作在进行更改前会查询该表。列出可用操作的代码也会查询它。
但第三部分以另一个需求结束:

考虑两个订单。两者都已付款,并且都保留了已捕获的支付记录:
两条记录都满足我们现有的不变量。两者都到达了同一张转换表条目。
但发货请求应该只对其中一个成功。
该表知道订单处于生命周期的哪个阶段。它尚未考虑决定是否允许发货所需的所有信息。
如何在不丢失我们刚刚获得的清晰度的情况下扩展它?
从最小的附加检查开始
我们已经有了 delivery_address 字段。我们不需要更改订单模型来检查它。
在这个例子中,让我们使用一个刻意小的、固定规则:"Istanbul" 和 "Izmir" 是支持的目的地标签。其他所有字符串都是不支持的。
这是一个说明性策略,不是关于真实承运商覆盖范围的声明。我们也将现有字符串作为目的地标签处理,而不是实现邮政编码解析。
def has_supported_address(order: Order) -> bool:
return order.delivery_address in {"Istanbul", "Izmir"}
我们可以从发货操作中调用这个函数:
def ship_order(order: Order) -> None:
validate_order(order)
target = next_state(
order.status,
OrderCommand.SHIP_ORDER,
)
if not has_supported_address(order):
raise InvalidOrderOperation(
"The delivery address is not supported."
)
order.status = target
这是一个合理的第一版修改。该函数仍然会拒绝无效记录和无效的生命周期移动。它现在也会在更改订单之前拒绝不支持的目的地。
但 available_actions() 会报告什么?
它在第三部分中的实现检查转换表是否包含条目。对于这两个已付款订单,它找到了:
PAID + SHIP_ORDER → SHIPPED
因此它为两者都提供了发货选项。
发货函数获得了一个操作列表不知道的规则。我们可以将地址检查复制到列表中,但那会重现我们刚刚移除的重复。
新条件属于它所控制的转换。
一个启用转换的条件
决定某个转换是否有资格的条件称为护栏(guard)。状态机 notation 通常将触发输入与必须同时满足的布尔条件区分开来。[1]
对于我们的发货转换:
PAID
-- SHIP_ORDER [has_supported_address] -->
SHIPPED
命令请求更改。护栏决定这个特定订单是否可以执行该转换。
我们的规则现在有三个独立的要求:
现有订单必须满足其不变量。
其当前状态必须允许请求的命令。
转换的护栏必须通过。
只有满足以上条件,操作才能进行。
注意,地址检查并没有取代支付检查。支持的地址不会使未付款的订单有资格发货。
不支持的目的地不会使订单变得不一致
我们应该把 has_supported_address() 放到 validate_order() 中吗?
对于我们选择的规则,不要这样做。
一个具有不支持目的地的已付款订单仍然可以是一条一致的记录。客户已付款;已捕获的支付已记录;交货目的地已知。订单在当前策略下不能做的是执行发货转换。
不变量询问记录是否有意义。
护栏询问某个特定转换现在是否允许。
还有另一个原因不要混淆它们。如果以后发货覆盖范围发生变化,这不一定会使已发货的订单变得无效。在操作开始时检查的条件不一定是一个必须永远保持为真的条件。
对于这部分,覆盖范围保持不变。但这个区别仍然有用。
我们需要两种付费状态吗?
另一种可能的解决方案是拆分 PAID:
PAID_WITH_SUPPORTED_ADDRESS
PAID_WITH_UNSUPPORTED_ADDRESS
这可以表示区别,但这会使生命周期负责携带支持数据中已经可用的信息。
现在假设我们还区分三个支付提供商。我们可以开始创建如下组合:
PAID_WITH_SUPPORTED_ADDRESS_PROVIDER_A
PAID_WITH_UNSUPPORTED_ADDRESS_PROVIDER_A
PAID_WITH_SUPPORTED_ADDRESS_PROVIDER_B
然后添加另一个条件。
每个独立维度都会乘以我们可能需要命名的组合数量。大多数这些名称描述的是数据组合而不是有用的生命周期阶段。
更多的状态本身并没有错。当单独的状态代表一个不同阶段并具有其自己的允许操作时,它可能是有用的。但我们当前的需求没有引入另一个阶段。它为现有转换添加了一个条件。
两个订单都仍然是已付款状态。
它们的支持数据决定了它们是否可以发货。
将条件与转换保持在一起
我们的表当前将状态-命令对直接映射到目标状态。让我们为每个条目提供空间来描述可选的护栏。
OrderState、OrderCommand、Order 和 Payment 的定义保持不变。
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass(frozen=True)
class Transition:
target: OrderState
guard: Optional[Callable[[Order], bool]] = None
rejection_reason: str = "Transition conditions are not satisfied."
def is_enabled(self, order: Order) -> bool:
return self.guard is None or self.guard(order)
guard 要么不存在,要么是一个接收订单并返回布尔结果的函数。
不存在的护栏意味着此条目没有额外的数据依赖条件。这并不意味着应该跳过记录验证或命令参数验证。
frozen=True 设置防止在构造后将普通赋值分配给转换定义字段。它不会使传递给其护栏的订单变得不可变。[2]
现在替换之前的表:
from types import MappingProxyType
from typing import Mapping
TRANSITIONS: Mapping[
tuple[OrderState, OrderCommand],
Transition,
] = MappingProxyType({
(
OrderState.CREATED,
OrderCommand.CAPTURE_PAYMENT,
): Transition(
target=OrderState.PAID,
),
(
OrderState.PAID,
OrderCommand.SHIP_ORDER,
): Transition(
target=OrderState.SHIPPED,
guard=has_supported_address,
rejection_reason="The delivery address is not supported.",
),
})
生命周期仍然很小:
我们没有添加另一个订单状态。
我们使一个转换更加精确。
使用订单选择下一个状态
选择函数需要访问支持数据,因此其签名必须更改。
之前,它只接收一个状态:
next_state(order.status, command)
现在它将接收订单:
next_state(order, command)
以下定义替换了第三部分中的版本:
def next_state(
order: Order,
command: OrderCommand,
) -> OrderState:
validate_order(order)
if not isinstance(command, OrderCommand):
raise TypeError(
"command must be an OrderCommand member."
)
try:
transition = TRANSITIONS[(order.status, command)]
except KeyError:
raise InvalidOrderOperation(
f"Cannot {command.value} "
f"while the order is {order.status.value!r}."
) from None
if not transition.is_enabled(order):
raise InvalidOrderOperation(
transition.rejection_reason
)
return transition.target
这个顺序是经过深思熟虑的。
首先,使用第三部分中不变的验证器验证记录。已创建的订单不能有已捕获的支付,而已付款或已发货的订单必须包含一个 Payment 记录。
然后找到当前状态和命令的转换。
最后,评估该转换的可选护栏。
函数返回一个目标或抛出异常。它仍然不更改订单。
缺失的转换和失败的护栏都会拒绝操作,但它们解释的是不同的失败:
生命周期中不包含这样的移动。
移动存在,但此订单当前不满足其条件。
我们将这些解释分开,而不发明额外的生命周期状态。
更新操作以使用新的选择器
因为 next_state() 现在会验证订单本身,操作不需要重复该调用。
用以下代码替换两个操作:
def capture_payment(order: Order, payment: Payment) -> None:
target = next_state(
order,
OrderCommand.CAPTURE_PAYMENT,
)
地址条件不再出现在 `ship_order()` 内部。它被附加到了 shipping 转换上。
支付捕获仍然检查其提供的参数并存储它。我们并没有让发货覆盖成为记录支付的前置条件。那将是另一条业务规则,而不是我们应该意外引入的后果。
预期的拒绝检查仍在变更前执行。
这些操作的职责范围也保持了之前的划分:支付捕获记录提供的 Payment,发货改变本地生命周期状态。两者都不会调用外部服务。
让读路径执行相同的护栏检查
现在更新操作列表:
```python
def available_actions(order: Order) -> tuple[OrderCommand, ...]:
validate_order(order)
actions: list[OrderCommand] = []
for command in OrderCommand:
transition = TRANSITIONS.get(
(order.status, command)
)
if transition is not None:
if transition.is_enabled(order):
actions.append(command)
return tuple(actions)
列表和写路径现在使用相同的转换定义和相同的护栏评估。
对于一个有效的已支付订单但地址不支持,发货既不会被展示也不会被执行。
这比仅仅共享状态-命令表更强大。两个读者现在都考虑了控制转换的支撑数据。
列表仍然是一份资格报告,而不是绕过操作的许可。它不会验证尚未提供的支付参数、建立用户授权、或将订单预留以防止后续变更。
操作在被调用时仍须进行检查。
护栏应该检查,而不是执行操作
因为读路径和写路径都会评估护栏,其行为必须是可预测的。
我们的地址护栏读取一个字段并返回结果:
def has_supported_address(order: Order) -> bool:
return order.delivery_address in {"Istanbul", "Izmir"}
用相同的地址再次调用它会产生相同的结果。它不会改变订单或联系其他系统。
这是我们对护栏的期望契约:在不改变条件或周围环境的情况下评估条件。SCXML 同样为其数据模型语言指定了无副作用的条件表达式。[1]
假设护栏改为预订一个货运来发现发货是否可能。
简单地展示可用操作就可能预订一个货运。调用 ship_order() 会再次评估护栏,并可能再预订一个。
"我可以做这个吗?"这个问题会执行被询问的事情。
同样,护栏不应该更新地址来使其自身条件通过。地址纠正是独立操作,有其自身的规则。
我们的可调用注解无法证明护栏是纯函数。这仍然是我们必须维护和测试的实现契约。小的地址谓词满足它;任意替换可能不满足。
护栏失败也与护栏错误不同。返回 False 意味着条件被评估但不满足。异常意味着评估失败。我们的代码不会捕获任意异常并假装业务只是拒绝了请求。
命令参数和操作放在哪里?
并非所有操作使用的数据都已存储在订单上。
capture_payment(order, payment)
order 是现有记录。payment 参数是随命令提供的输入数据。它只在操作接受后才成为订单的支撑数据。
这给了我们整体决策的三个不同输入:
![[lifecycle-command-execution-inputs.png]]
我们当前的护栏接口只接收订单,因为新的发货条件只需要已存储的数据。支付参数检查仍留在 capture_payment() 中。
我们不应该声称每个条件都已移入转换表。生命周期移动和发货护栏已移到那里。命令特定的输入验证还没有。
检查通过后,操作执行其操作。
对于支付捕获,这些操作是:
order.payment = payment
order.status = target
order.status = target
这些是本地数据更新。更完整的状态机表示法可以将更新或其他可执行操作与转换关联;例如 SCXML 将转换条件与可执行内容和数据赋值分开。[1]
我们不需要操作注册表来展示这种区别。我们的领域函数已经展示了检查在哪里结束、更新从哪里开始。
我们扩展了什么?
我们从有限控制模型开始:
state + command → next state
我们现在允许支撑数据影响转换是否可以发生,并且我们的操作既更新生命周期状态也更新支撑数据。
这是扩展有限状态机(EFSM)的核心思想:有限控制状态与变量结合、条件启用转换、以及与接受输入相关联的更新。[3]
术语 context 通常用于机器 consulta 的支撑数据。在这里,它命名的是一种职责,而不是我们必须创建的新类。
我们已经有了一个包含这些信息的 Order。将每个字段移入 OrderContext 对象本身并不会改进这个例子。
"有限"这个词也有一个重要的限定。控制状态集仍然是有限的,但完整配置包含数据值。通用的 EFSM 定义可以允许无界的数据域,因此不需要描述有限的总体状态空间。[3]
对于我们的订单,PAID 是一个控制状态。PAID 与特定地址和支付记录的组合是一个更完整的配置。
保持图表小巧不会让这些数据依赖的区分消失。
为什么将值保持在状态名称之外?
这种分离早于应用程序工作流库。
在 1996 年的论文《使用扩展有限状态机模型自动生成功能向量》中,Kwang-Ting Cheng 和 A. S. Krishnakumar 使用 EFSM 从顺序电路描述中推导测试。他们将数据变量及其操作保持在紧凑模型中,而不是将每个组合明确命名 为控制状态。[3]
他们的问题是电路测试,不是发货订单。相关的联系是表示方式:在保留显式控制流的同时,通过条件和更新表达值依赖行为。
后来的 SCXML 标准通过其数据模型、转换条件和可执行内容使类似的分离可见。[1]
两个参考都不要求我们采用特定框架。它们帮助解释为什么扩展转换模型不同于无限扩展状态名称列表。
测试数据依赖的用例
第三部分测试了由三个状态和两个命令形成的六种组合。
这些用例仍然重要。但从 PAID 测试一次发货已不再足够。
地址现在将有效的已支付订单分为至少两个相关组:
第二行值得注意。它确认我们添加的是发货限制,而不是支付限制。
让我们练习新的拒绝路径:
from copy import deepcopy
payment = Payment(
method="credit_card",
provider="stripe",
payment_id="payment-81",
captured_amount=Decimal("120.00"),
)
blocked_order = Order(
id="order-68",
customer_name="Alice",
delivery_address="Ankara",
status=OrderState.PAID,
payment=payment,
)
validate_order(blocked_order)
before = deepcopy(blocked_order)
assert available_actions(blocked_order) == ()
assert available_actions(blocked_order) == ()
try:
ship_order(blocked_order)
except InvalidOrderOperation:
pass
else:
raise AssertionError(
"An unsupported destination was allowed to ship."
)
assert blocked_order == before
assert blocked_order.payment is payment
验证成功,因为订单内部是一致的。
操作列表不提供任何内容:支付捕获不再允许,发货被护栏阻止。
发货请求引发异常而不改变记录。
调用操作列表两次也不会改变它。这个检查有助于暴露在回答资格问题时改变订单的护栏。
现在使用单独的订单验证接受的路径:
supported_order = Order(
id="order-69",
customer_name="Alice",
delivery_address="Istanbul",
status=OrderState.PAID,
payment=payment,
)
assert available_actions(supported_order) == (
OrderCommand.SHIP_ORDER,
)
ship_order(supported_order)
ship_order(supported_order)
assert supported_order.status is OrderState.SHIPPED assert supported_order.payment is payment assert available_actions(supported_order) == ()
validate_order(supported_order)
既有的无效记录测试仍然必要。一条已支付但无付款记录的订单,在任何发货护栏被考虑之前仍然应该失败。
我们为测试增加了另一个维度,而非取代之前的测试。
## 机器仍然是确定性的
现在,两条已支付的订单可能对同一命令产生不同结果。这是否意味着机器是非确定性的?
不。我们只是向决策过程输入了更多信息。
对于相同的有效订单数据、命令和固定的地址策略,选择器对同一目标或拒绝结果的处理方式是一致的。
每个状态–命令对仍然最多只有一个转换定义。它的护栏要么启用该转换,要么阻止它。
如果我们后续允许同一对可以有多个候选转换,就需要另一条明确规则:它们的护栏必须能毫不含糊地区分各种情况,或者模型必须定义如何解决竞争匹配。
我们在这里没有引入这种分支。
一个被拒绝的发货请求也不意味着订单成功地从 PAID 状态回退到 PAID 状态。没有发生任何转换。请求被拒绝了,原始配置保持不变。
当我们最终为转换附加操作时,这个区别就很重要了。
## 更丰富的模型保护了什么——以及没有保护什么
我们的三状态生命周期保持不变。支付模型及其一致性规则,以及两个操作的模拟性质也都保持不变。
变化在于:现在声明的转换可以在被执行之前检查支撑数据。写路径和操作列表共享这一条件。
我们仍然依赖受控操作。直接赋值状态或更改地址可以绕过这些操作,就像第三部分中一样。
我们还仍然假设一次不中断的本地决策。next_state() 不会锁定订单、预留承运商或提交数据库事务。它返回的目标不是可重用的授权令牌。
改进是具体的:
一个有效的订单只有在当前数据条件也通过的情况下,才能执行声明的转换。
我们已经超越仅询问订单在哪里。我们现在可以询问记录的其余部分是否允许提议的移动。
但是,如果条件无法在本地回答呢?
我们的发货护栏可以立即回答,因为覆盖率是程序中的一个固定规则。
现在假设承运商必须确认是否能接受特定货物。答案可能稍后才到。请求可能失败。客户可能在等待期间发送另一个命令。
把这个交互放在 has_supported_address() 内部会破坏我们建立的边界:检查可用操作将执行外部工作。
这还会暴露一个更大的问题。
系统不再能从已有信息做出一个即时决策。它需要请求信息、保留它正在等待的内容,并在结果到达时继续。
一个已发送的请求不等于已确认的发货。
但是,机器如何等待稍后发生的事情,同时又不丢失在此期间允许做什么的跟踪?
[1] World Wide Web Consortium, State Chart XML: State Machine Notation for Control Abstraction, W3C Recommendation, 2015. 见 §3.5,"Transition";§4,"Executable Content";以及 §5,"Data Model and Data Manipulation"。
[2] Python Software Foundation, dataclasses — Data Classes。dataclass 字段的文档以及 frozen=True 的限制。
[3] Kwang-Ting Cheng and A. S. Krishnakumar, "Automatic Generation of Functional Vectors Using the Extended Finite State Machine Model", ACM Transactions on Design Automation of Electronic Systems, vol. 1, no. 1, pp. 57–79, 1996. 作者上传的稿件包含了模型的启用条件、更新以及符号状态与完整配置之间的区别。