插件分层不是"把文件拆开":我如何用"请求生命周期"重新划定 controller/service/model 的权责边界

插件开发 14 浏览 0 回复 返回上级

之前写过一篇 controller 里塞 SQL 的重构经历,那次是"从乱到整齐"。这次想聊更细的一层:整齐之后,怎么让各层真正"各司其职",而不是换个地方堆代码。

先说我的踩坑现场。有个支付回调插件,最初分层是这样:

  • Controller:接收网关 POST,做签名验证、参数过滤、状态判断,然后直接调 $wpdb->update
  • Service:一个 PaymentService::process() 方法,接收 7 个参数,内部 switch-case 处理 4 种支付状态
  • Model:只有 get_order_by_id(),纯查询

看起来分了,但 controller 里还是有一堆"如果状态是 X 就改库存,如果是 Y 就改积分"的业务分支。service 成了"高级传话筒",model 根本不进写操作。

问题爆发在一次需求变更:同一个支付成功事件,既要改订单,又要触发分销佣金计算,还要发模板消息。我在 controller 里加了 3 个钩子调用,结果单元测试崩了——controller 依赖了 3 个外部模块的钩子注册顺序,换个环境顺序变了,测试就挂。

那次重构让我换了个视角:不是按"代码类型"分层,而是按"请求生命周期"分层

我的新边界定义

Controller = HTTP 翻译层

只干三件事:拿原始输入、做最基础的格式/安全校验、把 HTTP 语义转成业务语义。然后立刻交给 service。

// 之前:controller 里判断支付状态,决定调哪个 model 方法
// 现在:只负责"把网关数据变成内部能懂的语言"
public function callback() {
    $payload = $this->get_gateway_payload();
    if ( ! $this->validator->verify_sign( $payload ) ) {
        return $this->gateway->response_failed( 'sign_error' );
    }
    
    // 翻译:网关状态码 → 内部事件类型
    $event = $this->translator->to_internal_event( $payload );
    
    // 进业务层,controller 到此为止
    $result = $this->service->handle( $event );
    
    return $this->gateway->response_by_result( $result );
}

关键点:controller 不碰 WP_Error 以外的业务错误,不决定"成功之后该干什么"。

Service = 编排层,不是"实现层"

service 的核心是用例编排:这个业务场景需要哪些领域动作、按什么顺序、失败怎么回滚。具体怎么改数据库,交给 model;具体怎么发通知,交给事件分发。

public function handle( PaymentEvent $event ) {
    // 用例:支付成功 = 改订单状态 + 记录流水 + 触发后续业务
    
    return $this->transaction->run( function() use ( $event ) {
        $order = $this->order_model->lock_and_get( $event->order_id );
        
        if ( ! $order->can_transition_to( $event->target_status ) ) {
            return Result::ignored( 'invalid_transition' );
        }
        
        $this->order_model->transition( $order, $event->target_status );
        $this->ledger_model->record( $event );
        
        // 后续业务通过事件解耦,service 不直接调佣金/消息模块
        $this->dispatcher->emit( new PaymentCompleted( $order ) );
        
        return Result::success( $order );
    } );
}

注意 Result 对象:我不用 WP_Error 在 service 层抛来抛去,也不直接抛异常中断流程。成功、忽略、失败都是值,controller 再决定怎么翻译成 HTTP 响应。

Model = 领域状态机,不是"CRUD 包装"

之前 model 只有 get_*update_*,现在我把状态变更规则收进 model:

class OrderModel {
    private array $valid_transitions = [
        'pending'   => [ 'paid', 'cancelled' ],
        'paid'      => [ 'shipped', 'refunded' ],
        // ...
    ];
    
    public function can_transition_to( Order $order, string $to ): bool {
        $from = $order->status;
        return in_array( $to, $this->valid_transitions[ $from ] ?? [], true );
    }
    
    public function transition( Order $order, string $to ): void {
        if ( ! $this->can_transition_to( $order, $to ) ) {
            throw new InvalidTransition( "$order->status -> $to" );
        }
        // 实际更新,带乐观锁版本号检查
        $this->persist( $order->with_status( $to ) );
    }
}

model 抛异常,因为"状态非法"是程序错误,不是业务分支。service 层的 transaction->run 会捕获并回滚。

一个反直觉的决定:允许"薄 service"

有些场景确实简单,比如后台列表页的"切换状态"按钮。这种时候我宁可让 service 只有两行:

public function toggle( int $id ): Result {
    return $this->item_model->toggle_status( $id );
}

也不把 controller 直接连到 model。因为"要不要加前置检查、要不要记操作日志"这类决策,是可能变的。留一层 service,改动范围可控。

WordPress 特化的坑

分层在 WordPress 里有个天然张力:钩子是全局的,很容易"跨层穿透"。我的做法是:

  • controller 层不直接挂 add_action,只暴露 register_routes() 这类显式方法
  • service 层用内部事件对象,只在最外层插件启动文件里做"事件 → WordPress 钩子"的桥接
  • model 层完全不感知 WordPress,方便抽出去做纯单元测试
// 插件入口文件:唯一的"钩子胶水层"
add_action( 'my_plugin_payment_completed', function( $order ) {
    // 这里才触达 WordPress 生态的其他插件
    do_action( 'my_plugin_commission_calculate', $order );
    do_action( 'my_plugin_notification_send', $order );
} );

现在我的目录结构

src/
  Http/
    Controllers/          # 只处理 HTTP 输入输出
    Middleware/           # 签名验证、速率限制
  Application/
    Services/             # 用例编排,允许薄
    DTOs/                 # Request/Response 之外,还有 Event/Command
  Domain/
    Models/               # 状态机、业务规则
    Repositories/         # 接口,WP 实现在 Infrastructure
    Events/               # 纯对象,无框架依赖
  Infrastructure/
    WordPress/
      WpOptionRepository.php
      WpHookDispatcher.php
    Database/
      PdoTransaction.php

不是最简方案,但解决了我的核心痛点:测试时能绕过 WordPress 全局状态,运行时又能扎进 WordPress 生态

你们分层时有没有遇到过"看起来拆了,实际没拆"的幻觉?或者 WordPress 钩子和分层架构打架的经历?

评论0
回复 · 0
还没有回复
微信客服 微信客服