跳至正文
Ende Sun
返回博客

后端与架构

用 JSON 字段和 Filament 实现 Laravel 多语言内容

阅读需 9 分钟

用 JSON 字段和 Filament 实现 Laravel 多语言内容

出发点

我的作品集网站原本已经支持西班牙语、英语和中文界面,但内容本身(项目、工作经历、个人简介)只是单一语言的纯文本。选择中文时,菜单会被翻译,而所有项目描述仍然是英文。我希望每一段公开文字都能在后台按语言分别编辑,缺少翻译时有可预期的回退规则,同时又不想把一个小网站变成一套本地化平台。

为什么不用扩展包,也不用翻译表

常见做法有两种。一种是使用 spatie/laravel-translatable 这类扩展包;另一种是为每个模型建一张翻译表,例如带有 locale 字段、每种语言一行的 project_translations。两种做法都完全可行。但在这个项目里,我选择了基于 JSON 字段、自己写的一个小 trait:

  • 内容量非常小:几十个项目、少量文章,我从不需要在 SQL 里按翻译后的值筛选或排序。
  • 每个实体一行,让后台表单、内容加载器和测试都保持简单:一个可翻译字段就是一个数组 {"en": "...", "es": "...", "zh": "..."}。
  • 我需要的行为(明确的回退顺序、忽略空值)大约四十行代码就能实现。代码在自己手里,框架升级时就不会有意外。

代价也是真实存在的:无法高效地按翻译后的值建索引或排序;增加第四种语言时,需要修改每一行的数据,而不是插入新行。在这个规模下可以接受。如果有成千上万行数据并且需要按语言查询,我会改用翻译表。

Trait 的实现

每个模型声明哪些字段可翻译,trait 负责注册类型转换和读取方法:

trait HasTranslations
{
    public function initializeHasTranslations(): void
    {
        foreach ($this->translatable as $field) {
            $this->casts[$field] = 'json:unicode';
        }
    }

    public function t(string $field, ?string $locale = null): ?string
    {
        $resolved = $this->localeOf($field, $locale);

        return $resolved ? trim(((array) $this->getAttribute($field))[$resolved]) : null;
    }

    public function localeOf(string $field, ?string $locale = null): ?string
    {
        $values = (array) $this->getAttribute($field);

        foreach ([$locale ?? app()->getLocale(), ...Locales::FALLBACK] as $candidate) {
            if (filled($values[$candidate] ?? null)) {
                return $candidate;
            }
        }

        return null;
    }
}

有几个细节比看上去更重要:

  • json:unicode 会把中文和带重音的字符以可读形式保存,而不是 \u 转义序列。这样数据库导出和 diff 都能被人直接审阅。
  • 回退顺序是先用请求的语言,然后依次是英语、西班牙语、中文。它是一个常量,而不是散落在各个视图里的配置。
  • filled() 会忽略空字符串和空白,所以后台里一个空着的西班牙语标签页,永远不会盖过一段真实的英文内容。
  • 如果所有语言都没有内容,t() 返回 null,视图会直接跳过这个元素,而不是显示一个空框或翻译键名。
  • localeOf() 会告诉我实际使用的是哪种语言。博客就是靠它在文章回退时显示"仅提供英文版"的提示。

Filament 中每种语言一个标签页

在后台,每个可翻译字段会变成一组标签页,每种语言一个。由一个小的辅助类生成:

public static function make(string $field, Closure $component): Tabs
{
    return Tabs::make($field)
        ->tabs(array_map(
            fn (string $locale) => Tab::make(self::LABELS[$locale] ?? $locale)
                ->schema([$component("{$field}.{$locale}")]),
            Locales::ALL,
        ))
        ->columnSpanFull();
}

闭包接收到的是 title.es 这样的名称,借助 JSON 类型转换,Filament 会自动把这个路径映射到嵌套数组上。不需要任何自定义的状态处理:同一个辅助类可以用于文本框、多行文本框或 Markdown 编辑器。在列表页中,列通过 getStateUsing(fn ($record) => $record->t($field)),以同样的回退规则解析当前语言的值。

迁移现有数据,一条不丢

需求是保留所有现有内容,并把它们作为英文版本。迁移先修改字段类型,再把每个值包装起来,而且写成可以重复执行的形式:

private function wrap(?string $value): ?string
{
    if ($value === null) {
        return null;
    }

    $decoded = json_decode($value, true);
    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && ! array_is_list($decoded)) {
        return $value; // 已经是 {"en": ...}
    }

    return json_encode(['en' => $value], JSON_UNESCAPED_UNICODE);
}

这一步带来了两条经验。

在 SQLite 中修改字段类型会重建整张表。 Laravel 会先建临时表、复制数据、删除原表,再重命名副本。如果另一张表有指向它的 ON DELETE CASCADE 外键,这个中间的删除操作就会触发级联,把依赖的行一并删掉。在事务中关闭外键约束并不起作用,而测试恰恰是在事务里运行的。解决办法是只在字段类型还不正确时才修改,这样第二次执行就不会重建任何表。

把旧文本包装成英文是正确的,但会留下隐藏的工作。 这些值在有人翻译之前都只有英文,而回退机制让这个缺口变得不可见:西班牙语和中文页面会悄悄显示英文。在这个网站上,迁移之后有二十一篇旧项目介绍一直停留在这种状态,因为再也没有其他东西去修改那个字段。我写了一个简短的脚本,列出所有缺少西班牙语或中文的可翻译字段,几分钟就把它们全部找了出来。现在每次做这类改动之后,我都会跑一遍这个检查。

忽略重音、但不破坏中文的搜索

项目和文章都可以搜索。输入"migracion"要能找到"Migración",输入"喉部"要能找到喉部项目的中文标题。

我第一次尝试用的是 iconv 加 ASCII//TRANSLIT//IGNORE。在服务器所用的平台上,它把"Migración"变成了"Migraci'on":音译在原来重音的位置插入了一个撇号,于是比较失败。而且它还会把中文完全破坏掉。最终的版本依靠 Unicode 分解:

public static function normalize(string $text): string
{
    $text = Normalizer::normalize(trim($text), Normalizer::FORM_D) ?: $text;
    $text = preg_replace('/\p{Mn}/u', '', $text);
    $text = mb_strtolower($text);

    return trim(preg_replace('/\s+/u', ' ', $text));
}

NFD 会把"ó"拆成"o"加一个组合重音符号,\p{Mn} 再把这些非间距标记删掉。汉字不会以这种方式分解,因此原样保留。转为小写处理大小写问题,而简单的子串匹配对中文同样有效,因为中文本来就不用空格分词。查询和被索引的文本使用同一套规范化,服务器端用一次,浏览器端的筛选再用一次,保证两边结果始终一致。

值得保留的做法

  • 小而明确的回退规则胜过巧妙的"魔法",任何人一分钟就能读懂这个 trait。
  • 修改数据的迁移应当是幂等的,并且在改动之前先做检查。
  • 迁移内容之后,要检查哪些字段只存在于回退语言中。回退只是安全网,不等于翻译。