Skip to content

国际化(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
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.*

两种布局可以在同一个目录里混用。

配置 ​

typescript
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),运行时不读取任何文件:

typescript
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(见“校验错误的翻译”)

使用 ​

typescript
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,直接得到这种语言的消息:

typescript
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-RPCenvironmentVariable 指定的环境变量(默认没有)、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:

    typescript
    import {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 组件的消息来翻译:

typescript
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']
}))
json
{
  "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:

json
{
  "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>,消息用异常的模板参数格式化:

typescript
import {Exception} from 'lakutata'

class OrderNotFoundException extends Exception {
    public errno: string = 'E_ORDER_NOT_FOUND'
}

throw new OrderNotFoundException('Order {id} not found', {id: 7})
json
{"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 根据默认语言的消息生成这份声明(见下文),也可以手写:

typescript
//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

Released under the MIT License.