单一职责原则在 Laravel 控制器拆分中的案例

解读

国内大厂面试时,这条题表面问“怎么拆控制器”,实则考察三层内功:

  1. 对 SOLID 中“S”的理解深度;
  2. 对 Laravel 请求生命周期、路由、中间件、服务容器的实战熟练度;
  3. 对“可测试性”与“可维护性”的权衡经验。
    面试官通常先让你举一个线上真实场景,再追问“如果订单量涨十倍,你的拆法还能撑住吗?”——回答必须体现“代码先拆、业务再拆、数据最后拆”的渐进思路,并给出落地细节(目录结构、命名、依赖注入、事务边界、Job 拆分、测试覆盖)。切忌只背“胖控制器→瘦控制器”这种口号。

知识点

  1. 单一职责原则(SRP):一个类只因“一类变化原因”而变化。
  2. Laravel 请求处理流程:Route → Middleware → Controller → Service/Action → Model/Repository。
  3. 拆分模式:
    • 控制器只负责“HTTP 与路由”这一层(接收请求、返回响应)。
    • 业务动作封装到 Single Action Controller 或 Invokable Action 类。
    • 复用逻辑下沉到 Service/Domain Service/Repository。
    • 跨模块流程用 Pipeline 或 Job Chain。
  4. 国内高并发场景常见变化原因:活动规则、支付渠道、库存扣减、优惠券核销、风控策略——每一点都可能是独立变化轴,因此需要纵向拆分。
  5. 测试策略:Action/Service 层 100% 单元测试,控制器层只做 HTTP 集成测试,用 Laravel 的 RefreshDatabase + WithoutMiddleware 快速回归。

答案

以“电商下单”为例,原始胖控制器 OrderController@store 里混杂了参数校验、优惠券校验、库存锁定、价格计算、订单写入、支付单创建、事件触发,共 7 种变化原因。按 SRP 拆分步骤如下:

  1. 路由级拆分
    将“创建订单”独立成一条路由,不再与其他订单操作(列表、详情、取消)共用控制器。

    Route::post('/orders', CreateOrderController::class);
    
  2. 控制器级拆分
    采用 Laravel 单动作控制器:

    class CreateOrderController extends Controller
    {
        public function __construct(
            private CreateOrderAction $action,
            private OrderResourceTransformer $transformer
        ) {}
    
        public function __invoke(CreateOrderRequest $request): JsonResponse
        {
            $order = $this->action->execute($request->dto());
            return new JsonResponse(
                $this->transformer->toArray($order),
                201
            );
        }
    }
    

    该控制器只负责“HTTP 语义”:参数校验已由 FormRequest 完成,这里仅调用 Action 并返回约定格式。

  3. 业务动作级拆分

    class CreateOrderAction
    {
        public function __construct(
            private CouponService $coupon,
            private InventoryService $inventory,
            private OrderRepository $orderRepo,
            private PaymentService $payment,
            private EventDispatcher $events
        ) {}
    
        public function execute(CreateOrderDTO $dto): Order
        {
            return DB::transaction(function () use ($dto) {
                $coupon = $this->coupon->validate($dto->couponNo, $dto->userId);
                $this->inventory->lock($dto->skus);
                $price  = $this->calcPrice($dto->skus, $coupon);
                $order  = $this->orderRepo->create($dto, $price);
                $this->payment->createBill($order);
                $this->events->dispatch(new OrderCreated($order));
                return $order;
            });
        }
    }
    

    虽然 CreateOrderAction 看起来仍然多步,但所有步骤都围绕“创建订单”这一统一业务概念,变化原因只有一个——“订单如何被创建”。
    如果未来“优惠券规则”频繁变动,只需改动 CouponService,不会波及本类;若“库存锁定”算法升级,也只改 InventoryService,符合 SRP。

  4. 目录与命名规范(符合国内 PSR-4 大型项目约定)

    app/Http/Controllers/Order/CreateOrderController.php
    app/Http/Requests/Order/CreateOrderRequest.php
    app/Actions/Order/CreateOrderAction.php
    app/Services/CouponService.php
    app/Services/InventoryService.php
    app/Repositories/OrderRepository.php
    

    通过 php artisan make:controller Order/CreateOrderController --invokable 一键生成,Code Review 时目录即文档。

  5. 性能与扩展
    当订单量涨十倍,只需把 CreateOrderAction 中“库存锁定”一步替换为 Redis Lua 脚本,上层控制器与 Action 签名不变;
    若后续要做“秒杀”,再把 CreateOrderAction 拆成 Pipeline:

    CheckUserEligibility -> CheckCoupon -> CheckInventory -> CreateOrder -> CreatePayment
    

    每级管道只负责一个变化原因,可独立水平扩展。

  6. 测试覆盖

    class CreateOrderActionTest extends TestCase
    {
        use RefreshDatabase;
    
        public function test_create_order_with_coupon()
        {
            // 只测业务规则,不启 HTTP 内核,0.1s 内完成
        }
    }
    
    class CreateOrderControllerTest extends TestCase
    {
        public function test_return_201_and_order_resource()
        {
            // 只测路由、鉴权、序列化,不关注业务
        }
    }
    

    单元与集成边界清晰,符合国内 CI 流水线 3 分钟跑完全部用例的硬要求。

拓展思考

  1. 横向与纵向拆分取舍
    当业务继续膨胀,可能出现“变化轴”正交的情况:例如“创建订单”既要支持国内微信支付,又要支持海外信用卡,还要支持 B2B 账期。此时可以用“策略模式”把支付进一步抽象成 PaymentStrategyCreateOrderAction 只依赖 PaymentStrategy 接口,符合开闭原则。
    但如果支付渠道变化频率远高于订单主体逻辑,则考虑把“支付单创建”拆成独立领域服务,甚至独立微服务,通过消息队列解耦,避免 Action 层再次发胖。

  2. 与“充血模型”对比
    国内有团队把业务直接写在 Model 里(如 $order->createWithCoupon())。这种做法在小团队迭代快,但 Model 会因多重职责变得臃肿,单元测试需要带数据库,运行慢。采用 Action/Service 层可以保持 Model 仅做数据形态,Action 做业务组合,测试用内存假数据即可,符合国内 nightly build 资源紧张的现状。

  3. 与“DDD 应用服务”对齐
    在更大规模系统中,CreateOrderAction 可升级为应用服务(Application Service),而 CouponServiceInventoryService 成为领域服务(Domain Service),两者通过领域事件(OrderCreatedEvent)完成解耦。Laravel 的 EventServiceProviderQueue 天然支持事件驱动,因此这套拆法能平滑过渡到微服务架构,满足国内“先单体后拆分”的演进路线。

  4. 面试反向提问
    当面试官认可你的答案后,可以主动追问:“贵司当前订单峰值 QPS 多少?是否已采用分库分表?如果我引入 SRP 拆分,DB 事务边界需要跨库,怎么保证一致性?”——体现你对真实高并发场景的敬畏,往往比单纯背原则更能加分。