iT邦幫忙

2021 iThome 鐵人賽

DAY 9
0
Modern Web

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

[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 所開啟的資源。


上一篇
[Day 8] 整合 Koin DI 實作 Ktor Plugin
下一篇
[Day 10] 實作 Ktor Graceful Shutdown
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言