Controller 里塞了 800 行之后,我终于承认"拆三层"不是复制粘贴那么简单

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
站长杂谈 1 浏览 0 回复

上周翻一个老项目的用户中心模块,Controller 直接干到 847 行。打开文件那瞬间我以为是误点了 vendor 目录。更魔幻的是,里面混着参数校验、权限判断、积分计算、消息推送、第三方回调——甚至还有一段生成 PDF 的代码。当时就想给自己两拳:这哪是 MVC,这是"我全都要"大杂烩。

痛定思痛,我把这个项目按 controller / service / model 重新拆了一遍。拆完发现,"三层"不是物理上分三个文件就完事,关键是每层到底该"无知"到什么程度。分享下我现在的边界红线,踩过坑的应该能共鸣。

一、Controller:我只做"接待员",不当"厨子"

以前我的 Controller 典型画风:

```php public function recharge() { $user = User::find(input('user_id')); if (!$user) return error('用户不存在'); if ($user->status != 1) return error('用户被冻结'); $config = Config::where('key', 'recharge_rule')->value('value'); $rule = json_decode($config, true); $amount = input('amount'); if ($amount < $rule['min']) return error('最低' . $rule['min']); // ... 此处省略 60 行支付渠道判断、签名生成、日志记录 } ```

现在强制自己只留四件事:收参数、调 service、转格式、抛异常。上面那段压缩完就剩:

```php public function recharge(RechargeRequest $request) { $order = RechargeService::create($request->validated()); return success(new OrderResource($order)); } ```

参数校验扔给 FormRequest,业务规则全进 service。Controller 甚至不该知道"最低充值金额"这个配置存在——那是 service 要操心的事。

二、Service:我是"调度中心",不是"万能工具箱"

第一次拆 service 时我犯了个蠢:建了个 CommonService,里面 40 多个方法,从发送短信到计算距离啥都有。另一个极端是 service 里直接写 Db::table(...)->join(...) 的原生 SQL,那跟把 model 层架空有啥区别?

现在我定了几条硬规矩:

1. service 可以调多个 model,但禁止跨 service 调用

之前图省事,OrderService::pay() 里直接 UserService::updateLevel()。结果两边事务嵌套,死锁了两次才长记性。现在统一走事件监听:event('user.level_up', $user),解耦且能异步。

2. 复杂查询必须下沉到 model 或 repository

service 里只写 $user->hasValidOrders() 这种语义化调用,具体 whereHas 怎么拼是 model 的事。好处是换 ORM 或加缓存时,service 一行不用动。

3. 第三方接口封装到独立 gateway,service 只调 gateway

支付、短信、OSS 这些,以前直接 curl 写在 service 里,换渠道时改到哭。现在 AlipayGateway::pay()WechatGateway::pay(),service 只认 PaymentGatewayInterface

三、Model:我是"数据守门员",不是"SQL 生成器"

ThinkPHP 的模型功能太全,容易让人膨胀。我见过 model 里写 sendEmail() 的,也见过把缓存逻辑塞 boot() 里的。现在我的 model 只做三件事:

- 定义关系:hasMany、belongsTo 这些,保证 eager loading 能正常工作
- 封装查询作用域scopeActive()scopeWithBalance(),让 service 调用时像说人话
- 数据转换与保护:mutator、accessor、$hidden$casts

特别说下作用域。以前查"近 30 天有消费的活跃用户",service 里拼一堆 where。现在 model 里:

```php public function scopeRecentActive($query, $days = 30) { return $query->whereHas('orders', function ($q) use ($days) { $q->where('created_at', '>', now()->subDays($days)) ->where('status', Order::PAID); })->where('last_login_at', '>', now()->subDays(7)); } ```

service 里就一行 User::recentActive()->paginate(),清晰到不用写注释。

四、拆完之后的意外收获:测试好写了十倍

以前写单元测试要 mock 数据库、mock 缓存、mock 第三方接口,一个 Controller 测试 200 行起步。现在分层之后:

- Controller 测试:只验证参数传递和响应格式,HTTP 层测完收工
- Service 测试:mock 掉 model 和 gateway,纯测业务逻辑分支
- Model 测试:工厂模式造数据,验证查询作用域和关联加载

最爽的是 service 里那段积分计算,之前埋在生产环境里的 bug,现在拆出来单测,边界条件一目了然。改完跑一遍 phpunit,绿色通过才敢推代码。

五、还没想明白的地方

有些场景边界还是模糊。比如文件上传:校验规则放 Request,但"是否允许该类型"是业务规则还是配置?我目前扔在 service 里,但感觉将来可能得抽个 UploadPolicy 对象。另外事务到底该开在 service 还是更上层?我现在是 service 负责开,但跨 service 协作时又得往上提,还没找到最舒服的姿势。

你们项目里这三层是怎么划的?有没有那种"拆之前觉得没必要,拆完之后真香"的具体案例?或者反过来,过度拆分导致维护成本爆炸的坑?想听听实际操盘的经验,文档上那些"最佳实践"看多了,还是血淋淋的踩坑记录管用。

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