日志技术、框架
目录
门面模式
核心思想
结构
Java日志体系
常用的日志技术组合
主流日志框架与技术详解
区别与优点对比
日志门面:SLF4J vs JCL
字符串拼接 vs 参数化
字符串拼接(String Concatenation)
参数化(Parameterization)
在日志中的影响
在SQL中的完全不同的概念
日志实现:Logback vs Log4j 2
在SpringBoot中如何操作?
日志 vs AOP
两者的关系
1. 独立使用日志(常见做法)
2. 使用AOP统一记录日志(可选做法)
核心区别
实际应用建议
完整的AOP日志切面示例
配套的自定义注解(可选)
关键点说明
1. @Pointcut的作用
2. 常用的切入点表达式
各种通知类型的适用场景
门面模式
门面模式(Facade Pattern)是一种经典的设计模式。它是结构型设计模式的一种
核心思想
定义:为子系统中的一组接口提供一个一致的界面,此模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
通俗理解:就像一个公司的前台接待员,你不需要知道公司内部各个部门如何协调工作,只需要通过前台就能完成你想要办理的业务。
结构
// 复杂的子系统A
class SubSystemA {public void operationA() {System.out.println("执行子系统A的操作");}
}// 复杂的子系统B
class SubSystemB {public void operationB() {System.out.println("执行子系统B的操作");}
}// 复杂的子系统C
class SubSystemC {public void operationC() {System.out.println("执行子系统C的操作");}
}// 门面类 - 提供统一的简单接口
class Facade {private SubSystemA systemA;private SubSystemB systemB;private SubSystemC systemC;public Facade() {this.systemA = new SubSystemA();this.systemB = new SubSystemB();this.systemC = new SubSystemC();}// 统一的简单方法,隐藏了内部的复杂调用public void simpleOperation() {System.out.println("门面开始协调各个子系统...");systemA.operationA();systemB.operationB();systemC.operationC();System.out.println("门面完成所有操作");}
}// 客户端使用
public class Client {public static void main(String[] args) {// 不使用门面模式 - 复杂SubSystemA a = new SubSystemA();SubSystemB b = new SubSystemB();SubSystemC c = new SubSystemC();a.operationA();b.operationB();c.operationC();// 使用门面模式 - 简单Facade facade = new Facade();facade.simpleOperation(); // 一行代码完成所有操作}
}
Java日志体系
现代Java日志体系的核心思想:“门面模式” 或 “抽象层”。它将日志的接口和实现分离开。
-
日志门面/抽象层:提供了一套统一的日志API,你的应用程序代码只依赖于这个抽象层,而不关心底层具体使用哪种日志实现。这使得更换日志实现变得非常容易。
-
日志实现/框架:具体的日志库,负责实现实际的日志记录功能(如输出到控制台、文件、数据库等)。
常用的日志技术组合
在SpringBoot中,最常见的组合是:SLF4J + Logback。
-
日志门面:SLF4J
-
日志实现:Logback
为什么是这个组合?
因为SpringBoot的默认日志 starter spring-boot-starter-logging
就是采用这个组合。
当你引入 spring-boot-starter-web
或其他核心starter时,它已经被自动引入了。
<!-- 查看 spring-boot-starter-logging 的依赖树,你会看到 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- 它内部依赖了: -->
<!-- slf4j-api (门面) -->
<!-- logback-classic (实现,并且它本身也桥接到了slf4j) -->
<!-- 以及一些其他库的桥接器 -->
主流日志框架与技术详解
类别 | 名称 | 角色与描述 |
---|---|---|
日志门面 | SLF4J (Simple Logging Facade for Java) | 业界标准。提供统一的API,让程序与具体日志实现解耦。 |
JCL (Jakarta Commons Logging) / Apache Commons Logging | 旧时代的标准,现在已被SLF4J取代。Spring早期使用它。 | |
日志实现 | Logback | SLF4J的天然继任者,由SLF4J的作者设计。性能好,功能丰富,是SpringBoot的默认实现。 |
Log4j 2 | Log4j 1.x的重大升级版。在异步日志性能上极其出色,功能强大,是Logback的强大竞争对手。 | |
Log4j 1.x | 已被淘汰,存在性能和安全问题。 | |
JUL (java.util.logging) | JDK自带的日志工具,功能相对简单,一般不选它。 |
区别与优点对比
日志门面:SLF4J vs JCL
特性 | SLF4J | JCL (Commons Logging) |
---|---|---|
绑定机制 | 在编译时通过静态绑定找到日志实现。更稳定,不易出现类加载问题。 | 在运行时动态发现日志实现。容易出现类加载器内存泄漏问题。 |
性能 | 更好,尤其使用占位符{} 时,避免了不必要的字符串拼接。 | 稍差。 |
社区与趋势 | 现代Java开发的事实标准,是SpringBoot等主流框架的选择。 | 老旧,已基本被SLF4J取代。 |
SLF4J的占位符优势:
// SLF4J - 推荐:只有当日志级别是DEBUG时,才会进行字符串拼接
logger.debug("User id is: {}, name is: {}", userId, userName);// 传统方式 - 不推荐:无论级别如何,都会先进行字符串拼接,浪费性能
logger.debug("User id is: " + userId + ", name is: " + userName);
字符串拼接 vs 参数化
字符串拼接(String Concatenation)
// 字符串拼接:在代码中直接连接字符串
String message = "User id is: " + userId + ", name is: " + userName;
logger.debug(message);// 或者直接拼接
logger.debug("User id is: " + userId + ", name is: " + userName);
参数化(Parameterization)
// 参数化:使用占位符,由框架在内部处理
logger.debug("User id is: {}, name is: {}", userId, userName);
注意:日志参数化 = 立即求值 + 条件拼接(根据日志级别决定是否进行字符串拼接)
SLF4J的日志级别从低到高是:
TRACE → DEBUG → INFO → WARN → ERROR
上面代码实际执行顺序:
1. ✔ 立即求值:userId 和 userName被立即计算
2. ✔ 检查级别:判断当前是否允许DEBUG级别日志
3. ×/✔ 条件拼接:只有级别允许时,才进行 User id is: " + userId + ", name is: " + userName的拼接
在日志中的影响
1.性能影响
// × 字符串拼接:总是创建新字符串对象
logger.debug("Processing user: " + user.getName() + " with id: " + user.getId());
// 即使DEBUG级别关闭,也会创建:"Processing user: John with id: 123"// ✔ 参数化:延迟字符串创建
logger.debug("Processing user: {} with id: {}", user.getName(), user.getId());
// 如果DEBUG关闭,不会创建最终的日志字符串,节省内存和CPU
但是 参数化 没有避免了不必要的方法执行(立即求值)
传统字符串拼接(最差)logger.debug("User: " + userName + ", Data: " + fetchUserData());
// 1. 立即求值:fetchUserData() 执行
// 2. 立即拼接:创建完整的日志字符串
// 3. 检查级别:如果DEBUG关闭,丢弃已经拼接好的字符串
// × 浪费:方法执行 + 字符串拼接参数化日志(中等)logger.debug("User: {}, Data: {}", userName, fetchUserData());
// 1. 立即求值:fetchUserData() 执行
// 2. 检查级别:如果DEBUG关闭,直接返回
// 3. 条件拼接:只有DEBUG开启时才拼接
// ✔ 节省:避免了不必要的字符串拼接
// × 仍然浪费:方法执行条件检查(最优)if (logger.isDebugEnabled()) {logger.debug("User: {}, Data: {}", userName, fetchUserData());
}
// 1. 检查级别:如果DEBUG关闭,直接跳过
// 2. 条件求值:只有DEBUG开启时才执行fetchUserData()
// 3. 条件拼接:只有DEBUG开启时才拼接
// ✔ 完全节省:避免了不必要的方法执行和字符串拼接
为什么设计成这样?
Java语言限制
// Java的方法调用机制决定了参数必须立即求值
method(argument1, argument2);
// 在进入method方法体之前,argument1和argument2必须已经计算完成// 无法实现真正的"延迟参数求值",除非使用lambda
method(() -> expensiveOperation()); // 这样才可以延迟
早期的SLF4J设计时,Java还没有lambda表达式,所以无法实现真正的延迟求值。
现代改进
Log4j2的解决方案
// Log4j2支持lambda,实现真正的延迟
logger.debug("Data: {}", () -> expensiveOperation());
// expensiveOperation()只在DEBUG开启且需要输出时才执行
为什么Log4j2能做到
// Log4j2的API设计使用了Supplier
void debug(String message, Supplier<?>... paramSuppliers);// 参数传递的是"代码"而不是"值"
logger.debug("Data: {}", () -> expensiveOperation());
// 传递的是:一个可以执行expensiveOperation()的lambda表达式
// 而不是:expensiveOperation()的执行结果
2.可读性影响
// × 字符串拼接:冗长,难以阅读
logger.debug("User " + userName + " (ID: " + userId + ") from " + city + ", " + country + " performed action: " + action);// ✔ 参数化:清晰,易于阅读
logger.debug("User {} (ID: {}) from {}, {} performed action: {}", userName, userId, city, country, action);
在SQL中的完全不同的概念
1.日志参数化(例如SLF4J的{}占位符):
目的:主要是为了性能(避免不必要的字符串拼接)和代码可读性。
机制:在日志框架内部,如果日志级别足够(例如DEBUG级别开启),才会将参数替换到占位符中,生成完整的日志字符串。如果日志级别不够,则不会进行字符串拼接,但请注意,参数仍然会被求值(即如果参数是方法调用,该方法会被执行),只是不会拼接字符串。
2.SQL参数化(例如MyBatis的#{}):
目的:主要是为了安全(防止SQL注入)和性能(数据库可以缓存执行计划)。
机制:使用预编译语句,将参数值通过占位符(?)传递给数据库,数据库将参数值视为数据,而不是SQL代码的一部分。因此,即使参数值中包含SQL代码,也不会被数据库执行。
SQL注入风险
// × 危险的字符串拼接(动态SQL)
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
// 如果 username = "admin' OR '1'='1",就变成:
// SELECT * FROM users WHERE username = 'admin' OR '1'='1'// ✔ MyBatis参数化(使用#{})
@Select("SELECT * FROM users WHERE username = #{username}")
User findByUsername(String username);
// 会被编译为:SELECT * FROM users WHERE username = ?
// 然后安全地设置参数值
MyBatis中的#{} vs ${}
// ✔ #{}:参数化,防止SQL注入
@Select("SELECT * FROM users WHERE username = #{username} AND age = #{age}")
User findUser(@Param("username") String username, @Param("age") int age);
// 生成:SELECT * FROM users WHERE username = ? AND age = ?// × ${}:字符串替换,有SQL注入风险(但有时必要)
@Select("SELECT * FROM ${tableName} ORDER BY ${columnName}")
List<User> dynamicQuery(@Param("tableName") String tableName, @Param("columnName") String columnName);
// 生成:SELECT * FROM users ORDER BY create_time
// 注意:${}只能用于表名、列名等标识符,不能用于用户输入的值!
日志实现:Logback vs Log4j 2
这是选择的重点,也是SpringBoot中常见的“切换”场景。
Logback | Log4j 2 | |
---|---|---|
出身 | SLF4J作者的“亲儿子”,为SLF4J原生设计。 | 重建版,吸收了Logback的优点并加以改进。 |
性能 | 优秀,比Log4j 1.x快很多。 | 极其优秀,尤其是在异步日志场景下,性能可以比Logback高数倍甚至十倍。 |
配置方式 | 主要使用XML | XML, JSON, YAML, Properties。 |
特性亮点 | - 自动重新加载配置文件。 - 更平滑的过滤功能。 - 从Spring Boot“开箱即用”。 | - 强大的异步日志器 (AsyncLogger),是其性能杀手锏。 - 支持自定义日志级别。 - 插件化架构,扩展性强。 - 防止因日志阻塞应用线程。 |
缺点 | 异步性能不如Log4j 2。 | 配置相对复杂一些。需要排除默认的Logback并显式引入。 |
适用场景 | 绝大多数常规应用,对性能要求不是极端苛刻。 | 高并发、低延迟的系统,如金融交易、游戏服务器、大数据处理等。 |
在SpringBoot中如何操作?
1. 使用默认配置 (SLF4J + Logback)
你什么都不用做,直接在你的类中使用即可:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RestController;@RestController
public class MyController {// 1. 声明Logger,通常使用当前类作为参数private static final Logger logger = LoggerFactory.getLogger(MyController.class);public void someMethod() {// 2. 使用日志logger.info("This is an info message.");logger.error("This is an error message.", e); // 记录异常logger.debug("Debug with placeholder: {}", someValue);}
}
配置文件是 src/main/resources/application.yml
或 src/main/resources/logback-spring.xml
。
application.yml 示例:
logging:level:com.yourpackage: DEBUG # 设置特定包的日志级别org.springframework.web: WARNfile:name: myapp.log # 输出到文件pattern:console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" # 控制台输出格式
2.切换为 Log4j 2
如果你需要Log4j 2的高性能,可以很容易地切换。
- 排除默认的 Logback
- 引入 Log4j 2 的 Starter
在 pom.xml
中:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><exclusions><!-- 排除默认的日志starter --><exclusion><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-logging</artifactId></exclusion></exclusions>
</dependency><!-- 添加 Log4j 2 的 Starter -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
然后,你的代码完全不用改,因为使用的依然是SLF4J的门面API。只需要创建一个Log4j 2的配置文件,如 src/main/resources/log4j2.xml
或 src/main/resources/log4j2-spring.xml
。
推荐选择 | 理由 | |
---|---|---|
绝大多数SpringBoot项目 | SLF4J + Logback (默认) | 简单、省心、功能足够、与SpringBoot生态无缝集成。 |
高性能、高并发项目 | SLF4J + Log4j 2 | 追求极致的异步日志性能,对系统吞吐量和响应时间有极高要求。 |
核心建议:
-
在代码中,永远使用 SLF4J 的API(即
LoggerFactory.getLogger
),这样你的应用就与具体实现解耦了。 -
除非有明确的性能瓶颈,否则SpringBoot的默认配置(Logback)已经非常强大。
-
如果遇到性能问题,特别是I/O成为瓶颈时,优先考虑切换到Log4j 2并启用其异步日志功能。
日志 vs AOP
日志(Logging)
- 目的:记录应用程序运行时的状态、事件、错误等信息
- 技术:SLF4J、Logback、Log4j2等
- 使用方式:在代码中直接调用日志API
AOP(面向切面编程)
- 目的:将横切关注点(如日志、安全、事务)从业务逻辑中分离
- 技术:Spring AOP、AspectJ
- 使用方式:通过切面统一处理
两者的关系
1. 独立使用日志(常见做法)
@Service
public class UserService {private static final Logger logger = LoggerFactory.getLogger(UserService.class);public User findUserById(Long id) {logger.debug("开始查询用户,ID: {}", id); // 手动记录日志// 业务逻辑User user = userRepository.findById(id);logger.info("用户查询完成: {}", user.getName());return user;}
}
2. 使用AOP统一记录日志(可选做法)
@Aspect
@Component
public class LoggingAspect {@Before("execution(* com.example.service.*.*(..))")public void logBefore(JoinPoint joinPoint) {// AOP自动记录日志,业务代码中不需要写日志语句logger.info("方法执行: {}", joinPoint.getSignature().getName());}@AfterReturning("execution(* com.example.service.*.*(..))")public void logAfter(JoinPoint joinPoint) {logger.info("方法执行完成: {}", joinPoint.getSignature().getName());}
}@Service
public class UserService {// 业务代码很干净,没有日志语句public User findUserById(Long id) {return userRepository.findById(id); // 纯粹的业务逻辑}
}
核心区别
方面 | 日志 | AOP |
---|---|---|
目的 | 记录信息 | 解耦横切关注点 |
使用方式 | 代码中直接调用 | 通过切面自动织入 |
侵入性 | 侵入业务代码 | 非侵入式 |
关注点 | "记录什么" | "在哪里记录" |
实际应用建议
业务日志:建议在代码中直接使用日志框架记录
// 业务相关的日志最好在代码中明确写出
logger.info("订单{}状态更新为: {}", orderId, newStatus);
技术日志:可以使用AOP统一处理
// 方法执行时间、入参出参等通用日志可以用AOP
@Around("execution(* com.example.service.*.*(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {long start = System.currentTimeMillis();Object result = joinPoint.proceed();long duration = System.currentTimeMillis() - start;logger.debug("方法 {} 执行耗时: {}ms", joinPoint.getSignature(), duration);return result;
}
总结:日志是记录内容的技术,AOP是实现方式的技术。它们可以结合使用,但本质上是不同的概念。在SpringBoot项目中,您完全可以在不使用AOP的情况下正常使用日志功能。
完整的AOP日志切面示例
@Aspect
@Component
public class LoggingAspect {private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);// 1. 定义切入点 - 方式一:使用注解标记@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping) || " +"@annotation(org.springframework.web.bind.annotation.PostMapping) || " +"@annotation(org.springframework.web.bind.annotation.RequestMapping)")public void controllerMethods() {}// 2. 定义切入点 - 方式二:匹配包路径@Pointcut("execution(* com.example.service.*.*(..))")public void serviceMethods() {}// 3. 定义切入点 - 方式三:组合多个切入点@Pointcut("controllerMethods() || serviceMethods()")public void loggableMethods() {}// 4. 环绕通知 - 最常用的日志记录方式@Around("loggableMethods()")public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {String methodName = joinPoint.getSignature().toShortString();Object[] args = joinPoint.getArgs();// 方法执行前日志logger.info("开始执行方法: {}, 参数: {}", methodName, Arrays.toString(args));long startTime = System.currentTimeMillis();try {// 执行目标方法Object result = joinPoint.proceed();long executionTime = System.currentTimeMillis() - startTime;// 方法执行成功日志logger.info("方法执行成功: {}, 耗时: {}ms, 返回结果: {}", methodName, executionTime, result);return result;} catch (Exception e) {long executionTime = System.currentTimeMillis() - startTime;// 方法执行异常日志logger.error("方法执行异常: {}, 耗时: {}ms, 异常信息: {}", methodName, executionTime, e.getMessage(), e);throw e;}}// 5. 前置通知@Before("controllerMethods()")public void logBeforeController(JoinPoint joinPoint) {logger.debug("Controller方法调用: {}", joinPoint.getSignature().getName());}// 6. 后置通知(无论是否异常都执行)@After("serviceMethods()")public void logAfterService(JoinPoint joinPoint) {logger.debug("Service方法执行完毕: {}", joinPoint.getSignature().getName());}// 7. 返回通知(只有正常返回时执行)@AfterReturning(pointcut = "loggableMethods()", returning = "result")public void logAfterReturning(JoinPoint joinPoint, Object result) {logger.debug("方法正常返回: {}, 返回值: {}", joinPoint.getSignature().getName(), result);}// 8. 异常通知@AfterThrowing(pointcut = "loggableMethods()", throwing = "ex")public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) {logger.error("方法执行异常: {}, 异常类型: {}, 异常信息: {}", joinPoint.getSignature().getName(), ex.getClass().getSimpleName(), ex.getMessage());}
}
配套的自定义注解(可选)
// 自定义日志注解,用于标记需要记录日志的方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {String value() default "";boolean logParameters() default true;boolean logResult() default true;Level level() default Level.INFO;enum Level {DEBUG, INFO, WARN, ERROR}
}// 在切面中使用自定义注解
@Aspect
@Component
public class CustomLoggingAspect {@Pointcut("@annotation(com.example.annotation.Loggable)")public void loggableAnnotation() {}@Around("loggableAnnotation() && @annotation(loggable)")public Object logWithCustomAnnotation(ProceedingJoinPoint joinPoint, Loggable loggable) throws Throwable {// 根据注解配置进行日志记录if (loggable.logParameters()) {logger.info("方法参数: {}", Arrays.toString(joinPoint.getArgs()));}Object result = joinPoint.proceed();if (loggable.logResult()) {logger.info("方法返回: {}", result);}return result;}
}// 在业务方法上使用自定义注解
@Service
public class UserService {@Loggable(logParameters = true, logResult = false, level = Loggable.Level.DEBUG)public User findUserById(Long id) {return userRepository.findById(id);}
}
关键点说明
1. @Pointcut的作用
-
定义可重用的切入点表达式
-
提高代码的可读性和可维护性
-
避免在多个通知中重复编写相同的表达式
@Pointcut确实可以省略
// 方式1:使用组合Pointcut(可以省略)
@Pointcut("controllerMethods() || serviceMethods()")
public void loggableMethods() {}@Around("loggableMethods()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {// ...
}// 方式2:直接组合(省略组合Pointcut)
@Around("controllerMethods() || serviceMethods()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {// ...
}
推荐省略组合@Pointcut
的情况:
-
组合只在一个地方使用
-
表达式简单明了
-
项目规模较小
-
团队成员对AOP熟悉
推荐使用组合@Pointcut
的情况:
-
相同的组合在多个通知中重复使用
-
组合表达式很复杂
-
需要提高代码可读性
-
可能需要在运行时动态改变切入点
2. 常用的切入点表达式
// 匹配包下所有方法
@Pointcut("execution(* com.example.service.*.*(..))")// 匹配特定注解的方法
@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")// 匹配实现特定接口的类
@Pointcut("within(com.example.service.BaseService+)")// 匹配Controller层
@Pointcut("within(@org.springframework.stereotype.Controller *)")
各种通知类型的适用场景
- @Around: 最强大,可以控制方法执行,适合记录完整的方法调用信息(参数、返回值、执行时间、异常等)
- @Before: 简单记录方法开始执行
- @AfterReturning: 记录方法正常返回的结果
- @AfterThrowing: 专门记录异常情况
- @After: 无论成功失败都执行,适合资源清理
特性 | Spring AOP | AspectJ |
---|---|---|
架构层次 | 基于代理的运行时AOP | 完整的AOP解决方案 |
织入时机 | 运行时织入 | 编译时、编译后、加载时织入 |
性能 | 有运行时开销 | 接近原生性能 |
功能范围 | 方法级别拦截 | 完整的AOP支持 |
依赖 | 轻量,Spring框架内 | 需要AspectJ编译器/织入器 |
学习曲线 | 简单易用 | 相对复杂 |