1. 概念

国际化是指软件开发时,应该具备支持多种语言和地区的功能。 换句话说就是,开发的软件需要能同时应对不同国家和地区的用户访问,并根据用户地区和语言习惯,提供相应的、符合用具阅读习惯的页面和数据。

例如,iphone 手机在注册的时候会要求选择系统语言,我会选择简体中文,台湾同胞会选择中文繁体,美国人会选择英文。选择不同的系统语言后,手机界面上的文字语言、时间时区等,都会跟随变动。

在针对国际化的开发中,我们经常能见到 i18n,实际上就是国际化的英文internationalization 的缩写,和 k8s 类似:

  • in 分别为首末字符
  • 18 则为中间的字符数。

本文核心关注国际化中多语言的方案,后续再写时区。当然,我写的是比较浅显的概念,方案上可能也比较简单,大家可基于此入门。

2. 代码示例

在 Spring Boot 中,对于国际化的支持,默认是通过 AcceptHeaderLocaleResolver 解析器来完成的,这个解析器,默认是通过请求头的 Accept-Language 字段来判断当前请求所属的环境的,进而给出合适的响应。

我们可以先写代码,通过代码示例感受一下。

我们创建一个 Spring Boot 项目,不需要特殊的 pom 依赖,因为 ApplicationContent 默认就实现了国际化能力。

1. controller

我们先写一个 controller api:

@RestController
@RequestMapping("")
public class DemoController {
    private final static String ERROR_KEY_USERNAME_NOT_EXISTS = "user.username.not.exists";

    private final MessageSource messageSource;

    public DemoController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    private final Predicate<String> checkUsernamePredicate = (String username) -> {
        if (Objects.isNull(username)) {
            return false;
        }
        return username.startsWith("T");
    };

    @GetMapping("hello")
    public String sayHello(@RequestParam("username") String username) {
        if (checkUsernamePredicate.test(username)) {
            return "Hello!" + username;
        }
        return messageSource.getMessage(ERROR_KEY_USERNAME_NOT_EXISTS, new String[]{username}, LocaleContextHolder.getLocale());
    }
}

sayHello 的接口,如果传入的 username 参数不是以 T 开头,就会返回以 user.username.not.exists 为key,对应的多语言值。

对应多语言值的内容,我们配置在项目 resources 的目录中。

2. messages 文件

我们在 resources 目录下创建4个文件:

  1. messages.properties

    user.username.not.exists=账号 {0} 不存在
  2. messages_zh_CN.properties

    user.username.not.exists=账号 {0} 不存在
  3. messages_zh_TW.properties

    user.username.not.exists=帳號 {0} 不存在
  4. messages_en_US.properties

    user.username.not.exists=Account {0} does not exist
3. 测试

测试接口,传入参数 username=Jock,但 Header中带的 Accept-Language 值不同,对应的结果分别是:

  1. Accept-Language 不传:
    默认值,启用文件 messages.properties,结果为 “账号 Jock 不存在”。
  2. Accept-Language=zh-CN:
    启用文件 messages_zh_CN.properties,结果为 “账号 Jock 不存在”。
  3. Accept-Language=zh-TW:
    启用文件 messages_zh_TW.properties,结果为 “帳號 Jock 不存在”。
  4. Accept-Language=en-US:
    启用文件 messages_en_US.properties,结果为 “Account Jock does not exist”。

根据结果来看,达到了我们的预期结果。

4. messages文件位置

示例中,多语言的文件是写在 resources 目录中,文件命名也都是 messages开头的,但其实是可以自定义的。

假设我们将上述的文件放在 resources/i18n 目录下,我们可以通过在 application.propreties 文件中配置生效:

spring.messages.basename=i18n/messages

3. 源码阅读

我们简单看看 Spring 框架中是怎么封装的。

3.1. MessageSourceAutoConfiguration

在前面的示例代码中,最核心的方法是出自于 MessageSource,我们可以直接注入 MessageSource 的 Bean,说明在容器初始化时,自动装载了这个Bean,代码就在这个类中。

MessageSourceAutoConfiguration.java

@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, 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;
    }

    protected static class ResourceBundleCondition extends SpringBootCondition {

        private static ConcurrentReferenceHashMap<String, ConditionOutcome> cache = new ConcurrentReferenceHashMap<>();

        @Override
        public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
            String basename = context.getEnvironment().getProperty("spring.messages.basename", "messages");
            ConditionOutcome outcome = cache.get(basename);
            if (outcome == null) {
                outcome = getMatchOutcomeForBasename(context, basename);
                cache.put(basename, outcome);
            }
            return outcome;
        }

        private ConditionOutcome getMatchOutcomeForBasename(ConditionContext context, String basename) {
            ConditionMessage.Builder message = ConditionMessage.forCondition("ResourceBundle");
            for (String name : StringUtils.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(basename))) {
                for (Resource resource : getResources(context.getClassLoader(), name)) {
                    if (resource.exists()) {
                        return ConditionOutcome.match(message.found("bundle").items(resource));
                    }
                }
            }
            return ConditionOutcome.noMatch(message.didNotFind("bundle with basename " + basename).atAll());
        }

        private Resource[] getResources(ClassLoader classLoader, String name) {
            String target = name.replace('.', '/');
            try {
                return new PathMatchingResourcePatternResolver(classLoader)
                        .getResources("classpath*:" + target + ".properties");
            }
            catch (Exception ex) {
                return NO_RESOURCES;
            }
        }

    }

}

在这个类的代码中,我们可以分成3个部分:

  1. MessageSourceProperties:读配置文件中的参数,生成对应配置类的 Bean。
  2. MessageSource:基于 MessageSourceProperties 配置类中的值,初始化 MessageSource 实例,生成对应的 Bean。
  3. ResourceBundleCondition:和 @Condition 配合,判断 MessageSourceAutoConfiguration 当前配置类是否装载进容器。

3.1.1. MessageSourceProperties

该类对应配置文件的前缀是:

@ConfigurationProperties(prefix = "spring.messages")

MessageSourceProperties.java

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;
    ... ...
    }

还记得示例代码中,messages.properties 默认在 resources 的根目录下,而且默认文件名是 messages 。如果需要修改,可以在配置文件中修改 spring.messages.basename 的值。

就是源自于这里的配置类代码,而该属性的默认值为 messages

3.1.2. MessageSource

这里的代码只是创建 MessageSource 的对象,基于 MessageSourceProperties 对象的属性值,对 MessageSource 对象的属性进行赋值,并注册 Bean。

有关这个类的方法,后续再介绍。

3.1.3. ResourceBundleCondition

在当前配置类上有 @Conditional(ResourceBundleCondition.class) 的注解。

1. @Conditional

@Conditional.java

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Conditional {
    Class<? extends Condition>[] value();
}

@Conditional 注解是 Spring-context 模块提供了一个注解,该注解的作用是可以根据一定的条件来使 @Configuration 注解标记的配置类是否生效。

value() 值为实现 Condition 接口的一个 Class,Spring 框架根据实现Conditon 接口的 matches 方法返回true或者false来做以下操作,如果matches方法返回true,那么该配置类会被 Spring 扫描到容器里, 如果为false,那么 Spring 框架会自动跳过该配置类不进行扫描装配。

2. ResourceBundleCondition

该注解中,value 为 ResourceBundleCondition.class,按要求是实现了 Condition 接口。该类的定义也在当前类中,是个静态内部类。

通过阅读代码不难理解,方法中还是通过获取 spring.messages.basename ,判断是否有定义多语言的配置文件,当存在配置文件时 match 为 true,否则为 false。

即不存在多语言配置文件时,当前 MessageSourceProperties 配置类不会加载为容器中的 Bean,配置类中 @Bean 修饰的那些 Bean,同样也都不会被加载。

3.2. MessageSource

还是在前面的示例代码中,我们做多语言翻译的,是调用 MessageSource 接口的方法。

MessageSource.java

public interface MessageSource {
    @Nullable
    String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);

    String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

    String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}

可以看到这个接口只有3个重载的 getMessage 方法,实现类我们可以看 AbstractMessageSource。下面拿 MessageSource 的一个方法追溯下去:

1. MessageSource.getMessage
    String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
2. AbstractMessageSource

AbstractMessageSource 类中对应该方法的实现方法为:

    public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
        String msg = this.getMessageInternal(code, args, locale);
        if (msg != null) {
            return msg;
        } else {
            String fallback = this.getDefaultMessage(code);
            if (fallback != null) {
                return fallback;
            } else {
                throw new NoSuchMessageException(code, locale);
            }
        }
    }

可以看到调用的核心方法是 this.getMessageInternal(code, args, locale);

    @Nullable
    protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
        if (code == null) {
            return null;
        } else {
            if (locale == null) {
                locale = Locale.getDefault();
            }

            Object[] argsToUse = args;
            if (!this.isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
                String message = this.resolveCodeWithoutArguments(code, locale);
                if (message != null) {
                    return message;
                }
            } else {
                argsToUse = this.resolveArguments(args, locale);
                MessageFormat messageFormat = this.resolveCode(code, locale);
                if (messageFormat != null) {
                    synchronized(messageFormat) {
                        return messageFormat.format(argsToUse);
                    }
                }
            }

            Properties commonMessages = this.getCommonMessages();
            if (commonMessages != null) {
                String commonMessage = commonMessages.getProperty(code);
                if (commonMessage != null) {
                    return this.formatMessage(commonMessage, args, locale);
                }
            }

            return this.getMessageFromParent(code, argsToUse, locale);
        }
    }

这个方法里面,当有传入参数时,调用的是:

  1. this.resolveCode(code, locale);
  2. messageFormat.format(argsToUse);

this.resolveCode(code, locale); 是个接口,我们看看 ResourceBundleMessageSource 中的实现方法。

3. ResourceBundleMessageSource

ResourceBundleMessageSource.(String code, Locale locale);

   @Nullable
    protected MessageFormat resolveCode(String code, Locale locale) {
        Set<String> basenames = this.getBasenameSet();
        Iterator var4 = basenames.iterator();

        while(var4.hasNext()) {
            String basename = (String)var4.next();
            ResourceBundle bundle = this.getResourceBundle(basename, locale);
            if (bundle != null) {
                MessageFormat messageFormat = this.getMessageFormat(bundle, code, locale);
                if (messageFormat != null) {
                    return messageFormat;
                }
            }
        }

        return null;
    }

其中2个核心的方法调用是

  1. ResourceBundle bundle = this.getResourceBundle(basename, locale);
  2. MessageFormat messageFormat = this.getMessageFormat(bundle, code, locale);

this.getResourceBundle(basename, locale);

@Nullable
    protected ResourceBundle getResourceBundle(String basename, Locale locale) {
        if (this.getCacheMillis() >= 0L) {
            return this.doGetBundle(basename, locale);
        } else {
            Map<Locale, ResourceBundle> localeMap = (Map)this.cachedResourceBundles.get(basename);
            ResourceBundle bundle;
            if (localeMap != null) {
                bundle = (ResourceBundle)localeMap.get(locale);
                if (bundle != null) {
                    return bundle;
                }
            }

            try {
                bundle = this.doGetBundle(basename, locale);
                if (localeMap == null) {
                    localeMap = (Map)this.cachedResourceBundles.computeIfAbsent(basename, (bn) -> {
                        return new ConcurrentHashMap();
                    });
                }

                localeMap.put(locale, bundle);
                return bundle;
            } catch (MissingResourceException var5) {
                if (this.logger.isWarnEnabled()) {
                    this.logger.warn("ResourceBundle [" + basename + "] not found for MessageSource: " + var5.getMessage());
                }

                return null;
            }
        }
    }

this.getMessageFormat(bundle, code, locale);

   @Nullable
    protected MessageFormat getMessageFormat(ResourceBundle bundle, String code, Locale locale) throws MissingResourceException {
        Map<String, Map<Locale, MessageFormat>> codeMap = (Map)this.cachedBundleMessageFormats.get(bundle);
        Map<Locale, MessageFormat> localeMap = null;
        if (codeMap != null) {
            localeMap = (Map)codeMap.get(code);
            if (localeMap != null) {
                MessageFormat result = (MessageFormat)localeMap.get(locale);
                if (result != null) {
                    return result;
                }
            }
        }

        String msg = this.getStringOrNull(bundle, code);
        if (msg != null) {
            if (codeMap == null) {
                codeMap = (Map)this.cachedBundleMessageFormats.computeIfAbsent(bundle, (b) -> {
                    return new ConcurrentHashMap();
                });
            }

            if (localeMap == null) {
                localeMap = (Map)codeMap.computeIfAbsent(code, (c) -> {
                    return new ConcurrentHashMap();
                });
            }

            MessageFormat result = this.createMessageFormat(msg, locale);
            localeMap.put(locale, result);
            return result;
        } else {
            return null;
        }
    }

其实还可以继续追溯下去,但后续的代码就不列出来了。通过这部分代码,可以梳理出大致的逻辑:基于 basename 读取多语言配置文件值,将各语言(Locale)、各个 code 和 value 加载进内存(map),并设置缓存。后续可以基于 code 和 Locale 拿对应语言的值了。

4. 单个多语言文件方案

官方的方案设计上,多语言 code 的值是有两处分类:

  1. Locale语种分类:通过 basename 中定义的不同语种的配置文件,做语种上的分类,如:messages_zh_CN.properties、messages_en_US.properties,分别对应 Locale.CHINA、Locale.US。
  2. code 分类:在每个语种的多语言文件中,会基于 key-value,设置不同code的值。

但我们其实也可以尝试另一种方案,只创建一个多语言文件,将不同语种都存在这一个文件中,只不过不同语种的code生成规则不同。

如下示例。

1. controller
@RestController
@RequestMapping("")
public class DemoController {
    private final static String CUSTOM_ERROR_KEY_USERNAME_NOT_EXISTS = "i18n.%s.user.username.not.exists";

    private final MessageSource messageSource;

    public DemoController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    private final Predicate<String> checkUsernamePredicate = (String username) -> {
        if (Objects.isNull(username)) {
            return false;
        }
        return username.startsWith("T");
    };

    @GetMapping("bye")
    public String sayBye(@RequestParam("username") String username) {
        if (checkUsernamePredicate.test(username)) {
            return "Bye!" + username;
        }
        Locale locale=LocaleContextHolder.getLocale();
        String code = String.format(CUSTOM_ERROR_KEY_USERNAME_NOT_EXISTS, locale.getCountry().toLowerCase());
        return messageSource.getMessage(code, new String[]{username}, locale);
    }
}
2. messages 文件

我们在 resources 目录下创建1个文件:

  1. messages.properties

    i18n.cn.user.username.not.exists=账号 {0} 不存在
    i18n.us.user.username.not.exists=Account {0} does not exist
    i18n.tw.user.username.not.exists=帳號 {0} 不存在
3. 测试

测试接口,传入参数 username=Jock,但 Header中带的 Accept-Language 值不同,对应的结果分别是:

  1. Accept-Language 不传:
    默认值,启用文件 messages.properties,结果为 “账号 Jock 不存在”。
  2. Accept-Language=zh-CN:
    启用文件 messages_zh_CN.properties,结果为 “账号 Jock 不存在”。
  3. Accept-Language=zh-TW:
    启用文件 messages_zh_TW.properties,结果为 “帳號 Jock 不存在”。
  4. Accept-Language=en-US:
    启用文件 messages_en_US.properties,结果为 “Account Jock does not exist”。

根据结果来看,同样也达到了我们的预期结果。

5. 方案思考

5.1. 配置文件动态修改

一个完整的国际化多语言方案,不光只是能读多语言的值,还应该包括如何修改。

上述方案中,所有语言的值都是写在 properties 配置文件中的,那么可能不能每次维护,都从配置文件里面改吧。

很多项目都在使用 nacos、apollo 之类的配置中心,我们可以基于配置中心统一修改多语言的配置值。

当然项目上甚至可能开发了一套维护多语言的界面系统, nacos 这类配置中心也提供开放 SDK 接口,供我们系统的后端服务做多语言值同步。

5.2. 多语言其他方案

思考一下 Spring 实现的这一套多语言方案,整体设计上十分简单:

  • 基于 语种、多语言code、值,维护一套 key-value 容器。
  • 根据 api 请求头,拿到当前的语种。
  • 基于当前语种、多语言code,去 key-value 容器中拿值。

基于这个需求,我想在校的学生也是可以设计好表结构,实现这一套 API的。

所以说并不局限于Spring的这套框架,如果我不用它,基于数据库设计一个多语言系统,其实也没啥问题。如果担心性能,大不了在 key-value 容器的读取时加上缓存。

5.3. 翻译

在了解 Spring 框架的国际化多语言之前,我以为它能实现自动翻译。但是没想到每个单词每个语种的翻译,都需要我们自己去维护。其实也能理解,毕竟很多系统是有行业内专业名词的,需要自行维护。

但如果是可以使用通用翻译的话,那么可以自己再开发一个程序,通过调用有道翻译、百度翻译之类的API,替我们做全局的多语言翻译。然后针对个别特殊的名词,再自定义修改翻译内容,最终推送到配置中心的文件中。

5.4. 应用

在项目中,国际化多语言应该是处于底层基础能力。因为如果产品面向国际化,几乎所有业务的微服务都有多语言翻译的需求。

另外,像项目上通常在 API Response 里面全局封装 Error Message 内容返回,也应该是基于多语言的。


KerryWu
641 声望159 粉丝

保持饥饿