把控制器写瘦之后,我才看懂什么叫"业务层该长在哪儿"
上周重构一个用了两年的老模块,controller 里塞了四百多行,每次改需求都怕碰崩。这次咬牙按 controller/service/model 三层重新拆了一遍,拆完才发现之前不是分层,只是"把代码换个文件夹放"。
一、controller 现在只干三件事:接参、丢给 service、格式化返回
以前我的 controller 长这样:校验参数、查数据库、算业务逻辑、组装响应、记日志,全在一个方法里。现在强制自己 controller 不超过 30 行,多一行就怀疑是不是越界了。
有个细节挺反直觉:controller 里不要直接调 model,哪怕只是查条配置。一旦开了这个口子,两周后就会有人在这里写联表、写聚合、写事务,最后 controller 又胖回去。
二、service 不是"controller 的垃圾桶",得有自己的边界
我踩过最大的坑,是把 service 当成"controller 不想写的代码就往下塞"。结果 service 之间互相调用,A 调 B,B 调 C,C 又调 A,改一个字段要翻五六个文件。
现在我的规矩是:service 按"用例"拆分,不是按"表"拆分。比如订单模块,有 OrderCreateService、OrderCancelService、OrderQueryService,而不是一个 OrderService 包治百病。每个 service 只暴露自己这个用例需要的方法,内部私有的辅助逻辑再往下沉。
还有个血泪教训:service 之间禁止跨层调 model。OrderCreateService 需要查商品库存,不允许直接调 GoodsModel,必须走 GoodsQueryService。多一层转发看似麻烦,但哪天库存逻辑改了,你只用动一处。
三、model 回归本质:只封装数据访问,不碰业务
之前我在 model 里写了个自动计算会员折扣的 getAttr,后来会员规则改了,找这个逻辑找了半天。现在 model 里只有:作用域、关联定义、简单的数据类型转换。业务计算?去 service。
有个例外我保留了:软删除、自动时间戳这种框架级行为,还是交给 model。但凡是"根据业务条件决定怎么查"的,一律写成 service 里的查询构建器,或者 model 的作用域由 service 显式调用。
四、拆完之后最爽的一点:单元测试终于能写了
以前 controller 里嵌着 Request 对象和 response()->json(),测一个业务逻辑要起整个 HTTP 环境。现在 service 纯纯是 PHP 类,输入数组输出数组,PHPUnit 里直接 new 出来测, Mock 也只需要 Mock 它依赖的别的 service。
上周改了一个优惠券叠加规则,改了 OrderCreateService 里三个方法,跑完测试就提交了,心里特别踏实。换以前,得手动点一遍下单流程,还得担心是不是漏了某个边缘条件。
五、还没想清楚的:事务该放哪一层?
目前我是放在 service 的"主控方法"里,用 Db::transaction 包一层。但有个纠结:如果 A service 调 B service,B 里也有事务,嵌套会不会出问题?ThinkPHP 支持事务嵌套,但心理上总觉得别扭。这块还没找到特别舒服的做法,各位是怎么处理的?
分层这事,代码规范只能防君子,真要靠 code review 和自己手痒时克制住。我现在提交前会扫一眼:controller 有没有出现 model 的 use 语句?有的话,回炉重造。