1. 大量 println 并不等同于日志记录
当应用程序很小时,你可能会觉得直接写 println("I'm here"), println("and now here"), println("and now not here anymore") 就足够了。 这确实有效!但在某个阶段,经典的问题就会出现:你遇到了一个奇怪的 Bug,运行程序,看到十行显示“I'm here”的输出,然后……你根本不明白哪个“here”是关键的,也不清楚事情发生的先后顺序。
区分两种不同类型的控制台输出非常有帮助。第一种是用户输出:提示语、命令结果、像“请输入数字”、“已添加”、“已删除”这类消息。第二种是程序运行时的技术追踪:它决定要做什么、选择了哪个逻辑分支、处理了什么数据、注意到了什么警告。这种追踪才是我们所说的“日志记录(Logging)”。
还有一个重要的观点:日志不需要“精美”或“对用户友好”。它们的作用是稳定、一致且易于人工快速扫描。
为了让日志可控,我们需要两样东西:严重性级别和统一的格式。
2. 日志级别与最小级别
如果你把程序想象成一个人,那么日志就是它的日记。但这本日记记录的不是心情,而是“我做了什么以及为什么这么做”。和任何日记一样,并非所有的条目都同等重要。
在本讲座中,我们将引入四个经典级别:
| 级别 | 含义 | 典型场景 |
|---|---|---|
|
“开发人员详情” | 中间变量值、分支决策、处理细节 |
|
“程序的正常运行” | 启动/停止、命令处理、操作成功完成 |
|
“异常但尚可运行” | 奇怪的输入、跳过的数据、不完美但不至于崩溃的情况 |
|
“场景执行失败” | 操作错误、异常、无法继续当前场景 |
级别的主要实际用途在于能够配置:“只显示警告和错误,隐藏调试细节。” 这就是所谓的最小日志级别(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:试图立即解决所有未来的日志记录需求。
在这个阶段,很容易冲动地去实现“精美的日期格式”、写入文件、添加上下文、堆栈追踪、指标收集等等。 但这样你最终得到的不是一个简单的日志记录器,而是一个“痛苦制造器”。 极简日志记录器的价值在于你可以快速采纳并立即获得回报:级别控制、过滤和统一格式。
GO TO FULL VERSION