iT邦幫忙

2021 iThome 鐵人賽

DAY 11
0
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 11

[Day 11] 實作 Ktor i18n 機制

以微框架來說,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 }
    }
}

多國語言訊息檔支援 HOCON 及 Java Properties 2 種格式

語系檔格式 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 的特色是

  • Recursive placeholders resolution
  • Only 30Kb and no external dependencies
  • Java and Kotlin versions

此外,我習慣在替換參數值的時候,是依據變數名稱而非位置,所以我使用 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)
            )
        )
    }
}

支援多專案的 i18n 訊息通知實作範例

不同於 Web 框架都有預設的語系檔,我的開發習慣是一律依功能切分語系檔,每個功能只讀取自己專屬的語系檔,避免有太多不相關的訊息混淆,造成檔案龐大不易管理。以訊息通知功能為例,我實作 Messages 與 MessagesProvider 的子類別 I18nNotificationMessagesI18nNotificationMessagesProvider

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 訊息


上一篇
[Day 10] 實作 Ktor Graceful Shutdown
下一篇
[Day 12] 實作 API Response 及 i18n Response Message
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言