1. 简介

在本教程中,我们将探讨几种适用于 Kotlin 编程风格的日志惯用写法。

日志是软件开发中不可或缺的一部分。虽然看似简单(只是输出信息而已),但实现方式却多种多样。

每种语言、操作系统和环境都有其惯用甚至特立独行的日志解决方案。Kotlin 也不例外。

我们将以日志为切入点,深入探讨 Kotlin 中的一些高级特性,并分析其细微差别。


2. 日志惯用写法概览

日志的实现方式多种多样,但在 Kotlin 中,我们可以通过以下几种常见方式来实现:

  • 类属性方式
  • 伴生对象方式
  • 扩展方法
  • 委托属性

我们将依次分析这些方式的优缺点,帮助你选择最适合项目需求的写法。


3. 环境准备

本教程中我们将使用 SLF4J 作为日志门面,Logback 作为实现。

pom.xml 中添加以下依赖:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.30</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-core</artifactId>
    <version>1.2.3</version>
</dependency>

如果你打算使用其他日志实现(如 Log4j 或 Java Util Logging),这些方式同样适用。


4. 将 Logger 作为类属性

最直接的方式是将日志记录器作为类的私有属性:

class Property {
    private val logger = LoggerFactory.getLogger(javaClass)

    fun log(s: String) {
        logger.info(s)
    }
}

优点:

  • 简洁直观
  • 可复制粘贴

缺点:

  • 每个实例都会持有一个 logger,浪费内存
  • 每次实例化都会触发一次缓存查找

4.1. 提取通用方法

我们可以将 getLogger 抽取为一个工具方法,减少重复代码:

fun getLogger(forClass: Class<*>): Logger =
    LoggerFactory.getLogger(forClass)

这样我们就可以简化日志声明:

private val logger = getLogger(javaClass)

5. 使用伴生对象定义 Logger

Java 中我们通常使用 static 定义 logger,Kotlin 中则可以使用伴生对象模拟静态属性。

5.1. 基本写法

class LoggerInCompanionObject {
    companion object {
        private val loggerWithExplicitClass =
            getLogger(LoggerInCompanionObject::class.java)
    }

    fun log(s: String) {
        loggerWithExplicitClass.info(s)
    }
}

⚠️ 注意:

  • 使用 LoggerInCompanionObject::class.java 会导致日志名称固定,不利于复制粘贴

5.2. 使用 javaClass 的问题

如果我们直接使用 javaClass

private val loggerWithWrongClass = getLogger(javaClass)

输出的日志名称会变成:

com.baeldung.kotlin.logging.LoggerInCompanionObject$Companion

这显然不是我们想要的。

5.3. 通过反射获取外层类

为了自动获取外层类名,我们可以使用 Kotlin 反射:

@Suppress("JAVA_CLASS_ON_COMPANION")
private val logger = getLogger(javaClass.enclosingClass)

需要添加 kotlin-reflect 依赖:

<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-reflect</artifactId>
    <version>1.2.51</version>
</dependency>

5.4. 使用 @JvmStatic

为了让伴生对象更像 Java 的 static,我们可以加上 @JvmStatic

@JvmStatic
private val logger = getLogger(javaClass.enclosingClass)

5.5. 完整示例

class LoggerInCompanionObject {
    companion object {
        @Suppress("JAVA_CLASS_ON_COMPANION")
        @JvmStatic
        private val logger = getLogger(javaClass.enclosingClass)
    }

    fun log(s: String) {
        logger.info(s)
    }
}

优点:

  • 静态属性,节省内存
  • 日志名称正确
  • 可复制粘贴

6. 使用扩展方法

扩展方法可以让我们在不修改类的前提下添加日志能力。

6.1. 初步尝试

fun <T : Any> T.logger(): Logger = getLogger(javaClass)

使用方式:

class LoggerAsExtensionOnAny {
    fun log(s: String) {
        logger().info(s)
    }
}

6.2. Any 类型污染问题

上述写法会让所有类型都拥有 logger() 方法,比如:

"foo".logger().info("uh-oh!")

输出:

INFO java.lang.String - uh-oh!

⚠️ 问题:

  • 方法污染所有类型
  • 破坏封装性
  • IDE 自动补全建议过多

6.3. 使用标记接口限制作用域

定义一个空接口:

interface Logging

然后修改扩展方法:

fun <T : Logging> T.logger(): Logger = getLogger(javaClass)

使用时需实现接口:

class LoggerAsExtensionOnMarkerInterface : Logging {
    fun log(s: String) {
        logger().info(s)
    }
}

6.4. 使用 reified 类型参数优化

使用 reified 可以避免运行时反射:

inline fun <reified T : Logging> T.logger(): Logger =
    getLogger(T::class.java)

⚠️ 注意:

  • 这会改变继承行为(后续详述)

6.5. 与属性结合使用

扩展方法也可以用于初始化属性:

val logger = logger()

6.6. 与伴生对象结合使用

如果想在伴生对象中使用:

companion object : Logging {
    val logger = logger()
}

需要处理 javaClass 的问题:

inline fun <T : Any> getClassForLogging(javaClass: Class<T>): Class<*> {
    return javaClass.enclosingClass?.takeIf {
        it.kotlin.companionObject?.java == javaClass
    } ?: javaClass
}

更新扩展方法:

inline fun <reified T : Logging> T.logger(): Logger =
    getLogger(getClassForLogging(T::class.java))

7. 使用委托属性

委托属性是一种更高级的方式,可以避免命名空间污染,也不需要标记接口。

7.1. 实现方式

class LoggerDelegate<in R : Any> : ReadOnlyProperty<R, Logger> {
    override fun getValue(thisRef: R, property: KProperty<*>) =
        getLogger(getClassForLogging(thisRef.javaClass))
}

使用方式:

private val logger by LoggerDelegate()

优点:

  • 不污染 Any
  • 支持伴生对象

缺点:

  • 每次访问属性都会重新计算
  • 使用反射,性能略低

8. 关于继承的一些注意事项

不同方式在继承时的行为不同:

方式 日志名称是否随子类变化
属性 + javaClass
伴生对象 + T::class.java
扩展方法 + reified
委托属性 + thisRef.javaClass

例如:

open class LoggerAsPropertyDelegate {
    protected val logger by LoggerDelegate()
}

class DelegateSubclass : LoggerAsPropertyDelegate() {
    fun show() {
        logger.info("look!")
    }
}

输出:

INFO com.baeldung.kotlin.logging.DelegateSubclass - look!

即使 logger 定义在父类中,输出的仍是子类名称。


9. 总结

在本文中,我们探讨了 Kotlin 中定义和使用日志记录器的几种常见方式:

方式 内存效率 可复用性 日志名称准确性 复杂度
属性
伴生对象 ⭐⭐
扩展方法 ⚠️ ⭐⭐⭐
委托属性 ⭐⭐⭐⭐

推荐:

  • 如果你追求简洁,使用属性方式
  • 如果你追求性能和规范,使用伴生对象 + @JvmStatic
  • 如果你想统一日志行为,使用委托属性或扩展方法

完整代码示例请查看 GitHub


原始标题:Idiomatic Logging in Kotlin