/zh/posts/dynamic-translation-notes

动态翻译设计笔记

EN

动态翻译适合这样的场景:同一个 API 响应需要保持稳定结构,但要根据客户端请求展示不同语言的文本。

最直接的做法,是到处增加独立字段:nameCnnameEndescriptionCndescriptionEn,然后让每个前端自己决定展示哪个。这在小对象里能跑,但会老化得很快。响应会变得很吵,每个客户端也都要重复同一套语言选择逻辑。

我更喜欢另一种设计:把翻译作为结构化元数据存起来,然后在序列化时选择真正可见的值。

1. 保持领域字段稳定

客户端读取的字段应该仍然普通:

{
"name": "Warehouse A",
"address": "Some visible address"
}

客户端不应该需要知道 name 是来自原始字段、中文翻译、英文翻译,还是 fallback。

对象内部可以携带一个语言来源:

{
"name": "仓库 A",
"address": "某个地址",
"language": {
"name": {
"cn": "仓库 A",
"en": "Warehouse A"
},
"address": {
"cn": "某个地址",
"en": "Some visible address"
}
}
}

公开响应仍然可以隐藏 language,只渲染被选中的值。

2. 用请求上下文选择语言

语言选择通常应该来自请求上下文,而不是每个 service 方法。

基本规则是:

requestedLanguage = request.headers["Accept-Language"]

然后序列化阶段可以判断:

if requestedLanguage is blank:
write original value

if requestedLanguage is "zh-CN":
write language[fieldName].cn if present

if requestedLanguage is "en-US":
write language[fieldName].en if present

otherwise:
write original value

这样业务方法不管谁调用,都返回同一个对象。语言决策是展示问题,所以它应该靠近输出格式化。

3. 明确标记来源字段和渲染字段

我喜欢的形状里,两个注解就够了:

class ResultDTO {
@TranslatedField("name")
private String name;

@TranslatedField("address")
private String address;

@TranslationSource
private LanguageMap language;
}

渲染字段表达的是:“如果请求了多语言输出,就用这个 key 去查。”来源字段表达的是:“这个对象里存着翻译内容。”

这样序列化器就可以保持通用。它不需要知道仓库、合同、类目、文章或者其他任何领域模型。

4. 序列化是合适的切入点

一个上下文序列化器可以读取字段注解,找到父对象,读取语言来源,然后写出翻译后的值。

伪代码:

function serialize(value, fieldAnnotation, generator, provider):
requestedLanguage = getAcceptLanguage()

if value is null:
write null
return

if requestedLanguage is blank:
write value
return

if current field is TranslationSource:
write null
return

if current field has TranslatedField:
parent = generator.currentValue
source = findField(parent, annotatedWith = TranslationSource)
key = fieldAnnotation.value
translated = source.get(key)

if translated exists:
if fieldAnnotation.fixedLanguage is not blank:
write translated[fieldAnnotation.fixedLanguage]
else:
write translated[requestedLanguage]
return

write value

这让它具备动态行为,但不需要让每个接口都写动态逻辑。同一个 DTO 可以被很多方法返回,语言行为跟着响应对象走。

5. 在文本进入系统时生成翻译

翻译有两个位置可以做:

  • 读取响应时翻译
  • 创建或更新文本时翻译

对于面向用户的业务数据,我更倾向于在文本进入系统时翻译。也就是 create 或 update 流程里,一次性构建语言映射:

function buildLanguageInfo(text, sourceLanguage = auto):
info = new LanguageInfo()

try:
info.cn = translate(text, sourceLanguage, "zh-CN")
catch:
info.cn = text

try:
info.en = translate(text, sourceLanguage, "en-US")
catch:
info.en = text

return info

这样读请求会更快,也更可预测。它也避免了每次列表或详情查询都调用外部翻译服务。

代价是源文本更新时,需要刷新对应语言映射。对于多数后台或内容型数据,这个取舍是合理的。

6. Fallback 比完美翻译更重要

翻译系统应该软失败。

如果翻译生成失败,就把原文作为 fallback 存起来。如果请求语言的值为空,就返回原始字段。如果请求里没有语言头,就什么都不做。

fallback 链可以很简单:

function chooseText(original, languageInfo, requestedLanguage):
if requestedLanguage is blank:
return original

candidate = languageInfo[requestedLanguage]

if candidate is not blank:
return candidate

return original

这不炫技,但可以避免 i18n 变成响应损坏的来源。

7. 需要时允许固定语言字段

有些字段应该不管请求头是什么,都固定返回某种语言。比如外部集成可能要求英文地址,或者文档导出需要固定语言。

这应该显式表达:

@TranslatedField(value = "address", language = "en-US")
private String addressForExport;

关键是固定语言应该局部、可见。它不应该藏在某个 service 方法里,让之后的调用者看不到。

我现在的规则

对于动态翻译,我的规则是:

store all language variants once, choose the visible value late

这样 API 结构能保持稳定,客户端会更简单,语言行为也会靠近序列化阶段,而不是散落在业务代码里。