国际化(i18n)
lakutata/com/i18n(@lakutata/i18n 包)按键名管理各语言的消息:代码里写键名,每种语言在自己的文件里给出这个键对应的文字。消息使用 ICU MessageFormat 的语法,数字、货币、日期和复数由 Node.js 内置的 Intl 按语言格式化,不依赖第三方库。
翻译文件
一种语言一个 JSON 文件,文件名是语言标签(BCP 47:en、zh-CN、zh-Hant-TW):
locales/
en.json
zh-CN.json{
"order": {
"count": "{count, plural, =0 {还没有订单} other {共 # 个订单}}",
"created": "已为 {customer} 创建订单"
}
}JSON 的嵌套就是键名的层级:上面的键是 order.count 和 order.created。键名的每一段都不能包含 .。
一种语言也可以是一个目录,目录里的文件名(和子目录名)作为键名的前缀:
locales/
zh-TW/
order.json → order.*
admin/user.json → admin.user.*两种布局可以在同一个目录里混用。
配置
import {Application} from 'lakutata'
import {buildI18nOptions} from 'lakutata/com/i18n'
Application
.alias({'@locales': '/path/to/locales'})
.run(() => ({
id: 'shop.app',
name: 'Shop',
components: {
i18n: buildI18nOptions({
directory: '@locales', //支持别名,也可以是数组
defaultLocale: 'en', //默认语言,也是最后的回退语言(默认 en)
fallback: {'zh-TW': 'zh-CN'}, //可选:繁体缺少的消息用简体的
currency: 'CNY', //可选:{price, number, currency} 的默认货币
timeZone: 'Asia/Shanghai' //可选:日期和时间的时区(默认是进程的时区)
})
}
}))组件请命名为 i18n:中间件和 commanderTranslator 使用这个名称。
翻译也可以不放在目录里,直接在代码里传入:messages 选项按语言给出翻译映射,可以是对象字面量,也可以是 import 进来的 JSON。这样翻译和代码一起打包(lakutata package bundle),运行时不读取任何文件:
import {buildI18nOptions} from 'lakutata/com/i18n'
import en from './locales/en.json' with {type: 'json'}
import zhCN from './locales/zh-CN.json' with {type: 'json'}
const i18n = buildI18nOptions({
messages: {
'en': en,
'zh-CN': zhCN,
'ja': {order: {created: '{customer} の注文を作成しました'}}
}
})| 选项 | 说明 |
|---|---|
directory | 翻译文件所在的目录,可以是多个,支持别名 |
locales | 支持的语言,negotiate() 从中选择;默认为默认语言加上消息中出现的语言 |
defaultLocale | 默认语言,默认 en |
fallback | 某种语言缺少消息时,接着去哪些语言里找({'zh-TW': 'zh-CN'},或一个列表) |
messages | 在代码里直接给出的消息(按语言),在目录之后加载,同名的键会覆盖;只用它时不需要 directory |
missing | 缺少消息或参数时怎么办:'throw' 抛出异常;'key' 返回键名(缺少的参数原样保留为 {name}),第一次出现时发出警告。默认 'throw',NODE_ENV=production 时默认 'key' |
currency | 消息和参数都没有指定货币时使用的货币(ISO 4217) |
timeZone | 日期和时间的时区(IANA) |
validationMessages | 是否注册支持的语言的内置校验消息,默认 true(见“校验错误的翻译”) |
使用
import {Provider} from 'lakutata'
import {Inject} from 'lakutata/decorator/di'
import {type I18n} from 'lakutata/com/i18n'
class OrderService extends Provider {
@Inject('i18n')
protected readonly i18n: I18n
public summary(count: number, locale: string): string {
return this.i18n.t('order.count', {count: count}, locale)
}
}| 方法 | 说明 |
|---|---|
t(key, params?, locale?) | 某个键在某种语言下的消息,用参数格式化;不传语言时使用默认语言 |
has(key, locale?) | 某种语言(或它回退到的语言)是否有这个键 |
format(message, params?, locale?) | 格式化一条不在翻译文件里的 ICU 消息 |
translator(locale) | 得到绑定某种语言的翻译器(ITranslator),之后不用每次都传语言 |
negotiate(requested) | 从请求方希望的语言(优先的在前)中选出一种支持的语言,都不支持时用默认语言 |
addMessages(locale, messages) | 运行时添加某种语言的消息,同名的键会覆盖 |
getLocales()、getDefaultLocale()、getKeys(locale)、getTimeZone() | 支持的语言、默认语言、某种语言自己的键、时区 |
t()、has()、format() 和 translator() 不做语言协商:不支持的语言按原样查找,然后回退。addMessages() 返回一个 Promise,新语言的校验消息注册完成后它才完成;而新消息本身在 addMessages() 返回后就能通过 t() 取得。
按请求的语言回复
在 Entrypoint 上加上 I18nMiddleware,每个请求就会确定自己的语言;请求里的对象注入 Translator,直接得到这种语言的消息:
import {Application} from 'lakutata'
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
import {CLIAction, GET} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {buildI18nOptions, I18nMiddleware, Translator} from 'lakutata/com/i18n'
class OrderController extends Controller {
@Inject(Translator)
protected readonly translator: Translator
@GET('/orders/summary')
@CLIAction('order-summary')
public async summary(): Promise<string> {
const count: string = this.translator.t('order.count', {count: 3})
const total: string = this.translator.number(1234.5, {style: 'currency', currency: 'EUR'})
const today: string = this.translator.date(new Date(), {dateStyle: 'long'})
return `${this.translator.locale}: ${count}, ${total}, ${today}`
}
}
Application.run(() => ({
id: 'shop.app',
name: 'Shop',
components: {
i18n: buildI18nOptions({directory: '@locales'}),
entrypoint: buildEntrypoints({
controllers: [OrderController],
middlewares: [I18nMiddleware],
http: expressHTTPEntrypoint({port: 3000})
})
},
bootstrap: ['entrypoint']
}))请求所要的语言按下表的顺序读取,再和支持的语言协商(见“语言的协商与回退”),都不支持时用默认语言:
| 入口 | 来源(优先的在前) |
|---|---|
| HTTP(Express、Fastify) | Query 参数 ?lang=、Cookie lang,然后是请求头 Accept-Language(按权重) |
| Socket.IO | 连接握手时的 Query 参数 lang、Cookie lang、Accept-Language |
| HTTP 服务适配器、JSON-RPC(HTTP、WebSocket) | HTTP 请求(WebSocket 为建立连接的请求)的 Query 参数 lang、Cookie lang、Accept-Language |
| 命令行、Electron IPC、stdio 上的 JSON-RPC | environmentVariable 指定的环境变量(默认没有)、LANGUAGE、LC_ALL、LC_MESSAGES、LANG,最后是进程的语言 |
| 定时任务 | 无(默认语言) |
不是语言标签的 Query 参数或 Cookie 值会被跳过。
同一个请求里的对象(Controller、
@Scoped的 Provider、访问控制规则)注入的是同一个Translator;translator.locale是这个请求的语言,context.locale也是。中间件使用名为
i18n的组件。组件用了别的名字(@Inject('...') protected readonly i18n)、要换 Query 参数名或 Cookie 名(queryParameter、cookie)、要读取某个环境变量(environmentVariable),或者要用别的方式决定语言(比如登录用户保存的语言),就继承I18nMiddleware:typescriptimport {type BaseContext} from 'lakutata/com/entrypoint' import {I18nMiddleware} from 'lakutata/com/i18n' export class LanguageMiddleware extends I18nMiddleware { protected readonly queryParameter: string = 'locale' protected readonly environmentVariable: string = 'SHOP_LANG' //优先使用登录用户保存的语言 protected requested(context: BaseContext): string[] { const saved: unknown = context.data.userLanguage return [...(typeof saved === 'string' ? [saved] : []), ...super.requested(context)] } }没有配置
I18nMiddleware时,调用Translator的方法会抛出I18nTranslatorException。翻译器(
Translator和translator(locale)得到的ITranslator)有locale、t(key, params)、has(key)、format(message, params)、number(value, options)和date(value, options)。自己处理语言时,可以用
parseAcceptLanguage(header)解析Accept-Language,用environmentLocales()读取环境变量里的语言(zh_CN.UTF-8会转换为zh-CN),再交给negotiate()。不在请求里的地方(定时任务、单例组件),用组件的
t(key, params, locale)或translator(locale)。
命令行程序的帮助和提示
Commander 适配器(lakutata/adapter/commander)的 translate 选项让命令行程序的帮助和交互提示使用用户的语言:程序和命令的说明、参数和选项的说明、默认值和可选值的提示、帮助的标题(Usage:、Options:)、--help 和 --version 的说明,以及缺少必填参数时的提问。命令名和选项名不翻译。
commanderTranslator() 用 i18n 组件的消息来翻译:
import {Application} from 'lakutata'
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
import {CLIAction} from 'lakutata/decorator/ctrl'
import {buildI18nOptions, commanderTranslator, I18nMiddleware} from 'lakutata/com/i18n'
import zhCN from '../locales/zh-CN.json' with {type: 'json'}
class GreetController extends Controller {
@CLIAction('greet', {description: 'Greet someone'})
public async greet(): Promise<string> {
return 'Hello'
}
}
Application.run(() => ({
id: 'tool.app',
name: 'Tool',
components: {
i18n: buildI18nOptions({messages: {'zh-CN': zhCN}}),
entrypoint: buildEntrypoints({
controllers: [GreetController],
//执行命令的 Controller 注入的 Translator 也使用同一种语言
middlewares: [I18nMiddleware],
//TOOL_LANG=zh-CN tool --help,或 LANG=zh_CN.UTF-8
cli: commanderCLIEntrypoint({bin: 'tool', translate: commanderTranslator({environment: 'TOOL_LANG'})})
})
},
bootstrap: ['entrypoint']
})){
"cli": {
"description": "示例工具",
"commands": {
"greet": {
"description": "问候某人",
"arguments": {"name": "被问候的人"},
"options": {"times": "次数"}
}
},
"help": {"usage": "用法:", "options": "选项:", "default": "默认:{value}", "hints": "({hints})", "separator": ","}
}
}- 键名(默认前缀
cli.,可用prefix选项修改):- 程序:
description;命令:commands.<命令>.description、commands.<命令>.arguments.<参数>、commands.<命令>.options.<属性>; - 帮助:
help.usage、help.arguments、help.options、help.commands、help.helpOption、help.helpCommand、help.versionOption; - 提示:
help.default(default: {value})、help.choices(choices: {choices})、help.hints(({hints}),说明后面附加提示的格式)、help.separator(,)、help.negate(Turn off {flag}); - 提问:
prompt.json(Not a JSON object)。
- 程序:
- 没有消息的文字使用代码里的英文原文,所以只需要为其他语言提供消息。
- 语言在适配器启动时确定一次:依次读取
environment选项指定的环境变量、LANGUAGE、LC_ALL、LC_MESSAGES、LANG,最后是进程的语言,再与支持的语言协商。 - 中文、日文、韩文的宽字符按两列计算;帮助里的长行可以在这些字符之间换行,并且不会让标点出现在行首。
- 也可以直接给
translate一个函数:translate: () => (text) => messages[text.key] ?? text.text。
校验错误的翻译
配置了 I18nMiddleware 时,请求里校验失败的消息都使用请求的语言:请求参数(@HTTPAction、@CLIAction、@ServiceAction 的 DTO),请求中调用的方法的参数和返回值(@Accept、@Return),以及代码里直接调用的 DTO.validate、DTO.validateAsync。
框架内置了 20 种语言的校验消息,组件启动时为它支持的每种语言注册(只加载用到的语言):
en、zh-CN、zh-TW、ja、ko、fr、de、es、pt-BR、it、nl、pl、ru、uk、tr、vi、th、id、hi、ar
支持的语言使用最接近的内置语言:zh、zh-Hans、zh-SG 使用 zh-CN,zh-Hant、zh-HK、zh-MO 使用 zh-TW,其他语言按语言部分匹配(pt-PT 使用 pt-BR,fr-CA 使用 fr)。没有内置消息的语言,使用它回退到的语言的内置消息,最后是英文。
应用可以在翻译里覆盖或补充校验消息:写在 validation 下,键是校验的错误码,消息使用校验的模板语法({{#label}}、{{#limit}}),不是 ICU:
{
"validation": {
"any": {"required": "请填写 {{#label}}"},
"string": {"min": "{{#label}} 至少 {{#limit}} 个字符"}
}
}- 某种语言的校验消息依次叠加:回退到的语言的内置消息和应用消息,再是它自己的内置消息和应用消息,后面的覆盖前面的。
validationMessages: false不注册内置消息,只使用应用的validation消息。- 请求参数照常校验;只有校验失败时,才按请求的语言再校验一次,取得这种语言的消息。校验通过的请求不受任何影响。
- 少数消息里有校验库自己填入的英文内容,例如排序方向(
ascending)、引用错误的原因、自定义断言的消息,这些部分在各语言里仍是英文。 - 不使用 i18n 组件时,也可以直接用
lakutata的函数:loadValidationMessages(language)加载某种语言的内置消息,registerValidationMessages(language, messages)注册,之后校验时用{errors: {language}}选项指定语言,或者用runInValidationLanguage(language, fn)让一段代码(包括其中的异步调用)里的所有校验都使用该语言。
异常消息的翻译
配置了 I18nMiddleware 时,如果请求中抛出的异常在翻译里有对应的消息,它的消息会换成请求的语言。键名是 errors.<异常的 errno>,消息用异常的模板参数格式化:
import {Exception} from 'lakutata'
class OrderNotFoundException extends Exception {
public errno: string = 'E_ORDER_NOT_FOUND'
}
throw new OrderNotFoundException('Order {id} not found', {id: 7}){"errors": {"E_ORDER_NOT_FOUND": "订单 {id} 不存在"}}- 中文请求得到
订单 7 不存在,英文请求得到原来的Order 7 not found;errno等其他属性不变。 - 逐个传入的参数(
new XxxException('{0} of {1}', a, b))在消息里用{0}、{1}引用。 - 在翻译里没有对应消息的异常保持原样;消息无法格式化时(比如缺少参数)也保持原样,并发出警告。
- 不在请求里的地方,可以用
exceptionParams(exception)(来自lakutata)取得异常的参数,自己翻译。
语言的协商与回退
协商(negotiate):依次尝试请求方的每一种语言,先找完全相同的标签,再找只有语言部分的标签(en-GB → en),再找同一语言的其他地区(pt-PT → pt-BR,按支持的语言的顺序);都找不到时使用默认语言。
回退:在某种语言里找不到消息时,依次查找:它在 fallback 里配置的语言、去掉最后一段的标签(zh-Hant-TW → zh-Hant → zh)、默认语言。消息总是按请求的语言格式化:德语请求回退到英文消息时,数字仍然写成 1.000。
消息语法
| 语法 | 例子 |
|---|---|
| 参数 | 你好,{name}:数字按语言格式化,Date 按日期格式化,其他值转为字符串 |
| 数字 | {n, number}、{n, number, integer}、{n, number, percent} |
| 货币 | {price, number, currency}(货币来自参数 currency 或配置)、{price, number, currency/EUR} |
| 日期、时间 | {at, date, short}、{at, time, long},样式为 short、medium(默认)、long、full;值可以是 Date、时间戳、日期字符串或有 toDate() 的对象 |
| 复数 | {count, plural, =0 {还没有} one {# 个} other {# 个}}:先匹配 =数值,再按该语言的复数规则;# 是格式化后的数字;支持 offset:1 |
| 序数 | {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} |
| 选择 | {role, select, admin {管理员} other {用户}} |
| 转义 | '' 是一个撇号;'{literal}' 原样输出花括号(复数里的 '#' 原样输出 #) |
plural、selectordinal 和 select 必须有 other。消息在第一次使用时解析并缓存,语法错误会抛出 I18nMessageSyntaxException,指出所在的语言、键和位置,例如 zh-CN: order.count: the "other" option is required at 1。
键名的类型
应用可以声明键名和它们的参数,这样写错键名或漏掉参数时,编译就会报错。lakutata i18n types 根据默认语言的消息生成这份声明(见下文),也可以手写:
//src/i18n.d.ts
import 'lakutata/com/i18n'
declare module 'lakutata/com/i18n' {
interface I18nMessages {
'order.count': { count: number }
'order.created': { customer: string }
}
}没有声明时,t() 接受任意键名和参数。
命令行工具
lakutata 命令行(@lakutata/cli)处理翻译目录(--dir,默认 locales;多个目录用逗号分隔):
| 命令 | 作用 |
|---|---|
lakutata i18n check | 以默认语言(--default,默认 en)为准检查各语言的消息。错误:ICU 语法错误、默认语言没有的键、默认消息没有的参数、未知的校验错误码或不属于该错误码的变量;警告:缺少的消息(会回退)、没有用到的参数、与默认语言相同的消息(可能还没翻译)。有错误时退出码非 0,加上 --strict 时警告也算失败,适合放进 CI |
lakutata i18n types | 根据默认语言的消息生成键名和参数的类型声明(--out,默认 src/i18n.d.ts);应用从 @lakutata/i18n 引入时加 --module @lakutata/i18n |
lakutata i18n add <语言> | 以默认语言的消息为模板新建一种语言,供之后翻译(按默认语言的布局建文件或目录,不复制 validation 部分,新语言使用自己的内置校验消息) |
这些检查也可以作为函数在代码里调用:loadCatalogs(directories)、checkCatalogs(catalogs, defaultLocale)、generateMessageTypes(catalogs, defaultLocale)。
异常
| 异常 | 什么时候 |
|---|---|
I18nCatalogException | 翻译文件或配置无效:JSON 有误、文件名不是语言标签、目录不存在、别名无法解析 |
I18nMessageSyntaxException | 消息有语法错误 |
I18nMissingKeyException | 找不到键(missing: 'throw') |
I18nMissingParameterException | 缺少参数(missing: 'throw'),或货币类消息没有可用的货币 |
I18nTranslatorException | 没有配置 I18nMiddleware,或者在请求之外使用 Translator |