國際化方案(1)- 多語言

KerryWu發表於2023-05-12

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 內容返回,也應該是基於多語言的。

相關文章