首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >为什么在 PHP 中使用状态机?

为什么在 PHP 中使用状态机?

作者头像
Tinywan
发布2026-07-01 12:05:43
发布2026-07-01 12:05:43
850
举报
文章被收录于专栏:开源技术小栈开源技术小栈

概述

复杂的流程通常从简单的 if/else 语句开始,但很快就会变成难以阅读、测试和修改的纠缠逻辑。状态机为这种逻辑赋予了一个清晰的形状:您定义明确的的状态、可能发生的事件,以及允许(或禁止)从一个状态转移到另一个状态的规则。结果是可预测的、可调试的和安全的的行为。

为什么在 PHP 中使用状态机?

因为复杂的流程不会保持简单。原本只是几个标志和 if/else 检查的东西,很快就会变成矛盾的状态和隐藏的 bug。状态机将这种纠缠转化为一个明确的模型:有限的状态集、在它们之间移动的事件,以及使禁止转移不可能的规则(守卫)。

应用状态机模式的一些优势包括:

  • 清晰性:您可以在一个地方看到每一次允许的转移。
  • 安全性:禁止的移动(例如,在付款前退款)被设计时就阻塞了。
  • 可测试性:转移是纯函数的,易于单元测试。
  • 可观察性:每个状态变化都可以被记录和审计。

让我们看一个非常小的状态机示例:

代码语言:javascript
复制
// “纠缠”的逻辑
if ($order->paid && !$order->shipped && !$order->cancelled) {
    // ...
}
if ($order->shipped && $order->refunded) {
    // 不可能,但它可能发生
}

// 使用状态机
enum OrderState
{
    caseNew;
    case Paid;
    case Shipped;
    case Cancelled;
    case Refunded;
}

$transitions = [
    OrderState::New => [
        'pay' => OrderState::Paid,
        'cancel' => OrderState::Cancelled
    ],
    OrderState::Paid => [
        'ship' => OrderState::Shipped,
        'refund' => OrderState::Refunded
    ],
    OrderState::Shipped => [
        // 这里设计上不允许退款
    ],
];

状态机模式非常适合以下情况:

  • 您有 3 个或更多相互排斥的“阶段/状态”(已付款/已发货/已取消/已退款)。
  • 某些移动是被禁止的或有时限的(例如,在发货前取消)。
  • 您需要审计轨迹或可预测的副作用(电子邮件、网络钩子)。

核心概念

状态机由几个简单组件构建而成。

状态是您的对象可以处于的命名阶段(例如,新建、已付款、已发货)。

事件是发生的事情(付款、发货、取消)。

转移是规则,表示“从这个状态,当这个事件发生时,移动到那个状态”。

守卫是一个布尔规则,必须为 true 才能发生转移(例如,付款已授权)。

动作(或进入/退出效果)是在转移成功时触发的副作用,例如发送电子邮件或触发网络钩子。

保持状态机的核心是确定性的:给定相同的当前状态和事件,下一个状态是可预测的。将副作用放在边缘,这样转移决策保持纯函数,便于测试,并且安全地重复运行。

在编码前设计

抵抗直接跳入代码的冲动。从一个小设计文档和草图开始。将状态识别为名词(草稿、审核、已发布),事件识别为动词(提交、批准、发布)。先用纯语言写下规则。如果两个标志不能同时为真,那就是一个单一的状态。如果存在“有时允许”的规则,那就是您稍后要实现的守卫。

提取不变量并使它们明确。

代码语言:javascript
复制
“已发布的文章必须有 publish_at 在过去”
“只有作者可以更新草稿”
“您不能在文章发布后调度它”

这些将成为守卫和前提条件。捕获副作用,但放在边缘:

代码语言:javascript
复制
“在发布时,发送通知”
“在更新时,清除调度作业”

决定它们是否必须是幂等的(通常是的)以及部分失败时该做什么。

让我们看另一个快速示例,与订单无关。想象一个文章工作流,具有良好命名的状态和事件。保持名称简短且明确。

代码语言:javascript
复制
enum ArticleState
{
    case Draft;
    case InReview;
    case Scheduled;
    case Published;
    case Archived;
}

enum ArticleEvent
{
    case Submit;
    case Approve;
    case Schedule;
    case Publish;
    case Update;
    case Archive;
}

// 稍后要实现的样本不变量/守卫:
final readonly class ArticleContext
{
    publicfunction __construct(
        public int $authorId,
        public ?DateTimeImmutable $publishAt,
        public int $currentUserId,
        public bool $hasRequiredMeta,
    ) {}
}

// 用纯语言写下的规则,将转化为代码:
// - Draft --Submit--> InReview       (guard: hasRequiredMeta)
// - InReview --Approve--> Scheduled  (guard: publishAt in future) OR --> Published (guard: publishAt null or <= now)
// - Scheduled --Publish--> Published (guard: now >= publishAt)
// - Draft/InReview --Update--> Draft (guard: currentUserId === authorId)
// - Published --Archive--> Archived  (terminal)

决定如何持久化状态和历史。大多数系统需要在聚合上存储当前状态(article.state),并为审计和调试保留转移日志。如果并发是问题,计划使用版本列进行乐观锁定。如果涉及定时器,注意在哪里调度基于时间的转移(例如,队列或 cron,在 now >= publish_at 时发出 Publish)。

最后,检查复杂性。如果您有超过 7–9 个状态或事件,将工作流拆分为子机(例如,Editorial vs Lifecycle),以保持每个模型易懂。清晰的名称、明确的不变量和小图表将比您稍后编写的任何巧妙代码更能提升可靠性。

实现一个简单的状态机

保持您的模型明确且类型安全的。使用枚举来表示状态和事件,这样禁止的值无法偷偷进入。对于持久化,基于字符串的枚举很实用:数据库存储可读值,而您的代码享受严格类型。传递额外数据作为小的、不可变的值对象。只读上下文保持您的守卫诚实,并使测试简单。

转移需要清晰、快速的查找。对于小型状态机,match 表达式可以工作,但一旦您有多个转移,预计算的映射提供 O(1) 查找和单一真相来源。薄的 Transition 类改善可读性和工具支持。

守卫和动作是可调用函数,其中守卫决定移动是否允许,动作在成功移动后执行副作用。将动作放在边缘,这样决策逻辑保持纯函数和确定性的。

让我们回到订单示例,但现在创建一个完整但简单的状态机示例。

首先,我们从状态和事件开始。

代码语言:javascript
复制
enum OrderState: string
{
    case New = 'new';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}

enum OrderEvent: string
{
    case Pay = 'pay';
    case Ship = 'ship';
    case Cancel = 'cancel';
}

现在,如前所述,让我们构建一个简单的 Transition 类,它将处理状态之间的转移定义。为此,我们还需要创建一个“Context”类,这是一个简单的 DTO,包含转移所需的数据。

代码语言:javascript
复制
final readonly class Transition
{
    /**
     * @param null|callable(OrderContext):bool $guard
     * @param null|callable(OrderContext):void $action
     */
    public function __construct(
        public OrderState $to,
        public ?callable $guard = null,
        public ?callable $action = null,
    ) {}
}

final readonly class OrderContext
{
    public function __construct(
        public int $orderId,
        public int $amountCents,
        public int $authorizedCents,
        public \DateTimeImmutable $now = new \DateTimeImmutable(),
    ) {}
}

现在我们有了状态、事件、Transition 和 Context 的定义,让我们将它们全部粘合在一起,通过实现我们的 Order 类。

代码语言:javascript
复制
final class Order
{
    /** @var array<string, array<string, Transition>> */
    privatearray $rules;

    publicfunction __construct(
        public int $id,
        private OrderState $state = OrderState::New,
    ) {
        // 在这里预计算规则以实现 O(1) 查找
        $this->rules = $this->buildRules();
    }

    publicfunction state(): OrderState
    {
        return$this->state;
    }

    publicfunction apply(OrderEvent $event, OrderContext $context): void
    {
        $transition = $this->rules[$this->state->value][$event->value] ?? null;
        if (! $transition instanceof Transition) {
            thrownew RuntimeException("Invalid transition: {$this->state->value} + {$event->value}");
        }

        if ($transition->guard && !($transition->guard)($context)) {
            thrownew RuntimeException('Guard failed');
        }

        if ($transition->action) {
            // 通过这个,将副作用放在边缘
            $transition->action($context);
        }

        $this->state = $transition->to;
    }

    /** @return array<string, array<string, Transition>> */
    privatefunction buildRules(): array
    {
        return [
            OrderState::New->value => [
                OrderEvent::Pay->value => new Transition(
                    to: OrderState::Paid,
                    guard: fn(OrderContext $context) => $context->authorizedCents >= $context->amountCents,
                    action: fn(OrderContext $context) => $this->sendReceipt($context->orderId),
                ),
                OrderEvent::Cancel->value => new Transition(
                    to: OrderState::Cancelled,
                ),
            ],
            OrderState::Paid->value => [
                OrderEvent::Ship->value => new Transition(
                    to: OrderState::Shipped,
                    action: fn(OrderContext $context) => $this->notifyShipment($context->orderId),
                ),
            ],
            OrderState::Shipped->value => [], // 最终状态
            OrderState::Cancelled->value => [], // 最终状态
        ];
    }

    privatefunction sendReceipt(int $orderId): void
    {
        // 发送收据
    }

    privatefunction notifyShipment(int $orderId): void
    {
        // 通知发货
    }
}

通过这个实现,您可以看到除了简单之外,它为我们的代码库带来了很多优势:枚举提供清晰性,只读值对象保护不变量,类型化的 Transition 映射保持规则集中且快速。如果工作流继续增长,您可以将它拆分为更小的状态机,例如一个专注于支付工作流,另一个专注于履行工作流。

守卫、动作和副作用

守卫回答“我们可以转移吗?”并且必须是纯函数且快速的,这意味着:无 I/O、无 API 调用、无隐藏的随机检查。给定相同状态、事件和上下文,守卫应该始终返回相同结果。

动作回答“如果我们转移,还会发生什么?”并且应该是幂等的,这意味着运行两次是安全的,而不会双重收费或双重发邮件,例如。

为了保持状态机确定性和易于测试,在状态变化已提交后运行副作用,而不是在决策期间。

一种强制这种分离的实用方法是让动作产生领域事件,而不是直接执行 I/O。您的实体决定下一个状态并将事件发出到出箱中。然后,一个单独的层将持久化状态并将事件分发到电子邮件/网络钩子/支付适配器,具有重试和幂等性。

让我们通过添加领域事件出箱的概念来改进我们的实现。首先,我们创建事件。

代码语言:javascript
复制
interface DomainEvent {}

final readonly class ReceiptToSend implements DomainEvent
{
    public function __construct(public int $orderId) {}
}

final readonly class ShipmentToNotify implements DomainEvent
{
    public function __construct(public int $orderId) {}
}

现在,让我们更新 Transition 类的定义,以反映领域事件的使用。

代码语言:javascript
复制
final readonly class Transition
{
    /**
     * @param null|callable(OrderContext):bool               $guard
     * @param null|callable(OrderContext):list<DomainEvent>  $action 产生事件,这里无 I/O
     */
    public function __construct(
        public OrderState $to,
        public ?callable $guard = null,
        public ?callable $action = null,
    ) {}
}

现在是时候更新我们的 Order 类,使用领域事件和出箱概念。首先,为它添加一个新的 $outbox 属性:

代码语言:javascript
复制
final class Order
{
    /** @var list<DomainEvent> */
    private array $outbox = [];

    /** @var array<string, array<string, Transition>> */
    private array $rules;
    
    // ...
}

我们还需要更新 buildRules() 方法。之前它直接将 action 设置为方法,但现在我们希望 action 是一个领域事件列表。

代码语言:javascript
复制
/** @return array<string, array<string, Transition>> */
privatefunction buildRules(): array
{
    return [
        OrderState::New->value => [
            OrderEvent::Pay->value => new Transition(
                to: OrderState::Paid,
                guard: fn(OrderContext $context) => $context->authorizedCents >= $context->amountCents,
                action: fn(OrderContext $context) => [new ReceiptToSend($context->orderId)],
            ),
            OrderEvent::Cancel->value => new Transition(
                to: OrderState::Cancelled,
                action: fn(OrderContext $context) => [], // 这里无副作用
            ),
        ],
        OrderState::Paid->value => [
            OrderEvent::Ship->value => new Transition(
                to: OrderState::Shipped,
                action: fn(OrderContext $context) => [new ShipmentToNotify($context->orderId)],
            ),
        ],
        OrderState::Shipped->value => [], // 最终状态
        OrderState::Cancelled->value => [], // 最终状态
    ];
}

然后,在 apply 方法中,更新以收集事件到出箱:

代码语言:javascript
复制
public function apply(OrderEvent $event, OrderContext $context): void
{
    $transition = $this->rules[$this->state->value][$event->value] ?? null;
    if (! $transition instanceof Transition) {
        thrownew RuntimeException("Invalid transition: {$this->state->value} + {$event->value}");
    }

    if ($transition->guard && !($transition->guard)($context)) {
        thrownew RuntimeException('Guard failed');
    }

    $events = $transition->action ? ($transition->action)($context) : [];
    $this->outbox = array_merge($this->outbox, $events);

    $this->state = $transition->to;
}

最后,您可以有一个方法来检索和清空出箱:

代码语言:javascript
复制
/** @return list<DomainEvent> */
public function releaseEvents(): array
{
    $events = $this->outbox;
    $this->outbox = [];
    return $events;
}

现在,您的状态机是纯函数的:apply 只改变状态和出箱,而不进行任何 I/O。持久化后,一个事件处理器可以消费出箱,发送收据或通知发货,并处理重试。

何时以及如何扩展

状态机不是万能药。如果您的系统有少于 3 个状态,或者规则是简单的(例如,只有一个“已完成”标志),坚持 if/else 或简单标志就够了。状态机在复杂性开始时闪耀:当您有多个阶段、禁止转移或需要审计时。

要扩展,考虑这些模式:

  • 子机:对于大型工作流,将它拆分为专注的机器(例如,订单的支付机 vs 履行机)。每个都有自己的状态,但它们可以通过事件通信。
  • 并行状态:如果您的对象可以同时处于多个状态(例如,文章是“已发布”和“已存档”),使用正交区域或多个机器。
  • 层次状态:对于嵌套工作流(例如,审核阶段内的子审核),使用复合状态,其中一个状态包含另一个机器。
  • 历史状态:如果您需要“回退到上次子状态”,添加浅层或深层历史。

始终从简单开始。绘制一个状态图(使用 Mermaid 或 draw.io),标记状态(圆圈)、事件(箭头上的标签)、守卫([条件])和动作(/效果)。如果图表拥挤,拆分它。

常见陷阱和如何避免

  • 状态爆炸:如果状态超过 10 个,重新审视边界。合并相似的状态,或提取子工作流。
  • 守卫污染:保持守卫纯函数。外部检查(例如,库存可用)应该在事件触发前处理,作为前提条件,而不是守卫。
  • 动作 I/O:永远不要在动作中直接 I/O。始终使用事件出箱来解耦。
  • 并发:使用版本或时间戳进行乐观锁定。序列化事件应用以避免竞争条件。
  • 持久化:始终持久化当前状态和转移历史。使用事件溯源如果您需要完整审计。

结语

状态机将隐式、脆弱的 if/else 逻辑转化为明确、可测试的模型。它们不是魔法,但通过强制您思考状态、事件和规则,它们让复杂工作流变得可控。

下次当您看到纠缠的条件逻辑时,停下来问:“这是个状态机吗?”如果答案是“是”,花 10 分钟绘制它。您会惊讶于它如何澄清您的设计,并防止那些“不可能”但实际发生的 bug。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-11-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 开源技术小栈 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 概述
  • 为什么在 PHP 中使用状态机?
  • 核心概念
  • 在编码前设计
  • 实现一个简单的状态机
  • 守卫、动作和副作用
  • 何时以及如何扩展
  • 常见陷阱和如何避免
  • 结语
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档