国际化的定义各不相同。这是用于 W3C 国际化活动材料的高级工作定义。有些人使用其他术语(例如全球化)来指代同一概念。
国际化是产品、应用程序或文档内容的设计和开发,它可以为不同文化、地区或语言的目标受众轻松本地化。
国际化(Internationalization)通常用英文写成i18n,其中 18 是英文单词中i和n之间的字母数。[1]
本文主要基于java针对语言国际化进行阐述。
任何一个面向全世界的软件都会面临多语言国际化的问题,对于java web应用,要实现国际化功能,就是在数据展示给用户之前,替换成对应的语言。
1.使用spring自带的i18n(国际化)
这个比较简单,在网上找一找到处都是教程,这里将会手动配置一次spring i18n国际化来介绍一下。
以下是笔者使用的spring boot版本号.
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starters</artifactId>
<version>2.2.5.RELEASE</version>
1-1.在properties或yml资源文件里面配置i18n
笔者用的是yml文件,可以自行转换成properties文件格式。
- basename:以逗号分隔的基名列表(本质上是一个完全限定的类路径位置),每个基名都遵循ResourceBundle约定,对基于斜杠的位置提供宽松的支持。 如果它不包含包限定符(例如“org.mypackage”),它将从类路径根目录解析。
- cache-duration:加载的资源包文件缓存持续时间。 如果没有设置,捆绑包将被永久缓存。 如果没有指定持续时间后缀,将使用秒。
# i18n
spring:
messages:
basename: i18n/messages
cache-duration: 60
1-2.在resources文件夹下新增i18n文件夹,并新建相应的国际化文件
spring容器启动的时候,会根据配置的basename去对应的路径加载资源文件到MessageSource里,至于是怎么加载到MessageSource里的,在这里就不展开阐述了。
文件配置如图 1-1所示。
注意:红色框 里的就是i18n资源文件的配置,绿色框 是idea自动生成的文件夹,实际并不存在,无视就行。
# messages.properties
test.name=test rookie0peng
test.string=test string {0}
test.date=test date {0, date}
test.time=test time {0, time}
# messages_en.properties
test.name=test rookie0peng
test.string=test string {0}
test.date=test date {0, date}
test.time=test time {0, time}
# messages_zh.properties
test.name=测试阿鹏
test.string=测试字符串{0}
test.date=测试日期{0, date}
test.time=测试时间{0, time}
1-3.在代码中使用i18n进行国际化
这里演示的是比较简单的手动参数替换,还有更好一些的方法,比如说在响应数据写入流的时候进行参数替换。
@RestController
@RequestMapping("test")
public class TestController {
@Autowired
private MessageSource messageSource;
@ApiOperation(value = "随便打印数据")
@RequestMapping(path = "console", method = RequestMethod.POST)
public Map<String, String> console() {
Map<String, String> map = new LinkedHashMap<>();
//1.测试无参替换
map.put("test.name", messageSource.getMessage("test.name", null, LocaleContextHolder.getLocale()));
//2.测试字符串参数替换
Object[] stringParam = new Object[] {"1"};
map.put("test.string", messageSource.getMessage("test.string", stringParam, LocaleContextHolder.getLocale()));
//3.测试日期参数替换
Object[] dateParam = new Object[] {new Date()};
map.put("test.date", messageSource.getMessage("test.date", dateParam, LocaleContextHolder.getLocale()));
//3.测试时间参数替换
Object[] timeParam = new Object[] {new Date()};
map.put("test.time", messageSource.getMessage("test.time", timeParam, LocaleContextHolder.getLocale()));
return map;
}
}
1-4.测试spring i18n
启动项目后,查看调用/test/console接口返回的数据,调用三次,分别设置header中的语言:默认、中文、英文。
笔者用的是idea的HTTP Client,以下是请求参数:
### 默认——测试spring i18n
POST {{baseUrl}}/test/console
Content-Type: application/json
{
}
### 中文——测试spring i18n
POST {{baseUrl}}/test/console
Content-Type: application/json
Accept-Language: zh-CN,zh;q=0.9
{
}
### 英文——测试spring i18n
POST {{baseUrl}}/test/console
Content-Type: application/json
Accept-Language: en;q=0.9
{
}
以下是响应参数。
{
"test.name": "测试阿鹏",
"test.string": "测试字符串1",
"test.date": "测试日期2021年7月3日",
"test.time": "测试时间下午4:41:27"
}
{
"test.name": "测试阿鹏",
"test.string": "测试字符串1",
"test.date": "测试日期2021年7月3日",
"test.time": "测试时间下午4:41:27"
}
{
"test.name": "test rookie0peng",
"test.string": "test string 1",
"test.date": "test date Jul 3, 2021",
"test.time": "test time 4:41:32 PM"
}
1-5.分析执行结果
- 1-5-1.测试默认和测试中文的响应数据是一样的,可以确定系统默认使用的中文环境。
- 1-5-2.调用getMessage()方法时,不传第2个参数就是无参替换;否则,反之。
- 1-5-3.使用有参替换时,还可以在properties文件里加入date、time等参数,spring可以自动格式化成对应的日期和时间。
1-6.结论
这里只是简单的演示了spring i18n的功能,对于一些简单的场景已然满足需要,如果需要进行扩展的话,有几种思路。
- 1-6-1.如果使用了nacos等配置中心,则需要去注册中心手动拉取i18n的properties文件内容,并加载到应用程序的内存里,也可以在本地用户文件夹存放一份。
- 1-6-2.如果需要一些正则翻译的话,则需要自己动手写正则替换的表达式。
- 1-6-3.该例子展示的是在controller里进行替换,更好一点的方式是在filter,甚至是在响应数据写入流的时候进行替换,比如说指定某个响应对象的某个属性的序列化类(@JsonSerialize(using = TestJsonSerializer.class)),则该字段序列化的时候就会使用TestJsonSerializer.class进行序列化。在这个类里面就可以针对性的做我们想要的替换了。
2.自定义i18n
spring i18n是挺好用的,但是面对复杂的业务需求,还不够强大。比如,用户想添加一种语言;递归替换;布局可以自定义,用户添加布局字段时,针对该项目或组织的不同地区的人员,设置不同的翻译内容等等。
基于各种各样的原因,扩展i18n已是必须要做的事。
那么怎么扩展呢?国际化的本质就是将key替换成不同语言的value,这句话中有几个关键点:key、替换、语言、value。其中key、语言、value都是名词,代表着具体的数据;替换是动词,代表具体的翻译逻辑。那我们就需要针对这几个点进行设计与实现。
2-1.设计数据表
思路:通过一种语言和键找到对应的值。表结构设计比较简单,key、value、语言各建一个表,如图 2-1所示。
其中每个表只展示了主键编号字段,其实还有一些字段没展示出来,比如code、name,这些可以根据自己的风格去设计。如果是多租户的系统,在每张表后面加入对应的租户id,即可进行数据隔离。
- 2-1-1.如果用户新增的字段需要翻译,往语言键里增加一条数据,以及往语言值里增加与语言定义相同数量的记录即可。
- 2-1-2.如果用户新增语言定义,则往语言值里面增加与语言键相同数量的记录即可。
- 2-1-3.更新、删除同理。
2-2.数据缓存设计
在一个面向世界的应用里面,翻译的频率是很高的,而且随着时间的流逝,翻译的数据肯定会越来越多,如果每次响应数据的翻译都去查询数据库的话,那势必会造成数据库性能以及应用本身性能的浪费。对于这种修改频率不算高的数据,咱们可以缓存起来,用空间换时间。
这里打算用两级缓存的设计来适应该翻译场景,一级是redis,二级是应用内存。
- 2-2-1.将用到的数据从数据库缓存在redis里面,并且生成一个更新标志放入redis。
- 2-2-2.应用获取翻译数据的时候先判断redis更新标志是否为空。
- 2-2-2-1.为空,则代表redis尚未缓存翻译数据,将翻译数据从数据库拉取到内存,且推送到redis。
- 2-2-2-2.不为空,则代表redis已缓存翻译数据,然后再比对redis的更新标志和应用内存的更新标志是否一致。
- 2-2-2-2-1.不一致,则说明翻译数据已经改变,需要从redis重新拉取一次翻译数据,缓存在应用内存中。
- 2-2-2-2-2.一致,则说明翻译数据尚未改变,可以直接使用应用内存中的翻译数据。
- 2-2-3.将最后拿到的翻译数据(key-value)返回给实现翻译逻辑的组件。
如图 2-2所示。
代码如下所示。
/**
* <pre>
* @description: 语言数据组件
* @author: rookie0peng
* @date: 2021/7/4 21:28
* </pre>
*/
public interface LanguageDataComponent {
/**
* 通过语言定义和键获取值
* @param definition 语言定义
* @param key 键
* @return optional -> 值
*/
Optional<String> getValue(String definition, String key);
/**
* 更新语言键值对映射
* @param definition 语言定义
*/
void updateKeyValueMap(String definition);
}
@Service("languageDataComponent")
public class LanguageDataComponentImpl implements LanguageDataComponent {
/**
* 语言定义 -> (key, value)
*/
private static final Map<String, Map<String, String>> DEFINITION_TO_KEY_VALUE_MAP = new ConcurrentHashMap<>();
/**
* 一天
*/
private static final long ONE_DAY = 86400;
/**
* 更新标志
*/
private static final String CHANGE_FLAG = "CHANGE_FLAG";
@Autowired
private RedisTemplate<String, ?> redisTemplate;
@Override
public Optional<String> getValue(String definition, String key) {
if (isEmpty(definition) || isEmpty(key))
return Optional.empty();
Map<String, String> key2ValueMap = DEFINITION_TO_KEY_VALUE_MAP.get(definition);
return isNull(key2ValueMap) ? Optional.empty() : Optional.ofNullable(key2ValueMap.get(key));
}
@Override
public void updateKeyValueMap(String definition) {
if (isEmpty(definition))
return;
//1.获取redis更新标志和本地更新标志
String redisChangeFlag = this.getRedisChangeFlag(definition);
String localChangeFlag = this.getLocalChangeFlag(definition);
//2.redis更新标志为空,则从数据库获取数据并分别推送到redis和应用内存,注意缓存击穿、缓存穿透和缓存雪崩的情况
if (isEmpty(redisChangeFlag)) {
this.deleteByKey(definition);
this.getAndPutAllData(definition);
return;
}
//3.如果不相等,则从redis拉取数据并保存到应用内存
if (!redisChangeFlag.equals(localChangeFlag)) {
Map<String, String> key2ValueMap = redisTemplate.opsForHash()
.entries(definition).entrySet().stream()
.collect(Collectors.toMap(entry -> objectToString(entry.getKey()),
entry -> objectToString(entry.getValue())));
DEFINITION_TO_KEY_VALUE_MAP.put(definition, key2ValueMap);
}
}
/**
* 获取redis的更新标志
* @param definition 语言定义
* @return redis的更新标志
*/
private String getRedisChangeFlag(String definition) {
Object o = redisTemplate.opsForHash().get(definition, CHANGE_FLAG);
return StringUtil.objectToString(o);
}
/**
* 获取本地更新标志
* @param definition 语言定义
* @return 本地的更新标志
*/
private String getLocalChangeFlag(String definition) {
Map<String, String> key2ValueMap = DEFINITION_TO_KEY_VALUE_MAP.get(definition);
return isNull(key2ValueMap) ? null : key2ValueMap.get(CHANGE_FLAG);
}
/**
* 通过语言定义删除数据
* @param definition 语言定义
*/
private void deleteByKey(String definition) {
redisTemplate.delete(definition);
}
/**
* 获取与推送翻译数据
* @param definition 语言定义
* @return 翻译数据
*/
private Map<String, String> getAndPutAllData(String definition) {
//1.从数据库查询翻译数据
Map<String, String> databaseKeyValueMap = this.getLanguageDataFromDatabase(definition);
Map<String, String> key2ValueMap = isEmpty(databaseKeyValueMap) ? new ConcurrentHashMap<>()
: new ConcurrentHashMap<>(databaseKeyValueMap);
//2.设置更新标志
key2ValueMap.put(CHANGE_FLAG, UUIDUtil.randomUUID());
//3.将数据推送到redis
byte[] byteKey = definition.getBytes(StandardCharsets.UTF_8);
Map<byte[], byte[]> byteMap = key2ValueMap.entrySet().stream()
.collect(Collectors.toMap(
entry -> entry.getKey().getBytes(StandardCharsets.UTF_8),
entry -> entry.getValue().getBytes(StandardCharsets.UTF_8)));
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.hMSet(byteKey, byteMap);
return null;
});
Long expire = redisTemplate.getExpire(definition);
if (expire == null || expire < 300L)
redisTemplate.expire(definition, ONE_DAY, TimeUnit.SECONDS);
//4.将数据放到应用内存
DEFINITION_TO_KEY_VALUE_MAP.put(definition, key2ValueMap);
return key2ValueMap;
}
/**
* 从数据库获取数据
* @param definition 语言定义
* @return 数据
*/
private Map<String, String> getLanguageDataFromDatabase(String definition) {
//TODO 补充从数据库查询的代码
Map<String, String> map = new ConcurrentHashMap<>();
if (Locale.SIMPLIFIED_CHINESE.toLanguageTag().equals(definition)) {
map.put("test.key1", "这是第一个值");
map.put("test.key2", "这是第二个值");
} else {
map.put("test.key1", "the first value");
map.put("test.key2", "the second value");
}
return map;
}
}
2-3.将替换逻辑嵌入spring的filter或者序列化
笔者在这里只演示简单的key->value替换,至于递归替换、正则替换可以自行考虑加上。
- 2-3-1.当一个请求进来的时候,首先需要做一些前置处理。
- 2-3-1-1.根据请求的语言设置当前线程的语言环境。
- 2-3-1-2.更新一次当前应用内存的语言缓存数据。
- 2-3-2.当返回响应的时候,通过序列化对响应数据进行替换。
代码如下所示。
/**
* <pre>
* @description: 语言对象
* @author: rookie0peng
* @date: 2021/1/18 17:17
* </pre>
*/
public class Language {
/**
* 语言编码, 默认中文简体
*/
private String localeTag;
public Language() {
}
public Language(String localeTag) {
this.localeTag = localeTag;
}
public String getLocaleTag() {
return localeTag;
}
public void setLocaleTag(String localeTag) {
this.localeTag = localeTag;
}
@Override
public String toString() {
return "Language{" +
"localeTag='" + localeTag + '\'' +
'}';
}
}
/**
* <pre>
* @description: 基于线程上下文的语言工具类
* @author: rookie0peng
* @date: 2021/1/18 17:16
* </pre>
*/
public class LanguageContextUtil {
private static final ThreadLocal<Language> CONTEXT = new ThreadLocal<>();
/**
* 设置语言信息
* @param language 语言信息
*/
public static void set(Language language) {
CONTEXT.set(language);
}
/**
* 获取语言信息
* @return 语言信息
*/
public static Language get() {
return CONTEXT.get();
}
/**
* 移除语言信息
*/
public static void remove() {
CONTEXT.remove();
}
}
/**
* <pre>
* @description: 语言过滤器
* @author: rookie0peng
* @date: 2021/1/18 17:16
* </pre>
*/
@Component
public class LanguageFilter implements Filter {
@Autowired
private LanguageDataComponent languageDataComponent;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
try {
//1.设置当前线程的语言环境
String localeTag;
Locale locale = servletRequest.getLocale();
LanguageContextUtil.set(isNull(locale) || isNull(localeTag = locale.toLanguageTag())
? new Language(Locale.SIMPLIFIED_CHINESE.toLanguageTag()) : new Language(localeTag));
//2.更新语言数据
languageDataComponent.updateKeyValueMap(LanguageContextUtil.get().getLocaleTag());
filterChain.doFilter(servletRequest, servletResponse);
} finally {
LanguageContextUtil.remove();
}
}
}
/**
* <pre>
* @description: i18n序列化
* @author: rookie0peng
* @date: 2021/7/4 21:28
* </pre>
*/
public class I18nJsonSerializer<T> extends JsonSerializer<T> {
@Override
public void serialize(T t, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
//1.获取语言组件
LanguageDataComponent languageDataComponent = SpringUtil.getBean(LanguageDataComponent.class);
//2.通过 语言标志 和 键 获取对应的 值
Optional<String> valueOptional = languageDataComponent.getValue(LanguageContextUtil.get().getLocaleTag(),
StringUtil.objectToString(t));
//3.写入流
if (valueOptional.isPresent())
jsonGenerator.writeString(valueOptional.get());
else
jsonGenerator.writeString(StringUtil.objectToString(t));
}
}
public class TestTranslateVO implements Serializable {
private static final long serialVersionUID = -273426001439788094L;
private String code;
/**
* 指定i18n序列化类
*/
@JsonSerialize(using = I18nJsonSerializer.class)
private String name;
public TestTranslateVO() {
}
public TestTranslateVO(String code, String name) {
this.code = code;
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "TestTranslateVO{" +
"code='" + code + '\'' +
", name='" + name + '\'' +
'}';
}
}
/**
* <pre>
* @description: 通过接口测试i18n
* @author: rookie0peng
* @date: 2021/1/20 13:07
* </pre>
*/
@Api(tags = "测试接口-作者:rookie0peng")
@RestController
@RequestMapping("test")
public class TestController {
@Autowired
private MessageSource messageSource;
@ApiOperation(value = "随便打印数据")
@RequestMapping(path = "console", method = RequestMethod.POST)
public Map<String, String> console() {
Map<String, String> map = new LinkedHashMap<>();
//1.测试无参替换
map.put("test.name", messageSource.getMessage("test.name", null, LocaleContextHolder.getLocale()));
//2.测试字符串参数替换
Object[] stringParam = new Object[] {"1"};
map.put("test.string", messageSource.getMessage("test.string", stringParam, LocaleContextHolder.getLocale()));
//3.测试日期参数替换
Object[] dateParam = new Object[] {new Date()};
map.put("test.date", messageSource.getMessage("test.date", dateParam, LocaleContextHolder.getLocale()));
//3.测试时间参数替换
Object[] timeParam = new Object[] {new Date()};
map.put("test.time", messageSource.getMessage("test.time", timeParam, LocaleContextHolder.getLocale()));
return map;
}
@ApiOperation(value = "测试自定义i18n")
@RequestMapping(path = "test/custom-i18n", method = RequestMethod.POST)
public List<TestTranslateVO> testCustomI18n() {
//test.key1和test.key2在LanguageDataComponentImpl.getLanguageDataFromDatabase方法中定义了
//test.key3用来测试不翻译的场景
return ListUtil.newArrayList(
new TestTranslateVO("test.key1", "test.key1"),
new TestTranslateVO("test.key2", "test.key2"),
new TestTranslateVO("test.key3", "test.key3"));
}
}
2-4.测试自定义i18n
启动项目后,查看调用test/custom-i18n接口返回的数据,调用三次,分别设置header中的语言:默认、中文、英文。
笔者用的是idea的HTTP Client,以下是请求参数:
### 默认——测试自定义i18n
POST {{baseUrl}}/test/test/custom-i18n
Content-Type: application/json
{
}
### 中文——测试自定义i18n
POST {{baseUrl}}/test/test/custom-i18n
Content-Type: application/json
Accept-Language: zh-CN,zh;q=0.9
{
}
### 英文——测试自定义i18n
POST {{baseUrl}}/test/test/custom-i18n
Content-Type: application/json
Accept-Language: en;q=0.9
{
}
以下是返回参数。
[
{
"code": "test.key1",
"name": "这是第一个值"
},
{
"code": "test.key2",
"name": "这是第二个值"
},
{
"code": "test.key3",
"name": "test.key3"
}
]
[
{
"code": "test.key1",
"name": "这是第一个值"
},
{
"code": "test.key2",
"name": "这是第二个值"
},
{
"code": "test.key3",
"name": "test.key3"
}
]
[
{
"code": "test.key1",
"name": "the first value"
},
{
"code": "test.key2",
"name": "the second value"
},
{
"code": "test.key3",
"name": "test.key3"
}
]
2-5.分析执行结果
- 2-5-1.测试默认和测试中文的响应数据是一样的,可以确定系统默认使用的中文环境。
- 2-5-2.对于使用了@JsonSerialize(using = I18nJsonSerializer.class)注解的属性,会根据key自动替换成对应的值。
- 2-5-3.根据key没找到值时,还是会使用原本的key。
2-6.结论
这里只是简单的演示了自定义i18n的功能,但是已然支持用户新增语言、自定义翻译后的值、多机部署等。如果想要支持正则替换、递归翻译也可以自行扩展。
3.总结
这里演示了两种i18n的实现方案,具体想用哪种就见仁见智了。图方便,开箱即用,那就选spring i18n;图灵活,可扩展性强,那就选自定义i18n。自然,肯定还有很多我没想到的方案,如果可以的话,欢迎大家在评论区指点。
参考
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!