Xi Minghui的日志探索 2467-614

这是我在B站上根据动力节点的IT楠老师教学公开视频学习时制作的博客,方便自己日后翻看,顺便公开也许能帮助到需要学习日志的人。

学习视频:【资料白送,java日志框架】源码级教学,重思想,通俗易懂,【log4j,logback,日志门面】

本篇由Xi Minghui发表在博客网站

第一章 日志概念

一、 日志的作用

  1. 调试代码
  2. 定位错误
  3. 数据分析:用ELK等大数据分析技术提炼出有用的业务数据,如用户行为、画像、兴趣爱好等

二、 主流的日志框架

1. 日志门面(即规范、接口)

日志门面即规范、接口,就像JDBC(规范/接口)和其实现(各JDBC驱动)。

2. 日志实现

3. 日志桥接器

存在各种各样的桥接器将不同实现对应到日志门面中,具体详情略。

4. 关于日志相关的讨论

三、 日志框架的发展历史

关于日志的发展背景可以看 imango 写的文章 《Java日志系统历史从入门到崩溃》

第二章 JUL日志

一、初识JUL

1. 日志组件

JUL的主要有以下组件:

说明:虽然日志框架/库有很多种,但是设计思想都是相似的,可能只是叫法不同。

2. JUL日志级别

JUL的日志级别设计的有点一言难尽,Java 9引入的 “System Logger” 则和主流的日志门面/实现(如 Spring Boot Logging、SLF4j、Log4j)设计的级别几乎一致(可能会多个ALL、OFF或者比Log4j少个FATAL,但大体差不多),为: ALL、TRACE、DEBUG、INFO、WARNING、ERROR、OFF
  1. SEVERE
  2. WARNING
  3. INFO
  4. CONFIG
  5. FINE
  6. FINER
  7. FINEST

还有两个特殊的级别:OFF(全关)和ALL(全开)

3. 简单使用JUL

运行下面代码,日志内容将在控制台上被打印出来:

import java.util.logging.Logger; public class Test { public static void main(String[] args) { Logger logger = Logger.getLogger("MyLogger"); logger.severe("This is a message at severe level."); logger.warning("This is a message at warning level."); logger.info("This is a message at info level."); logger.config("This is a message at config level."); logger.fine("This is a message at fine level."); logger.finer("This is a message at finer level."); logger.finest("This is a message at finest level."); /* 作者的控制台输出如下: Jun 07, 2024 9:46:14 PM org.ximinghui.blog.Test main SEVERE: This is a message at severe level. Jun 07, 2024 9:46:14 PM org.ximinghui.blog.Test main WARNING: This is a message at warning level. Jun 07, 2024 9:46:14 PM org.ximinghui.blog.Test main INFO: This is a message at info level. 说明:JDK日志会根据机器语言地区环境输出对应的语言和格式,作者 是英文/美国,对于简体中文/中国的环境这里看到的级别可能是中文 */ } }

思考:日志被打印出来了,但是CONFIG、FINE、FINER、FINEST级别的日志怎么没有出现呢?让我们一探究竟。

4. JUL默认配置

JUL有一个默认的配置,为 logging.properties文件,位于 JAVA_HOME/conf目录中。以JDK 21版本为例,内容如下(仅粘贴配置行,注释被移除了):

handlers= java.util.logging.ConsoleHandler .level= INFO java.util.logging.FileHandler.pattern = %h/java%u.log java.util.logging.FileHandler.limit = 50000 java.util.logging.FileHandler.count = 1 java.util.logging.FileHandler.maxLocks = 100 java.util.logging.FileHandler.formatter = java.util.logging.XMLFormatter java.util.logging.ConsoleHandler.level = INFO java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter

这个配置文件只有几行很简单配置,我们可以看到日志级别被设置在INFO级别,这也就是为什么上面我们的低级别日志没有出现。

配置文件中给了许多注释,很简单清楚的解释了一些信息:

让我们调整JDK默认的日志配置文件,将两个日志级别都由 “INFO” 改为 “FINEST”,再次测试发现低级别日志已经可以打印出来了:

Jun 07, 2024 10:54:52 PM Test main SEVERE: This is a message at severe level. Jun 07, 2024 10:54:52 PM Test main WARNING: This is a message at warning level. Jun 07, 2024 10:54:52 PM Test main INFO: This is a message at info level. Jun 07, 2024 10:54:52 PM Test main CONFIG: This is a message at config level. Jun 07, 2024 10:54:52 PM Test main FINE: This is a message at fine level. Jun 07, 2024 10:54:52 PM Test main FINER: This is a message at finer level. Jun 07, 2024 10:54:52 PM Test main FINEST: This is a message at finest level.

探索完成后,让我们把JDK默认配置文件中的日志级别还修改回原来的INFO。后续使用中我们应使用每个应用/程序自己的日志配置文件。

5. 编程方式配置JUL

接下以编程的方式对日志进行配置,我们尝试配置一个和默认配置同效的日志配置。

import java.util.logging.*; public class Test { public static void main(String[] args) { // 创建Logger日志器,设置为INFO级别,然后将上面的控制台处理器添加到该Logger中 Logger logger = Logger.getLogger("MyLogger"); logger.setUseParentHandlers(false); // 不从父Logger继承 logger.setLevel(Level.INFO); // 创建一个Console处理器,和默认配置文件一样,该处理器使用SimpleFormatter格式化器,日志级别也同为INFO ConsoleHandler consoleHandler = new ConsoleHandler(); consoleHandler.setFormatter(new SimpleFormatter()); consoleHandler.setLevel(Level.INFO); // 添加处理器 logger.addHandler(consoleHandler); logger.severe("通过编程方式配置了和默认配置文件一样的日志效果 (severe)"); logger.warning("通过编程方式配置了和默认配置文件一样的日志效果 (warning)"); logger.info("通过编程方式配置了和默认配置文件一样的日志效果 (info)"); logger.config("通过编程方式配置了和默认配置文件一样的日志效果 (config)"); logger.fine("通过编程方式配置了和默认配置文件一样的日志效果 (fine)"); logger.finer("通过编程方式配置了和默认配置文件一样的日志效果 (finer)"); logger.finest("通过编程方式配置了和默认配置文件一样的日志效果 (finest)"); } }

6. 更多Handler

玩完了控制台处理器,还有什么别的日志处理器可以玩呢?查阅JDK 21 API,发现JDK中一共有6个实现类:

Handler
  |- MemoryHandler
  |- StreamHandler
       |- ConsoleHandler
       |- FileHandler
       |- SocketHandler

Console控制台处理器和File文件处理器最常用,让我们再浅尝下File处理器(其他类型的处理器就不研究了,JUL不是一个主流的日志库)。

7. 文件日志处理器

在上面的基础上,我们再创建一个 File日志处理器追加到Logger对象中。

此时,Logger的日志级别为INFO,Console处理器的级别为INFO,File处理器的级别为WARNING(即文件中仅存WARNING或更高级别的日志信息)。

代码如下:

import java.util.logging.*; public class Test { public static void main(String[] args) throws Exception { // 创建Logger日志器,设置为INFO级别,然后将上面的控制台处理器添加到该Logger中 Logger logger = Logger.getLogger("MyLogger"); logger.setUseParentHandlers(false); // 不从父Logger继承 logger.setLevel(Level.INFO); // 创建一个Console处理器,和默认配置文件一样,该处理器使用SimpleFormatter格式化器,日志级别也同为INFO ConsoleHandler consoleHandler = new ConsoleHandler(); consoleHandler.setFormatter(new SimpleFormatter()); consoleHandler.setLevel(Level.INFO); // 创建一个File处理器 FileHandler fileHandler = new FileHandler("D:/my.log", true); fileHandler.setFormatter(new SimpleFormatter()); fileHandler.setLevel(Level.WARNING); // 添加处理器 logger.addHandler(consoleHandler); logger.addHandler(fileHandler); logger.severe("通过编程方式配置了和默认配置文件一样的日志效果 (severe)"); logger.warning("通过编程方式配置了和默认配置文件一样的日志效果 (warning)"); logger.info("通过编程方式配置了和默认配置文件一样的日志效果 (info)"); logger.config("通过编程方式配置了和默认配置文件一样的日志效果 (config)"); logger.fine("通过编程方式配置了和默认配置文件一样的日志效果 (fine)"); logger.finer("通过编程方式配置了和默认配置文件一样的日志效果 (finer)"); logger.finest("通过编程方式配置了和默认配置文件一样的日志效果 (finest)"); } }

提示:FileHandler还有一些别的构造方法,如 FileHandler(String pattern, int limit, int count, boolean append)。其中 pattern 参数用于指示日志文件名(含路径), limit 参数用于设置每个日志分割文件的最大多少字节(Bytes),参数 count 则指定日志文件最多保存多少个(文件数达到count时会自动删除早期的日志内容),最后 append 参数用于指定当待写入的日志文件已经存在时程序应该将其内容清空并写入还是直接在原有内容的基础上追加写入。

二、探索JUL

该部分涉及到的相关概念在日志中是通用或相似的。

1. Logger之间的父子关系

a. 规范日志Logger名

上面的代码中我们通过 Logger.getLogger("MyLogger")创建了一个名为 “MyLogger” 的日志Logger对象,通常在正式的编码中,我们会用全限定类名来命名一个Logger,如:

package org.ximinghui.blog; import java.util.logging.*; public class Test { public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class.getName()); // org.ximinghui.blog.Test System.out.printf("Logger名为:%s", logger.getName()); } }

b. 探索父子关系

在上述代码中,我们创建了一个名为 org.ximinghui.blog.Test的Logger对象,这个对象默认将会有一个父Logger,为 “RootLogger”。

RootLogger 是所有Logger的顶级父Logger,就像Java中所有类都是Object的子类一样。

让我们验证父Logger的存在:

public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class.getName()); Logger parent = logger.getParent(); // 获取父Logger // java.util.logging.LogManager$RootLogger@533ddba System.out.printf("父Logger对象为:%s", parent); }

除了RootLogger外,基于包名的Logger间会默认存在一种父子关系,如 org.ximinghui将会成为 org.ximinghui.blog.Test的父Logger。

验证代码:

public static void main(String[] args) { Logger rootLogger = Logger.getLogger(""); Logger comExampleLogger = Logger.getLogger("com.example"); Logger comExampleCommonLogger = Logger.getLogger("com.example.common"); // 断言 "com.example" 的父亲是 RootLogger assert comExampleLogger.getParent().equals(rootLogger); // 断言 "com.example.common" 的父亲是 "com.example" assert comExampleCommonLogger.getParent().equals(comExampleLogger); }

c. Logger的单例特性

名字相同的Logger都是同一个Logger对象

public static void main(String[] args) { Logger logger1 = Logger.getLogger("org.ximinghui.blog"); Logger logger2 = Logger.getLogger("org.ximinghui.blog"); System.out.println(logger1); // java.util.logging.Logger@2e5d6d97 System.out.println(logger2); // java.util.logging.Logger@2e5d6d97 }

d. 父子继承特性

父Logger的配置会继承到子Logger

Talk is cheap. Show my code:

public static void main(String[] args) { // 创建父子Logger Logger parent = Logger.getLogger("org.ximinghui"); Logger child = Logger.getLogger("org.ximinghui.blog"); // 设置父Logger的级别 parent.setLevel(Level.WARNING); // 子Logger将会继承父Logger的级别配置 child.info("这是一条普通消息"); // 该语句不会被打印 child.warning("这是一条警告消息"); // 语句被打印 // 向父Logger中再添加一个Console处理器 // 注意:将会有两个Console处理器,因为 “parent” Logger也从RootLogger那里继承了一个默认的Console处理器 parent.addHandler(new ConsoleHandler()); // 验证往父Logger中添加处理器也会作用到子Logger child.warning("该消息将会被打印2次"); // 消息被打印2次 // 设置 “child” Logger不从父Logger继承 // 说明:这里JDK的方法命名似乎不准确,调用该方法后不仅仅未使用父Logger的Handlers,也不会使用父Logger的配置(如Logger的日志级别) child.setUseParentHandlers(false); // 说明:不从父Logger继承后,子Logger本身没有添加过任何日志处理器,即日志处理器为空,所以消息不会被打印 child.warning("这条消息将不会被打印"); // 消息未被打印 }

拓展知识:Logger对象的管理、对象间父子关系维护、读取配置等工作是由 LogManager类(全限定类名java.util.logging.LogManager) 负责的。

2. 日志格式化

a. 重写Formatter抽象类

简单重写 java.util.logging.Formatter抽象类:

public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class.getName()); ConsoleHandler consoleHandler = new ConsoleHandler(); // 之类使用匿名内部类重新实现了自己的格式化逻辑 consoleHandler.setFormatter(new Formatter() { @Override public String format(LogRecord record) { // 处理日志格式 return "%s: %s%n".formatted(record.getLevel(), record.getMessage()); } }); logger.setUseParentHandlers(false); logger.addHandler(consoleHandler); logger.info("我是一条普通日志信息"); logger.warning("我是一条警告日志信息"); /* 作者的控制台输出如下: INFO: 我是一条普通日志信息 WARNING: 我是一条警告日志信息 */ }

说明:在重写的 format(LogRecord record)方法中,我们将得到一个 record 对象,该对象内部有丰富的属性供我们获取,如日志打印代码所在的类/方法、日志打印时间、日志级别等等。

b. String的format方法

Stringformat(String format, Object... args)静态方法和 formatted(Object... args)(JDK 15+可用)成员方法用于对字符串进行格式化,它和 System.out.printf(String format, Object ... args)System.out.format(String format, Object ... args))方法的底层实现相同,都是调用 java.util.Formatter类的 format方法实现格式化。

代码演示如下:

public static void main(String[] args) { // 通过 String.format(String, Object...) 方法格式化 String string1 = String.format("你好呀,我叫%s,今年%d了,是Java的吉祥物!", "Duke", Year.now().getValue() - 1996); System.out.println(string1); // 自Java 15引入了 formatted(Object...) 方法,字符串对象可以直接调用 String string2 = "你好呀,我叫%s,今年%d了,是Java的吉祥物!".formatted("Duke", Year.now().getValue() - 1996); System.out.println(string2); // printf(String, Object ...) 方法也具备同样的效果 System.out.printf("你好呀,我叫%s,今年%d了,是Java的吉祥物!", "Duke", Year.now().getValue() - 1996); /* 作者的控制台输出如下: 你好呀,我叫Duke,今年28了,是Java的吉祥物! 你好呀,我叫Duke,今年28了,是Java的吉祥物! 你好呀,我叫Duke,今年28了,是Java的吉祥物! */ }

在上述的代码中,我们通过使用 %s%d占位符完成了 String(字符串) 和 Decimal(十进制整数)格式化。常用的占位符除了上面两种外还有 %f(浮点型)和 %n(换行)。

c. 探索更多format转换和标志

演示如下(代码和解释内容主要取自Oracle提供的 Java教程 ):

public static void main(String[] args) { long n = 461012; // d:十进制整数 // n:换行(应该始终使用%n而不是\n) System.out.format("%d%n", n); // "461012" // 08:宽度为八个字符,不足时前面补0 // 备注:“08”也可以写成“ 8”(空格8)或“8”,后两种写法同效,表示不足时前面补空格 System.out.format("%08d%n", n); // "00461012" // +:正负符号 // 备注:未添加“+”时正数打印前面不会有正数符号,负数打印前面有负数符号,添加后正负数总是明确打印正负符号 // 备注:注意正负符号也占1个字符数,即8位字符宽度之一。换句话说宽度指定的是整个字符串的长度,任何字符都将计算数量 System.out.format("%+8d%n", n); // " +461012" // ,:对数字分组(通常是千位分隔,具体取决于语言环境) // 备注:注意字符串结果中的逗号“,”也算作字符数量 System.out.format("%,8d%n", n); // " 461,012" System.out.format("%+,8d%n%n", n); // "+461,012" // (:当数值为负数时使用括号将数值包起来,正数保持不变 System.out.format("%(d%n", 100); // 100 System.out.format("%(d%n%n", -100); // (100) double pi = Math.PI; // f:浮点数 // 备注:严格的占位符类型校验。整数使用%d,浮点数使用%f,若给%d传递的实际参数为浮点数会报错 System.out.format("%f%n", pi); // "3.141593" // .3:小数点后三位 System.out.format("%.3f%n", pi); // "3.142" // -:表示左对齐 // 备注:这里的两个示例可以对比观察左对齐和右对齐的区别 // 批注:Oracle网站中这里存在一处错误 System.out.format("%-10.3f%n", pi); // "3.142 " System.out.format("%10.3f%n", pi); // " 3.142" // Local.FRANCE:传该参数明确指定使用法语语言格式进行格式化 // 备注:法语中小数符号是逗号“,”而不是点“.”,许多使用拉丁字母的欧洲国家习惯相同 // 批注:Oracle网站中这里存在一处错误 System.out.format(Locale.FRANCE, "%-10.4f%n%n", pi); // "3,1416 " Calendar c = Calendar.getInstance(); // tB:日期时间中的月份全名,具体取决于语言环境,如英文是“May”,中文就是“五月” // te:日期时间中的日期(即天数),如果想要1位数日期前添加0(如09而不是9)请使用“td” // tY:日期时间中的年份,具体取决于语言环境,如果想要两位数的年份请使用“ty” // 备注:如果想要数值月份,请使用“tm”(1位数前会添加0) System.out.format("%tB %te, %tY%n", c, c, c); // "May 29, 2006" // tl:日期时间中的小时(12小时制),如果想要使用24小时制请用“tk”(即0~23) // tM:日期时间中的分钟数(0~9前面会补0) // tp:日期时间中的上午或下午,具体取决于语言环境,如英文是“am/pm”,中文就是“上午/下午” System.out.format("%tl:%tM %tp%n", c, c, c); // "2:34 am" // tD:等效于“%tm/%td/%ty” System.out.format("%tD%n", c); // "05/29/06" String name = "Xi Minghui"; // 1$ / 2$:通过数字加$的方式指定该占位符从后面参数中取值的位置,如:实例中3处name占位符不需要传3次name变量 System.out.format("%2$s是一名软件工程师,%2$s今年%1$d岁了,%2$s喜欢Java编程语言", c.get(Calendar.YEAR) - 1998, name); }

占位符(来自视频课件):

占位符 说明
%s 字符串类型
%d 整数类型(十进制)
%f 浮点类型
%n 插入行
%c 字符类型
%b 布尔类型
%x 整数类型(十六进制)
%o 整数类型(八进制)
%a 十六进制浮点表示类型
%e 指数类型
%tx 日期与时间类型(格式在不同的日期与时间库转换)

日期处理(来自 视频课件 ):

格式符 说明 示例
c 包括完整日期时间的时间信息 星期 10月 21 14:52:10 GMT+08:00 2021
F “年-月-日”格式 2021-10-21
D “月/日/年”格式 10/21/21
r “HH:MM:SS PM”格式 (12小时制) 02:53:20 下午
T “HH:MM:SS”格式 (24小时制) 14:53:39
R “HH:MM”格式 (24小时制) 14:53
b 月份缩写 10月
y 两位的年 21
Y 四位的年 2021
m 10
d 21
H 24小时制的时 14
I 12小时制的时 2
M 57
S 46
s 秒为单位的时间戳 1634799527
p 上午还是下午 下午

d. 解读一下试试吧

public static void main(String[] args) { System.out.printf( // 试试解读下这个format模板字符串吧 "%1$tb %1$td, %1$tY %1$tl:%1$tM:%1$tS %1$Tp %2$s%n%4$s: %5$s%6$s%n", LocalDateTime.now(), "org.ximinghui.blog.Test getName", "MyLogger", "警告", "我是一条消息~", "" ); }

答案:

Jun 10, 2024 2:26:48 PM org.ximinghui.blog.Test getName 警告: 我是一条消息~

e. 探索SimpleFormatter格式化器

SimpleFormatterjava.util.logging.SimpleFormatter)为控制台处理器默认的日志格式化器,接下来让我们一睹SimpleFormatter真容:

作者去除了源码中的注释,并对其进行简化了以方便我们阅读:

简化内容:
1. 移除了默认就存在的无参构造器
2. 并将format属性和该属性的getLoggingProperty方法取值调用直接替换为实际运行中具体的值
import java.io.*; import java.time.*; public class SimpleFormatter extends Formatter { @Override public String format(LogRecord record) { // 将日志对象中的“instant”时间属性值转换成带有(系统默认)时区的“ZonedDateTime”日期时间对象 ZonedDateTime zdt = ZonedDateTime.ofInstant(record.getInstant(), ZoneId.systemDefault()); // 获取类和方法名(若不存在则取Logger名),示例:org.ximinghui.blog.Test getName String source; if (record.getSourceClassName() != null) { source = record.getSourceClassName(); if (record.getSourceMethodName() != null) { source += " " + record.getSourceMethodName(); } } else { source = record.getLoggerName(); } // 调用父类的formatMessage(LogRecord)方法获取格式化的日志消息 String message = formatMessage(record); // 如果日志对象中还携带了异常对象,则将异常对象处理成字符串并赋值给throwable String throwable = ""; if (record.getThrown() != null) { StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw); pw.println(); record.getThrown().printStackTrace(pw); pw.close(); throwable = sw.toString(); } // 这里其实就是我们 “d. 解读一下试试吧” 中完全相同的逻辑了 return String.format("%1$tb %1$td, %1$tY %1$tl:%1$tM:%1$tS %1$Tp %2$s%n%4$s: %5$s%6$s%n", zdt, source, record.getLoggerName(), record.getLevel().getLocalizedLevelName(), message, throwable); } }

3. 独立于JDK的日志配置

编程的方式配置指定的日志配置:

public static void main(String[] args) throws IOException { // LogManager类是单例设计的 LogManager logManager = LogManager.getLogManager(); // 让 LogManager 读取我们传给它的日志配置 // logManager.readConfiguration(new FileInputStream("你的 logging.properties 文件路径")); // 作为演示这里就直接用字符串取代 logging.properties 配置文件了 String myLoggingConfig = """ handlers= java.util.logging.ConsoleHandler .level= CONFIG java.util.logging.ConsoleHandler.level = CONFIG java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter """; // 让 LogManager 读取我们传给它的日志配置 logManager.readConfiguration(new ByteArrayInputStream(myLoggingConfig.getBytes())); // 检验日志配置是否生效 Logger logger = Logger.getLogger("TestLogger"); logger.config("山有木兮木有枝"); // 消息被打印 logger.finest("心悦君兮君不知"); // 消息未打印 // 在原配置内容上面追加以下配置(添加名为“org.ximinghui.blog”的Logger配置): myLoggingConfig += """ org.ximinghui.blog.handlers = java.util.logging.ConsoleHandler org.ximinghui.blog.level = WARNING """; // 让 LogManager 重新读取日志配置 logManager.readConfiguration(new ByteArrayInputStream(myLoggingConfig.getBytes())); // 检验日志配置是否生效 logger = Logger.getLogger("org.ximinghui.blog.Test"); logger.finest("该消息不会被打印"); // 消息未打印 logger.warning("该消息将会被打印"); // 消息被打印,但是出现了2次 // TODO: 思考为什么日志会被打印2次? // 答案:该Logger也从RootLogger继承了一个ConsoleHandler,所以日志会出现2次 // 尝试修复该问题: myLoggingConfig += "org.ximinghui.blog.useParentHandlers = false\n"; logManager.readConfiguration(new ByteArrayInputStream(myLoggingConfig.getBytes())); logger.warning("该消息将会被打印1次"); }

一些日志配置表(不全,取自 Oracle Doc: Logging configuration):

Logging Property Description
handlers 为RootLogger添加的处理器,类名逗号分隔
java.util.logging.FileHandler.level 设置所有FileHandler实例的日志级别,默认为FINE
java.util.logging.FileHandler.pattern 日志文件名模式
java.util.logging.FileHandler.limit 最大文件大小(单位为Byte),默认为1000000,0表示无限制
java.util.logging.FileHandler.count 用于日志文件轮换的日志文件数量,默认为10000
java.util.logging.FileHandler.formatter FileHandler实例使用的格式化程序的类名
java.util.logging.ConsoleHandler.level 设置所有ConsoleHandler实例的默认日志级别
java.util.logging.FileHandler.append 指定FileHandler是否应附加到任何现有文件,默认为false
java.util.logging.ConsoleHandler.formatter ConsoleHandler实例使用的格式化程序的类名
java.util.logging.SimpleFormatter.format 指定用于日志消息的格式

也可以看看: FileHandler (Java SE 21 & JDK 21)

4. 日志过滤器

Filterjava.util.logging.Filter)类为函数式接口,JDK中没有其实现类。

a. 示例一

public class Test { static { // 从RootLogger中获取ConsoleHandler,并为其添加过滤器 // 过滤器规则:消息中包含“密码”字样的不打印 Logger.getLogger("").getHandlers()[0].setFilter(record -> !record.getMessage().contains("密码")); } public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class.getName()); logger.info("开始注册账户"); // 被打印 logger.info("用户名是user"); // 被打印 logger.info("密码是00000"); // 未打印 } }

b. 示例二

该示例中演示SensitiveInformationFilter过滤器如何实现对日志中身份证号码的脱敏处理。

import java.util.logging.*; import java.util.regex.*; public class Test { public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class.getName()); logger.setFilter(SensitiveInformationFilter.getInstance()); logger.info("新增用户:name='Xi Minghui', idNumber='410201199801010116', signaturePhrase='人只有醒来后才知道自己做了一场梦'"); } } /** * 敏感信息过滤器 */ class SensitiveInformationFilter implements Filter { // 单例设计 private SensitiveInformationFilter() { } // 单例设计 private static final SensitiveInformationFilter INSTANCE = new SensitiveInformationFilter(); // 单例设计 public static SensitiveInformationFilter getInstance() { return INSTANCE; } @Override public boolean isLoggable(LogRecord record) { if (isMaskingRequired(record)) { maskSensitiveData(record); } return true; } /** * 是否需要数据脱敏 */ private boolean isMaskingRequired(LogRecord record) { return Pattern.compile("\\b\\d{18}\\b").matcher(record.getMessage()).find(); } /** * 进行数据脱敏 * <p> * 示例:410201199801010116 -> 410201********0116 */ private void maskSensitiveData(LogRecord record) { String message = record.getMessage(); Matcher matcher = Pattern.compile("\\b(\\d{18})\\b").matcher(message); if (!matcher.find()) throw new IllegalStateException("找不到身份证号码"); String idNumber = matcher.group(1); String front = idNumber.substring(0, 6); String back = idNumber.substring(14); String maskedIdNumber = front + "********" + back; message = message.replace(idNumber, maskedIdNumber); record.setMessage(message); } }

5. 异常打印

public static void main(String[] args) throws IOException { // java.util.logging.Logger.throwing(String sourceClass, String sourceMethod, Throwable thrown) // 方法打印的日志级别为FINER,要想显示打印内容必须设置“FINER”或更低的“FINEST”日志级别 String config = """ org.ximinghui.blog.level= FINEST java.util.logging.ConsoleHandler.level = FINEST org.ximinghui.blog.handlers= java.util.logging.ConsoleHandler """; LogManager.getLogManager().readConfiguration(new ByteArrayInputStream(config.getBytes())); Logger logger = Logger.getLogger("org.ximinghui.blog.Test"); try { System.out.println(1 / 0); } catch (ArithmeticException e) { logger.throwing(Test.class.getName(), Thread.currentThread().getStackTrace()[1].getMethodName(), e); } }

第三章 Log4j日志

Log4j(准确说是 Log4j 1.x)已经过时,最后一个版本1.2.17的发布日期是2012年5月26日,目前存在非常多的安全漏洞,强烈建议使用 reload4j 平替。

一、简单使用

有了JUL的基础,我们再学其他的日志框架非常轻松,概念都是相似的。我们将以探索的方式一步步尝试解开Log4j的面纱。

首先引入依赖:

<dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.17</version> </dependency>

获取Logger并打印日志:

import org.apache.log4j.Logger; public class Test { public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class); logger.info("Hello Log4j"); } } /* 控制台输出如下(日志打印没有出现,取代的是Log4j打印的一些警告信息): log4j:WARN No appenders could be found for logger (org.ximinghui.blog.Test). log4j:WARN Please initialize the log4j system properly. log4j:WARN See http://logging.apache.org/log4j/1.2/faq.html#noconfig for more info. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 警告说无法为名为 “org.ximinghui.blog.Test” 的Logger找到任何appenders,请初始化log4j的系统属性,更多信息请浏览 http://logging.apache.org/log4j/1.2/faq.html#noconfig */

“Appender”中文意思是“日志追加器”或“输出器”,可以简单理解为日志到达的目的地,和JUL的日志处理器(Handler)相似,在日志库中比处理器这个叫法更加流行,本文后续都会用“Appender”来称呼。

作者访问警告中的链接得知,这是由于log4j在运行时找不到名为“log4j.properties”或“log4j.xml”配置文件导致的。

我们后面再使用配置文件,目前用 org.apache.log4j.BasicConfigurator.configure() 进行一些基础的配置:

public static void main(String[] args) { // 进行基础的log4j配置 BasicConfigurator.configure(); // 获取一个Logger Logger logger = Logger.getLogger(Test.class); // 打印日志 logger.info("Hello Log4j"); // 这次Hello Log4j成功打印了 /* 作者控制台如下: 0 [main] INFO org.ximinghui.blog.Test - Hello Log4j */ }

除了INFO还有什么日志级别呢?

Log4j API文档 列出了所支持的日志级别,表格如下:

级别 描述
OFF OFF级别具有最高可能的等级,旨在关闭日志记录。
FATAL FATAL级别指定了非常严重的错误事件,这些事件很可能导致应用程序中止。
ERROR ERROR级别指定了可能仍允许应用程序继续运行的错误事件。
WARN WARN级别指定了可能有害的情况。
INFO INFO级别指定了突出显示应用程序在粗粒度级别上进展的信息性消息。
DEBUG DEBUG级别指定了最有利于调试应用程序的细粒度信息事件。
TRACE TRACE级别指定了比DEBUG更细粒度的信息事件。
ALL ALL级别具有最低可能的等级,旨在开启所有日志记录。

尝试其他级别的日志打印:

public static void main(String[] args) { BasicConfigurator.configure(); Logger logger = Logger.getLogger(Test.class); logger.fatal("我是日志内容"); // 成功打印 logger.error("我是日志内容"); // 成功打印 logger.warn("我是日志内容"); // 成功打印 logger.info("我是日志内容"); // 成功打印 logger.debug("我是日志内容"); // 成功打印 logger.trace("我是日志内容"); // 这一条未被打印 /* 作者控制台如下: 0 [main] FATAL org.ximinghui.blog.Test - 我是日志内容 1 [main] ERROR org.ximinghui.blog.Test - 我是日志内容 1 [main] WARN org.ximinghui.blog.Test - 我是日志内容 1 [main] INFO org.ximinghui.blog.Test - 我是日志内容 1 [main] DEBUG org.ximinghui.blog.Test - 我是日志内容 */ }

上述代码未打印trace级别的日志内容,可能的猜测是 BasicConfigurator.configure() 进行初始化的时候设置了默认日志级别为debug。

实践:请动手debug代码,尝试寻找 “BasicConfigurator” 是否进行了默认日志级别配置?若是,又在哪一步进行的默认日志级别配置呢?

提示:Log4j 1.x 是否也有一个类似JUL中的LogManager这样的Logger管理者的设计呢?

答案:自行研究探索。

二、编程式配置

点进去 BasicConfigurator.configure() 方法,源码只有两行:

public static void configure() { Logger root = Logger.getRootLogger(); root.addAppender(new ConsoleAppender(new PatternLayout(PatternLayout.TTCC_CONVERSION_PATTERN))); }

代码中往Root Logger中添加了一个控制台Appender,并给控制台Appender指定了一种Pattern类型的Layout。让我们根据这个思路试试:

public static void main(String[] args) { /* 1、准备Layout * * 这里我们发现 org.apache.log4j.Layout 抽象类有一些实现类,比 * 如“SimpleLayout”、“PatternLayout”、“HTMLLayout”等,我们 * 先试试“SimpleLayout”吧 */ SimpleLayout simpleLayout = new SimpleLayout(); /* 2、准备Appender * * org.apache.log4j.Appender 接口有许多实现类,我们这里创建一 * 个最常见的控制台Appender */ ConsoleAppender consoleAppender = new ConsoleAppender(simpleLayout); // 3、将Appender添加到Logger中 Logger logger = Logger.getLogger(Test.class); logger.addAppender(consoleAppender); // 4、运行观察效果 logger.error("我是日志内容"); logger.warn("我是日志内容"); logger.info("我是日志内容"); /* 作者控制台如下: ERROR - 我是日志内容 WARN - 我是日志内容 INFO - 我是日志内容 */ }

日志被打印了,说明我们用简单的3行代码成功对Logger进行了配置。

再试试 “PatternLayout”:

public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class); PatternLayout layout = new PatternLayout(); logger.addAppender(new ConsoleAppender(layout)); // 观察效果 // 只有日志内容被打印出来,不带任何其他的信息 logger.info("我是日志内容"); // 我是日志内容 }

如何用 PatternLayout 打印出丰富一些的日志内容呢?

观察其构造方法发现可以传一个字符串类型的pattern,根据意思能猜出来这里应该是一个类似格式化模板的字符串。我们用搜索引擎查询“Log4j Pattern Layout”试图找一些字符串规则的说明,找到了Log4j 1.x 的 API文档

试着打印 时间 [级别] 类名: 消息 这样的格式:

public static void main(String[] args) { PatternLayout layout = new PatternLayout("%d{yyyy-MM-dd HH:mm:ss.SSS} [%p] %c: %m"); Logger logger = Logger.getLogger(Test.class); logger.addAppender(new ConsoleAppender(layout)); logger.info("我是日志内容"); /* 作者的控制台: 2024-06-11 11:30:07.060 [INFO] org.ximinghui.blog.Test: 我是日志内容 */ }

三、将日志存到数据库

思考将日志保存到数据库应该从哪里入手实现?大概率是Appender,因为Appender就是日志各种各样的目的地,到控制台、到文件、到数据库、到网络等等。

让我们观察下Appender的实现关系图(IntelliJ IDEA的分析界面):

log4j-appender-hierarchy

看来log4j库中已经提供了丰富的Appenders,比如控制台Appender(ConsoleAppender)、文件Appender(FileAppender),和文件Appender的子类“RollingFileAppender”(分割日志文件)和“DailyRollingFileAppender”(每天自动分隔日志文件),还有 JDBCAppender(看名字就知道这应该就是我们所需要的Appender)等等。抽象类 AppenderSkeleton 看名字是Appender的基础架构,就是默认实现了一些Appender接口的方法,使Appender的子实现类继承该抽象类后可以只实现3个必要的方法而无需实现大量的接口(所有的)方法。

尝试JDBCAppender:

public static void main(String[] args) { JDBCAppender appender = new JDBCAppender(); Logger logger = Logger.getLogger(Test.class); logger.addAppender(appender); logger.info("又是美好的一天,我们的应用依然在运行"); // 报错了,日志未打印 } /* 作者的控制台: Exception in thread "main" java.lang.NullPointerException at org.apache.log4j.jdbc.JDBCAppender.getLogStatement(JDBCAppender.java:198) at org.apache.log4j.jdbc.JDBCAppender.flushBuffer(JDBCAppender.java:288) at org.apache.log4j.jdbc.JDBCAppender.append(JDBCAppender.java:186) at org.apache.log4j.AppenderSkeleton.doAppend(AppenderSkeleton.java:251) at org.apache.log4j.helpers.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:66) at org.apache.log4j.Category.callAppenders(Category.java:206) at org.apache.log4j.Category.forcedLog(Category.java:391) at org.apache.log4j.Category.info(Category.java:666) at org.ximinghui.blog.Test.main(Test.java:15) */

在上面的代码中我们只是简单new了一个JDBCAppender对象添加到Logger中,执行前就知道程序肯定无法顺利运行,因为JDBC肯定需要数据库相关的信息,我们都没有提供。虽然不熟悉Log4j,但熟悉JDBC的都知道至少要有JDBC的一些概念:driver(数据库驱动)、jdbcUrl(JDBC连接字符串)、user(数据库用户名)、password(数据库密码),肯定有个地方或有种方式让我们设置这些信息。这里我们执行一下是想看看报错的信息中有没有能够帮助我们设置JDBC的信息,显然并没有,那只好看看 JDBCAppender 对象都有什么方法了,比如有没有类似 dbConfigsetDatabaseSource 之类的能够配置数据库的方法。

观察JDBCAppender类的方法,刚好有一些setter方法可以对应到JDBC的概念:

log4j-jdbcappender-methods

用于JDBC配置的应该就是上面这些方法了,试试看:

public static void main(String[] args) { JDBCAppender appender = new JDBCAppender(); // 这里要设置JDBC驱动类,所以要添加数据库依赖,我添加的是 com.mysql:mysql-connector-j:8.4.0 appender.setDriver("com.mysql.cj.jdbc.Driver"); // 这里需要准备一个数据库,我这里在本地启动了一个MySQL数据库 appender.setUser("root"); appender.setPassword("123456"); // 需要提前创建好所需要的数据库,我这里是“test”数据库 appender.setURL("jdbc:mysql://127.0.0.1/test"); // 这里写的SQL语句可以直接使用Log4j的占位符,比如 %m 表示日志消息…… // 需要提前传教好所需的 my_log 表:CREATE TABLE my_log (time VARCHAR(255), level VARCHAR(50), class VARCHAR(255), message TEXT); String sql = ("INSERT INTO my_log(time, level, class, message) VALUES('%d{yyyy-MM-dd HH:mm:ss}', '%p', '%c', '%m')"); appender.setSql(sql); Logger logger = Logger.getLogger(Test.class); logger.addAppender(appender); logger.info("又是美好的一天,我们的应用依然在运行"); }

程序运行完毕,现在查看数据库的 my_log 表是否有日志。

log4j-db-table

可以看到日志已经成功保存到数据库中了!

四、配置文件

在 “一、简单使用” 部分我们已经从警告信息中得知需要配置文件 log4j.propertieslog4j.xml

一个通用的思想,关于日志具体的配置文件格式,无论是Log4j还是别的日志库,都可以去官网看看、或者搜索引擎查找资料、也可以直接看配置文件中对应的类的源码(一般都是对应类的属性值)。

参考 官网手册 页面上的配置片段写一个我们自己的“log4j.properties”配置文件试试。

说明: 配置文件放到resources目录下就好,log4j会自动从类路径(class path)中查找并加载(若找到)log4j.propertieslog4j.xml 配置文件。而Maven会将resources目录下的文件构建到class path中。

log4j.rootLogger=DEBUG, APPENDER1 log4j.appender.APPENDER1=org.apache.log4j.ConsoleAppender log4j.appender.APPENDER1.layout=org.apache.log4j.PatternLayout log4j.appender.APPENDER1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n
public static void main(String[] args) { Logger logger = Logger.getLogger(Test.class); logger.info("一切看起来都很棒!"); } /* 作者的控制台: 2024-06-12 16:42:58,218 [main] INFO - 一切看起来都很棒! */

在配置文件中如何配置一个自定义的Logger呢?如“com.example”

Log4j 1.x的配置方式略有不同,但大体上概念都是相似的,具体配置示范如下:

# Properties编码集为ISO 8895-1,不支持中文字符 # 这里中文用于学习说明,实际实践时请移除中文字符 log4j.rootLogger=DEBUG, APPENDER1 log4j.appender.APPENDER1=org.apache.log4j.ConsoleAppender log4j.appender.APPENDER1.layout=org.apache.log4j.PatternLayout log4j.appender.APPENDER1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n # 思想都是相似的,配置上不同的地方去搜一下就好了 # 配置名为“com.example”的logger log4j.logger.com.example=DEBUG, APPENDER1 # additivity的意思是“叠加性”,表示是否从父Logger继承 log4j.additivity.com.example=DEBUG, APPENDER1

代码就不贴了,自行检验下就好。

log4j.xml也是对这些概念进行配置,无非就是appender、level、additivity等等之类的,只是格式不同,需要的话网上查一下就好,有很多示例。

五、文件Appenders

1. 普通文件

参考上面配置文件的内容,试着写一个FileAppender的配置。

配置文件中 APPENDER1 部分为Appender名,我们给新加的文件Appender起个名叫 FILE 吧,并追加到 log4j.rootLogger 中。

# Properties编码集为ISO 8895-1,不支持中文字符 # 这里中文用于学习说明,实际实践时请移除中文字符 log4j.rootLogger=DEBUG, APPENDER1, FILE log4j.appender.APPENDER1=org.apache.log4j.ConsoleAppender log4j.appender.APPENDER1.layout=org.apache.log4j.PatternLayout log4j.appender.APPENDER1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n # 从上面copy下来改改 # 这里改成FileAppender的全限定类名 log4j.appender.FILE=org.apache.log4j.FileAppender log4j.appender.FILE.layout=org.apache.log4j.PatternLayout log4j.appender.FILE.layout.ConversionPattern=%d [%t] %-5p %c - %m%n # 文件Appender肯定需要告诉它文件名,可以大胆的尝试修改后运行看看 # 这里的layout等等通常都是对应类的属性 # 点进去FileAppender能看到里面有一些setter方法,比如setFile(String name) # 所以file应该就是正确的配置了,运行试试看 # log4j.appender.FILE.fileName=logs/test.log # 错误,属性fileName不存在 log4j.appender.FILE.file=logs/test.log # 就像fileName那样,我们还找到了append的setter方法 log4j.appender.FILE.append=false

运行看看:

public static void main(String[] args) { Logger logger = LogManager.getLogger("test"); logger.info("一切看起来都很棒!"); }

代码成功运行,观察后未发现存在问题,控制台日志打印正常,文件日志也正常,一切看起来都很棒!

2. 滚动文件

三、将日志存到数据库 部分我们可以看到FileAppender还有一些子类,我们再试试它的子类 RollingFileAppender

# Properties编码集为ISO 8895-1,不支持中文字符 # 这里中文用于学习说明,实际实践时请移除中文字符 log4j.rootLogger=DEBUG, APPENDER1, APPENDER2 log4j.appender.APPENDER1=org.apache.log4j.ConsoleAppender log4j.appender.APPENDER1.layout=org.apache.log4j.PatternLayout log4j.appender.APPENDER1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n log4j.appender.APPENDER2=org.apache.log4j.RollingFileAppender log4j.appender.APPENDER2.layout=org.apache.log4j.PatternLayout log4j.appender.APPENDER2.layout.ConversionPattern=%d [%t] %-5p %c - %m%n log4j.appender.APPENDER2.file=logs/test.log # 翻看org.apache.log4j.RollingFileAppender类, # 我们发现了可能用于控制日志文件大小和个数的属性。 # 经验证猜测属实。 # maximumFileSize设置了单个文件最大512字节 log4j.appender.APPENDER2.maximumFileSize=512 # maxBackupIndex则表示日志文件最多保存10个 log4j.appender.APPENDER2.maxBackupIndex=10

运行看看:

public static void main(String[] args) { Logger logger = LogManager.getLogger("test"); // 循环打印日志内容,用以验证日志文件是否按照预期写入 while (true) logger.info("一切看起来都很棒!"); }

Okay, 一切看起来都很棒 again!

3. 每日滚动

DailyRollingFileAppender根据日期进行日志文件分隔。

log4j.rootLogger=DEBUG, APPENDER1, APPENDER2 log4j.appender.APPENDER1=org.apache.log4j.ConsoleAppender log4j.appender.APPENDER1.layout=org.apache.log4j.PatternLayout log4j.appender.APPENDER1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n log4j.appender.APPENDER2=org.apache.log4j.DailyRollingFileAppender log4j.appender.APPENDER2.layout=org.apache.log4j.PatternLayout log4j.appender.APPENDER2.layout.ConversionPattern=%d [%t] %-5p %c - %m%n log4j.appender.APPENDER2.file=logs/test.log
public static void main(String[] args) throws Exception { Logger logger = LogManager.getLogger("test"); while (true) { logger.info("一切看起来都很棒!"); Thread.sleep(100); // 线程睡眠100毫秒 } }

运行代码,然后在运行期间修改系统日期,观察到日志文件已按日进行分割。

第四章 SLF4J日志门面

SLF4J门面只是定义的一套日志相关的接口,它不具备日志打印功能。SLF4J的能力则又各种各样的实现去做。我们面向SLF4J接口进行日志相关的编程,这样我们的项目就不依赖具体的日志库(如JUL、Logback、Log4j等),将来想要切换日志库只需要更换下依赖项就好了。

一、简单使用

1. 尝试API

我们先不提供任何实现,观察下SLF4J的行为:

引入依赖:

<!-- 日志门面 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.13</version> </dependency>

代码:

import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class Test { public static void main(String[] args) { Logger logger = LoggerFactory.getLogger(Test.class); logger.info("Hello SLF4J"); /* 作者控制台如下: SLF4J(W): No SLF4J providers were found. SLF4J(W): Defaulting to no-operation (NOP) logger implementation SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details. */ } }

日志没用被打印,控制台有一些SLF4J打印的消息,告诉我们没有找到provider(即“实现”)。

2. 引入实现

加入(实现)依赖:

<!-- SLF4J简单的日志实现:仅用于学习研究 --> <!-- 说明:这里我们未选用常用的Logback或Log4j等实现,先引入简单的实现学习下SLF4J门面 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.13</version> </dependency>

代码:

import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class Test { public static void main(String[] args) { Logger logger = LoggerFactory.getLogger(Test.class); logger.info("当前系统状态{},{}", "稳如狗", "一切都在优雅地运行中"); /* 作者控制台如下: [main] INFO org.ximinghui.blog.Test - 当前系统状态稳如狗,一切都在优雅地运行中 */ } }

说明:这次日志就成功打印了。在上述代码中我们使用了 {}符号 进行占位,并随后传递实参。

二、SLF4J和实现

SLF4J门面称为“Facade”或“API”,上文中的 “slf4j-api” 就是。它的实现有时候被称为“provider(提供器)”、“impl”、“implement”,上文中的 “slf4j-simple” 就是实现。

一般SLF4J的实现是,实现库依赖 SLF4J(即slf4j-api),实现库的类实现SLF4J的接口,Logback就是这样的例子,这种也被称为原生实现(即不需要转换/桥接这样的中间者)。像JUL、Log4j 1.x,它们早于SLF4J出现,所以不能像SLF4J之后的日志库一样依赖和实现它,所以存在 “slf4j-jdk14”、“slf4j-log4j12” 等这样的库,它们最为中间者封装(表现形式可能是继承)JUL、Log4j的类并实现SLF4J接口,以此达到通过SLF4J接口使用JUL、Log4j类的目的,这种行为似乎被称为 “绑定/bind”,其原理见 视频2-12集03:27处,由于JUL和Log4j 1.x不主流,这里就不再演示了。

1. SLF4J查找和使用实现的原理

视频 中引入的SLF4J的版本是1.7.30,作者引入的是更新的2.0.13版本。1.7版本和2.0版本查找实现的原理不同,下面会分别演示两种原理。

a. 老版本的实现原理(即视频中演示的情况)

作者在该示例中使用的操作系统是 Windows 11,Java版本是21(LTS),JDK路径位于 D:\Program Files\jdk-21.0.2+13,请根据自己情况进行适当调整。

1) 我们先创建模拟日志门面项目 - SLF5J

我们创建一个模仿 slf for j 的项目,叫slf5j。它就像sfl4j那样是一个日志门面,提供了日志接口。

打开命令提示符,按下面步骤操作:

# 确保 D:\projects 文件夹已被创建 mkdir D:\projects\ cd /D D:\projects\ # 创建一个空的maven标准项目结构 mkdir slf5j\src\main\java cd slf5j type nul > pom.xml # 创建包和2个空的Java源码文件 cd src\main\java mkdir org\ximinghui\slf5j type nul > org\ximinghui\slf5j\Logger.java type nul > org\ximinghui\slf5j\LoggerFactory.java # 将文章下方的内容填充到 pom.xml、Logger.java、LoggerFactory.java 文件中 notepad ..\..\..\pom.xml notepad org\ximinghui\slf5j\Logger.java notepad org\ximinghui\slf5j\LoggerFactory.java

通过上面的命令,我们在 D:\projects\ 文件夹中创建了一个名为 “slf5j” 的空项目,并创建了pom.xml、Logger.java和LoggerFactory.java这3个文件。

现在我们填充3个文件内容,内容如下:

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.ximinghui.slf5j</groupId> <artifactId>slf5j</artifactId> <version>1.0</version> </project>
package org.ximinghui.slf5j; // SLF5J 的 Logger 接口 public interface Logger { void info(String msg); }
package org.ximinghui.slf5j; import org.ximinghui.slf5j.impl.LoggerBinder; public class LoggerFactory { public static Logger getLogger(Class<?> clazz) { return LoggerBinder.getLogger(clazz.getName()); } }

至此,我们的 “slf5j” 项目就创建完成了。但是目前还无法编译这个项目,因为导入语句 “import org.ximinghui.slf5j.impl.LoggerBinder” 指向的类不存在。让我们解释一下。

重点:slf5j项目中用到了 org.ximinghui.slf5j.impl.LoggerBinder 类,但是该类在 “slf5j” 中并不存在,它在slf5j的实现库中。在这个演示中我们将会创建 “jul”、“xlog”两个项目作为slf5j的两个实现。就是说 jul-1.0.jarxlog-1.0.jar 中都存在 org.ximinghui.slf5j.impl.LoggerBinder 类,但是 slf5j-1.0.jar 中没有,同时 slf5j-1.0.jar 又使用到了这个类。那么在没有实现库的情况下,项目在运行时期会因为找不到 LoggerBinder 类而报错,但我们只需要添加任何一个实现库依赖,该类就在classpath中可用了。虽然两个实现库的LoggerBinder类全限定类名、方法签名都一样,但是具体的实现逻辑(即代码体)不同,这就是为什么我们添加什么实现库依赖,具体工作的代码就是哪个库。

我们无法通过标准的Maven构建 slf5j 项目,因为编译期间需要检查依赖的类(即本示例中的LoggerBinder)是否存在,且不能做到在类不存在的情况下执行构建,所以我们下面将手动干预下Maven的构建过程。

我们在另一个位置创建出 org.ximinghui.slf5j.impl.LoggerBinder 类。

说明:可以通过另一种方式完成slf5j的编译,即另起一个项目,其中包含slf5j所用到的类,然后改项目被 slf5j 所依赖,且依赖范围设为 “provided”

/* 这个类具体的代码内容是无意义的,仅为了确保存在一个符合 标准(即全限定类名和方法签名一致)的类供slf5j完成编译 作者将该类创建在 D:\projects\my-lib\org\ximinghui\slf5j\impl\LoggerBinder.java */ package org.ximinghui.slf5j.impl; import org.ximinghui.slf5j.Logger; public class LoggerBinder { public static Logger getLogger(String name) { return null; } }
# 一条命令创建 LoggerBinder.java 类 mkdir D:\projects\my-lib\org\ximinghui\slf5j\impl & echo package org.ximinghui.slf5j.impl;import org.ximinghui.slf5j.Logger;public class LoggerBinder{public static Logger getLogger(String name){return null;}} > D:\projects\my-lib\org\ximinghui\slf5j\impl\LoggerBinder.java

我们现在手动编译 “slf5j” 的源码并将它的字节码文件放入 target/classes 文件夹。

说明:当 “target\classes\” 文件夹中已存在 *.class 类文件且类对应的源码文件也没有新的变更时,Maven就会跳过这些类的编译。我们利用Maven的该特点绕过了Maven的类检查工作。

# 设置与项目Java版本一致的JDK环境 # 说明:这里的设置是临时的,仅对于当前cmd窗口有效,不会影响系统环境变量设置 set JAVA_HOME=D:\Program Files\jdk-21.0.2+13\ set PATH=%JAVA_HOME%\bin;%PATH% # 检验下java版本,确保上面的JDK设置是否生效 java -version # 进入源码文件夹 cd /D D:\projects\slf5j\src\main\java # 编译 *.java 文件 javac -d "D:\projects\slf5j\target\classes" -classpath ".;D:\projects\my-lib" org\ximinghui\slf5j\Logger.java org\ximinghui\slf5j\LoggerFactory.java # 删除LoggerBinder.class字节码文件 # 源码“D:\projects\my-lib\org\ximinghui\slf5j\impl\LoggerBinder.java”也会被编译到我们-d参数指定的目录中 # 我们需要删除它,以确保打的 slf5j-1.0.jar 中没有LoggerBinder类 del ..\..\..\target\classes\org\ximinghui\slf5j\impl\LoggerBinder.class # 接下来让我们将 slf5j 安装到本地Maven仓库中 # 如果安装失败多执行几次 mvn install 试试 cd /D D:\projects\slf5j & mvn install
2) 创建日志实现 - JUL

现在我们创建我们的第一个 SLF5J Provider,名为 “jul”,简单演示我们如何实现通过 SLF5J 接口调用 JDK 中 JUL 的日志打印能力。

项目内容如下图所示:

project-jul

说明:不想手动创建该项目可以 从这里 下载图中的项目。

同样将 jul 安装到maven本地仓库中。

3) 创建日志实现 - XLOG

我们再创建第二个 SLF5J Provider,并起名叫 “xlog”,简单演示原生实现 slf5j,(就像logback之于slf4j)。

project-xlog

说明:不想手动创建该项目可以 从这里 下载图中的项目。

将 xlog 也安装到maven本地仓库中。

4) 演示多实现切换原理

我们新建一个测试项目,并引入 slf5j 依赖:

<!-- SLF5J 日志门面 --> <dependency> <groupId>org.ximinghui.slf5j</groupId> <artifactId>slf5j</artifactId> <version>1.0</version> </dependency>

准备我们的测试代码:

import org.ximinghui.slf5j.Logger; import org.ximinghui.slf5j.LoggerFactory; public class Test { private static final Logger LOGGER = LoggerFactory.getLogger(Test.class); public static void main(String[] args) { LOGGER.info("Hello slf5j"); } }

到目前为止,我们测试项目的所有日志打印相关的代码都来自slf5j包,不依赖具体的实现(xlog、jul)。现在我们只需要再引入jul依赖:

<!-- SLF5J实现库:JUL --> <dependency> <groupId>org.ximinghui.jul</groupId> <artifactId>jul</artifactId> <version>1.0</version> </dependency>

运行代码,我们实际使用的日志打印就是JDK自带的JUL。然后我们移除jul的依赖,再换成引入xlog依赖:

<!-- SLF5J实现库:Xlog --> <dependency> <groupId>org.ximinghui.xlog</groupId> <artifactId>xlog</artifactId> <version>1.0</version> </dependency>

再次运行代码,就会发现实际使用的日志打印变成了xlog提供的能力。

b. 新版本的实现原理(如2.0.13)

新版本SLF4J已经采用SPI机制,相比上面的实现方式方便太多了,优雅的使用JDK的能力一行代码就能找到classpath存在的所有实现类。关于SP的内容请百度学习,这里不再演示。它的使用原理也很简单,就是在jar包的“META-INF\services\”中存在一个全限定接口名命名的文本文件,文件中写的是该接口实现类的全限定类名。这样JDK就会按照这个标准去读取。

三、SLF4J和桥接器

SLF4J还提供了一些桥接器,如 jcl-over-slf4jlog4j-over-slf4j,用于帮助已经使用JCL、Log4j的项目过渡到SLF4J。桥接器提供了一套看起来与原有库大致相似的API,包括相同的包名类名和方法签名,但实现不同,桥接器的实现是将方法的调用转移到SLF4J。用桥接器取代原有的JCL、Log4j依赖项后,项目中使用到原有库的常见的代码不需要更改,但一些过于依赖原有库的自定义调用还是需要调整(因为桥接器并非将所有的原有库API都统统定义,只定义了一些常用的API),但可以帮助减缓项目中日志库过渡产生的痛苦。

桥接器似乎通常是面向一些旧的项目,比如那时候还没有日志门面,或者使用早已被废弃的JCL等。

第五章 Logback日志

一、简单使用

<!-- SLF4J 日志门面 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.13</version> </dependency> <!-- Logback 日志实现 --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.5.6</version> </dependency>

先写到这里,内容后续补充

第六章 Log4j2日志

Log4j2 Shell漏洞

log4j2-shell-bug

第七章 Log4j2日志

第八章 Spring Boot日志