[Day 9] 使用 Config4k 以 Typesafe 及 Validatable 的方式读取 Ktor 设定档

Web 框架提供 API 让开发者读取设定档是基本的必备功能,以 Spring 框架为例,从最早只支援 XML 格式,到现在可以使用 java-based configuration,以 type-safe 的方式建立设定档物件,避免以往在执行期才发现 XML 写错的情况。至於往後比较新的框架就直接使用更简洁的 YAML 或 HOCON 格式了。

目前 Ktor 外部设定档只支援 HOCON 格式,然後透过 ApplicationConfig 介面读取设定值,不过 ApplicationConfig 只对外开放 getString() 方法,也就是所有设定值都当作字串处理,失去 HOCON 格式 type-safe 的优点,而且内部的 com.typesafe.config.Config 物件变数宣告为 private,所以也无法直接拿到 Config 物件进行操作。

使用 Config4k 转换 HOCON 设定档为 Kotlin Data Class

为了解决上述问题,我使用 Config4k 将 Ktor 设定档 application.conf 转为 kotlin data class,不仅可以达到 type-safe 的效果,直接操作物件的写法也更简洁易懂。首先,我使用 com.typesafe.config.ConfigFactory.load() 函式读取 application.conf 设定档,就可以拿到 com.typesafe.config.Config 物件。如果想读取其它设定档,例如 ProjectManager 读取子专案的设定档,那可以使用 ConfigFactory.parseFile(File(projectConfigFile)).resolve() 取得 Config 物件,不过要记得要呼叫 resolve 方法才会替换变数。接下来只要再呼叫 Config4k 的 extract 函式,指定 path 就可以拿到对应的 data class 物件

object ApplicationConfigLoader {

    private val logger = KotlinLogging.logger {}

    init {
        Config4kExt.registerCustomType()
    }

    fun load(): MyApplicationConfig {
        try {
            logger.info { "load application config file..." }
            val myConfig = ConfigFactory.load()
            return myConfig.extract("app")
        } catch (e: Throwable) {
            logger.error("fail to load project config file", e)
            throw e
        }
    }
}

data class MyApplicationConfig(
    val server: ServerConfig,
    val infra: InfraConfig
)

data class ServerConfig(
    val project: String,
    val env: EnvMode,
    val instance: String,
    val shutDownUrl: String
)

data class InfraConfig(
    val i18n: I18nConfig? = null,
    val logging: LoggingConfig? = null,
    val auth: AuthConfig? = null,
    val openApi: OpenApiConfig? = null,
    val database: DatabaseConfig? = null,
    val redis: RedisConfig? = null,
    val cache: CacheConfig? = null,
    val notification: NotificationConfig? = null,
)

Ktor 设定档 application.conf

app {
    server {
        project = "fanpoll"
        env = "dev" # dev, test, prod
        instance = 1
        shutDownUrl = "/ops/server/shutdown/"${?SERVER_SHUTDOWN_KEY}
    }
    infra {
        i18n {
            langs = ["zh-TW", "en"]
        }
        auth {
            logging {
                enabled = true
                destination = "AwsKinesis" # File(default), Database, AwsKinesis
            }
            session {
                expireDuration = 1d
                extendDuration = 15m
            }
            subscribeRedisSessionKeyExpired = true
        }
        redis {
            host = ${?REDIS_HOST}
            port = ${?REDIS_PORT}
            #password = ${?REDIS_PASSWORD}
            rootKeyPrefix = "fanpoll-"${app.server.env}
            client {
                coroutines = 20
                dispatcher {
                    fixedPoolSize = 3
                }
            }
        }
        //以下省略
    }
}

为 Config4k 增加资料验证功能

除了读取设定值之外,我们也必须要在 Server 启动的时候验证设定值是否合法,提早发现错误避免後续引发更大的问题,所以要思考「如何让 Config4k 能在读取後立即做资料验证」。在我浏览 Config 4k 原始码後,发现可以注册 CustomType 及其 Reader,於是我定义了 ValidateableConfig 介面,当 Config4k 执行 parse 函式建立 data class 物件後,再呼叫我定义的 validate() 函式。此外,我在实作过程中发现 Config4k 不支援 sealed data class,所以参考了 Config4k 的 ArbitraryTypeReader 修改我的 validateableConfigReader,实作程序码可参考 Github Repo

object Config4kExt {

    fun registerCustomType() {
        registerCustomType(validateableConfigReader)
    }

    private val validateableConfigReader = object : CustomType {

        override fun parse(clazz: ClassContainer, config: Config, name: String): Any {
            return extractWithParameters(clazz, config, name).also { (it as ValidateableConfig).validate() }
        }

        override fun testParse(clazz: ClassContainer): Boolean {
            return clazz.mapperClass.isSubclassOf(ValidateableConfig::class)
        }
    }
}

interface ValidateableConfig {

    fun validate()

    fun require(value: Boolean, lazyMessage: () -> Any) {
        try {
            kotlin.require(value, lazyMessage)
        } catch (e: IllegalArgumentException) {
            throw InternalServerException(InfraResponseCode.SERVER_CONFIG_ERROR, e.message)
        }
    }
}

data class SessionConfig(
    val expireDuration: Duration? = null,
    val extendDuration: Duration? = null
) : ValidateableConfig {

    override fun validate() {
        require(if (expireDuration != null && extendDuration != null) expireDuration > extendDuration else true) {
            "expireDuration $expireDuration should be greater than extendDuration $extendDuration"
        }
    }
}

Ktor Plugin 读取设定同时支援 DSL 及外部设定档

Ktor Plugin 的开发惯例是使用 DSL 方式进行设定,另一方面,我们也能读取外部设定档来初始化 Plugin,那麽有没有简洁的程序写法让两种方式并行呢?

继续以我自己实作的 RedisPlugin 为例,Plugin 内部使用 nested builder pattern 实作 DSL,最後产生 RedisConfig 物件,而这个 RedisConfig 物件,同时也是 Config4k 所转换的 data class,所以不管是透过 DSL 或是外部设定档,最後 Plugin 都是操作相同的 RedisConfig 物件。除此之外, build() 函式之後也是呼叫与 Config4k 相同的 validate() 函式进行资料验证,程序码都是共用的。

完整 RedisPlugin 程序码

class RedisFeature(configuration: Configuration) {

    class Configuration {

        lateinit var host: String
        var port: Int = 6379
        var password: String? = null
        lateinit var rootKeyPrefix: String

        private lateinit var client: CoroutineActorConfig

        fun client(block: CoroutineActorConfig.Builder.() -> Unit) {
            client = CoroutineActorConfig.Builder().apply(block).build()
        }

        fun build(): RedisConfig {
            return RedisConfig(host, port, password, rootKeyPrefix, client)
        }
    }
    // 以下省略
}

data class RedisConfig(
    val host: String, val port: Int = 6379, val password: String?, val rootKeyPrefix: String,
    val client: CoroutineActorConfig
) {

    override fun toString(): String {
        return "url = redis://${if (password != null) "[needPW]@" else ""}$host:$port" +
                " ; rootKeyPrefix = $rootKeyPrefix"
    }
}

data class CoroutineActorConfig(val coroutines: Int = 1, val dispatcher: ThreadPoolConfig? = null) {

    fun validate() {
        dispatcher?.validate()
    }

    class Builder {

        var coroutines: Int = 1
        private var dispatcher: ThreadPoolConfig? = null

        fun dispatcher(block: ThreadPoolConfig.Builder.() -> Unit) {
            dispatcher = ThreadPoolConfig.Builder().apply(block).build()
        }

        fun build(): CoroutineActorConfig {
            return CoroutineActorConfig(coroutines, dispatcher).apply { validate() }
        }
    }
}

data class ThreadPoolConfig(
    val fixedPoolSize: Int? = 1,
    val minPoolSize: Int? = null,
    val maxPoolSize: Int? = null,
    val keepAliveTime: Long? = null
) : ValidateableConfig {

    fun isFixedThreadPool(): Boolean = fixedPoolSize != null && (minPoolSize == null && maxPoolSize == null && keepAliveTime == null)

    override fun validate() {
        require(
            (minPoolSize != null && maxPoolSize != null && keepAliveTime != null) ||
                    (minPoolSize == null && maxPoolSize == null && keepAliveTime == null)
        ) {
            "minPoolSize, maxPoolSize, keepAliveTime should be configured"
        }
    }

    class Builder {

        var fixedPoolSize: Int? = 1
        var minPoolSize: Int? = null
        var maxPoolSize: Int? = null
        var keepAliveTime: Long? = null

        fun build(): ThreadPoolConfig {
            return ThreadPoolConfig(fixedPoolSize, minPoolSize, maxPoolSize, keepAliveTime).apply { validate() }
        }
    }
}

最後我们再看一下 RedisPlugin 的 install function 内部实作,如果两种方式并行时,会以外部设定档为优先 => config = appConfig.infra.redis ?: configuration.build(),然後 initClient 函式就根据 RedisConfig 物件进行初始化。

override fun install(pipeline: Application, configure: Configuration.() -> Unit): RedisFeature {
    val configuration = Configuration().apply(configure)
    val feature = RedisFeature(configuration)

    val appConfig = pipeline.get<MyApplicationConfig>()
    config = appConfig.infra.redis ?: configuration.build()

    initClient(config)
    // 以下省略
}

这2天说明如何整合 Koin DI、Config4k 来安装初始化 Plugin,明天再反过来看,如何在停止 Server 时,做 Graceful Shutdown 关闭 Plugin 所开启的资源。


<<:  Day8 单纯贝氏分类器 (Naive Bayes Classifier)

>>:  Day2 安装<Cocoapods>,以及第三方套件<RealmSwift>

Day_27:让 Vite 来开启你的Vue之 跌入深坑偶像剧_ v-if & v-for 他俩不能在一起啊

Hi Dai Gei Ho~ 我是 Winnie ~ 今天的文章中,我们要来说说 v-if & v-...

Domain Storytelling - 简单的方法说出一个Domain story

上篇回顾 Story Telling - 简易有效的讨论 讲到会议很烦很冗长没重点还要开好几次, 是...

Day 3 映像档 Images

在你要执行(新建)一个容器的时候,你就需要有映像档。映像档是一个模版,让 docker 知道要基於怎...

[Lesson20] ButterKnife

ButterKnife可以让我们在宣告元件时之後不用再打findViewById这行,可以帮忙省下宣...

Day11:Swift 基础语法 —Array

前言 上一篇文章讲到 Dictionary, 今天讲另一个值的集合 - Array。 Array 和...