Scala 元编程:在日志库中的应用

2019-07-17 22:29:50 +08:00
 sadhen

Scala 中常用的第三方日志库,我这边了解的有 log4s^1和 Scala Logging^2两个。

在 Scala Logging 中:

logger.debug(s"Some $expensive message!")

会被 Scala 的宏转换成:

if (logger.isDebugEnabled) logger.debug(s"Some $expensive message!")

因为在实际代码运行时,实际上会先做字符串插值,然后在看日志级别为 DEBUG 的日志是否需要输出。所以我们通过 if 语句,防止不必要的字符串操作,进而改善性能。

那么 Scala Logging 是如何做到改写表达式的呢?

在上一篇^3实现 lombok.Data 的时候,我们实际上是通过注解告诉编译器,我们需要在该注解所作用的类上面生成 getter 和 setter。说白了,就是注解 @data 让我们定位具体的类,然后我们再插入代码。而这个例子实际上是直接将生成代码的规则和具体的方法衔接起来。

完整的实现如下所示:

final class Logger private (val underlying: org.slf4j.Logger) {
  def debug(message: String): Unit = macro LoggerMacro.debugMessage
}

private object LoggerMacro {

  type LoggerContext = blackbox.Context {type PrefixType = Logger}

  private def deconstructInterpolatedMessage(c: LoggerContext)
    (message: c.Expr[String]) = {
    import c.universe._
    message.tree match {
      case q"scala.StringContext.apply(..$parts).s(..$args)" =>
        val format = parts.iterator.map({ case Literal(Constant(str: String)) => str })
          // Emulate standard interpolator escaping
          .map(StringContext.treatEscapes)
          // Escape literal slf4j format anchors if the resulting call will require a format string
          .map(str => if (args.nonEmpty) str.replace("{}", "\{}") else str)
          .mkString("{}")

        val formatArgs = args.map(t => c.Expr[Any](t))

        (c.Expr(q"$format"), formatArgs)

      case _ => (message, Seq.empty)
    }
  }

  private def formatArgs(c: LoggerContext)(args: c.Expr[Any]*) = {
    import c.universe._
    args.map { arg =>
      c.Expr[AnyRef](
        if (arg.tree.tpe <:< weakTypeOf[AnyRef]) arg.tree
        else q"$arg.asInstanceOf[_root_.scala.AnyRef]"
      )
    }
  }

  def debugMessageArgs(c: LoggerContext)
    (message: c.Expr[String], args: c.Expr[Any]*): c.universe.Tree = {
    import c.universe._
    val underlying = q"${c.prefix}.underlying"
    val anyRefArgs = formatArgs(c)(args: _*)
    if (args.length == 2)
      q"if ($underlying.isDebugEnabled) $underlying.debug($message, _root_.scala.Array(${anyRefArgs.head}, ${anyRefArgs(1)}): _*)"
    else
      q"if ($underlying.isDebugEnabled) $underlying.debug($message, ..$anyRefArgs)"
  }
  
  def debugMessage(c: LoggerContext)
    (message: c.Expr[String]): c.universe.Tree = {
    val (messageFormat, args) = deconstructInterpolatedMessage(c)(message)
    debugMessageArgs(c)(messageFormat, args: _*)
  }
}

首先,blackbox.Context 事实上限定了这个宏的作用域—即在类 Logger 之中。可以观察到,单例 LoggerMacro 的每一个方法都带有 LoggerContext 这个参数,每一个方法的具体实现,也和 LoggerContext 有一定的关系。

debugMessage 函数首先将字符串插值这个表达式通过 deconstructInterpolateMessage 解构成 messageFormat 和 args。下面这段代码可以非常明确的解释,什么是 messageFormat 以及什么是 args:

logger.info("Info :{}" , user.getName())

如果是 Scala 的字符串插值的话,就是 s"Info :${user.getName}"。

解构之后,我们只需要通过 Quasiquote 将带有条件语句的代码重新构造起来就可以了。

编译期和运行时

另外一个需要注意的点是,在使用 @data 的时候,我们实际上需要在工程中开启 Paradise 插件,而我们在使用 Scala Logging 的时候,实际上直接依赖 Scala Logging 就可以了,不需要开启 Paradise 插件。这就涉及到一个问题:我们在上一节中做了详细解释的代码,到底是在哪个环节执行的。

很简单,我们可以通过在 debugMessage 增加日志的方式,确定这个细节。

最终发现,实际上,我们依赖了 Scala Logging,但是项目自身没有使用编译插件,在编译过程中,编译器遇到 Scala Logging 中会生成代码的方法时,实际上还是会去利用编译插件,生成代码。

总结

实际上,这一篇的内容虽然在宏的具体使用接口上和 lombok.Data 那一篇有细节上的差异,但实际上最终生成代码的还是在使用 Quasiquote,所以如何高效地在 REPL 中尝试 Quasiquote 至关重要。Quasiquote 是伊甸园元编程中最枯燥最耗时的一个环节,而通过何种方式去将常规的代码和宏生成的代码衔接起来,则是伊甸园中一扇隐秘的大门。

阅读原文:Scala 元编程:在日志库中的应用

4420 次点击
所在节点    Scala
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/583938

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX