背景
近期负责的项目出现了可能的国外客户,因此要将系统进行国际化,以备开拓国际市场。毕竟赚美元很是带劲。项目前后端分离,前端是基于React开发,后端呢就是SpringCloud全家桶。背景大概就是这样,总是目标就是对系统进行国际化改造,以便支持国际用户。
国际化改造的内容
我翻了翻网上关于国际化的文章大部分都是集中在前台或是后台的,没有比较系统的东西。这里先来梳理一下完整的国际化所涉及的内容。
- 前端文字资源的国际化,这个是非常明显的,就是文字内容的国际化
- 前端其他资源的国际化,这个很容易忽略,特别是涉及到一些图片或是音乐什么都需要特别的处理
- 后端的返回内容的国际化,比如说一些错误提示,不涉及到存储
- 后端保存内容的国际化,比如某些日志,一些特别的日志可能会显示给用户,但又要保存下来的那种
- 后端主动推送内容的国际化,这些比较特殊,比如说实时的提醒
这里面比较成熟的方案是前端文字资源的国际化以及后端返回内容的国际化。其他的未找到比较成熟的解决方案,我在这里只介绍大体思路。
国际化改造
前台的国际化改造
前端我们是采用react框架,国际化自然是采用react-i18next。
package.json添加依赖。"react-i18next": "11.12.0",
增加i18n.js在app.js代码主目录下。
//i18n.js
import i18n from 'i18next';
import {initReactI18next} from 'react-i18next';
import {getStorageItem} from "../utils/localStorage";
import {taskCreateCn, taskCreateEn} from './taskCreateCn';
const resources = {
zh_CN: {
translation: {
'locale': 'zh_CN',
'login': '登录',
'chrome': '谷歌浏览器',
}
},
en_US: {
translation: {
'locale': 'enUS',
'login': 'login',
'chrome': 'chrome',
}
},
};
const selectedLanguage = getStorageItem('language');
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
lng: selectedLanguage ? selectedLanguage : 'zh_CN',
keySeparator: false, // we do not use keys in form messages.welcome
interpolation: {
escapeValue: false, // react already safes from xss
},
}).then();
export default i18n;
app.js,重点在于引入i18n.js
import "@babel/polyfill";
import {ConfigProvider} from 'antd';
import 'antd/dist/antd.css';
import enUS from 'antd/lib/locale/en_US';
import zh_CN from 'antd/lib/locale-provider/zh_CN';
import 'core-js/es6/map';
import 'core-js/es6/set';
import 'raf/polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
// import '../utils/mock';
import store from './redux/Store';
import Router from './Router';
import './i18n'; // 在这里导入 i18n.js
import {useTranslation} from "react-i18next";
const App = () => {
const {t, i18n} = useTranslation()
React.translate = t
return (
t('locale') === 'zh_CN' ?
<ConfigProvider locale={zh_CN}>
<Provider store={store}>
<Router/>
</Provider>
</ConfigProvider>
:
<ConfigProvider locale={enUS}>
<Provider store={store}>
<Router/>
</Provider>
</ConfigProvider>
)
}
ReactDOM.render(<App/>, document.getElementById('content'))
具体的页面代码
import React, {useEffect} from 'react';
import {Button, Col, Divider, Form, Input, message, Row, Select, Tooltip} from 'antd';
import {getStorageItem, setStorageItem} from "../../utils/localStorage"
import {useTranslation} from 'react-i18next'
const Login = () => {
const {t, i18n} = useTranslation();
const selectedLanguage = getStorageItem('language');
return (
<div className='login-background'>
<Row className='top' justify={"center"}>
<Col>
<div className='title'><span>{t('chrome')}</span></div>
</Col>
</Row>
</div>
)
}
export default Login;
前台进行后台请求的需要配合拦截器,增加国际化头
// 添加响应拦截器
axios.interceptors.request.use(function (config){
config.headers.Local="zh_CN";
return config;
})
基本意思已经在示例代码中了,组件中引入
const {t, i18n} = useTranslation();
,原有文字都是采用t(‘key’)替换,然后文字在i18n.js中间中在各种语言中给出对应文字。
后端的国际化改造
后端的国际化改造更加复杂一点,因为系统是分布式的,因此语言信息需要通过调用链传播到各个服务,但传统的国际化方式是没有办法跨服务的,特别是各个服务之间的通过Feign相互调用时。
基本思路:我们采用的是增加自定义的LoaclResovlery以及增加自定义的Feign拦截器实现的国际化头的传递。在通过标准的springboot国际化方式实现国际化。
为各个服务注入LocaleResolver,注意这里国际化头采用的Locale。
/**
* @author zhaoe
*/
@Component
public class LocaleConfig {
public static final String LOCAL_HEAD_NAME = "Locale";
@Bean
public LocaleResolver localeResolver() {
LocaleHeaderLocaleResolver localeResolver = new LocaleHeaderLocaleResolver();
localeResolver.setLocaleHeadName(LOCAL_HEAD_NAME);
localeResolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return localeResolver;
}
@Bean
public WebMvcConfigurer localeInterceptor() {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(@Nonnull InterceptorRegistry registry) {
LocaleChangeInterceptor localeInterceptor = new LocaleChangeInterceptor();
localeInterceptor.setParamName(LOCAL_HEAD_NAME);
registry.addInterceptor(localeInterceptor);
}
};
}
}
自定义的LocaleContextResolver
/**
* @author zew
*/
public class LocaleHeaderLocaleResolver implements LocaleContextResolver {
public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = LocaleHeaderLocaleResolver.class.getName() + ".LOCALE";
public static final String TIME_ZONE_REQUEST_ATTRIBUTE_NAME = LocaleHeaderLocaleResolver.class.getName() + ".TIME_ZONE";
@Nullable
private Locale defaultLocale;
@Nullable
private TimeZone defaultTimeZone;
@Nullable
private String localeHeadName;
public void setLocaleHeadName(@Nullable String localeHeadName) {
this.localeHeadName = localeHeadName;
}
@Nullable
public String getLocaleHeadName() {
return this.localeHeadName;
}
public void setDefaultLocale(@Nullable Locale defaultLocale) {
this.defaultLocale = defaultLocale;
}
@Nullable
public Locale getDefaultLocale() {
return this.defaultLocale;
}
public void setDefaultTimeZone(@Nullable TimeZone defaultTimeZone) {
this.defaultTimeZone = defaultTimeZone;
}
@Nullable
protected TimeZone getDefaultTimeZone() {
return this.defaultTimeZone;
}
@Override
public Locale resolveLocale(HttpServletRequest request) {
parseLocaleHeaderIfNecessary(request);
return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME);
}
@Override
public LocaleContext resolveLocaleContext(final HttpServletRequest request) {
parseLocaleHeaderIfNecessary(request);
return new TimeZoneAwareLocaleContext() {
@Override
@Nullable
public Locale getLocale() {
return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME);
}
@Override
@Nullable
public TimeZone getTimeZone() {
return (TimeZone) request.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME);
}
};
}
@Override
public void setLocaleContext(HttpServletRequest request, @Nullable HttpServletResponse response,
@Nullable LocaleContext localeContext) {
Assert.notNull(response, "HttpServletResponse is required for LocaleHeaderLocaleResolver");
Locale locale = null;
TimeZone timeZone = null;
if (localeContext != null) {
locale = localeContext.getLocale();
if (localeContext instanceof TimeZoneAwareLocaleContext) {
timeZone = ((TimeZoneAwareLocaleContext) localeContext).getTimeZone();
}
response.setHeader(getLocaleHeadName(),
(locale != null ? locale.toString() : "-") + (timeZone != null ? ' ' + timeZone.getID() : ""));
}
request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME,
(locale != null ? locale : determineDefaultLocale(request)));
request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME,
(timeZone != null ? timeZone : determineDefaultTimeZone(request)));
}
private void parseLocaleHeaderIfNecessary(HttpServletRequest request) {
if (request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) != null) {
return;
}
String headName = getLocaleHeadName();
Locale locale = processLocale(request, headName);
request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME,
(locale != null ? locale : determineDefaultLocale(request)));
TimeZone timeZone = processTimeZone(request, headName);
request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME,
(timeZone != null ? timeZone : determineDefaultTimeZone(request)));
}
private Locale processLocale(HttpServletRequest request, String headName) {
Locale locale = null;
if (headName == null) {
return null;
}
String localeStr = request.getHeader(headName);
if (localeStr != null) {
String localePart = localeStr;
int spaceIndex = localePart.indexOf(' ');
if (spaceIndex != -1) {
localePart = localeStr.substring(0, spaceIndex);
}
try {
locale = (!"-".equals(localePart) ? parseLocaleValue(localePart) : null);
} catch (IllegalArgumentException ex) {
if (request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) == null) {
throw new IllegalStateException("Invalid locale Header '" + getLocaleHeadName() +
"' with value [" + localeStr + "]: " + ex.getMessage());
}
}
}
return locale;
}
private TimeZone processTimeZone(HttpServletRequest request, String headName) {
TimeZone timeZone = null;
if (headName == null) {
return null;
}
String localeStr = request.getHeader(headName);
if (localeStr != null) {
String timeZonePart = null;
int spaceIndex = localeStr.indexOf(' ');
if (spaceIndex != -1) {
timeZonePart = localeStr.substring(spaceIndex + 1);
}
try {
if (timeZonePart != null) {
timeZone = StringUtils.parseTimeZoneString(timeZonePart);
}
} catch (IllegalArgumentException ex) {
if (request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) == null) {
throw new IllegalStateException("Invalid locale Header '" + getLocaleHeadName() +
"' with value [" + localeStr + "]: " + ex.getMessage());
}
}
}
return timeZone;
}
@Nullable
protected Locale determineDefaultLocale(HttpServletRequest request) {
if (getDefaultLocale() == null) {
return request.getLocale();
} else {
return getDefaultLocale();
}
}
@Nullable
protected TimeZone determineDefaultTimeZone(HttpServletRequest request) {
return getDefaultTimeZone();
}
@Nullable
protected Locale parseLocaleValue(String locale) {
return StringUtils.parseLocaleString(locale);
}
@Override
public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) {
setLocaleContext(request, response, (locale != null ? new SimpleLocaleContext(locale) : null));
}
}
特别的文字动态国家化工具类
/**
* 国际化工具类
*
* @author zhaoe
*/
@Component
public class MessageI18nUtils {
private static MessageSource messageSource;
public MessageI18nUtils(MessageSource messageSource) {
MessageI18nUtils.messageSource = messageSource;
}
/**
* 获取单个国际化翻译值
*/
public static String get(String msgKey) {
try {
return messageSource.getMessage(msgKey, null, LocaleContextHolder.getLocale());
} catch (Exception e) {
return msgKey;
}
}
}
再在资源文件夹中建立i18n/messages.properties,i18n/messages_zh_CN.properties,i18n/messages_en_US.properties文件。每个properties都采用KV形式描述key对应的文字。
对了,别忘了application.yml中添加下面的配置信息。
spring:
messages:
encoding: UTF-8
basename: i18n/messages
ok,上面都是传统的国际化,下面处理分布式服务特别的头传输问题。这里解决方案只适用于Feign哦。
这里的RequestInterceptor
是Feign的拦截器。通过拦截器手动的添加上header实现将国际化头传递到下游服务。
/**
* @author zew
*/
@Slf4j
@Configuration
public class FeignConfig implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (Objects.isNull(attributes)) {
return;
}
HttpServletRequest request = attributes.getRequest();
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
if (StringUtils.equalsIgnoreCase(name, LocaleConfig.LOCAL_HEAD_NAME)) {
String values = request.getHeader(name);
requestTemplate.header(name, values);
}
}
}
}
}
这样在记录的时候使用MessageI18nUtils.get
即可得到请求动态的文字内容。
其他改造思路
保存内容的国际化
保存内容的国际化的实现方式比较多。
1.可以采用双写的方式,也就是将多种语言的内容都保存下来,前端查询的时候根据Local来返回对应的内容。
2. 可以将保存的内容也采用key的形式,再返回前台时,通过前台请求的Local在转换为对应的内容。
3. 当然也有野路子,可以直接只存储一种语言,然后再返回前台时,检测到其他语言的话,调用翻译API进行实时的内容转换,返回前台。
具体采用哪种模式大家可以根据自己的需要采用,第三个路线优点是实现快,存储少,但就是路子有点野,翻译质量也是参差不齐。
推送内容的国际化
我们推送的方式是采用消息订阅的方式。
推送内容的国际化,我们采用的是分别推送的方法,就是分别用MessageI18nUtils.get(key,local)给出不同语言的内容,分别推送到不同的topic上。
前端在切换语言内,调整订阅不同的提醒topic,英语则订阅us_US/原topic,中文订阅cn_ZH/原topic。这样内容直接显示出来就ok了。
总结
国际化实际上是一个非常系统的过程,绝不是这里一篇文章能面面俱到的说清楚的,所以可能有不少问题需要仔细思考以及根据自己的业务情况进行特殊的调整。