controller 层"薄到透明"之后,我把 service 拆成了"编排器"与"策略器"两种形态

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

之前写过一篇用"命令对象"救 controller 的帖子,这次换个方向聊——当 controller 已经瘦成一层皮,service 反而开始膨胀,里面既有"先查 A 再调 B 最后写 C"的流程代码,也有"如果满足 X 条件就用 Y 算法"的策略代码。混在一起之后,单测写起来像拆毛线团。

我现在的拆法是:把 service 再切成两层,一个叫 编排器(Orchestrator),一个叫 策略器(Policy/Strategy)。controller 只认识编排器,编排器只负责"按什么顺序调用谁",策略器才管"具体怎么算"。

一个具体场景:积分兑换商品

老写法,service 里一锅炖:

class ExchangeService {
    public function execute(int $userId, int $productId): array {
        $user = $this->userRepo->find($userId);
        $product = $this->productRepo->find($productId);
        
        // 校验积分
        if ($user->points < $product->cost) {
            throw new InsufficientPointsException();
        }
        
        // 扣积分、减库存、写订单——但库存策略有两种
        if ($product->type === 'physical') {
            // 实物:预占库存,72小时未支付释放
            $this->inventory->reserve($productId);
        } else {
            // 虚拟:直接扣减,发兑换码
            $this->inventory->deduct($productId);
            $code = $this->codeGenerator->generate($productId);
        }
        
        $this->points->deduct($userId, $product->cost);
        $order = $this->orderRepo->create([...]);
        
        return ['order_id' => $order->id, 'code' => $code ?? null];
    }
}

问题很明显:加一种商品类型(比如"限时秒杀"要单独扣库存逻辑),得改这个 service 的核心流程。而且单测要 mock 一堆东西,测"积分不足"还得把 inventory 和 codeGenerator 全 stub 掉。

新拆法:编排器只管"台词",策略器管"演技"

编排器长这样,纯流程,无 if:

class ExchangeOrchestrator {
    public function __construct(
        private ExchangeValidator $validator,
        private ExchangeStrategyResolver $resolver,
        private PointsService $points,
        private OrderRepository $orders,
    ) {}
    
    public function execute(ExchangeRequest $request): ExchangeResult {
        // 1. 校验(抛异常即中断,编排器不 catch)
        $context = $this->validator->validate($request);
        
        // 2. 找对策略
        $strategy = $this->resolver->for($context->product);
        
        // 3. 策略执行(扣库存、发码等副作用)
        $strategySideEffects = $strategy->apply($context);
        
        // 4. 扣积分
        $this->points->deduct($context->user, $context->product->cost);
        
        // 5. 落订单
        $order = $this->orders->create($context, $strategySideEffects);
        
        return new ExchangeResult($order, $strategySideEffects);
    }
}

策略器是接口 + 多实现:

interface ExchangeStrategy {
    public function apply(ProductContext $ctx): SideEffects;
    public function rollback(SideEffects $fx): void; // 补偿用
}

class PhysicalProductStrategy implements ExchangeStrategy {
    public function apply(ProductContext $ctx): SideEffects {
        $reservationId = $this->inventory->reserve($ctx->product->id, ttl: 72 * 3600);
        return new SideEffects(['reservation_id' => $reservationId]);
    }
    // ...
}

class VirtualProductStrategy implements ExchangeStrategy {
    public function apply(ProductContext $ctx): SideEffects {
        $this->inventory->deduct($ctx->product->id);
        $code = $this->codeGenerator->generate($ctx->product->id);
        return new SideEffects(['code' => $code]);
    }
    // ...
}

model 层我现在的底线

model 只干三件事:字段映射、基础查询构造、关联定义。任何"业务规则"都不许进 model,比如"用户积分不能为负"这种——这是校验层或领域层的活。我踩过的坑:把 $user->canAfford($cost) 写进 User model,结果后台 cron 脚本和前台 API 对"能不能欠费"的理解不一样,改一处崩另一处。

现在 model 里最多留一个 scopeForActive 这种纯查询范围的 helper,而且必须是无副作用的。

一个反直觉的发现

拆完之后,编排器其实非常稳定——上线三个月只改过一次,是加了一个埋点。频繁变的是策略器(新增商品类型)和校验规则(风控加字段)。单测也舒服了:测编排器只需要 mock 策略接口,测策略器只需要给固定输入,不用搭整个请求上下文。

唯一多出来的成本是"策略发现"那一步。我用的是一个简单的 map 匹配,复杂场景可以上 Symfony 的 ServiceLocator 或者自己写个 tagged iterator。WordPress 插件里没 Symfony 全套,但 apply_filters('my_exchange_strategies', $map) 也能让其他扩展包注册新策略,算是个轻量替代。

你们 service 层膨胀之后是怎么收的?有没有把"编排"和"策略"混在其他地方的习惯?

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