CodeGym /课程 /Kotlin SELF /日志级别与极简日志记录器

日志级别与极简日志记录器

Kotlin SELF
第 60 级, 课程 0
可用

1. 大量 println 并不等同于日志记录

当应用程序很小时,你可能会觉得直接写 println("I'm here"), println("and now here"), println("and now not here anymore") 就足够了。 这确实有效!但在某个阶段,经典的问题就会出现:你遇到了一个奇怪的 Bug,运行程序,看到十行显示“I'm here”的输出,然后……你根本不明白哪个“here”是关键的,也不清楚事情发生的先后顺序。

区分两种不同类型的控制台输出非常有帮助。第一种是用户输出:提示语、命令结果、像“请输入数字”、“已添加”、“已删除”这类消息。第二种是程序运行时的技术追踪:它决定要做什么、选择了哪个逻辑分支、处理了什么数据、注意到了什么警告。这种追踪才是我们所说的“日志记录(Logging)”。

还有一个重要的观点:日志不需要“精美”或“对用户友好”。它们的作用是稳定、一致且易于人工快速扫描。

为了让日志可控,我们需要两样东西:严重性级别和统一的格式。

2. 日志级别与最小级别

如果你把程序想象成一个人,那么日志就是它的日记。但这本日记记录的不是心情,而是“我做了什么以及为什么这么做”。和任何日记一样,并非所有的条目都同等重要。

在本讲座中,我们将引入四个经典级别:

级别 含义 典型场景
DEBUG
“开发人员详情” 中间变量值、分支决策、处理细节
INFO
“程序的正常运行” 启动/停止、命令处理、操作成功完成
WARN
“异常但尚可运行” 奇怪的输入、跳过的数据、不完美但不至于崩溃的情况
ERROR
“场景执行失败” 操作错误、异常、无法继续当前场景

级别的主要实际用途在于能够配置:“只显示警告和错误,隐藏调试细节。” 这就是所谓的最小日志级别(minLevel):只有当日志行的级别不低于配置的级别时,该条目才会被打印。

3. 极简 Logger:级别、格式与过滤

将 LogLevel 定义为 enum class

如果我们把级别定义为简单的字符串("WARN", "ERROR"),就会给自己埋坑: "WARM", "ERORR", "warning"——编译器会接受这些拼写错误,而之后你会困惑为什么过滤功能“有时有效,有时无效”。

这就是为什么日志级别是 enum class 的理想候选者:它是一组由编译器严格控制的固定选项。

enum class LogLevel {
    DEBUG, INFO, WARN, ERROR
}

这里的便利之处在于,enum 会按声明顺序存储元素,每个元素都有一个 ordinal(0, 1, 2, 3……)。 你可以利用这个顺序来比较级别:级别在定义中越靠后,严重性就越高。

这里有个小警告(不用担心):如果你后续更改了 enum class 中级别的定义顺序,通过 ordinal 进行的过滤逻辑也会随之改变。 对于学习用的迷你日志记录器来说,这没问题,但了解这一点很有用。

统一的日志格式

如果日志打印得随心所欲,它们将变得难以阅读。一个人写 "[INFO] started",另一个人写 "INFO: start",第三个人写 "start ok"。最终,单凭肉眼甚至难以分辨哪是哪。

一种足以应对日常使用的极简且实用的格式是:

timestamp [LEVEL] message

为什么 timestamp(时间戳)很有用?因为它能提供事件发生的顺序,并让你大致估算操作之间的耗时。 为什么我们要使用简单的 System.currentTimeMillis()?因为它即刻可用,无需复杂的日期格式化,目前对我们来说,纪律性比美观更重要。 美观很容易补上,但养成以统一方式记录日志的习惯要难得多。

让我们写一个小函数来格式化日志行。虽然没必要必须写成独立函数,但这样代码可读性更高。

fun formatLogLine(ts: Long, level: LogLevel, message: String): String {
    return "$ts [$level] $message"
}

如果你调用 formatLogLine(123, LogLevel.WARN, "Suspicious input"), 你将得到一个可预测的行:

123 [WARN] Suspicious input

“可预测”就是关键。

Logger 类:在统一位置实现过滤与打印

现在让我们把所有内容放入一个 Logger 类中。核心思路很简单:在一个地方完成三项任务。

任务一:根据当前的 minLevel,判断是否允许打印该级别的消息。

class Logger(private val minLevel: LogLevel) {

    private fun enabled(level: LogLevel): Boolean {
        return level.ordinal >= minLevel.ordinal
    }
}

任务二:如果允许,则使用统一格式构建日志行并打印。

class Logger(private val minLevel: LogLevel) {

    private fun enabled(level: LogLevel): Boolean =
        level.ordinal >= minLevel.ordinal

    fun log(level: LogLevel, message: String) {
        if (!enabled(level)) return // 使用卫语句,不满足条件直接退出

        val ts = System.currentTimeMillis()
        println("$ts [$level] $message")
    }
}

注意代码风格:我们使用了卫语句(guard clause)——“如果不符合条件,立即退出”。这使代码逻辑变得平铺直叙:你不需要将所有代码嵌套在 if 中。

现在添加一个小测试来感受 minLevel 是如何工作的:

fun main() {
    val logger = Logger(minLevel = LogLevel.WARN)

    logger.log(LogLevel.DEBUG, "Details")   // 不会打印
    logger.log(LogLevel.INFO, "Started")   // 不会打印
    logger.log(LogLevel.WARN, "Odd input") // 将会打印
    logger.log(LogLevel.ERROR, "Failed")   // 将会打印
}

输出大概如下(你的时间戳当然会不同):

1700000000000 [WARN] Odd input
1700000000001 [ERROR] Failed

比起分散在代码各处的“几百个 println”,这已经是巨大的进步。

debug/info/warn/error 方法

如果每次都要写 logger.log(LogLevel.INFO, "...") 会很快感到厌烦。 而且代码可读性会变差:你的视线会被重复的 LogLevel. 所干扰。

这就是为什么日志记录器几乎总是提供包装方法。

class Logger(private val minLevel: LogLevel) {

    private fun enabled(level: LogLevel): Boolean =
        level.ordinal >= minLevel.ordinal

    fun log(level: LogLevel, message: String) {
        if (!enabled(level)) return
        val ts = System.currentTimeMillis()
        println("$ts [$level] $message")
    }

    fun debug(message: String) = log(LogLevel.DEBUG, message)
    fun info(message: String)  = log(LogLevel.INFO, message)
    fun warn(message: String)  = log(LogLevel.WARN, message)
    fun error(message: String) = log(LogLevel.ERROR, message)
}

现在的代码读起来就像是对发生事件的叙述:

fun main() {
    val logger = Logger(LogLevel.INFO)

    logger.info("App started") // 将会打印
    logger.debug("x=10, y=20") // 不会打印
}

这正是我们这样做的目的:debug 用于“深度挖掘”,info 用于记录正常运行。

用户输出 vs. 日志记录

非常容易一不小心将“给用户看的消息”和“日志记录”混在一起。在控制台应用程序中这尤其棘手:因为它们本质上都是 println。

让我们达成一个简单的准则:用户输出保持为普通的 println,技术事件通过 Logger 处理。

想象我们有一个学习用的 CLI 应用程序,包含“添加记录”和“展示列表”等场景。我们不必纠结于领域模型,重点在于消息流。

fun handleAdd(rawAmount: String, logger: Logger) {
    logger.info("Handle add started")

    val amount = rawAmount.toIntOrNull()
    if (amount == null) {
        println("Error: amount must be a number") // 用户消息
        logger.warn("Add failed: amount is not a number: '$rawAmount'")
        return
    }

    println("Added: $amount") // 用户消息
    logger.info("Add finished: amount=$amount")
}

注意语气上的区别。用户消息不需要知道“handle started”(处理开始了)。它只需要一个简短的答案:出什么错了,或者什么成功了。 而日志则可以是“干巴巴”且技术性的:它们不是给用户看的,而是给你自己(以及未来那个试图回想当时为什么这么写的你)看的。

让我们测试一个迷你场景:

fun main() {
    val logger = Logger(LogLevel.INFO)

    println("Enter amount:")
    val raw = readln()

    handleAdd(raw, logger)
}

如果用户输入 abc,输出看起来大概如下:

Enter amount:
abc
1700000000000 [INFO] Handle add started
Error: amount must be a number
1700000000001 [WARN] Add failed: amount is not a number: 'abc'

没错,用户会同时看到日志和消息。在实际的应用中,日志通常会被发送到单独的流(例如 stderr)或文件中——但那是另一个故事了。 现在重要的是学会区分它们的含义。

使用 priority 而非 ordinal

我们已经在用 ordinal,它确实有效。但有一个细微差别:ordinal 与 enum 中的声明顺序绑定。 如果团队中有人决定“把 ERROR 放在第一位,因为它最重要”——过滤逻辑就会崩溃。

为了让行为更稳定,你可以定义一个显式的数值优先级。这依然简单,但更显“成熟”。

enum class LogLevel(val priority: Int) {
    DEBUG(10),
    INFO(20),
    WARN(30),
    ERROR(40)
}

这时过滤检查就独立于声明顺序了:

class Logger(private val minLevel: LogLevel) {

    private fun enabled(level: LogLevel): Boolean =
        level.priority >= minLevel.priority

    fun log(level: LogLevel, message: String) {
        if (!enabled(level)) return

        val ts = System.currentTimeMillis()
        println("$ts [$level] $message")
    }

    fun debug(message: String) = log(LogLevel.DEBUG, message)
    fun info(message: String)  = log(LogLevel.INFO, message)
    fun warn(message: String)  = log(LogLevel.WARN, message)
    fun error(message: String) = log(LogLevel.ERROR, message)
}

对于学习项目,你可以继续使用 ordinal,但理解这一点很有用。

工作原理

有时看一次演示比读十遍文档更有用。在日志记录中,关键在于打印的决策点被集中在了一个地方。

flowchart TD
    A["应用程序代码
logger.info(...)"] --> B["Logger.enabled(level)"] B -->|false| C["不打印"] B -->|true| D["格式化行
ts + [LEVEL] + message"] D --> E["统一位置执行 println(...)"]

如果你养成了这种习惯,你就获得了一个非常棒的超能力:当你想要更改日志格式或添加新细节时,你只需要在一个地方修改,而不是在整个项目中到处寻找 println("DEBUG")。

4. 常见错误

错误 1:为了“省事”,将所有消息都设为 INFO。
是的,这样做一晚上可能很省事。但随后你会拥有数百万条 INFO 日志,重要的警告或错误被埋没在其中。 日志级别不是为了好看,而是为了让你控制噪声:DEBUG 用于详情,INFO 用于正常事件,WARN 用于异常,ERROR 用于操作失败。

错误 2:在不同地方以不同的方式格式化日志。
如果代码的一部分写的是 "ts=$ts level=$level msg=$message",而另一部分写的是 "$level: $message",日志将无法用肉眼“扫描”。 正确的方法是:行格式应该存在于 Logger 内部,外部代码只需传递有意义的消息即可。

错误 3:混淆用户输出和日志,导致用户看到“内部详情”。
用户消息应该回答“我该怎么做/发生了什么?”,而日志应该回答“程序做了什么?”。 如果你在面向用户的界面中打印 HandleAdd started; parsing raw=...,用户会感觉自己像是个被强迫的测试人员。 最好将 println 用于 UX,而将 logger.* 用于诊断。

错误 4:过滤规则不直观。
如果日志有时打印有时不打印,且规则看起来像是“如果今天是周二且行不为空,则打印”,你将不再信任日志。 在基础级别,规则应该是简单且统一的:如果 level >= minLevel,则打印。

错误 5:使用字符串而不是 enum class 表示级别。
字符串在第一次拼写错误之前都很方便。enum class 提供了类型安全:编译器不允许你传入“无效”的级别。 这正是严格类型让生活变得简单而非困难的典型案例。

错误 6:忘记 ordinal 依赖于 enum 的声明顺序。
只要你有四个级别且声明顺序“正确”,一切都很好。但如果你改变了顺序,过滤逻辑也随之改变。 如果你需要更多的稳定性,请定义一个显式的 priority 并据此进行比较。

错误 7:试图立即解决所有未来的日志记录需求。
在这个阶段,很容易冲动地去实现“精美的日期格式”、写入文件、添加上下文、堆栈追踪、指标收集等等。 但这样你最终得到的不是一个简单的日志记录器,而是一个“痛苦制造器”。 极简日志记录器的价值在于你可以快速采纳并立即获得回报:级别控制、过滤和统一格式。

1
任务
Kotlin SELF, 第 60 级, 课程 0
已锁定
日志行
日志行
1
任务
Kotlin SELF, 第 60 级, 课程 0
已锁定
级别过滤器
级别过滤器
1
任务
Kotlin SELF, 第 60 级, 课程 0
已锁定
快速方法
快速方法
1
任务
Kotlin SELF, 第 60 级, 课程 0
已锁定
用户与日志
用户与日志
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION