[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 讯息


<<:  [Day3] 人脸侦测 (Face Detection)

>>:  Day-16 Excel手把手作图表(一)

【3D动画】AWS ELB 使用情境介绍

Youtube 完整影片连结:https://bit.ly/3FCGqSF 大家好,这次在内容形式...

Day 29 KubeEdge小专题: 使用Job实现定期备份功能

今天我们要使用Cron job作排程,搭配几天前用python实现的备份程序。 容器化应用 将应用打...

【前端效能优化】WebP - 较小容量的图片格式选择

常见的图片格式有 GIF:常用来做动态图片 JPEG:适合 Banner、风景等大图片 PNG:透明...

Day23 Load balance with Istio

昨天非常粗浅的介绍过 istio 後,今天我们要来实际将 Istio mesh 注入我们的 clus...

Day4 WordPress 介绍,基础设定与发文

上篇文章我们在 BlueHost 架起了 WordPress 环境,但也许你还不知道什麽是 Word...