controller 里塞了 800 行业务判断,model 却只会 `return $wpdb->get_results`:我把"贫血模型"喂胖之后,发现 service 根本无事可做

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

上周重构一个三年前的会员积分插件,打开代码差点窒息——controller 里塞着「查询用户等级→计算折扣→写积分流水→发通知邮件」全套流程,model 层只有裸 SQL,service 文件夹里躺着一个空文件。典型的"controller 过劳死,model 饿得贫血,service 查无此人"。

这次拆完我换了个思路:不是按"代码放哪层"来分,而是按"谁对变化负责"来切。记录一下我的新边界,可能和你惯用的不一样。


一、controller 只干"接线"的活

以前我的 controller 长这样:

public function handle_exchange() {
    $user_id = get_current_user_id();
    $item_id = absint( $_POST['item_id'] );
    
    // 查库存
    $stock = $wpdb->get_var( $wpdb->prepare( ... ) );
    if ( $stock < 1 ) wp_send_json_error( '没货了' );
    
    // 查用户积分够不够
    $points = get_user_meta( $user_id, 'points', true );
    $item = $wpdb->get_row( ... );
    if ( $points < $item->price ) wp_send_json_error( '积分不足' );
    
    // 扣积分、减库存、写流水、发邮件...
    // 80 行过去了
}

现在 controller 就剩这些:

public function handle_exchange() {
    try {
        $dto = ExchangeRequest::from_post( $_POST );
        $result = $this->exchange_service->execute( $dto );
        wp_send_json_success( $result->to_array() );
    } catch ( ValidationException $e ) {
        wp_send_json_error( $e->getMessage(), 422 );
    } catch ( InsufficientPointsException $e ) {
        wp_send_json_error( $e->getMessage(), 403 );
    }
}

所有"如果...就..."的业务判断全赶出去。controller 只管:接输入→转 DTO→调 service→抓异常→吐格式。HTTP 状态码、JSON 结构、nonce 校验这些"Web 味"的东西留在这,业务味的一点不要。


二、model 不是"数据库皮",是"领域对象"

我之前对 model 的理解就是"封装 $wpdb",结果每个 model 都是 CRUD 工具人。现在让它胖一点,但只胖在"业务规则能内聚"的地方:

class UserPoints {
    private int $user_id;
    private int $balance;
    
    public function __construct( int $user_id, int $balance ) {
        $this->user_id = $user_id;
        $this->balance = $balance;
    }
    
    public function debit( int $amount, string $reason ): PointsTransaction {
        if ( $amount > $this->balance ) {
            throw new InsufficientPointsException(
                "需 {$amount},仅有 {$this->balance}"
            );
        }
        // 注意:这里不碰数据库,只生成"待执行"的领域事件
        return new PointsTransaction( $this->user_id, -$amount, $reason );
    }
    
    public function get_balance(): int {
        return $this->balance;
    }
}

关键点:model 不 save()。它负责"这笔账对不对",不负责"落库"。这样单元测试直接 new 出来就能跑,不用 mock $wpdb。


三、service 是"编排员",不是"搬运工"

以前我以为 service 就是"controller 太长,砍一半放中间"。现在它的职责是用例级别的事务边界

class ExchangeService {
    public function __construct(
        private PointsRepository $points_repo,
        private StockRepository $stock_repo,
        private TransactionRepository $trans_repo,
        private EventDispatcher $events
    ) {}
    
    public function execute( ExchangeRequest $dto ): ExchangeResult {
        return $this->trans_repo->transaction( function() use ( $dto ) {
            // 1. 加载聚合根
            $user_points = $this->points_repo->of_user( $dto->user_id );
            $stock = $this->stock_repo->of_item( $dto->item_id );
            
            // 2. 领域对象自己做判断
            $stock->reserve( 1 );
            $transaction = $user_points->debit( $stock->get_price(), 'exchange' );
            
            // 3. 持久化(只有这里碰数据库)
            $this->stock_repo->save( $stock );
            $this->points_repo->save( $user_points );
            $this->trans_repo->save( $transaction );
            
            // 4. 副作用抛出去异步处理
            $this->events->dispatch( new Exchanged( $transaction ) );
            
            return new ExchangeResult( $transaction->get_id(), $user_points->get_balance() );
        } );
    }
}

service 里的顺序就是业务故事:加载→判断→改状态→保存→发事件。没有 if 嵌套地狱,因为判断全在 model 里。没有直接 SQL,因为仓储接口挡着呢。


四、repository 不是 model,是"持久化翻译官"

我把数据访问拆了一层 repository,model 和数据库表不再 1:1。比如积分余额存在 usermeta,流水存在自定义表,但调用方无感知:

interface PointsRepository {
    public function of_user( int $user_id ): UserPoints;
    public function save( UserPoints $points ): void;
}

class WpPointsRepository implements PointsRepository {
    public function of_user( int $user_id ): UserPoints {
        $balance = (int) get_user_meta( $user_id, 'myplugin_points', true );
        return new UserPoints( $user_id, $balance );
    }
    
    public function save( UserPoints $points ): void {
        update_user_meta( $points->get_user_id(), 'myplugin_points', $points->get_balance() );
    }
}

哪天要把积分迁到独立表?换个别名 `DbPointsRepository` 实现接口就行,service 一行不改。


五、我踩的坑:别为了"干净"而干净

第一次拆的时候走火入魔,一个简单查询也搞了 DTO→Repository→Service→Controller 四层,同事吐槽"取个配置像过安检"。现在我的原则:

  • 纯读取、无业务判断的:controller 直接调 repository,service 跳过
  • 只有一行 CRUD 的:model 里放个静态工厂方法,不必强上 repository
  • WordPress 钩子回调:如果就是改个 option,别硬套三层,但要在注释里标"此处直接操作,未来若加逻辑需下沉"

分层是手段,不是 KPI。最终看的是"改需求时,我要打开几个文件"。


你们拆的时候有没有遇到过"拆完发现 service 就一行,不如不拆"的情况?后来怎么平衡的?

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