跳至正文
Ende Sun
返回博客

后端与架构

每次部署都同步内容,又不覆盖后台里的修改

阅读需 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 里至少能省掉两个。