Controller 里塞了 600 行之后,我终于对"分层"这两个字低头了
上周翻自己两年前写的项目,打开一个 Controller 差点没背过气去——验证、算价、调支付、发通知、写日志,全挤在一个方法里,注释写着"TODO: 稍后重构"。稍后了两年,它还在那儿。
这次新项目我铁了心要拆干净,但真动手才发现,"controller / service / model" 这六个字谁都会念,边界画在哪却天天打架。记录几段踩坑实录,权当给自己立个碑。
一、Controller 的"接客"底线:我允许它脏到什么程度
以前我觉得 Controller 越瘦越好,最好就一行 $this->service->handle($request)。结果前端传参花样太多,有的接口要兼容表单有的要兼容 JSON,校验规则还不一样。全塞 Service?Service 里开始 if ($from === 'miniapp') 这种鬼东西,更恶心。
现在我的底线:Controller 可以脏在"适配",不能脏在"业务"。参数清洗、格式转换、异常包装成统一响应,这些脏活我放 Controller。但一旦涉及"这笔钱怎么算""这个单能不能退",必须过手 Service。说白了,Controller 是翻译官,不是账房先生。
有个具体规矩:Controller 里禁止出现 Db::name 或 Model::where。不是装清高,是吃过亏——去年一个需求改动,我改了 Service 里的逻辑,忘了 Controller 里还有段直接查库的兜底代码,测试环境一切正常,上线后两边数据对不上,查了两小时才想起那行漏网之鱼。
二、Service 的"算账"困局:一个类膨胀到 2000 行之后
第一个项目我把订单相关全塞 OrderService,从创建到售后到结算,2000 行起跳。后来加个"拼团订单",我犹豫了三天:继承?组合?还是再开个 GroupOrderService?
现在拆法是按"场景"不是按"实体"。OrderCreateService、OrderRefundService、OrderSettleService,各管一段生命周期。它们共享一个 OrderRepository(对,我又加了一层,后面说),但业务逻辑互不串门。
有个坑特别隐蔽:Service 之间互相调用。A 订单完成要发积分,我图省事直接 PointService::grant(),后来积分规则改了,A 没感知,两边数据又裂了。现在硬规定:Service 之间禁止直接调用,有事走事件 Event::trigger('order.completed', $payload),谁爱听谁听,发布订阅解耦。
但事件也有坑——监听里抛异常会不会阻断主流程?我现在的做法是业务事件默认异步队列,涉及资金的事件才同步,且监听里自己 try-catch,别让主订单跟着陪葬。
三、Model 和 Repository 那层纱:我到底在防什么
ThinkPHP 的 Model 能查能写能关联,功能太全反而危险。我现在是 Model 只定义结构:字段类型、关联关系、自动时间戳、获取器/修改器。所有查询逻辑收进 Repository,包括简单的 findById。
有站长觉得脱裤子放屁,User::find($id) 一行的事非要 UserRepo::findById($id)。我一开始也这么想,直到要换数据源——某个模块从 MySQL 切到 MongoDB,Model 层全废,但 Repository 接口不变,里面换实现就行。这是防自己,不是防框架。
Repository 里我还有个私货:禁止返回 Model 实例给 Service。查出来是数组或 DTO,Service 爱怎么拼怎么拼。曾经我直接返回 Model,Service 里顺手 $order->save() 改了状态,后来追踪副作用追到哭。现在 Repository 是只读窗口,写操作必须走明确的方法签名,比如 updateStatus($id, $status, $operator),参数写死,不留自由发挥空间。
四、那层漏不掉的"胶水":DTO、Validator、Transformer 往哪搁
拆完三层,发现还有堆东西没地儿去。请求参数校验我放独立目录 app\validate,但校验规则里带业务逻辑怎么办?比如"优惠券是否可用"——这得查库啊。我的妥协:基础格式校验放 Validator,业务规则校验放 Service 入口,Service 里先调 CouponValidator::checkUsable($code),再往下走。
DTO 我目前用得比较克制,主要是跨层传递时防数组乱飞。ThinkPHP 没有原生 DTO 支持,我自己写了个基类用 __set 做只读限制,构造函数里一次性灌入,后面改属性抛异常。笨办法,但管用。
Transformer 以前我放 Controller,现在觉得它属于"输出适配",跟 Controller 的输入适配对称,就并排放了。但有个项目前端要兼容三端,Transformer 开始分裂成 OrderTransformerForApp、OrderTransformerForAdmin……现在我在考虑是不是该上资源类或者视图模型,还没想太清楚,有经验的老哥可以指点下。
五、一个正在跑的实例:社区发帖接口怎么落地
拿我最近写的社区发帖接口串一遍:
Controller:PostController::store(PostRequest $request),PostRequest 里校验标题长度、内容非空、板块 ID 存在。Controller 把 PostRequest 转成 PostCreateDto,调 PostCreateService::execute($dto, $currentUser)。
Service:PostCreateService 只做一件事——组装并持久化帖子。它先调 ForumRepository::findActiveById($dto->forumId) 确认板块存在且未封禁,再调 PostRepository::create($dto->toArray() + ['user_id' => $currentUser->id]),最后触发 PostCreated 事件。积分、通知、搜索索引,谁爱监听谁监听。
Repository:PostRepository::create 里就是 PostModel::create($data)->toArray(),返回纯数组。如果后面要切到 ES 做主存储,这里改实现,Service 无感知。
Model:PostModel 里定义了 belongsTo(UserModel)、belongsTo(ForumModel),有个获取器把 content 做 XSS 过滤。但注意,这个过滤只在读取时触发,写入时不管——写入清洗是 Service 调 htmlspecialchars 的事,分层各负其责,不互相甩锅。
六、还没想好的问题
事务放哪层?我现在放 Service,一个 Service 方法一个事务。但跨 Service 的事务怎么办?比如发帖同时扣积分,是两个 Service,我目前用事件 + 补偿机制,不是真事务,心里不踏实。
缓存怎么穿透分层?Repository 里直接 Cache::remember?还是 Service 控制?我现在是 Repository 透明缓存,但清理逻辑散落在 Service 事件监听里,总觉得会漏。
这些坑估计还得再摔几跤才能定型。分层这东西,没有银弹,只有当前项目里" least 让自己想吐"的方案。
你们 Controller 里最长多少行?我 600 行那个记录保持至今,欢迎来破。

