动态翻译设计笔记
EN动态翻译适合这样的场景:同一个 API 响应需要保持稳定结构,但要根据客户端请求展示不同语言的文本。
最直接的做法,是到处增加独立字段:nameCn、nameEn、descriptionCn、descriptionEn,然后让每个前端自己决定展示哪个。这在小对象里能跑,但会老化得很快。响应会变得很吵,每个客户端也都要重复同一套语言选择逻辑。
我更喜欢另一种设计:把翻译作为结构化元数据存起来,然后在序列化时选择真正可见的值。
1. 保持领域字段稳定
客户端读取的字段应该仍然普通:
{ |
客户端不应该需要知道 name 是来自原始字段、中文翻译、英文翻译,还是 fallback。
对象内部可以携带一个语言来源:
{ |
公开响应仍然可以隐藏 language,只渲染被选中的值。
2. 用请求上下文选择语言
语言选择通常应该来自请求上下文,而不是每个 service 方法。
基本规则是:
requestedLanguage = request.headers["Accept-Language"] |
然后序列化阶段可以判断:
if requestedLanguage is blank: |
这样业务方法不管谁调用,都返回同一个对象。语言决策是展示问题,所以它应该靠近输出格式化。
3. 明确标记来源字段和渲染字段
我喜欢的形状里,两个注解就够了:
class ResultDTO { |
渲染字段表达的是:“如果请求了多语言输出,就用这个 key 去查。”来源字段表达的是:“这个对象里存着翻译内容。”
这样序列化器就可以保持通用。它不需要知道仓库、合同、类目、文章或者其他任何领域模型。
4. 序列化是合适的切入点
一个上下文序列化器可以读取字段注解,找到父对象,读取语言来源,然后写出翻译后的值。
伪代码:
function serialize(value, fieldAnnotation, generator, provider): |
这让它具备动态行为,但不需要让每个接口都写动态逻辑。同一个 DTO 可以被很多方法返回,语言行为跟着响应对象走。
5. 在文本进入系统时生成翻译
翻译有两个位置可以做:
- 读取响应时翻译
- 创建或更新文本时翻译
对于面向用户的业务数据,我更倾向于在文本进入系统时翻译。也就是 create 或 update 流程里,一次性构建语言映射:
function buildLanguageInfo(text, sourceLanguage = auto): |
这样读请求会更快,也更可预测。它也避免了每次列表或详情查询都调用外部翻译服务。
代价是源文本更新时,需要刷新对应语言映射。对于多数后台或内容型数据,这个取舍是合理的。
6. Fallback 比完美翻译更重要
翻译系统应该软失败。
如果翻译生成失败,就把原文作为 fallback 存起来。如果请求语言的值为空,就返回原始字段。如果请求里没有语言头,就什么都不做。
fallback 链可以很简单:
function chooseText(original, languageInfo, requestedLanguage): |
这不炫技,但可以避免 i18n 变成响应损坏的来源。
7. 需要时允许固定语言字段
有些字段应该不管请求头是什么,都固定返回某种语言。比如外部集成可能要求英文地址,或者文档导出需要固定语言。
这应该显式表达:
|
关键是固定语言应该局部、可见。它不应该藏在某个 service 方法里,让之后的调用者看不到。
我现在的规则
对于动态翻译,我的规则是:
store all language variants once, choose the visible value late |
这样 API 结构能保持稳定,客户端会更简单,语言行为也会靠近序列化阶段,而不是散落在业务代码里。