分层写到"贫血"与"充血"之间:我用"命令对象"模式把 controller 从"参数搬运工"里救出来的实践

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 77 浏览 0 回复

之前按常规 MVC 拆完,controller 里还是挤满了 $request->get_param('foo')validate_foo()service->do_something($foo, $bar, $baz) 的流水线代码。十几个参数一多,改个字段名要动三四个文件,service 方法签名膨胀得像气球。

后来试了下把"用户意图"封装成命令对象,结构突然清爽了。核心就一层:

// 不再传散参,传一个"意图"
class Create_Reward_Command {
    public int $user_id;
    public string $reward_type;
    public ?array $meta = null;
    
    // 校验内聚在这里,不是 controller 也不是 service
    public function validate(): Validation_Result {
        $rules = [
            'reward_type' => in_array($this->reward_type, ['daily', 'event'], true),
            'meta'        => $this->reward_type === 'event' 
                ? !empty($this->meta['event_id']) 
                : true,
        ];
        return new Validation_Result($rules);
    }
}

controller 缩到只剩骨架:

public function handle( WP_REST_Request $request ): WP_REST_Response {
    $cmd = Create_Reward_Command::from_request( $request ); // 工厂方法做类型转换
    
    if ( ! $cmd->validate()->passes() ) {
        return new WP_REST_Response( $cmd->validate()->errors(), 400 );
    }
    
    // 只认命令对象,不认原始请求
    $result = $this->reward_service->execute( $cmd );
    
    return new WP_REST_Response( $result->to_array(), 201 );
}

service 层的变化更关键。以前它是"参数解析器+业务逻辑+事务协调"三合一,现在只干编排:

public function execute( Create_Reward_Command $cmd ): Reward_Result {
    // 防腐:命令进来时已经是"已校验的合法意图"
    // 这里只关心"怎么完成",不关心"参数对不对"
    
    return $this->transactional( function() use ( $cmd ) {
        $reward = $this->factory->create( $cmd->to_spec() );
        
        $this->event_bus->dispatch( new Reward_Created( $reward ) );
        
        return Reward_Result::from_entity( $reward );
    });
}

model 层我刻意做了"反向依赖"——命令对象不直接引用 model,而是 model 提供 from_spec(Reward_Spec $spec) 的工厂入口。这样命令对象活在"应用层",model 活在"领域层",编译期(好吧,PHP 没有,但 IDE 能检查)就能拦住跨层引用。

踩过的一个坑:一开始把 WP_REST_Request 的解析逻辑塞进了命令对象的 __construct,结果写单元测试时要 mock 整个请求对象。后来拆成 from_request 工厂 + 纯数组构造器,测试时直接 new Create_Reward_Command(['user_id' => 1, ...]) 就行,和 HTTP 层彻底脱钩。

另一个意外收获是日志。命令对象天然带"可序列化上下文",异常时直接 json_encode($cmd) 丢进日志,比从前在 controller 里手动组装 ['action' => 'create_reward', 'params' => [...]] 省事得多,也杜绝了"日志里的字段名和实际参数对不上"的慢性 bug。

现在我的目录结构长这样,供参考:

src/
  Commands/           # 意图 + 校验 + 工厂
    Create_Reward_Command.php
  Services/           # 只接收 Command,返回 Result
    Reward_Service.php
  Models/             # 纯领域逻辑,不知 HTTP 为何物
    Reward.php
  Results/            # 显式的输出契约
    Reward_Result.php

还在纠结的是"查询"怎么弄。命令模式写操作很顺,但列表页那种"N 个筛选条件组合 + 分页 + 排序"的场景,再套命令对象有点重。目前折中方案是单独一个 Queries/ 目录,用"查询对象"而不是命令对象,允许更灵活的链式组装。这块还没跑通大流量场景,有实践过的可以聊聊。

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