单一职责原则在 Laravel 控制器拆分中的案例
解读
国内大厂面试时,这条题表面问“怎么拆控制器”,实则考察三层内功:
- 对 SOLID 中“S”的理解深度;
- 对 Laravel 请求生命周期、路由、中间件、服务容器的实战熟练度;
- 对“可测试性”与“可维护性”的权衡经验。
面试官通常先让你举一个线上真实场景,再追问“如果订单量涨十倍,你的拆法还能撑住吗?”——回答必须体现“代码先拆、业务再拆、数据最后拆”的渐进思路,并给出落地细节(目录结构、命名、依赖注入、事务边界、Job 拆分、测试覆盖)。切忌只背“胖控制器→瘦控制器”这种口号。
知识点
- 单一职责原则(SRP):一个类只因“一类变化原因”而变化。
- Laravel 请求处理流程:Route → Middleware → Controller → Service/Action → Model/Repository。
- 拆分模式:
- 控制器只负责“HTTP 与路由”这一层(接收请求、返回响应)。
- 业务动作封装到 Single Action Controller 或 Invokable Action 类。
- 复用逻辑下沉到 Service/Domain Service/Repository。
- 跨模块流程用 Pipeline 或 Job Chain。
- 国内高并发场景常见变化原因:活动规则、支付渠道、库存扣减、优惠券核销、风控策略——每一点都可能是独立变化轴,因此需要纵向拆分。
- 测试策略:Action/Service 层 100% 单元测试,控制器层只做 HTTP 集成测试,用 Laravel 的
RefreshDatabase+WithoutMiddleware快速回归。
答案
以“电商下单”为例,原始胖控制器 OrderController@store 里混杂了参数校验、优惠券校验、库存锁定、价格计算、订单写入、支付单创建、事件触发,共 7 种变化原因。按 SRP 拆分步骤如下:
-
路由级拆分
将“创建订单”独立成一条路由,不再与其他订单操作(列表、详情、取消)共用控制器。Route::post('/orders', CreateOrderController::class); -
控制器级拆分
采用 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 并返回约定格式。 -
业务动作级拆分
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。 -
目录与命名规范(符合国内 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 时目录即文档。 -
性能与扩展
当订单量涨十倍,只需把CreateOrderAction中“库存锁定”一步替换为 Redis Lua 脚本,上层控制器与 Action 签名不变;
若后续要做“秒杀”,再把CreateOrderAction拆成 Pipeline:CheckUserEligibility -> CheckCoupon -> CheckInventory -> CreateOrder -> CreatePayment每级管道只负责一个变化原因,可独立水平扩展。
-
测试覆盖
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 分钟跑完全部用例的硬要求。
拓展思考
-
横向与纵向拆分取舍
当业务继续膨胀,可能出现“变化轴”正交的情况:例如“创建订单”既要支持国内微信支付,又要支持海外信用卡,还要支持 B2B 账期。此时可以用“策略模式”把支付进一步抽象成PaymentStrategy,CreateOrderAction只依赖PaymentStrategy接口,符合开闭原则。
但如果支付渠道变化频率远高于订单主体逻辑,则考虑把“支付单创建”拆成独立领域服务,甚至独立微服务,通过消息队列解耦,避免 Action 层再次发胖。 -
与“充血模型”对比
国内有团队把业务直接写在 Model 里(如$order->createWithCoupon())。这种做法在小团队迭代快,但 Model 会因多重职责变得臃肿,单元测试需要带数据库,运行慢。采用 Action/Service 层可以保持 Model 仅做数据形态,Action 做业务组合,测试用内存假数据即可,符合国内 nightly build 资源紧张的现状。 -
与“DDD 应用服务”对齐
在更大规模系统中,CreateOrderAction可升级为应用服务(Application Service),而CouponService、InventoryService成为领域服务(Domain Service),两者通过领域事件(OrderCreatedEvent)完成解耦。Laravel 的EventServiceProvider与Queue天然支持事件驱动,因此这套拆法能平滑过渡到微服务架构,满足国内“先单体后拆分”的演进路线。 -
面试反向提问
当面试官认可你的答案后,可以主动追问:“贵司当前订单峰值 QPS 多少?是否已采用分库分表?如果我引入 SRP 拆分,DB 事务边界需要跨库,怎么保证一致性?”——体现你对真实高并发场景的敬畏,往往比单纯背原则更能加分。