ThinkPHP 企业级分层架构的完整链路学习
通过新增视频功能案例,深入拆解ThinkPHP企业级分层架构的完整链路,涵盖Controller、Validate、Service、Model四层职责,重点讲解事务双写、多租户隔离、缓存一致性与防御性编程,助你写出高可靠的新增接口。
从一次「新增视频功能」看 ThinkPHP 企业级分层架构的完整链路
一、问题起点
后台新增一条短视频,表单里有:标题、文件、分组、关联商品……
接口看起来很简单,但真正落地时你要面对的是:
- 参数格式怎么拦?
- 主表和关联表怎么保证同时成功?
- 多商户数据怎么隔离?
- 缓存什么时候清?
- Service 和 Model 的边界到底在哪?
我们直接看一段生产级的 add 方法,把它从头到尾拆开。
二、先看全貌:四层各司其职
Controller 接参数、归一化、调校验、交棒
↓
Validate 格式守门员(require / integer / 自定义规则)
↓
Service 业务大脑(事务、多表、缓存、业务断言)
↓
Model 纯数据层(白名单、时间戳、单表 CRUD)
↓
MySQL 最终真相源
每一层只说一句话:
| 层 | 口头禅 |
|---|---|
| Controller | "参数我洗好了,校验你上" |
| Validate | "长得不对,直接拦" |
| Service | "要么全成,要么全败" |
| Model | "我只管安全地把这一行写进这张表" |
三、Controller 做了什么(你可能忽略的预处理)
在 Service 被调用之前,Controller 其实已经干完了三件关键事:
// 1. 拆表单壳(前端 name="video[title]" 结构)
$data = $this->postDataDefault('video');
// 2. 关联字段单独剥离、归一化成纯 int 数组
$goodsIds = $this->normalizeIds($data['goods_ids'] ?? []);
unset($data['goods_ids']);
// 3. 组装"给验证器看的完整数据"去校验
$checkData = array_merge($data, ['goods_ids' => $goodsIds]);
$this->validateOrFail($checkData, 'add', $this->videoValidate);
这里有个 新手容易错的设计点 :
$data(给主表用)和$checkData(给校验用)是两份数据。
- 校验阶段需要
goods_ids参与规则判断 - 入库阶段主表没有
goods_ids这个字段
校验用完整语义数据,入库用干净表字段 ——两份数据故意不一样,是高级写法,不是多此一举。
四、Service::add 完整拆解
public function add(array $data, array $goodsIds, int $wxappId): array
{
$data['wxapp_id'] = $wxappId;
unset($data['goods_ids']);
Db::startTrans();
try {
if (empty($data['video_title'])) {
throw new Exception('视频标题不能为空');
}
if (empty($data['video_file_id'])) {
throw new Exception('请上传视频文件');
}
$videoId = $this->model->insertVideo($data);
if (!empty($goodsIds)) {
VideoGoodsRel::bindGoods($videoId, $goodsIds, $wxappId);
}
$this->clearCache($wxappId);
Db::commit();
return [$videoId, ''];
} catch (Throwable $e) {
Db::rollback();
return [false, $e->getMessage()];
}
}
4.1 第一行:强制打上归属标记
$data['wxapp_id'] = $wxappId;
多租户系统的铁律: wxapp_id 永远由服务端从登录态取,绝不信任前端传值 。Controller 不管归属,Service 在写库前强制打标。
4.2 再 unset 一次:防御性编程
Controller 已经清过 goods_ids,Service 为什么再清?
因为 Service 是公共业务入口 ,它不假设"上游一定干净":
- 未来可能有定时脚本直接调 Service
- 可能有其他 Controller 复用
- 重构时上游忘了处理
每一层只信自己手里的数据。 这是分层架构能长期维护的核心原因。
4.3 开事务:草稿模式
Db::startTrans();
MySQL 进入"暂存模式"——后续所有写操作对外部连接 不可见 ,只有 commit 那一刻同时生效。
4.4 事务内的业务断言
if (empty($data['video_title'])) {
throw new Exception('视频标题不能为空');
}
你可能会问:验证器不是校验过了吗?
对,但职责不同:
| 位置 | 校验的是什么 |
|---|---|
| Validate | HTTP 请求参数的格式合法性 |
| Service 这里 | 业务上下文里"能不能干这件事" |
Validate 可以被绕过(内部调用、脚本、别人 new 了 Service 直接用)。Service 里的断言是 最后一道代码级保险 ,而且放在事务开头——还没写任何东西就发现不对,rollback 成本为零。
4.5 insertGetId:数据什么时候真正落盘?
$videoId = $this->model->insertVideo($data);
Model 内部:
public function insertVideo(array $data): int
{
$data['create_time'] = time();
$data['update_time'] = time();
return $this->allowField($this->allowField)->insertGetId($data);
}
三件事:
- 补时间戳(Model 管表约定字段,Service 不管)
allowField白名单过滤(多余字段直接丢弃,防脏写)insertGetId执行 INSERT + 取LAST_INSERT_ID()
关键认知: 拿到 $videoId 的那一瞬间,数据已经写进表了 (事务内可见,外部不可见)。不是先插再查,是 MySQL 自增分配完 ID 后顺手把号码还给你。高并发下每个连接拿到的 LAST_INSERT_ID 互相隔离,不会串。
4.6 写关联表:用刚拿到的 ID 串起来
VideoGoodsRel::bindGoods($videoId, $goodsIds, $wxappId);
内部逻辑(精简后):
// 先清旧关联(新增时实际无旧数据,但方法复用于编辑)
self::where('video_id', $videoId)->delete();
$data = [];
$sort = 100;
foreach ($goodsIds as $goodsId) {
$data[] = [
'video_id' => $videoId,
'goods_id' => $goodsId,
'goods_sort' => $sort,
'wxapp_id' => $wxappId,
'create_time' => time()
];
$sort += 10;
}
(new self)->insertAll($data);
主表一条 + 关联表 N 条, 靠 $videoId 串联,在同一个事务里 。
4.7 清缓存 → commit
$this->clearCache($wxappId);
Db::commit();
commit 之前:数据在事务内存在,外部查不到。
commit 之后: 主表 + 关联表同时对全库可见 ,缓存已清,下次读拿新数据。
4.8 任何一步炸了 → 全撤
catch (Throwable $e) {
Db::rollback();
return [false, $e->getMessage()];
}
| 炸在哪儿 | 结果 |
|---|---|
| 标题为空(throw) | 一条没写,回滚 |
| 主表写入成功,关联表失败 | 主表也撤,无孤儿数据 |
| 数据库连接异常 | 全撤 |
不会出现"视频有、关联没有"的中间态。
Service 返回统一约定 [成功时ID, ''] 或 [false, 错误信息],Controller 只管翻译成 HTTP 响应格式,不碰业务逻辑。
五、一张表看全链路
| 阶段 | 位置 | 动作 | 数据状态 |
|---|---|---|---|
| 接参 | Controller | 拆壳、归一化 | $data干净 |
| 校验 | Validate | scene + rule | 不过直接拦 |
| 打标 | Service | 加 wxapp_id | 归属明确 |
| 开事务 | Service | startTrans | 草稿模式 |
| 断言 | Service try | 业务兜底 | 早失败 |
| 写主表 | Model | INSERT + 白名单 + 时间戳 | 表里有数据(事务内) |
| 拿 ID | insertGetId 返回 | MySQL 自增回执 | $videoId |
| 写关联 | RelModel | insertAll N条 | 两张表都有 |
| 清缓存 | Service | del key | 读不脏 |
| 提交 | commit | 对外可见 | ✅ 成功 |
| 异常 | rollback | 全部撤销 | ✅ 干净 |
六、为什么这段代码值得你 Ctrl+C
30 行,同时覆盖了:
- ✅ 多租户数据隔离
- ✅ 事务双写(主表 + 关联表)
- ✅ 分层职责零越界
- ✅ 防御性 unset
- ✅ 事务内前置断言
- ✅ 缓存一致性
- ✅ 统一返回约定
以后你写任何「主表 + 关联表」的新增接口(文章/商品/SKU/活动),把这个方法 改字段名就能用 。
七、给新手的三个思考题
- 如果把
insertVideo放在事务外面,关联表写失败了,主表数据回得去吗? - 如果 Service 不 unset 直接把
$data丢给 Model,Model 有allowField兜底,为什么 Service 还要清? - 编辑接口里
bindGoods先delete再insertAll,而不是逐条 diff 更新——你觉得是懒还是对?
最后一句 :分层架构的价值不在于"多写几个文件夹",而在于 每一层被单独调用时,都不会把系统搞脏 。你项目里这个
add方法,就是这句话最好的注脚。
来团QQ客服