后端与架构
每次部署都同步内容,又不覆盖后台里的修改
阅读需 8 分钟
每次部署都同步内容,又不覆盖后台里的修改
问题所在
重做作品集网站意味着一下子要加入大量新内容:项目分类、大约二十六个项目、工作经历、教育背景、技能清理,以及最初的几篇博客文章,而且全部需要三种语言。如果在生产环境的后台里手工录入,不仅慢、容易出错,而且无法审阅。与此同时,网站上线后我也会直接在后台修改内容。所以我两者都需要:内容像代码一样放在 git 里审阅并自动应用,同时后台也是一个一等公民的编辑器,它的修改绝不会被悄无声息地撤销。
内容即受版本控制的 PHP 文件
所有准备好的内容都放在 database/content/portfolio.php 里,这个文件返回一个普通数组:分类、技能(包含新增或更新、合并和删除列表)、项目、经历、教育、博客栏目和文章,以及我的个人简介。可翻译字段的结构与数据库中完全一致:
'laringe' => [
'match' => ['id' => 19],
'category' => 'health-cv',
'title' => [
'en' => 'Laryngeal region segmentation and tracking',
'es' => 'Segmentación y seguimiento de la región laríngea',
'zh' => '喉部区域分割与跟踪',
],
'destacado' => true,
'skills' => ['Python', 'OpenCV'],
],
一个 Artisan 命令 portfolio:sync-content 读取并应用这个文件。部署脚本会在数据库迁移之后、健康检查之前运行它。
事务与试运行
这个命令把所有操作放在同一个数据库事务里:
DB::beginTransaction();
try {
$categoryIds = $this->syncCategories($content['categories'] ?? []);
$this->syncSkills($content['skills'] ?? []);
$this->syncProjects($content['projects'] ?? [], $owner, $categoryIds);
// 经历、教育、博客、个人简介……
$dryRun ? DB::rollBack() : DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
加上 --dry-run 时,它会打印完整的变更报告(新建、匹配、保留),然后回滚,因此我能准确看到一次部署会做什么。真正执行时则是全有或全无:中途出错时不会留下只应用了一半的数据,连续执行两次的最终状态与执行一次完全相同。
第一条经验来自第一次部署。新代码默认项目分类已经存在,没有分类时会抛出异常,但当时同步还不是部署流程的一部分。健康检查失败,部署自动回退到上一个版本,可数据库迁移却保留了下来。把同步加入部署流程、放在健康检查之前,问题就解决了。如果代码依赖某些数据,那么加载这些数据就属于部署本身,而不是事后的手动步骤。
匹配数据行:稳定的键与历史 id
文件中的每个实体都有一个稳定的 key,比如 laringe。但生产环境里已经有旧网站留下的、没有任何键的数据行。因此匹配分三步:先按键查找;找不到时,按文件里声明的 match.id 查找,接管这一已有行并写上它的键;否则就新建一行。
private function matchByKey(string $modelClass, string $key, ?int $matchId): Model
{
$byKey = $modelClass::query()->where('key', $key)->first();
if ($byKey) {
return $byKey;
}
if ($matchId) {
$byId = $modelClass::query()->find($matchId);
if ($byId && $byId->key === null) {
$byId->key = $key;
return $byId;
}
}
return new $modelClass(['key' => $key]);
}
$byId->key === null 这个判断就是第二条经验。没有它时,命令在生产环境中运行正常,因为那里的历史 id 早已存在;但在全新的数据库上却会悄悄出错。在新库里,自增 id 是在同一次运行中依次分配的。先创建的项目拿到了 id 1;后面某个 match.id 为 1 的实体找到了这行刚创建的数据,把它"接管"过来,并用自己的数据覆盖了它。一个项目就这样悄无声息地变成了另一个。只有当该行还没有键时才按 id 接管,就堵住了这个漏洞。
测试也随之改变。以前测试跑在空数据库上,而这恰恰是具有误导性的情形。现在测试会先按真实 id 插入历史数据行,这才是命令在生产环境中真正面对的状态。
用逐字段哈希保护后台修改
对于写入的每一个可翻译字段,命令都会把 JSON 值的 SHA1 存进 content_sync_hashes 表,以模型、行和字段为索引。下一次运行时:
- 如果当前值与存储的哈希不再一致,说明自上次同步以来有人在后台修改过它,命令会保留该值并报告"kept (edited in panel)";
- 如果存储的哈希已经与新值一致,则无需任何操作;
- 否则写入新值并更新哈希。
if ($hashRow && $hashRow->hash !== $currentHash) {
$this->changeLog[] = "kept (edited in panel): {$this->label($model)}.{$field}";
return;
}
按字段处理很重要:在后台修正一个西班牙语标题,不应该让整行数据从此再也接收不到内容更新。
这里也有局限:哈希的可靠程度取决于它背后的历史记录。在我的本地数据库里,一些在命令之外写入的值,让两篇没人在后台动过的文章看起来像是"在后台被修改过"。不过这是正确的出错方向:命令选择保留数据而不是覆盖,并且在报告中明确说明。
哈希没有覆盖到的字段
哈希保护只作用于可翻译字段,而 published_at 每次运行都会直接按文件赋值。文件里草稿的值是 null,我在后台发布了其中一篇,结果下一次部署又把它撤回成了草稿。这就是第三条经验。现在规则写得很明确:
if ($isNew && array_key_exists('published_at', $data)) {
$model->published_at = $data['published_at'];
}
文件只在文章创建时设定日期。此后,发布、定时发布或改回草稿都由后台决定,而且这个决定在之后的每次部署中都会保留下来。
反过来的情况同样存在。文件没有声明的字段永远不会被改动,这对由后台管理的数据来说是正确的。但这也意味着,文件从未提及的历史内容,比如只有英文的旧项目介绍,会一直保持原样,直到文件明确接管这个字段为止。
值得保留的做法
- 内容放在 git 里,像代码一样审阅,由部署流程自动运行一个幂等的命令来应用。
- 先试运行,再用一个事务把所有操作包起来。
- 从一开始就决定每个字段归谁管:归文件、归后台,还是"文件只在创建时设定"。如果早点把这张表写下来,这三个 bug 里至少能省掉两个。