Logback 手册 - 第二章:架构
🏷️ Logback 手册
来源:https://logback.qos.ch/manual/architecture.html
作者:Ceki Gülcü,Sébastien Pennec,Carl Harris
版权所有 © 2000-2022 QOS.ch Sarl
本文档已根据 知识共享署名 - 非商业性使用 - 相同方式共享 2.5 许可协议 进行许可。
All true classification is genealogical.
“所有真正的分类都是系谱学的。”
—查尔斯·达尔文,《物种起源》
It is difficult, if not impossible, for anyone to learn a subject purely by reading about it, without applying the information to specific problems and thereby forcing himself to think about what has been read. Furthermore, we all learn best the things that we have discovered ourselves.
“对于任何人来说,仅仅通过阅读了解一个主题是困难的,如果不是不可能的话,因为这种方法没有将信息应用于具体问题,从而迫使自己思考所阅读的内容。此外,我们最好是自己发现的事物。”
—唐纳德·克努特,《计算机编程艺术》
logback 的架构
logback 的基本架构足够通用,可以适用于不同的情况。目前,logback 分为三个模块,logback-core、logback-classic 和 logback-access。
core 模块为其他两个模块奠定了基础。classic 模块扩展了 core。classic 模块对应于 log4j 的显着改进版本。Logback-classic 本地实现了 SLF4J API,因此您可以轻松地在 logback 和其他日志系统(例如 log4j
或 JDK 1.4 中引入的 java.util.logging
(JUL))之间切换。第三个名为 access 的模块与 Servlet 容器集成,以提供 HTTP 访问日志功能。单独的文档涵盖了 access 模块文档。
在本文档的其余部分,我们将使用“logback”来指代 logback-classic 模块。
Logger、Appenders 和 Layouts
Logback 基于三个主要类构建:Logger
、Appender
和 Layout
。这三种类型的组件共同工作,使开发人员能够根据消息类型和级别记录消息,并在运行时控制消息的格式化方式和报告位置。
Logger
类是 logback-classic 模块的一部分。另一方面,Appender
和 Layout
接口是 logback-core 的一部分。作为一个通用模块,logback-core 并不关心记录器。
Logger 上下文
任何日志 API 相对于简单的 System.out.println
的最大优势在于其能够禁用某些日志语句,同时允许其他语句无障碍地打印出来。这种能力假设日志空间,即所有可能的日志语句的空间,根据开发人员选择的某些标准进行分类。在 logback-classic 中,这种分类是记录器的固有部分。每个单独的记录器都附加到一个 LoggerContext
,负责生成记录器并将它们排列成树状层次结构。
记录器是命名实体。它们的名称区分大小写,并且遵循层次命名规则:
WARNING
命名层次结构
如果一个记录器的名称后跟一个点是后代记录器名称的前缀,则称该记录器是另一个记录器的祖先。如果记录器本身和后代记录器之间没有祖先,则称该记录器是子记录器的父记录器。
例如,名称为 "com.foo"
的记录器是名称为 "com.foo.Bar"
的记录器的父记录器。同样地,"java"
是 "java.util"
的父记录器,也是 "java.util.Vector"
的祖先记录器。这种命名方案对大多数开发人员来说应该是熟悉的。
根记录器位于记录器层次结构的顶部。它之所以特殊,是因为它处于每个层次结构的初始状态。像每个记录器一样,它可以通过其名称检索,如下所示:
Logger rootLogger = LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME);
所有其他记录器也是使用 org.slf4j.LoggerFactory 类中找到的类静态 getLogger
方法检索的。此方法将所需记录器的名称作为参数。Logger
接口中的一些基本方法如下所示。
package org.slf4j;
public interface Logger {
// 打印方法:
public void trace(String message);
public void debug(String message);
public void info(String message);
public void warn(String message);
public void error(String message);
}
有效级别,也称级别继承
记录器可以被分配级别。可能级别集合(TRACE
、DEBUG
、INFO
、WARN
和 ERROR
)在 ch.qos.logback.classic.Level
类中定义。需要注意的是,在 logback 中,Level
类是 final
的,无法被子类化,因为通过 Marker
对象存在更灵活的方法。
如果给定的记录器未分配级别,则它将从最近的具有分配级别的祖先记录器那里继承级别。更正式地说:
WARNING
给定记录器 L 的有效级别等于其层次结构中从 L 自身开始向上朝根记录器移动时的第一个非空级别。
为了确保所有记录器最终都可以继承一个级别,根记录器始终有一个分配的级别。默认情况下,此级别为 DEBUG。
以下是四个具有不同分配级别值的示例,以及根据级别继承规则得出的有效(继承的)级别。
示例 1
记录器名称 | 分配的级别 | 有效级别 |
---|---|---|
root | DEBUG | DEBUG |
X | 无 | DEBUG |
X.Y | 无 | DEBUG |
X.Y.Z | 无 | DEBUG |
在上面的示例 1 中,只有根记录器分配了级别。这个级别值 DEBUG
被其他记录器 X
、X.Y
和 X.Y.Z
继承。
示例 2
记录器名称 | 分配的级别 | 有效级别 |
---|---|---|
root | ERROR | ERROR |
X | INFO | INFO |
X.Y | DEBUG | DEBUG |
X.Y.Z | WARN | WARN |
在上面的示例 2 中,所有记录器都有分配的级别值。级别继承不起作用。
示例 3
记录器名称 | 分配的级别 | 有效级别 |
---|---|---|
root | DEBUG | DEBUG |
X | INFO | INFO |
X.Y | 无 | INFO |
X.Y.Z | ERROR | ERROR |
在上面的示例 3 中,记录器 root
、X
和 X.Y.Z
分配了级别值 DEBUG
、INFO
和 ERROR
。记录器 X.Y
从其父记录器 X
继承其级别值。
示例 4
记录器名称 | 分配的级别 | 有效级别 |
---|---|---|
root | DEBUG | DEBUG |
X | INFO | INFO |
X.Y | 无 | INFO |
X.Y.Z | 无 | INFO |
在上面的示例 4 中,记录器 root
和 X
分配了级别值 DEBUG
和 INFO
。记录器 X.Y
和 X.Y.Z
从最近的父记录器 X
那里继承其级别值。
打印方法和基本选择规则
按定义,打印方法确定日志请求的级别。例如,如果 L
是一个记录器实例,则语句 L.info("..")
是一个级别为 INFO 的日志语句。
如果日志请求的级别高于或等于其记录器的有效级别,就说此日志请求是 可用 的。否则,此请求被视为 禁用。如前所述,没有分配级别的记录器将从其最近的祖先那里继承一个级别。以下是总结的规则。
WARNING
基本选择规则
发往级别为 p 的日志请求,由具有有效级别 q 的记录器处理,如果 p>=q,则此请求是可用的。
这一规则是 logback 的核心。它假设级别按如下顺序排序:TRACE<DEBUG<INFO< WARN<ERROR
。
更形象地说,以下表格显示了选择规则的工作原理。在下表中,垂直标题显示了日志请求的级别,由 p 指定;水平标题显示了记录器的有效级别,由 q 指定。行(日志请求级别)与列(有效级别)的交叉点是基本选择规则的布尔结果。
日志请求级别 p \ 有效级别 q | TRACE | DEBUG | INFO | WARN | ERROR | OFF |
---|---|---|---|---|---|---|
TRACE | 是 | 否 | 否 | 否 | 否 | 否 |
DEBUG | 是 | 是 | 否 | 否 | 否 | 否 |
INFO | 是 | 是 | 是 | 否 | 否 | 否 |
WARN | 是 | 是 | 是 | 是 | 否 | 否 |
ERROR | 是 | 是 | 是 | 是 | 是 | 否 |
以下是基本选择规则的一个示例。
import ch.qos.logback.classic.Level;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
....
// 获取名称为 "com.foo" 的记录器实例。假设记录器是 ch.qos.logback.classic.Logger 类型,以便我们可以设置其级别
ch.qos.logback.classic.Logger logger =
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("com.foo");
// 将其级别设置为 INFO。setLevel() 方法需要一个 logback 记录器
logger.setLevel(Level. INFO);
Logger barlogger = LoggerFactory.getLogger("com.foo.Bar");
// 这个请求是可用的,因为 WARN >= INFO
logger.warn("Low fuel level.");
// 这个请求是禁用的,因为 DEBUG < INFO。
logger.debug("Starting search for nearest gas station.");
// 名为 "com.foo.Bar" 的记录器实例 barlogger 将从名称为 "com.foo" 的记录器那里继承其级别。因此,以下请求是可用的,因为 INFO >= INFO。
barlogger.info("Located nearest gas station.");
// 这个请求是禁用的,因为 DEBUG < INFO。
barlogger.debug("Exiting gas station search");
检索记录器
使用相同名称调用 LoggerFactory.getLogger
方法将始终返回对完全相同的记录器对象的引用。
例如,在
Logger x = LoggerFactory.getLogger("wombat");
Logger y = LoggerFactory.getLogger("wombat");
x
和 y
引用 完全相同 的记录器对象。
因此,可以配置一个记录器,然后在代码的其他位置检索同一个实例,而无需传递引用。与生物亲子关系的基本矛盾相反,亲代总是出现在子代之前,logback 记录器可以以任何顺序创建并配置。特别是,“父”记录器将找到并链接到其后代,即使在后代之后才实例化。
通常在应用程序初始化时进行 logback 环境的配置。首选方法是通过读取配置文件。我们将很快讨论这种方法。
Logback 使得可以按 软件组件 名称对记录器进行命名变得很容易。可以通过在每个类中实例化具有与类的完全限定名称相同的记录器名称的记录器来实现这一点。这是一种有用且简单的定义记录器的方法。由于日志输出带有生成记录器的名称,因此这种命名策略使得很容易识别日志消息的来源。但是,这只是一种可能的,尽管常见的命名记录器的策略。logback 不限制可能的记录器集。作为开发人员,您可以自由地按照自己的意愿对记录器进行命名。
尽管如此,似乎将记录器命名为它们所在的类是已知的最佳通用策略。
Appenders 和 Layouts
根据其记录器选择性地启用或禁用日志请求的能力只是问题的一部分。Logback 允许日志请求打印到多个目的地。在 logback 术语中,输出目的地称为 appender。当前支持的 appender 包括控制台、文件、远程套接字服务器、MySQL、PostgreSQL、Oracle 和其他数据库、JMS 以及远程 UNIX Syslog 守护程序。
可以将多个 appender 附加到一个记录器。
addAppender
方法将 appender 添加到给定的记录器。给定记录器的每个启用的日志请求将转发到该记录器中的所有 appender,以及该记录器的记录器层次结构中的更高级的 appender。换句话说,appender 从记录器层次结构中继承。例如,如果将控制台 appender 添加到根记录器,那么所有启用的日志请求将至少打印到控制台。如果另外添加文件 appender 到一个记录器,比如 L,则 L 及 L 的子记录器的启用的日志请求将打印到文件,并且也会打印到控制台。可以通过将记录器的 additivity 标志设置为 false 来覆盖这种默认行为。
总结了控制 appender 添加性的规则如下。
WARNING
Appender 添加性
记录器 L 的日志语句的输出将发送到 L 及其祖先中的所有 appender。这就是“appender 添加性”的含义。
但是,如果记录器 L 的祖先之一,比如 P,的 additivity
标志设置为 false
,则 L 的输出将发送到 L 及其祖先,直到 P 为止,但不发送到 P 的任何祖先中的 appender。
记录器的默认 additivity
标志为 true
。
下表是一个示例:
日志器名称 | 附加的 Appender | Additivity 标志 | 输出目标 | 注释 |
---|---|---|---|---|
root | A1 | 不适用 | A1 | 由于根日志器位于日志器层次结构的顶部,Additivity 标志对其不适用。 |
x | A-x1, A-x2 | true | A1, A-x1, A-x2 | "x" 和根日志器的 Appender。 |
x.y | 无 | true | A1, A-x1, A-x2 | "x" 和根日志器的 Appender。 |
x.y.z | A-xyz1 | true | A1, A-x1, A-x2, A-xyz1 | "x.y.z"、"x" 和根日志器的 Appender。 |
security | A-sec | false | A-sec | Additivity 标志设置为 false ,因此不会累积 Appender。只使用 Appender A-sec。 |
security.access | 无 | true | A-sec | 只使用 "security" 的 Appender,因为 "security" 的 Additivity 标志设置为 false 。 |
通常,用户希望自定义输出目标和输出格式。这可以通过将 布局 与 Appender 关联来实现。布局负责根据用户的需求格式化日志记录请求,而 Appender 负责将格式化的输出发送到其目标位置。PatternLayout
是 logback 的标准组件之一,它允许用户根据类似于 C 语言的 printf
函数的转换模式指定输出格式。
例如,具有转换模式 "%-4relative [%thread] %-5level %logger{32} - %msg%n"
的 PatternLayout
将输出类似于:
176 [main] DEBUG manual.architecture.HelloWorld2 - Hello world.
第一个字段是自程序启动以来经过的毫秒数。第二个字段是发出日志请求的线程。第三个字段是日志请求的级别。第四个字段是与日志请求关联的日志器的名称。'-' 之后的文本是请求的消息。
参数化日志记录
由于 logback-classic 中的日志器实现了 SLF4J 的 Logger 接口,某些打印方法可以接受多个参数。这些打印方法的变体主要旨在提高性能,同时尽量减少对代码可读性的影响。
对于某个日志器 logger
,以下写法:
logger.debug("Entry number: " + i + " is " + String.valueOf(entry[i]));
会涉及到构造消息参数的成本,即将整数 i
和 entry[i]
都转换为字符串,并拼接中间字符串。无论消息是否被记录,这样的成本都会产生。
避免参数构造成本的一种可能方法是将日志语句包裹在一个测试中。以下是一个示例:
if(logger.isDebugEnabled()) {
logger.debug("Entry number: " + i + " is " + String.valueOf(entry[i]));
}
这样,如果对于 logger
来说调试日志记录被禁用,就不会产生参数构造成本。另一方面,如果对于 DEBUG 级别启用了日志记录,则需要两次评估是否启用了日志记录:一次在 debugEnabled
方法中,一次在 debug
方法中。实际上,这种开销微不足道,因为评估日志器的时间少于实际记录请求所需的时间的 1%。
更好的选择
基于消息格式存在一种更便捷的替代方法。假设 entry
是一个对象,你可以这样写:
Object entry = new SomeObject();
logger.debug("The entry is {}.", entry);
只有在评估是否记录日志的决策为肯定时,才会将日志记录器实现格式化消息,并将 '{}'
替换为 entry
的字符串值。换句话说,当禁用日志语句时,这种形式不会产生参数构造成本。
以下两行代码将产生完全相同的输出。然而,在 禁用 日志语句的情况下,第二种变体的性能至少比第一种变体快 30 倍。
logger.debug("The new entry is "+entry+".");
logger.debug("The new entry is {}.", entry);
还有一个接受两个参数的变体。例如,你可以这样写:
logger.debug("The new entry is {}. It replaces {}.", entry, oldEntry);
如果需要传递三个或更多参数,还可以使用可变参数 (Object...)
的变体。例如,你可以这样写:
logger.debug("Value {} was inserted between {} and {}.", newVal, below, above);
请注意,可变参数的变体会产生创建 Object[]实例的成本。
窥探内部机制
在介绍了 logback 的基本组件之后,我们现在准备描述当用户调用记录器的打印方法时,logback 框架所采取的步骤。现在让我们分析一下当用户调用名为 com.wombat 的记录器的 info()
方法时,logback 所采取的步骤。
1. 获取过滤器链的决策
如果存在,则会调用 TurboFilter
链。Turbo 过滤器可以根据与每个日志请求相关的信息(如 Marker
、Level
、Logger
、消息或 Throwable
)设置上下文级阈值,或者过滤掉某些事件。如果过滤链的返回值是 FilterReply.DENY
,则丢弃该日志请求。如果是 FilterReply.NEUTRAL
,则继续下一步,即第 2 步。如果返回值是 FilterReply.ACCEPT
,则跳过下一步,直接进入第 3 步。
2. 应用基本选择规则
在这一步中,logback 将比较记录器的有效级别与请求的级别。如果根据此测试禁用了日志请求,则 logback 将在不进行进一步处理的情况下丢弃该请求。否则,它将继续下一步。
3. 创建 LoggingEvent
对象
如果请求通过了前面的过滤器,logback 将创建一个包含请求的所有相关参数的 ch.qos.logback.classic.LoggingEvent
对象,例如请求的记录器、请求级别、消息本身、可能随请求传递的异常、当前时间、当前线程、发出日志请求的类的各种数据以及 MDC
。请注意,其中一些字段是惰性初始化的,也就是只在实际需要时才初始化。MDC
用于为日志请求添加额外的上下文信息。有关 MDC 的详细信息将在 后续章节 中讨论。
4. 调用附加器
在创建 LoggingEvent
对象之后,logback 将调用所有适用的附加器(即从记录器上下文继承的附加器)的 doAppend()
方法。
logback 发行版中提供的所有附加器都扩展了抽象类 AppenderBase
,该类在同步块中实现了 doAppend
方法,确保线程安全。AppenderBase
的 doAppend()
方法还会调用附加到附加器上的自定义过滤器(如果存在)。可以动态附加到任何附加器上的自定义过滤器将在 单独的章节 中介绍。
5. 格式化输出
格式化日志输出是被调用的附加器的责任。然而,并非所有附加器都将格式化日志事件的任务委托给布局。布局将格式化 LoggingEvent
实例并将结果作为字符串返回。请注意,某些附加器(如 SocketAppender
)不会将日志事件转换为字符串,而是进行序列化。因此,它们没有布局,也不需要布局。
6. 发送 LoggingEvent
在日志事件完全格式化之后,每个附加器将其发送到目标位置。
下面是一个序列 UML 图,展示了所有工作流程的运行方式。您可能希望单击图片以显示更大的版本。
性能
针对日志记录的计算成本经常被提及为反对意见之一。这是一个合理的关注点,因为即使是中等规模的应用程序也可能生成数千个日志请求。我们的开发工作主要花在了测量和调整 logback 性能上。尽管如此,用户仍应了解以下性能问题。
1. 完全关闭日志记录时的日志性能
您可以通过将根记录器的级别设置为 Level.OFF
(最高级别)来完全关闭日志记录。当完全关闭日志记录时,日志请求的成本只包括方法调用和整数比较。在 3.2Ghz Pentium D 机器上,这个成本通常约为 20 纳秒。
然而,任何方法调用都涉及“隐藏”的参数构造成本。例如,对于某个记录器 x 写入,
x.debug("Entry number: " + i + "is " + entry[i]);
会产生构造消息参数的成本,即将整数 i
和 entry[i]
转换为字符串,并连接中间字符串,而不管消息是否会被记录。
参数构造的成本可能相当高,取决于所涉及参数的大小。为避免参数构造的成本,您可以利用 SLF4J 的参数化日志记录:
x.debug("Entry number: {} is {}", i, entry[i]);
这种变体不会产生参数构造的成本。与前面对 debug()
方法的调用相比,它的速度要快得多。只有在日志请求将被发送到附加器时,消息才会被格式化。此外,负责格式化消息的组件已进行了高度优化。
尽管如上所述,将日志语句放在紧密循环中(即非常频繁地调用的代码)是一个赔本的提议,很可能会导致性能下降。即使关闭了日志记录,将日志记录在紧密循环中也会减慢应用程序的运行速度,并且如果打开了日志记录,将生成大量(因此无用)的输出。
2. 日志记录开启时判断是否记录日志的性能
在 logback 中,无需遍历记录器层次结构。记录器在创建时就知道其有效级别(即在考虑级别继承后的级别)。如果更改了父记录器的级别,则会联系所有子记录器以注意到更改。因此,在根据有效级别接受或拒绝请求之前,记录器可以做出准即时的决策,而无需查询其祖先记录器。
3. 实际记录日志(格式化和写入输出设备)
这是格式化日志输出并将其发送到目标位置的成本。同样,在设计布局(格式化程序)时已经付出了大量努力以尽快执行。附加器也是如此。当将日志记录到本地机器上的文件时,实际记录的典型成本约为 9 至 12 微秒。当将日志记录到远程服务器上的数据库时,成本可能高达几毫秒。
尽管功能丰富,但 logback 的主要设计目标之一是执行速度,这是仅次于可靠性的要求。为了提高性能,logback 的一些组件已经多次进行了重写。