在项目中,很多时候需要国际化的支持,这篇文章要介绍一下 JDK 国际化相关的类,以及 SpringBoot 项目中多语言国际化的使用。
Locale 什么是国际化?国际化也称作本地化,简单的说就是根据本地不同的“语言”和“国家/地区”环境,前端页面、接口返回的信息或者错误提示等也要切换成对应的语言。在Java中表示国际化的类是java.util.Locale,它包含了两层含义,一是“语言”,二是“国家/地区”,比如同样是中文,既有中国大陆地区的中文(zh_CN),又有中国台湾(zh_TW)和中国香港地区的中文(zh_HK)。如果有了“语言”和“国家/地区”两个参数,我们可以创建一个确定的Locale对象,此外也有其他的创建方式,下面给出几个示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Locale locale1 = new Locale ("zh" ,"CN" ); Locale locale2 = new Locale ("zh" ); Locale locale3 = Locale.CHINA; Locale locale4 = Locale.CHINESE; Locale locale 5 = Locale.getDefault();
用户既可以同时指定语言和国家/地区参数定义一个本地化对象①,也可以仅通过语言参数定义一个泛本地化对象②。Locale类中通过静态常量定义了一些常用的本地化对象,③和④处就直接通过引用常量返回本地化对象。此外,用户还可以获取系统默认的本地化对象,如⑤所示。
JDK的国际化工具类 JDK的java.text包中提供了几个支持国际化的格式化操作工具类:NumberFormat、DateFormat、MessageFormat。NumberFormat可以按本地化的方式对货币金额进行格式化操作,DateFormat则可以按本地化的方式对日期进行格式化操作,MessageFormat则在NumberFormat和DateFormat的基础上提供了强大的占位符字符串的格式化功能,它支持时间、货币、数字以及对象属性的格式化操作。下面是一个格式化功能的演示实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Test public void testMessageFormat () { String pattern1 = "{0},你好!你于{1}在工商银行存入{2} 元。" ; String pattern2 = "At {1,time,short} On {1,date,long},{0} paid {2,number, currency}." ; Object [] params = {"John" , new GregorianCalendar ().getTime(), 1000 }; String msg1 = MessageFormat.format(pattern1, params); MessageFormat mf = new MessageFormat (pattern2, Locale.US); String msg2 = mf.format(params); System.out.println(msg1); System.out.println(msg2); }
输出结果:
1 2 John,你好!你于22-1-21 下午5:26在工商银行存入1,000 元。 At 5:26 PM On January 21, 2022,John paid $1,000.00.
国际化资源文件 通常项目如果需要本地化,是通过对不同本地化类型分别提供对应的资源文件,并以规范的方式进行命名。命名规范如下:
1 <资源名>_<语言代码>_<国家/地区代码>.properties
其中,语言代码和国家/地区代码都是可选的。<资源名>.properties是默认的本地化资源文件,<资源名>_<语言代码>_<国家/地区代码>.properties则是具体到某个Locale的资源文件,在系统中首先是精准匹配,如果没有找到对应的资源文件,会使用默认的本地化资源文件。
假设资源名为resource,语言为英文,国家为美国,则与其对应的本地化资源文件命名为resource_en_US.properties。本地化信息在资源文件以属性名/值的方式表示:
1 2 3 greeting.common=How are you! greeting.morning = Good morning! greeting.afternoon = Good Afternoon!
对应语言为中文,国家/地区为中国大陆的本地化资源文件则命名为resource_zh_CN.properties,资源文件内容如下:
1 2 3 greeting.common=\u60a8\u597d\uff01 greeting.morning=\u65e9\u4e0a\u597d\uff01 greeting.afternoon=\u4e0b\u5348\u597d\uff01
这三个文件我们放在项目的resources/i18n目录下。
ResourceBundle 如果应用程序中拥有大量的本地化资源文件,通过传统的File操作资源文件太过笨拙。Java为我们提供了用于加载本地化资源文件的方便类java.util.ResourceBundle。ResourceBundle为加载及访问资源文件提供了便捷的操作,对于上面的两个资源文件,ResourceBundle的使用示例如下:
1 2 3 4 5 6 7 8 9 @Test public void testResourceBundle () { ResourceBundle rb1 = ResourceBundle.getBundle("i18n/resource" , Locale.US); ResourceBundle rb2 = ResourceBundle.getBundle("i18n/resource" , Locale.CHINA); System.out.println("us:" +rb1.getString("greeting.common" )); System.out.println("cn:" +rb2.getString("greeting.common" )); }
输出:
LocaleResolver 前面讲的都是Java JDK自带的和国际化相关的类,对于Spring来说,它由LocaleResolver这个组件来处理Locale,LocaleResolver是一个接口,包含有两个方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public interface LocaleResolver { Locale resolveLocale (HttpServletRequest request) ; void setLocale (HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) ; }
我们知道SpringMVC中客户端发起的http请求都会进入到DispatcherServlet中并最终由doDispatch(request, response)方法处理。DispatcherServlet中就持有一个LocaleResolver组件,用来专门处理国际化问题。此外,DispatcherServlet中还有一个initLocaleResolver(ApplicationContext context)用来对LocaleResolver变量赋值和初始化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 private void initLocaleResolver (ApplicationContext context) { try { this .localeResolver = context.getBean(LOCALE_RESOLVER_BEAN_NAME, LocaleResolver.class); if (logger.isTraceEnabled()) { logger.trace("Detected " + this .localeResolver); } else if (logger.isDebugEnabled()) { logger.debug("Detected " + this .localeResolver.getClass().getSimpleName()); } } catch (NoSuchBeanDefinitionException ex) { this .localeResolver = getDefaultStrategy(context, LocaleResolver.class); if (logger.isTraceEnabled()) { logger.trace("No LocaleResolver '" + LOCALE_RESOLVER_BEAN_NAME + "': using default [" + this .localeResolver.getClass().getSimpleName() + "]" ); } } }
可以看到,这个方法会从Spring容器中获取一个LocaleResolver对象。LocaleResolver这个接口是有好几个实现类的,那么默认获取到的LocaleResolver对象具体是什么类型呢?这就需要看到WebMvcAutoConfiguration这个类,它是一个跟 SpringMVC 功能(也就是spirng的web功能)相关的自动配置类。这个类中就自动配置了LocaleResolver,具体代码如下
1 2 3 4 5 6 7 8 9 10 11 12 @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = "spring.mvc", name = "locale") public LocaleResolver localeResolver () { if (this .mvcProperties .getLocaleResolver() == WebMvcProperties.LocaleResolver.FIXED) { return new FixedLocaleResolver (this .mvcProperties.getLocale()); } AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver (); localeResolver.setDefaultLocale(this .mvcProperties.getLocale()); return localeResolver; }
上面代码中看到通过spring.mvc.locale可以配置localeResolver的属性值,如果什么都不配置,默认使用的是AcceptHeaderLocaleResolver对象。
当然,我们也可以自己实现一个LocaleResolver并加入到容器中,Spring就不会自动配置LocaleResolver对象了,而是会使用我们的自定义LocaleResolver来处理国际化。代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class MyLocaleResolver implements LocaleResolver { private static final String DEFAULT_LANG = "en_US" ; @Override public Locale resolveLocale (HttpServletRequest request) { String lang = request.getHeader("Accept-Language" ); if (lang == null || lang.length() == 0 ) { lang = request.getParameter("lang" ); } lang = LangUtils.parse(lang); if (lang == null || lang.length() == 0 ) { lang = DEFAULT_LANG; } String [] language = lang.split("_" ); if (language.length == 2 ) { return new Locale (language[0 ], language[1 ]); } else { return new Locale (language[0 ]); } } @Override public void setLocale (HttpServletRequest request, HttpServletResponse response, Locale locale) { } }
注入ioc容器中,交由spring来管理:
1 2 3 4 @Bean public LocaleResolver localeResolver () { return new MyLocaleResolver (); }
LocaleContextHolder 上面讲到了Spring是利用LocaleResolver组件解析出一个Locale实例,那么在项目中怎么获取到这个实例呢?Spring还提供了一个类LocaleContextHolder,通过LocaleContextHolder的getLocale()方法可以很方便地得到这个Locale实例。
1 Locale locale = LocaleContextHolder.getLocale();
查看LocaleContextHolder中的源码,可以知道它利用了ThreadLocal线程副本的功能,将一个LocaleContext对象保存在线程副本中。
1 2 3 4 5 6 private static final ThreadLocal <LocaleContext> localeContextHolder = new NamedThreadLocal <>("LocaleContext" ); private static final ThreadLocal <LocaleContext> inheritableLocaleContextHolder = new NamedInheritableThreadLocal <>("LocaleContext" );
LocaleContext是一个接口,它只有一个getLocale()方法,该方法就是返回一个Locale实例。
1 2 3 4 5 6 7 8 9 10 11 public interface LocaleContext { @Nullable Locale getLocale () ; }
那LocaleContext是什么时候放入线程副本中的呢? 我们知道请求进入到Servlet之后,会调用service方法,HttpServlet则根据GET、POST等不同请求方式去调用doGet、doPost等方法,DispatcherServlet继承自FrameworkServlet,FrameworkServlet里面重写了这些doGet、doPost方法,并且最终都会调用的它的processRequest方法,在这个processRequest方法中有会调用doService方法(这是个抽象方法),DispatcherServlet实现了doService方法,并且最终调用doDispatch方法。 现在再来看FrameworkServlet中的processRequest这个方法源码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 protected final void processRequest (HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { long startTime = System.currentTimeMillis(); Throwable failureCause = null ; LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext(); LocaleContext localeContext = buildLocaleContext(request); RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes requestAttributes = buildRequestAttributes(request, response, previousAttributes); WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request); asyncManager.registerCallableInterceptor(FrameworkServlet.class.getName(), new RequestBindingInterceptor ()); initContextHolders(request, localeContext, requestAttributes); try { doService(request, response); } catch (ServletException | IOException ex) { failureCause = ex; throw ex; } catch (Throwable ex) { failureCause = ex; throw new NestedServletException ("Request processing failed" , ex); } finally { resetContextHolders(request, previousLocaleContext, previousAttributes); if (requestAttributes != null ) { requestAttributes.requestCompleted(); } logResult(request, response, failureCause, asyncManager); publishRequestHandledEvent(request, response, startTime, failureCause); } }
在这个方法中首先会调用buildLocaleContext(request)方法得到一个LocaleContext对象,然后在initContextHolders(request, localeContext, requestAttributes)方法里把这个LocaleContext对象对象放入线程本地中。DispatcherServlet中重写了buildLocaleContext(request),看看它是怎么做的?
1 2 3 4 5 6 7 8 9 10 @Override protected LocaleContext buildLocaleContext (final HttpServletRequest request) { LocaleResolver lr = this .localeResolver; if (lr instanceof LocaleContextResolver) { return ((LocaleContextResolver) lr).resolveLocaleContext(request); } else { return () -> (lr != null ? lr.resolveLocale(request) : request.getLocale()); } }
DispatcherServlet里会拿到它持有的LocaleResolver组件,也就是从Spring容器中获取到的LocaleResolver实例,用它来去创建一个LocaleContext实现类对象(匿名内部类的方式)。至此LocaleContextHolder的原理和来龙去脉也就清晰了。
MessageSource MessageSource是Spring中定义的获取本地化信息的接口,并且还有参数的信息替换功能,参数替换跟前面介绍的MessageFormat用法类似。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public interface MessageSource { String getMessage (String code, Object[] args, String defaultMessage, Locale locale) ; String getMessage (String code, Object[] args, Locale locale) throws NoSuchMessageException; String getMessage (MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException; }
MessageSource有很多实现类,那么SpringBoot会使用哪个呢?这就要看MessageSourceAutoConfiguration这个自动配置类,这个类会向容器中注入一个MessageSource实例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 @Configuration @ConditionalOnMissingBean(value = MessageSource.class, search = SearchStrategy.CURRENT) @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) @Conditional(ResourceBundleCondition.class) @EnableConfigurationProperties public class MessageSourceAutoConfiguration { private static final Resource [] NO_RESOURCES = {}; @Bean @ConfigurationProperties(prefix = "spring.messages") public MessageSourceProperties messageSourceProperties () { return new MessageSourceProperties (); } @Bean public MessageSource messageSource (MessageSourceProperties properties) { ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource (); if (StringUtils.hasText(properties.getBasename())) { messageSource.setBasenames(StringUtils.commaDelimitedListToStringArray( StringUtils.trimAllWhitespace(properties.getBasename()))); } if (properties.getEncoding() != null ) { messageSource.setDefaultEncoding(properties.getEncoding().name()); } messageSource.setFallbackToSystemLocale(properties.isFallbackToSystemLocale()); Duration cacheDuration = properties.getCacheDuration(); if (cacheDuration != null ) { messageSource.setCacheMillis(cacheDuration.toMillis()); } messageSource.setAlwaysUseMessageFormat(properties.isAlwaysUseMessageFormat()); messageSource.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage()); return messageSource; } }
可以看到向Spring容器中注入的是一个ResourceBundleMessageSource对象,ResourceBundleMessageSource它是基于JDK的ResourceBundle,并且实现了MessageSource接口。 从上面还能看到和国际化资源文件配置相关的属性类MessageSourceProperties,这些配置的前缀都是spring.messages,再查看MessageSourceProperties得知可以配置资源文件的basename、encoding、cacheDuration等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public class MessageSourceProperties { private String basename = "messages" ; private Charset encoding = StandardCharsets.UTF_8; @DurationUnit(ChronoUnit.SECONDS) private Duration cacheDuration; private boolean fallbackToSystemLocale = true ; private boolean alwaysUseMessageFormat = false ; private boolean useCodeAsDefaultMessage = false ; }
从上面的basename = "messages"可知,SpringBoot 默认国际化资源文件为:classpath:messages.properties。如果项目中的资源文件不是默认文件名,则我们可以通过**spring.messages.basename**指定。比如在本文示例项目中配置如下,表示默认使用classpath下i18n目录下的resource.properties文件。
1 2 3 spring: messages: basename: i18n.resource
测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @RunWith(SpringRunner.class) @SpringBootTest(classes = I18nBootstrap.class) @Slf4j public class I18nTest { @Autowired private MessageSource messageSource; @Test public void getMessage () { String usMsg = messageSource.getMessage("greeting.morning" , null , Locale.US); String cnMsg = messageSource.getMessage("greeting.morning" , null , Locale.CHINA); log.info("us:{}" , usMsg); log.info("cn:{}" , cnMsg); } }
输出:
1 2 2022-01-23 22:44:50.980 INFO 16728 --- [ main] c.l.springboot.i18n.test.I18nTest : us:Good morning! 2022-01-23 22:44:50.989 INFO 16728 --- [ main] c.l.springboot.i18n.test.I18nTest : cn:早上好!
工具类 根据上面的分析,我们就可以自己封装一个简单的工具类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Component public class MessageUtil { @Autowired private MessageSource messageSource; public String get (String code) { Locale locale = LocaleContextHolder.getLocale(); return messageSource.getMessage(code, null , locale); } public String get (String code, String[] args) { Locale locale = LocaleContextHolder.getLocale(); return messageSource.getMessage(code, args, locale); } public String get (String code, String[] args, String defaultMessage) { Locale locale = LocaleContextHolder.getLocale(); return messageSource.getMessage(code, args, defaultMessage, locale); } }