×
微信咨询
微信二维码

长按图片识别添加或保存到相册

产品服务 帮助文档

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);
}

三件事:

  1. 补时间戳(Model 管表约定字段,Service 不管)
  2. allowField 白名单过滤(多余字段直接丢弃,防脏写)
  3. 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/活动),把这个方法 改字段名就能用 。


七、给新手的三个思考题

  1. 如果把 insertVideo 放在事务外面,关联表写失败了,主表数据回得去吗?
  2. 如果 Service 不 unset 直接把 $data 丢给 Model,Model 有 allowField 兜底,为什么 Service 还要清?
  3. 编辑接口里 bindGoods 先 delete 再 insertAll,而不是逐条 diff 更新——你觉得是懒还是对?

最后一句 :分层架构的价值不在于"多写几个文件夹",而在于 每一层被单独调用时,都不会把系统搞脏 。你项目里这个 add 方法,就是这句话最好的注脚。

大纲

文章目录

    联系我们
    联系方式
    • 官方服务热线:17721141027
    • 邮箱:kf@ilaituan.com
    • QQ:20262336
    扫码添加客服
    微信
    联系我们