以微框架來說,i18n 不是必備的功能,但如果是想要開發面向一般大眾的服務,在這個國際化的時代,i18n 就是不可缺少的功能。一般來說,Web 框架會有一個預設名稱的多國語系檔案,例如 Spring Boot 透過 MessageSource 讀取預設檔名為 message_${locale}.properties
的檔案,如果不存在某個語言的語系檔,那就會自動 fallback 到預設語系檔。至於 Play Framework 則需要先設定系統支援的語言有那些 play.i18n.langs = [ "en", "en-US", "fr" ]
,透過 MessageApi 讀取預設檔名為 conf/messages.${languageTag}
的檔案。
Ktor 本身並沒有實作 i18n,所以我又要自立自強了 Orz... 我是參考 Play Framework 的設計進行實作,不過我沒有完整移植,我省略一些我不需要的東西,然後再根據我的想法做調整,總之接下來看我是如何實作 i18n 機制。
首先,我們先在 application.conf 設定系統支援的語言 app.infra.i18n.langs = ["zh-TW", "en"]
然後使用 Config4k 轉換 i18n 設定值為 I18nConfig 物件,最後再轉換為 AvailableLangs
物件並註冊至 Koin DI,這樣子其它地方就可以透過 Koin DI 拿到 AvailableLangs,取得系統支援語言清單
data class I18nConfig(val langs: List<String>? = null) : ValidateableConfig {
override fun validate() {
if (langs != null) {
require(langs.isNotEmpty()) { "i18n langs should not be empty" }
langs.forEach { tag ->
LocaleUtils.isAvailableLocale(
try {
Locale.Builder().setLanguageTag(tag).build()
} catch (e: IllformedLocaleException) {
throw InternalServerException(InfraResponseCode.SERVER_CONFIG_ERROR, "invalid i18n lang: $tag")
}
)
}
}
}
fun availableLangs(): AvailableLangs? = langs?.let { AvailableLangs(it.map { tag -> Lang(tag) }) }
}
class AvailableLangs(val langs: List<Lang>) {
fun first(): Lang = langs.first()
}
fun Application.koinBaseModule(appConfig: MyApplicationConfig): Module {
val availableLangs = appConfig.infra.i18n?.availableLangs() ?: AvailableLangs(listOf(Lang.SystemDefault))
return module(createdAtStart = true) {
single { availableLangs }
}
}
語系檔格式 Java ResourceBundle 是使用 Properties,但如果屬性階層多且複雜的話,我喜歡使用 HOCON 格式比較簡潔。因為要支援2種格式,所以我們先來定義 interface,Messages
負責讀取語系檔裡的屬性值,MessagesProvider
則是負責載入語系檔,還有根據指定的候選語系回傳最吻合語系的 Messages 物件
interface Messages {
val lang: Lang
fun get(key: String, args: Map<String, Any>? = null): String?
fun isDefined(key: String): Boolean
}
interface MessagesProvider<T : Messages> {
val messages: Map<Lang, T>
val langs: List<Lang>
get() = messages.keys.toList()
operator fun get(lang: Lang) = messages[lang]
private fun preferredWithFallback(candidates: List<Lang>): T {
val availables = messages.keys
val lang = candidates.firstOrNull { candidate -> availables.firstOrNull { it.satisfies(candidate) } != null }
?: availables.first()
return messages[lang]!!
}
fun preferred(candidates: List<Lang>? = null): T = preferredWithFallback(candidates ?: langs)
fun preferred(lang: Lang? = null): T = preferred(lang?.let { listOf(it) })
}
Properties 格式的內部實作,我採用 Github 上面的 properlty 套件取代傳統的 Java ResourceBundle
,properlty 的特色是
此外,我習慣在替換參數值的時候,是依據變數名稱而非位置,所以我使用 org.apache.commons.text.StringSubstitutor
而非 MessageFormat
來替換參數值
class PropertiesMessagesImpl(override val lang: Lang, private val properties: Properlty) : Messages {
override fun get(key: String, args: Map<String, Any>?): String? = properties[key]?.let {
(args?.let { StringSubstitutor(args) } ?: StringSubstitutor()).replace(it)
}
override fun isDefined(key: String): Boolean = properties[key] != null
}
class HoconMessagesImpl(override val lang: Lang, var config: Config) : Messages {
override fun get(key: String, args: Map<String, Any>?): String? = config.tryGetString(key)?.let {
(args?.let { StringSubstitutor(args) } ?: StringSubstitutor()).replace(it)
}
override fun isDefined(key: String): Boolean = config.hasPath(key)
fun withFallback(another: HoconMessagesImpl) {
config = config.withFallback(another.config)
}
}
下面是對應的 MessagesProvider 實作
class PropertiesMessagesProvider(availableLangs: AvailableLangs, basePackagePath: String, filePrefix: String) :
MessagesProvider<PropertiesMessagesImpl> {
override val messages: Map<Lang, PropertiesMessagesImpl> = availableLangs.langs.associateWith {
PropertiesMessagesImpl(
it, Properlty.builder().add("classpath:$basePackagePath/$filePrefix$it.properties")
.ignoreUnresolvablePlaceholders(true)
.build()
)
}
}
class HoconMessagesProvider(availableLangs: AvailableLangs, basePackagePath: String, filePrefix: String, allowUnresolved: Boolean = false) :
MessagesProvider<HoconMessagesImpl> {
override val messages: Map<Lang, HoconMessagesImpl> = availableLangs.langs.associateWith {
HoconMessagesImpl(
it, ConfigFactory.load(
"$basePackagePath/$filePrefix$it.conf",
ConfigParseOptions.defaults().apply { allowMissing = false },
ConfigResolveOptions.defaults().setAllowUnresolved(allowUnresolved)
)
)
}
}
不同於 Web 框架都有預設的語系檔,我的開發習慣是一律依功能切分語系檔,每個功能只讀取自己專屬的語系檔,避免有太多不相關的訊息混淆,造成檔案龐大不易管理。以訊息通知功能為例,我實作 Messages 與 MessagesProvider 的子類別 I18nNotificationMessages
及 I18nNotificationMessagesProvider
class I18nNotificationMessages(private val messages: Messages) : Messages {
fun getEmailSubject(
type: NotificationType,
args: Map<String, String>? = null
): String = getMessage(type, NotificationChannel.Email, "subject", args)
fun getPushTitle(
type: NotificationType,
args: Map<String, String>? = null
): String = getMessage(type, NotificationChannel.Push, "title", args)
fun getPushBody(
type: NotificationType,
args: Map<String, String>? = null
): String = getMessage(type, NotificationChannel.Push, "body", args)
fun getSMSBody(
type: NotificationType,
args: Map<String, String>? = null
): String = getMessage(type, NotificationChannel.SMS, "body", args)
private fun getMessage(
type: NotificationType, channel: NotificationChannel, part: String, args: Map<String, String>? = null
): String = get("${type.id}.$channel.$part", args)
override val lang: Lang = messages.lang
override fun get(key: String, args: Map<String, Any>?): String = messages.get(key, args)
?: throw InternalServerException(InfraResponseCode.DEV_ERROR, "notification i18n message key $key is not found")
override fun isDefined(key: String): Boolean = messages.isDefined(key)
}
class I18nNotificationMessagesProvider(messagesProvider: MessagesProvider<*>) : MessagesProvider<I18nNotificationMessages> {
override val messages: Map<Lang, I18nNotificationMessages> = messagesProvider.messages
.mapValues { I18nNotificationMessages(it.value) }
}
因為架構上要支援多專案模組化開發,所以每個子專案擁有自己的訊息通知語系檔,所以在 ops 及 club 這2個子專案都會有 notification_zh-TW.prperties
語系檔,然後在語系檔定義每個 NotificationType 的某個 NotificationChannel 的某個屬性值的訊息文字,例如 ops 子專案的 dataReport
NotificationType 的 Email
NotificationChannel 的信件主旨
ops_dataReport.Email.subject=[維運] 資料查詢報表: ${dataType} ${queryTime}
因為多了一個子專案維度,所以我實作 I18nNotificationProjectMessages
儲存各個子專案的 MessagesProvider
class I18nNotificationProjectMessages {
private val providers: MutableMap<String, I18nNotificationMessagesProvider> = mutableMapOf()
fun addProvider(projectId: String, provider: I18nNotificationMessagesProvider) {
providers[projectId] = provider
}
fun getMessages(notificationType: NotificationType, lang: Lang): I18nNotificationMessages =
providers[notificationType.projectId]!!.preferred(lang)
}
然後先在 Notification Ktor Plugin 建立 I18nNotificationProjectMessages
物件並註冊至 Koin DI
override fun install(pipeline: Application, configure: Configuration.() -> Unit): NotificationFeature {
pipeline.koin {modules(module(createdAtStart = true) {
val i18nNotificationProjectMessagesProviders = I18nNotificationProjectMessages()
single { i18nNotificationProjectMessagesProviders }
}))}
}
後續子專案在初始化時,要把自己的 I18nNotificationMessagesProvider
註冊到 I18nNotificationProjectMessages
裡面
fun Application.opsMain() {
val availableLangs = get<AvailableLangs>()
val i18nNotificationProjectMessages = get<I18nNotificationProjectMessages>()
i18nNotificationProjectMessages.addProvider(
OpsConst.projectId,
I18nNotificationMessagesProvider(
PropertiesMessagesProvider(
availableLangs,
"i18n/notification/${OpsConst.projectId}",
"notification_"
)
)
)
}
以訊息通知功能來說,我是把使用者的語言偏好儲存在資料庫裡面,寄送通知時再從資料庫取出即可。至於 API 回應結果的訊息文字,則需要根據使用者 HTTP Request 的語言偏好進行回應,明天我再說明如何實作 API 回應碼及 i18n 訊息