Spring boot 的 I18n 設定與進一步包裝

Spring boot 的 I18n 設定與進一步包裝

2021, Dec 10    

在專案中加入多國語言設定是很常見的需求,Spring boot 當然也有相應的支援,透過簡易的設定就可以套用

依賴引用

Spring boot 的 i18n 支援是含在 spring-context-support 裡的,理論上當在使用 Spring boot 的時候應該都會有才是

application.yaml

basename 代表語系檔的路徑以及檔名,依照這裡的設定須將語系檔放在 resource/i18n 之下,且命名為 message.properties

messages:
  basename: i18n/messages
  encoding: UTF-8
  cache-duration: 3600

語系檔範例

不同語系需放在不同檔案,命名上用上面設定的 basename 後面加上 _語系 來區隔

# message.properties
user.controller.not.found.by.id=[預設] 使用者 ID [{0}] 不存在

# message_en_US.properties
user.controller.not.found.by.id=UseID [{0}] do not exist.

# message_zh_TW.properties
user.controller.not.found.by.id=使用者 ID [{0}] 不存在

{0} 是用來帶入參數的,讓訊息可以做到某種程度的客製化

套用

注入 MessageSource 的資源,透過 key 值去對應訊息,如果後面帶入的語系不存在就會用預設的 message.properties 內容

@Slf4j
@RestController
public class DemoController {

    @Autowired
    private MessageSource messageSource;
    @Autowired
    private UserDao userDao;

    @GetMapping("/{userId}")
    @ResponseStatus(HttpStatus.OK)
    public User getOneUser(@PathVariable("userId") Long userId) throws Exception {
        Optional<User> userOption = userDao.findById(userId);
        if (userOption.isEmpty()) {
            String msg = messageSource.getMessage(
                "user.controller.not.found.by.id",
                new String[]{userId.toString()},
                Locale.TAIWAN);
            log.error(msg);
        }
        return userOption.get();
    }
}

進階使用

上面的範例是直接寫入 Locale.TAIWAN 來套用語系,實際情境下通常是透過 API 帶入語系參數來決定訊息的語系,因此可以改寫成

...
if (userOption.isEmpty()) {
    String msg = messageSource.getMessage(
        "user.controller.not.found.by.id",
        new String[]{userId.toString()},
        LocaleContextHolder.getLocale())
    log.error(msg);
);
...

LocaleContextHolder.getLocale() 會從 request 的 header 欄位 Accept-Language 來解析語系,如果語系找不到會先抓主機的預設語系,若預設語系也不存在才會去抓 message.properties

進一步包裝

每次使用都要像上面寫法注入後呼叫 getMessage 參數的帶法也不是這麼方便,而且語系的參數基本上就是帶入 LocaleContextHolder.getLocale()

秉持著不願意多寫重複的 code,於是決定多做一層包裝,寫成類似 builder 的方式來讓撰寫可以更簡潔,加入例外處理當輸入不存在的 key 值時可以直接回傳 key 而不會導致 crash,其實最主要是可以在 unit test 的時候不用去管 i18n 的套用

@Slf4j
public class I18nDto {
    private I18nDto(String key) {
        this.key = key;
    }

    private String key;
    private List<String> args = new ArrayList<>();

    public static I18nDto key(String key) {
        return new I18nDto(key);
    }

    public I18nDto args(Object... objects) {
        for (Object object : objects) {
            args.add(Objects.toString(object));
        }
        return this;
    }

    @Override
    public String toString() {
        try {
            MessageSource messageSource = StaticApplicationContext.getBean(MessageSource.class);
            return messageSource.getMessage(key, args.toArray(), LocaleContextHolder.getLocale());
        } catch (Exception ex) {
            log.warn("I18N toString: {}", ex.getMessage(), ex);
            return key;
        }
    }
}

由於這個 class 不會被建立成 Spring boot 的 Bean 因此沒辦法用自動注入的方式取得 MessageSource,採用靜態方法去拿到 ApplicationContext 下的 MessageSource

@Component
public class StaticApplicationContext {
    private static StaticApplicationContext instance;

    @Autowired
    private ApplicationContext applicationContext;

    @PostConstruct
    public void registerInstance() {
        instance = this;
    }

    public static <T> T getBean(Class<T> clazz) {
        return instance.applicationContext.getBean(clazz);
    }
}

之後調用時只需要

...
if (userOption.isEmpty()) {
    String msg = I18nDto.key("user.controller.not.found.by.id")
                        .args(userId).toString();
    log.error(msg);
);
...

語法上簡潔許多,調用時也不需要額外注入資源