Controller 把 SQL 写进 `foreach` 那晚,我终于承认"分层"不是新建几个文件夹

站长杂谈 30 浏览 0 回复 返回上级

上周重构一个三年前的后台项目,打开某个 Controller 差点没背过气去——两百行的方法里塞了联表查询、事务控制、短信发送、积分计算,末尾还顺手给运营拼了个 Excel 导出。更离谱的是,同样一段"扣减库存并写日志"的逻辑,在订单模块、秒杀模块、积分商城里各自复制了一份,变量名都不带统一的。

说实话,我早期对分层的理解就是物理隔离:Controller 放 `public function`,Model 放 `Db::table`,Service 文件夹建一个,完事。直到某个凌晨被电话打醒,说用户下单扣了两次钱,我才开始认真想:分层到底拆的是什么?

我现在执行的土规矩

Controller 只干三件事:接参数、调 Service、返回格式。超过十行必怀疑。有个同事曾经在里面写 `if ($user['level'] > 3) { ... }`,我问他这 3 是什么,他说"运营口头说的"。现在这种判断全塞进 Service,Controller 只看见 `$this->orderService->create($dto)`,至于里面怎么算会员折扣、怎么匹配优惠券,不操心。

Service 是我花时间最多的地方。以前喜欢一个 `OrderService` 包打天下,现在强迫自己按"用例"拆:`OrderCreateService`、`OrderCancelService`、`OrderQueryService`。听起来啰嗦,但好处是测试能精准打击,改取消逻辑不用担心把创建流程带崩。跨 Service 调用必须走接口或者事件,禁止直接 `new`,防止隐形循环依赖——这坑我踩过,A 调 B,B 调 C,C 又调 A,死循环把 PHP-FPM 子进程打光了。

Model 层我现在当"贫血对象"用,只负责字段映射、关联定义、作用域封装。业务逻辑绝不往里塞。之前图省事在 UserModel 里写了个 `registerAndSendSms()`,后来要接第三方注册通道,Model 里耦合了短信模板 ID,改起来跟拆炸弹似的。现在 Model 只保证 `$user->orders()->where('status', 1)->get()` 这种查询能优雅表达,至于注册后发什么、要不要发,是 Service 协调的事。

DTO 和 VO 不是形式主义

以前 Controller 直接 `input('post.')` 往 Service 丢数组,某个字段改名,上下游全炸。现在强制过一遍 DTO,校验、类型转换、默认值填充在 DTO 构造函数里解决。Service 返回也不直接扔 Model 集合,套个 VO 或者原始数组,Controller 爱转 JSON 还是渲染模板,随意。

有个具体场景:后台订单列表和前台订单中心,字段需求差很多。以前我在 Controller 里 `unset` 敏感字段,现在 Service 直接提供 `toAdminArray()` 和 `toUserArray()`,或者更干净点,两个不同 VO。多写几行,但再也不怕前台接口漏出成本价。

事务边界我现在的做法

早期爱在 Model 里 `Db::transaction()`,后来发现在 Service 里才合理。一个下单流程可能涉及库存、订单、日志、分销佣金,这些分布在不同 Model,事务显然该在 Service 层包裹。但这里有个细节:Service 里调用的子方法,如果内部也开事务,会嵌套。我现在约定"事务由最外层用例 Service 控制",内部方法只抛异常,由外层决定回滚还是补偿。

还没完全想清楚的

事件驱动和直接 Service 调用怎么平衡?目前我的标准是:同步强依赖走 Service 接口,异步弱耦合走事件。比如下单后清缓存、发通知,走事件;但扣库存必须同步确认,直接调 Service。这个边界有时候模糊,尤其当"异步"变成"准同步"的时候。

另外 Repository 模式我还在试水。简单项目感觉多了层抽象,复杂项目确实能隔离换数据库的风险。目前折中方案是:只有可能换存储介质的模块(比如日志从 MySQL 迁到 ClickHouse)才上 Repository,其他还是直接 Eloquent/ThinkPHP Model。

分层干净的标准,我现在就一个土办法:随机打开一个文件,能不能在五秒内说出"这一层不该出现什么"。如果看见 Controller 里有 SQL,或者 Model 里调了短信接口,立刻浑身难受——这病得治,但治好了睡得香。

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