配置管理:HOCON配置文件、环境变量、多环境配置、配置热加载

配置管理这件事,说大不大,说小不小。我见过不少项目,代码写得挺漂亮,结果配置一团糟——开发环境连的是生产数据库,测试环境用的密钥直接硬编码在代码里。嗯,这种坑我踩过不止一次。

今天我们就来聊聊Ktor项目里怎么把配置管好。说白了,就是让同一个代码包,在不同环境里能自动适配,而且改配置不用重新打包部署。

HOCON配置文件:Ktor的默认选择

Ktor默认使用HOCON(Human-Optimized Config Object Notation)格式。如果你用过Lightbend的Config库,应该不陌生。它的语法比JSON简洁,支持继承、引用、环境变量替换。

一个典型的application.conf长这样:

ktor {
    deployment {
        port = 8080
        port = ${?PORT}  // 环境变量覆盖
    }
    application {
        modules = [ com.example.ApplicationKt.module ]
    }
}

database {
    url = "jdbc:postgresql://localhost:5432/myapp"
    url = ${?DATABASE_URL}
    pool {
        maxSize = 10
        connectionTimeout = 30000
    }
}

redis {
    host = "localhost"
    host = ${?REDIS_HOST}
    port = 6379
}

注意那个${?PORT}的写法。问号表示「如果环境变量存在则覆盖,不存在则用默认值」。我个人习惯把所有敏感信息都用这种方式处理,避免把密码写死在配置文件里。

小技巧:HOCON支持多行字符串,用三个双引号包裹。写SQL语句或者长文本时特别好用。

环境变量:运行时动态注入

为什么需要环境变量?你想想看,代码要部署到开发、测试、生产三个环境,数据库地址肯定不一样。如果每次部署都改配置文件,那运维同学会疯掉的。

Ktor读取环境变量的方式很灵活。除了刚才说的${?VAR}语法,你还可以在代码里直接获取:

val dbUrl = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/default"

不过我不建议到处写System.getenv()。更好的做法是统一在配置文件中定义好,然后通过依赖注入或者配置对象来访问。

我在项目中遇到过一个问题:环境变量名拼写错误,导致应用一直用默认配置连接本地数据库,上线后才发现。从那以后,我养成了一个习惯——在应用启动时打印关键配置项,方便排查。

避坑指南:我曾经在生产环境遇到过环境变量包含换行符的情况(比如从K8s Secret挂载的变量),导致配置解析失败。建议在读取后做trim处理。

多环境配置:开发、测试、生产各司其职

多环境配置的核心思路是「共用基础配置,按需覆盖」。Ktor/HOCON支持通过include指令实现:

// application.conf(基础配置)
include "common.conf"

// 根据环境变量加载不同配置
include required(classpath("application.${?ENV}.conf"))

然后你准备三个文件:

  • application.dev.conf:开发环境,日志级别DEBUG,连接本地数据库
  • application.test.conf:测试环境,使用内存数据库,开启Mock服务
  • application.prod.conf:生产环境,连接池调大,开启监控

启动时指定环境变量即可:

ENV=prod java -jar myapp.jar

我个人习惯在application.conf里放所有环境的默认值,然后每个环境的配置文件只写差异部分。这样即使忘记指定ENV,也能用默认配置跑起来,不至于直接报错。

配置项 开发环境 测试环境 生产环境
数据库地址 localhost:5432 test-db:5432 prod-db:5432
日志级别 DEBUG INFO WARN
连接池大小 5 10 50
缓存TTL 10秒 60秒 300秒

配置热加载:不改代码,不停服务

配置热加载是个很实用的功能。想象一下:线上某个限流阈值需要调整,你不想重启服务(因为重启意味着连接断开、缓存失效),这时候热加载就派上用场了。

Ktor本身不直接提供配置热加载,但我们可以结合Kotlin的协程和文件监控来实现。我的做法是这样的:

fun Application.configureHotReload() {
    val configFile = File("application.conf")
    val watcher = FileSystems.getDefault().newWatchService()
    configFile.parentFile.toPath().register(
        watcher,
        StandardWatchEventKinds.ENTRY_MODIFY
    )

    launch(Dispatchers.IO) {
        while (isActive) {
            val key = watcher.take()
            key.pollEvents().forEach { event ->
                if (event.context().toString() == configFile.name) {
                    log.info("配置文件变更,重新加载...")
                    // 重新加载配置的逻辑
                    reloadConfig()
                }
            }
            key.reset()
        }
    }
}

当然,这只是一个简化版本。实际项目中我还会加上防抖处理——文件保存时可能会触发多次修改事件,需要合并成一次重新加载。

核心要点:配置热加载不是银弹。对于数据库连接池大小、线程池配置这类需要重建资源的配置项,热加载后要妥善处理旧资源的释放。我建议只对「无状态」的配置项(如限流阈值、开关标志)做热加载。

知识体系总览

下面这张图概括了配置管理的整体结构,你可以对照着梳理自己的项目:

配置管理体系 配置来源 HOCON文件 环境变量 命令行参数 加载策略:优先级覆盖 + 环境感知 多环境配置:dev / test / prod 配置热加载:文件监控 + 协程 最佳实践 ✅ 敏感信息用环境变量 ✅ 启动时打印关键配置 ✅ 基础配置 + 差异覆盖 ✅ 配置变更做防抖处理 ✅ 热加载只用于无状态配置 ❌ 避免硬编码配置路径 ❌ 不要在生产用默认值 ❌ 避免配置文件中写密码 ❌ 不要忽略配置校验

配置管理看起来琐碎,但做好了能省很多事。我见过太多项目因为配置混乱导致线上事故——数据库连错、缓存穿透、日志爆炸。花半小时把配置体系搭好,比事后排查问题划算得多。

最后说一句:配置也是代码,需要版本管理、需要评审、需要测试。别把它当成「随便改改就行」的东西。


公众号:蓝海资料掘金营,微信deep3321