支柱页刚给了总线全景,本节先解决最日常的一个问题:一段业务代码到底该写在哪层。MVC 三个字母人人会背,但「这行代码算 M 还是算 C」的争论在每个团队都真实存在。读完本节,你应当有一套可执行的归属判断标准,而不是凭感觉随手塞。
先看一段真实感很强的坏代码。业务是校园二手书下单:校验参数、扣库存、生成订单、发站内信,全部写在控制器里:
// 反面示例:胖控制器 class Order extends BaseController { public function create() { $bookId = input('post.book_id'); $num = (int) input('post.num', 1); // 校验逻辑 if ($num < 1) return json(['code' => 1, 'msg' => '数量不合法']); // 数据访问逻辑 $book = Db::name('book')->where('id', $bookId)->find(); if (! $book || $book['stock'] < $num) { return json(['code' => 2, 'msg' => '库存不足']); } // 业务流程逻辑 Db::name('book')->where('id', $bookId)->dec('stock', $num)->update(); $orderId = Db::name('order')->insertGetId([ 'user_id' => session('user_id'), 'book_id' => $bookId, 'num' => $num, 'amount' => $book['price'] * $num, ]); // 通知逻辑 Db::name('message')->insert(['user_id' => $book['user_id'], 'content' => '你的书被拍下了']); return json(['code' => 0, 'data' => ['order_id' => $orderId]]); } }
这段代码能跑,问题在别处:同一个「下单」流程如果明天要从小程序接口再入口一次,整段要复制;库存扣减没有事务保护(这个隐患第 4 章会正式修);想给下单流程写单元测试,发现它和 HTTP 输入、数据库、响应格式全焊死在一起。分层的动机全部来自这些真实的疼,不是教科书洁癖。
MVC 的本意是按「变化的原因」切分:界面怎么展示会变、业务规则会变、数据怎么存会变,三者变化节奏不同,混在一起就互相牵制。落到 ThinkPHP 的目录上:
| 层 | 目录 | 只负责 | 绝对不碰 |
|---|---|---|---|
| 控制器 Controller | app/controller | 收请求、调服务、返响应 | SQL、复杂业务规则、写文件 |
| 服务层 Service(约定) | app/service | 业务流程编排、事务边界 | HTTP 输入输出、模板 |
| 模型 Model | app/model | 单表读写、关联、数据自治 | 其他表的事务编排、HTTP |
| 视图 View | app/view | 展示与转义 | 查数据库、业务判断 |
控制器瘦身的标准可以量化:一个动作方法体超过一屏、出现两层以上缩进的业务判断、或者直接写出表名,就该往下沉了。服务层不是框架强制的目录,是社区沉淀的实践——把「流程」放服务、把「数据」放模型,控制器只剩三行骨架。
把上面的胖控制器按职责拆开。控制器只留壳:
class Order extends BaseController { // 类型提示的参数由容器装配,输入校验交给验证器(第 6 章展开) public function create(OrderService $service) { $params = $this->request->only(['book_id', 'num']); $result = $service->create(session('user_id'), $params); return json($result); } }
流程进服务层,事务边界也在这里(事务语法第 4 章细讲,先看结构):
namespace app\service; use app\model\Book; use app\model\Order; use think\facade\Db; class OrderService { public function create(int $userId, array $params): array { $book = Book::findOrEmpty($params['book_id'] ?? 0); if ($book->isEmpty() || $book->stock < ($params['num'] ?? 0)) { return ['code' => 2, 'msg' => '库存不足']; } Db::startTrans(); try { $order = Order::create([ 'user_id' => $userId, 'book_id' => $book->id, 'num' => $params['num'], 'amount' => $book->price * $params['num'], ]); $book->stock -= $params['num']; $book->save(); event('OrderPaid', $order); // 通知类动作交给事件,见 2.2 Db::commit(); return ['code' => 0, 'data' => ['order_id' => $order->id]]; } catch (\Throwable $e) { Db::rollback(); return ['code' => 3, 'msg' => '下单失败,请重试']; } } }
对照前后两版:控制器从四十行缩到七行;下单流程成了一个不依赖 HTTP 的普通 PHP 方法,命令行脚本或队列消费器可以直接复用;站内信这种「附带动作」拆成事件,服务层不再认识消息表。分层的回报到这才算拿到手。
拿不准某段代码放哪层时,连问三个问题。第一问:它是在翻译 HTTP 世界还是业务世界?读请求头、拼 JSON 响应属于控制器;第二问:它描述的是单张表的事还是多张表的流程?单表的存取与自动处理归模型,跨表流程归服务层;第三问:换一个入口(命令行、队列)它还成立吗?成立就下沉到服务或领域层,不成立就留在控制器。三问之后仍有争议的少数代码,放服务层——往上挪比往下挖容易。
背景:老项目里有个留言板,控制器方法里直接写了查询、分页、敏感词过滤和 JSON 输出。操作:先照抄现状跑通;再把敏感词过滤抽成服务类方法,把留言的存取挪进 Message 模型(哪怕暂时只有两个方法),控制器只留三行。结果:控制器缩到一屏以内,敏感词服务在评论模块被直接复用。解读:你会发现抽出的服务方法天然就是可测试单元,这正是第 8 章写测试时的福利。变式:把订单导出功能(查询、拼表格、流式下载)也按三问法分一层,注意「下载」属于 HTTP 翻译,必须留在控制器。
⚠️ 常见坑:不要把服务层做成「控制器的搬运工」——如果服务方法只是原样转发模型调用,那它没有存在的价值;服务层的存在理由是承载跨模型的流程与事务。
💡 关键直觉:分层的判断单位是「变化的原因」,不是代码行数。一段十行的纯数据转换放在控制器可能没问题,两行的 SQL 拼接放在控制器则一定有问题。
控制器瘦身后,该看请求在框架里的完整动线了。下一节逐段拆解请求生命周期,事件在哪个节点广播也会一并看清。