日志记录功能在开发中很常用,不仅可以记录程序运行的细节,方便调试,也可以记录用户的行为,是框架中不可或缺的组件。
配套视频
- https://www.bilibili.com/video/av91201048/
- https://www.bilibili.com/video/av91206578/ (粤语)
- https://www.youtube.com/watch?v=PWvQxXQr1rE
- https://www.youtube.com/watch?v=2Pvj0uJ1i9I (粤语)
使用方法
为最大程度复用现有的组件,当前我们没有依赖如 Log4j、LogBack、SLF4j 的日志方案,而是就地取材使用了 JDK 自带的 JUL(java.util.logging
)作为日志组件。通过它可以满足一般的日志业务需求。该组件结构内容如下图所示。
笔者一开始还是依赖 slf4j 的,以后把这个依赖也去掉了,直接上 JUL,常见过去的博文《使用 slf4j + Java.util.logger》。
和其他日志组件一样,JUL 提供了若干的级别用于描述日志信息的重要程度,有严重 SEVERE
(最高级)、警告 WARNING
、信息 INFO
、配置 CONFIG
、跟踪调试 FINE/FINER/FINEST
(最低级)。默认的级别是 INFO
,比 INFO
更低级别的日志将不予显示。开发者应根据源码上下文的不同采用相应级别的日志输出。JUL 简单用法如下所示(测试例子在 aj-demo 项目中, https://gitee.com/sp42_admin/ajaxjs/blob/master/aj-demo/src/main/java/com/demo/base/SimpleLogger.java)。
import java.util.logging.Level;
import java.util.logging.Logger;
public class SimpleLogger {
// 通过静态方法 Logger.getLogger 创建 LOGGER 实例
private static final Logger LOGGER = Logger.getLogger(SimpleLogger.class.getName());
public static void main(String[] args) {
LOGGER.info("Logger 名称" + LOGGER.getName());
LOGGER.warning("数组越界!");
int[] a = { 1, 2, 3 };// 数组长度为 3
int index = 4;
LOGGER.config("index is set to " + index);
try {
System.out.println(a[index]);
} catch (ArrayIndexOutOfBoundsException ex) {
LOGGER.log(Level.SEVERE, "发送异常", ex);
}
}
}
运行 main()
函数,控制台显示如下图所示,打印格式一般为“[运行时间] [当前类名] [方法名]
”。
直接使用 java.util.logging
的话比较麻烦,不妨对其进行封装一下,而且还打算提供其他额外的实用功能,比如下面这些。
- 封装了主要三种级别的日志输出方法,分别是
config()
、info()
和warning()
方法,均可定位日志所发生的行数及类*.java
源文件的超链接,直接出现在 IDE 控制台里面,将大大便于调试,点击即可进入发生异常源码的那一行。这是 Logger 的基本功能。 - 日志消息支持带有多个字符串参数,类似
String.format()
机制,可以减轻开发者手动拼接字符串的负担。 warning()
支持传入Throwable
类型的异常参数,并自动打印。- 支持不同颜色输出日志,便于区分。
- 通过 FileHandle r实现
WARNING
级别的或以上的日记磁盘记录保存,按照当前日期命名。
新建的日志组件位于 com.ajaxjs.util.logger
(源码在这里),只有两个类,分别是核心类 LogHelper 和保存到磁盘的日志处理器 FileHandler(FileHandler 源码并非原创,而是在 Tomcat 的代码基础上稍微修改而来,参见文档)。如下图所示为控制台打印日志显示时情形。
一般情况下通过工厂模式(Factory Pattern)创建 LogHelper,执行 LogHelper.getLog()
并传入目标类的 class 引用。如下代码(在 com.ajaxjs.util.logger.LogHelper
约第 239 行)粗体中就是常见的创建方法,实际使用把 TestLogHelper 替换为当前类即可。
import com.ajaxjs.util.logger.LogHelper;
public class TestLogHelper {
// 创建类成员为日志服务
private static final LogHelper LOGGER = LogHelper.getLog(TestLogHelper.class);
public void testGetLog() {
// …… 其他代码
LOGGER.warning("发生异常!……");
LOGGER.info("bar");
LOGGER.warning("foo");
// 带有多个日志消息的对象参数,用 {0},{1},{2} 预留消息位置
LOGGER.warning("脚本引擎 {0} 没有 {1}() 这个方法", "js", "foo");
LOGGER.warning(new Exception("致命错误!"), "脚本引擎 {0} 没有 {1}() 这个方法", "js", "foo");
}
}
创建的日志组件成为静态成员而不是实例成员,这样的好处是节省资源,无须每个实例都创建一次日志实例,实际上也没有那个必要。
下面演示了 LogHelper 更多的用法,其中字符串参数 msg_tpl
表示为信息模版,用占位符 {0},{1},{2}
预消息位置。
public void config(String msg);
public void config(String msg_tpl, Object... params);
public void info(String msg);
public void info(String msg_tpl, Object... params);
public void warning(String msg);
public void warning(String msg_tpl, Object... params);
public void warning(Throwable ex, String msg);
public void warning(Throwable ex, String msg_tpl, Object... params);
调用例子如下。
log.info("脚本引擎 {0} 没有 {1}() 这个方法", "js", "foo");
log.warning(new Exception("致命错误!"), "脚本引擎 {0} 没有 {1}() 这个方法", "js", "foo");
要保持日志内容就要通过 FileHandler 将其保存为 *.log
文件,以备程序的后续维护。默认保存的位置固定在 /WEB-INF/LogHelper
目录下。不是所有的日志都被记录,LOGGER.info()
仅仅提示用的所以不会被记录,LOGGER.warning()
处理异常信息就会被纪录下来。这些异常信息对于程序调试非常有帮助,WARNING
级别以上的才会被 FileHandler 所记录。
源码原理分析
LogHelper 打印日志的同时,如果可以显示出该日志的执行源头,那么将为开发者调试提供莫大的便利:知道异常从哪个类来、哪个方法来,也知道是在哪一行代码上发生的。于是不禁要问这是从哪里来的?
首先观察内部 logger.logp(Level.WARNING, className, getMethodName(), msg)
,它调用了原生的 JDK API 方法,其中参数 className
是源头发出日志记录请求的类名,对此 LogHelper 已经把 className
作为类的属性保存起来,需要的时候直接传入即可;而 getMethodName()
是发出日志记录请求的方法名,这正是个中的关键。如下是 getMethodName()
的源码(在 com.ajaxjs.util.logger.LogHelper
约第 239 行)。
/**
* 获取所在的方法,调用时候
*
* @return 方法名称
*/
private String getMethodName() {
StackTraceElement frame = null;
// Thread.getCurrentThread().getStackTrace() 暴露了当前线程的运行栈信息
for (StackTraceElement ste : Thread.currentThread().getStackTrace()) {
String clzName = ste.getClassName();
if (ste.isNativeMethod() || clzName.equals(Thread.class.getName()) || clzName.equals(getClass().getName()))
continue;// 过滤不要的类
if (clzName.equals(className)) {// className是字符串,表示所在的类名
frame = ste;
break;
}
}
if (frame != null) // 超链接,跳到源码所在行数
return String.format(".%s(%s:%s)", frame.getMethodName(), frame.getFileName(), frame.getLineNumber());
else
return null;
}
在方法执行时线程会将该方法压入栈,因此可以通过线程获取相应的栈帧元素来取得方法的调用信息。执行Thread.getCurrentThread().getStackTrace()
返回当前线程的运行栈信息,类型是 java.lang.StackTraceElement[]
栈帧元素数组。StackTraceElement 专门用于跟踪堆栈元素的信息,观察 JDK 源码如下可见。
public final class StackTraceElement implements java.io.Serializable {
// Normally initialized by VM (public constructor added in 1.5)
private String declaringClass; // 类名
private String methodName; // 方法名
private String fileName; // 文件名
private int lineNumber; // 行号
……
}
相关属性正好是为显示当前类名、方法名、文件名、行号等信息而准备的,可方便读取所需的信息。最后 getMethodName()
生成如 String.format(".%s(%s:%s)", …)
格式的字符串。只要符合这种格式控制台就可以输出可供点击的超链接。
如何打印彩色的日志到控制台?
通过修改控制台的字符颜色,在大量输出日志的情况下通过颜色来分类日志。该功能需要 IDE 和代码控制两个方面支持。其效果如下图所示。
IDEA配置方法:点击右上角的【Edit Configurations】,找到【VM options】,填入-Dspring.output.ansi.enabled=ALWAYS,如下图所示。重新启动应用,就会发现控制台日志变成彩色了。
Eclipse 配置方法:如图所示,搜索插件市场,找到 ANSI Escape 插件并安装。
输出日志时,须加上特定的前缀和后缀方能令颜色生效。例如最开始的前缀统一为 \u001b
,它是 Unicode 编码中被定义为转义字符。具体生成规则参见 LogHelper 源码,这里就是不赘述了。
最后补充一下,写入本地文件使用了 Tomcat 的组件 FileHandler。另外,关于实时读取日志文件,可参考这里。