我们的博客「ゆるろぐ」,其实最近悄悄推出了英语版。这次我想分享其中的细节,特别是如何解决「SSR 网站多语言化时 API 成本是否会爆炸」这个问题。
前提:使用 EmDash CMS
这个博客使用 EmDash(一个基于 Astro 的 CMS)驱动。文章通过管理界面编写,前端使用 Astro 进行服务器端渲染(SSR)。
操作感觉很直观,与 WordPress 相似,有人称其为"WordPress 的精神继承者"。
EmDash 本身就提供了"多语言内容"的机制,文章、页面、菜单和分类法(分类、标签)等都可以作为每个语言区域的独立记录。只需通过 translationOf 字段标记"这篇英文文章是这篇日文文章的翻译",就可以将其视为同一篇文章的不同语言版本。
从 microCMS 迁移到 EmDash + Cloudflare
说到多语言化,如果用 SSG 的话就很简单了
通常来说,"通过 API 自动化多语言翻译"这样的需求,如果是 SSG(静态站点生成)就相对容易解决。
- 在构建时仅调用一次翻译 API
- 将结果作为静态文件缓存
- 之后只需要分发该 HTML
因此,即使文章数量增加再多,API 成本也只在「每次构建时」才会产生。频率也很容易控制,缓存也很直接。
但是 EmDash 是 SSR。那么每次请求都要调用 API 吗?
问题就在这里。EmDash 的 CMS 页面按规范来说,全都是 SSR(output: "server")渲染的。不是采用静态构建来创建页面的方式,而是每当收到请求时,就从 CMS 的 API・DB 获取最新内容并渲染的机制。
那么如果「显示英文页面时,就地用 Gemini 翻译并输出」这样简单的实现方式会怎样呢?
- 每次请求都会产生翻译 API 费用
- 延迟也会增加(需要等待 LLM 的响应)
- 同一篇文章每次可能都会返回略有不同的翻译结果(缺乏再现性)
这完全是行不通的。即使是 SSR,如果采用「每次调用 API」的方案,成本和用户体验都会崩溃。
解决方案:将翻译从「请求时」转移到「内容创建时」
答案很简单,不要在请求时进行翻译。
具体来说,我建立了这样的流程。
- 在 EmDash 管理面板中撰写和发布日语文章
- 只运行一次翻译脚本(
scripts/translate.mjs)- 从 EmDash API 获取日语文章的 Portable Text(结构化富文本)
- 提取应该翻译的文本部分,然后发送给 Gemini API(
gemini-3.6-flash) - 图像和亚马逊产品卡片等块保持原样直接通过,不进行翻译
- 将翻译结果作为新的英语文章保存到 EmDash 数据库
- 在
translationOf中指定原始日语文章的 ID,将其链接为同一文章组的英语版本 - 保存为草稿,以便审核后发布
- 在
也就是说,翻译结果不是「实时生成的内容」,而是作为「保存在 CMS 中的另一种语言的内容」来处理的。
这样做的话,SSR 的请求处理本身和平时显示文章没有任何区别。只需通过 EmDash 的常规本地化查询(getEmDashEntry(..., { locale: "en" }))从数据库中获取已保存的英文内容。与 Gemini API 的通信只在翻译文章时发生一次,浏览时完全不会产生。
也就是说,这样的结构
翻译运行的时机 | 请求时的 API 成本 | |
|---|---|---|
SSG + 按需翻译 | 构建时 | 零(静态文件交付) |
SSR + 按需翻译(反面例子) | 每个请求 | 文章数 × 访问次数就会产生费用 😱 |
SSR + 预翻译保存到DB(本次方案) | 内容创建时(仅1次) | 零 |
SSG 的「构建时统一缓存」这一思路,如果要应用到 SSR 的世界中,「将翻译后的内容作为永久化数据保存在 DB 中」就是其替代方案。这是本次的重要发现。即使是 SSR,内容本身也不需要动态生成,只要提前完成翻译这种「重型处理」,配信本身就能达到普通 CMS 页面的轻量级性能。
本地和生产环境 DB 不同,多次出现「咦,数据消失了!?」的故事
本次实现中,最令人精疲力尽的就是这个问题。EmDash 在本地开发环境和生产环境中分别使用不同的 DB。说起来理所当然,但正因为如此,我多次遭遇过「刚才还有的功能突然消失了!」的情况。
症状大致如下
- 页头导航突然变成空的
- 页脚的"Categories"列表消失了
- 文章页面的侧边栏小部件消失
- 辛苦创建的本地菜单项和小工具被还原了
而且这些问题都会在"它刚才还能正常运行啊"这个时刻发生,所以一开始我都是在怀疑"自己是不是弄坏了什么?"的同时去寻找原因。
原因1:将生产数据库拉取到本地的处理(pull.sh)
在开发过程中,我们曾多次运行脚本将生产数据库完整地拉取到本地,原因是"想在本地也能看到生产环境的最新数据"。这样做的话,本地数据库会被完全覆盖成生产环境的状态(比如英文文章还是草稿状态,或是菜单结构还未完成公开前的配置)。
也就是说,即使想在本地确认"生产环境中还未公开的功能",但 pull 之后会立即恢复到与生产环境相同的状态,所以在本地进行的超前工作会暂时消失。这就是发生的问题。
原因2:每次重新进入本地管理界面时运行的"种子"处理
另一个原因是,为了重新登录本地管理界面而建立的机制(开发用旁路登录)同时也会每次重新读取seed.json(初始数据定义文件)。
这很麻烦,
- 菜单项目·小部件 → 被种子端的内容「完全匹配地」覆盖(=本地手动添加的部分会消失)
- 分类·标签等分类法 → 「已存在的项目会被跳过」所以不会消失
这样就出现了按项目不同行为也不同的规范。所以才会出现「分类还在,但菜单和小部件每次都消失」这样看似莫名其妙的现象。
采取的措施
从根本上消除这种二重结构(通过pull覆盖/通过种子覆盖)很困难,所以作为务实的折中方案
- 将
seed.json中的演示用虚拟内容(菜单项目·小部件·分类等)全部清空 - 每次进入本地环境时,通过API重新创建必要的菜单和小部件
通过这样的运维方式来应对。这不是根治,而是缓解方案,但至少能防止「被意外的演示数据覆盖」的事故发生。
总结
- EmDash 本来就是一个支持多语言的 CMS,能够为每个语言环境持有内容
- 如果是 SSG,「在构建时翻译→缓存」就可以了,但 SSR 的话「每次都翻译」会变成费用地狱
- 解决方案是仅在内容创建时执行一次翻译,而不是在请求时,并将结果作为普通CMS内容保存到数据库中
- 使用的翻译引擎是Gemini API(
gemini-3.6-flash) - 不过在将CMS多语言化时,不仅要整理翻译流程,还要记住「本地和生产环境中数据库分离本身会产生新的陷阱」
- 特别是「在生产环境还没有新功能时,预先在本地环境中创建」这类开发方式,容易受到环境同步机制(拉取、数据填充、缓存等)副作用的影响
本次的经验教训是,只要提前掌握「何时进行翻译」和「何时以及覆盖什么的环境同步」这两个时序设计,即使是SSR也能够在不被运维成本和事故困扰的情况下实现多语言支持
您的网站也可以考虑实现多语言化!
我主要从事以标记语言为中心,使用JavaScript、React、Next.js进行前端开发的工作。当我参与的网站顺利发布时,我会感到很高兴!我的爱好是弹吉他。喜欢猫和烤红薯🐱🍠
Hiratch
前端工程师 / 2022年入职