国际化

对于一个国际化的 App 来说,UI 和各种文案的国际化是必须的。

一般来说,开发只要根据用户的语言,然后调用对应方法,就可以拿到对应的文案了。

  String errorMessage = getErrorMessage("exception.HTTP_404", "en-US,en;q=0.5")

底层处理也很简单,大部分语言都有相关的支持。例如 Java 中文件结构可以是这样的:

.
└── resources
    └── i18n
        ├── exception_ar.properties
        ├── exception_en.properties
        ├── exception_es.properties
        ├── exception_fr.properties
        ├── exception_ko.properties
        ├── exception_pt.properties
        └── exception_ru.properties

使用的时候底层只要这么调用就行了:

ResourceBundle.getBundle('i18n/exception', "en-US,en;q=0.5").getString("HTTP_404");

开发维护exception_en.properties文件,可以通过脚本把英文文案上传到对应的翻译平台,然后其他的语言是在翻译平台翻译好后下载下来的。

 

I18N Service

微服务化后,如果还是每个项目自己做国际化,这种模式就出现问题了。

首先,如何在下游服务中拿到用户的语言?你要解决吧也不难,所有服务透传用户语言就行了,大部分 RPC 框架可以透传整个调用链的context。但我们目前是 HTTP + gRPC,这个对我们来说很麻烦。

第二个问题是,如果每个要做国际化的项目都要处理文案上传,下载,那是不是也很麻烦?

那既然微服务化了,那这种通用的功能就应该做成一个服务来解决吧?

可是做成服务并不能解决第一个问题,而且做成服务反而多了很多麻烦的地方,例如如果你调用 I18N Service 失败了怎么处理?嗯,我在处理一个异常的时候发生了异常… 而且性能上也是一个问题。

 

处理过程前置

生成国际化的文案需要两个信息,一个是具体的文案的代号,代号可以是上面提到的exception.HTTP_404这种格式,也可以直接用英文文案exception.Page Not Found

我不太喜欢直接用英文的文案在代码里做文案的代号,因为里面可能会有特殊字符要处理,英文文案也有可能会变。文案变了要改代码,这个我无法接受。

文案的代号会在各个服务中产生,不分层次。

生成国际化文案另一个需要的信息是用户语言,这个信息离用户越近越容易拿到。抛开做国际化,下层服务理论上根本不用关心这个信息。

既然用户语言离用户越近越容易拿到,那么哪一层离用户最近?那当然是用户的手机 App 代码了!

所以,是否可以在 Server 返回一些代号,然后 Client 去解析并读取对应的文案?

 

流程设计

那我们就不要在 Server 做任何翻译了,通过一些模版语言把国际化文案的代号告诉 Client 就行了:

{
  "errorMessage" : "{exception.USER_NOT_FOUND}",
  "errorCode" : 110
}

因为 Server 会不断新增文案,所以 Client 无法把翻译文件都整合在本地,需要再调用一个 Server 的 API 去获得用户对应语言的翻译就行了。

转换好后变成如下内容:

{
  "errorMessage" : "用户不存在!",
  "errorCode" : 110
}

这部分文案完全可以长时间在本地缓存,虽然多了一次远程调用,因为翻译变动非常少,所以缓存命中率会很高。

别的优化策略还有很多,例如 Server 可以分析 Client 对各种文案的调用频率,提前下发高频文案给 Client,这样也能大大优化 Client 性能。

 

兼容现有代码

上述方案如果在一个全新的项目中采用我觉得是没什么问题的,但是如果不是一个新 App 怎么办呢?

是否可以把干这个活的下放一层呢?我们不是有 API Gateway 吗?

之前介绍过我们 API Gateway 的发展历程:

Service Mesh 实践(四):从开源 Ingress 到自研 API Gateway

有了 API Gateway 后就可以很好地解决这个问题了!既不需要 Client 改代码,也可以集中处理集群内所有的流量。

 

流程设计

实现思路和上面的思路差不多,但这里可以画张图,更清晰一点:

I18N

整个处理流程差不多就是这样了,那现在最大的问题就是,应该如何设计这个模版语言?

 

I18N Language

其实,在上面的例子中,{exception.USER_NOT_FOUND}已经是一个最简单的语法了,但很明显这个简单的表达式是不够的。

我把我们项目里所有的翻译都排摸了一遍,总结了一下我们遇到的需求,并全部设计了一下。

 

参数化

{exception.USER_NOT_FOUND} 会转换成 用户不存在!,那如果想要变成用户 Dozer 不存在!呢?

也就是说文案中要支持传入参数。以前,这种参数化的需求一般会在最终翻译的文案里放一些占位符,例如:用户 {nickname} 不存在!

以前业务代码的处理流程是:

  • 根据exception.USER_NOT_FOUND读取到文案用户 {nickname} 不存在!
  • 把文案中的{nickname}替换掉
  • 返回文案

改造后业务代码直接返回:{exception.USER_NOT_FOUND},API Gateway 读取文案并处理,那是不是少传递了点信息?

是的,API Gateway 不知道nickname应该被替换成什么。

所以最终语法可以变成这样:{exception.USER_NOT_FOUND,nickname=Dozer}

API Gateway 拿到翻译后的文案后,发现业务代码额外传了一些参数,就会去文案中把对应的占位符替换掉了。

 

默认值

不同语言翻译的进度是不一样的,一般业务上线后,一些小众语言并不会翻译完,这时候会把英文的文案暂时返回给用户。

但这里的默认值指的不是这个。

这里的默认值指的是,有些文案连英文的翻译都没有。

举个例子:我们商店里的商品需要翻译,但商品和上面遇到的异常不一样,异常是伴随着代码一起产生的,而商品是会在代码上线后,在后台新配置的。按照我们的工作流程,这种商品后续会定期有对应的脚本提取出来英文文案,然后提交到翻译平台,最终再翻译成各种别的语言。

在这个空窗期中,这个商品是没有任何翻译的,包括英文。

所以遇到这样的场景,需要传递一个默认翻译的信息,如果有翻译就用,没有就用默认值。

语法可以是这样的:{product.sticker_name_1=长草团子}

 

嵌套翻译

我们有一类文案,场景是显示订单信息,内容是:订阅了商品一、订阅了商品二、退订了商品一。这句文案分为两部分,前面的动词和后面的名字。

上面提到的参数化可以把商品名作为一个参数传进去,但是传进去的参数就是最终文案了。只适合用户名这种不需要翻译的内容。

如果参数也需要做翻译怎么办?就像高阶函数,那就实现嵌套翻译吧!

语法也不难,这样设计就行:{order_history.buy_product,product={product.sticker_name_1=长草团子}

 

完整语法

最后,完整的语法如下:

{i18n-key-name[=fallback-value][,argument-key1=argument-value1][,argument-key2={another-i18n-key-name}]}
  • [ ]里的内容代表可选参数
  • { }可以替换成别的符号,根据自己的需求来设计

 

语法解析

语法设计好了,业务代码只要实现语法生成就行了,只是一些简单的文本拼接,根本不需要任何 SDK。只要注意按照约定做一些转义。

例如上面你把用户的昵称放到了表达式中,用户输入的数据必定是不可靠的,你需要把{ } ,等符号做转义,否则会影响解析的结果。

具体解析算法的实现就参考编译原理,用有限状态机实现一下就行了。这部分的解析性能是非常高的,把表达式遍历一遍就可以完成,也就是O(n)

然后 API Gateway 再配合一些缓存策略,整体的性能就几乎没有影响了。

本作品由 Dozer 创作,采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。