AOP 注解驱动无侵入日志采集:@MyLog + 异步线程池完整设计
AOP注解驱动无侵入式日志采集设计
引言
在企业级应用开发中,日志采集是系统可观测性的基石。传统的日志记录方式往往需要在业务代码中显式调用日志API,导致业务逻辑与日志逻辑深度耦合,代码可读性和可维护性大幅降低。AOP(面向切面编程)通过将横切关注点从业务逻辑中剥离,实现了日志采集的无侵入式设计。
本文将深入分析AOP切面编程在日志采集中的应用,探讨自定义注解(如@MyLog、@LoginLog)的设计思路,以及异步日志记录如何在不阻塞业务线程的前提下完成高效采集。
一、传统日志记录的痛点
1.1 侵入式日志代码的问题
// 传统方式:日志逻辑与业务逻辑深度耦合
@RestController
@RequestMapping("/api/user")
public class UserController {
@PostMapping("/create")
public Result<Void> createUser(@RequestBody UserDTO dto) {
// 日志记录侵入业务代码
LogRecord record = new LogRecord();
record.setModule("用户管理");
record.setAction("创建用户");
record.setParams(JSON.toJSONString(dto));
record.setOperator(SecurityUtils.getCurrentUserId());
record.setStartTime(LocalDateTime.now());
try {
userService.create(dto);
record.setStatus("SUCCESS");
return Result.success();
} catch (Exception e) {
record.setStatus("FAIL");
record.setErrorMsg(e.getMessage());
throw e;
} finally {
record.setEndTime(LocalDateTime.now());
logService.save(record); // 同步写入,阻塞业务线程
}
}
}
上述代码存在以下问题:
- 代码膨胀:每个接口都需重复编写日志记录逻辑
- 关注点混杂:业务逻辑与日志逻辑交织,降低可读性
- 性能隐患:同步写入日志数据库会阻塞业务线程
- 维护困难:日志格式变更需修改所有业务方法
1.2 AOP方案的解决思路
AOP通过动态代理机制,在方法执行前后织入日志采集逻辑,业务代码完全无感知:
flowchart TD
A[客户端请求] --> B[Controller方法]
B --> C{AOP代理拦截}
C --> D[前置通知: 记录开始时间/参数]
D --> E[执行目标方法]
E --> F{方法是否正常返回?}
F -->|是| G[返回通知: 记录返回值]
F -->|否| H[异常通知: 记录异常信息]
G --> I[后置通知: 计算耗时/异步保存日志]
H --> I
I --> J[返回响应]
style C fill:#4CAF50,color:#fff
style D fill:#2196F3,color:#fff
style G fill:#2196F3,color:#fff
style H fill:#f44336,color:#fff
style I fill:#FF9800,color:#fff
二、自定义日志注解设计
2.1 通用操作日志注解 @MyLog
/**
* 通用操作日志注解
* 标注在Controller方法上,自动采集操作日志
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyLog {
/** 操作模块 */
String module() default "";
/** 操作类型 */
OperationType type() default OperationType.OTHER;
/** 操作描述 - 支持SpEL表达式 */
String description() default "";
/** 是否保存请求参数 */
boolean saveParams() default true;
/** 是否保存返回结果 */
boolean saveResult() default false;
/** 是否启用脱敏 */
boolean enableDesensitize() default true;
}
/**
* 操作类型枚举
*/
public enum OperationType {
CREATE, // 新增
UPDATE, // 修改
DELETE, // 删除
EXPORT, // 导出
IMPORT, // 导入
QUERY, // 查询
OTHER // 其他
}
2.2 登录日志注解 @LoginLog
/**
* 登录日志专用注解
* 针对登录场景的特殊需求:记录登录IP、浏览器、终端等
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LoginLog {
/** 登录类型 */
LoginType loginType() default LoginType.ACCOUNT;
/** 是否记录登出日志 */
boolean isLogout() default false;
}
public enum LoginType {
ACCOUNT, // 账号密码
SMS, // 短信验证码
SOCIAL, // 社交账号
QR_CODE // 扫码登录
}
三、切面核心实现
3.1 切面执行流程
sequenceDiagram
participant Client as 客户端
participant Proxy as AOP代理
participant Aspect as 日志切面
participant Target as 目标方法
participant ThreadPool as 异步线程池
participant DB as 日志存储
Client->>Proxy: 发起请求
Proxy->>Aspect: @Before 前置通知
Aspect->>Aspect: 解析注解元数据
Aspect->>Aspect: 构建日志记录对象
Aspect->>Aspect: 记录开始时间/请求参数
Proxy->>Target: 执行业务方法
alt 正常返回
Target-->>Aspect: 返回结果
Aspect->>Aspect: @AfterReturning 记录返回值
else 异常抛出
Target-->>Aspect: 抛出异常
Aspect->>Aspect: @AfterThrowing 记录异常
end
Aspect->>ThreadPool: 提交异步日志保存任务
ThreadPool->>DB: 异步写入日志
Aspect-->>Client: 返回响应
3.2 切面核心代码
/**
* 操作日志切面
* 拦截@MyLog注解标注的方法,采集操作日志
*/
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
@Autowired
private AsyncLogService asyncLogService;
/**
* 定义切点:匹配所有标注了@MyLog注解的方法
*/
@Pointcut("@annotation(com.example.annotation.MyLog)")
public void logPointcut() {
}
/**
* 前置通知:记录方法开始执行信息
*/
@Before("logPointcut()")
public void doBefore(JoinPoint joinPoint) {
// 将开始时间存入ThreadLocal,供后续通知使用
LogContext.setStartTime(System.currentTimeMillis());
LogContext.setParams(buildParams(joinPoint));
}
/**
* 返回通知:方法正常返回后记录日志
*/
@AfterReturning(pointcut = "logPointcut()", returning = "result")
public void doAfterReturning(JoinPoint joinPoint, Object result) {
saveLog(joinPoint, null, result);
}
/**
* 异常通知:方法抛出异常后记录日志
*/
@AfterThrowing(pointcut = "logPointcut()", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
saveLog(joinPoint, e, null);
}
/**
* 构建并异步保存日志记录
*/
private void saveLog(JoinPoint joinPoint, Exception e, Object result) {
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
MyLog myLog = method.getAnnotation(MyLog.class);
LogRecord record = new LogRecord();
record.setModule(myLog.module());
record.setType(myLog.type().name());
record.setDescription(myLog.description());
record.setStartTime(LogContext.getStartTime());
record.setDuration(System.currentTimeMillis() - LogContext.getStartTime());
record.setParams(myLog.saveParams() ? LogContext.getParams() : null);
record.setResult(myLog.saveResult() ? toJsonString(result) : null);
record.setStatus(e == null ? "SUCCESS" : "FAIL");
record.setErrorMsg(e != null ? e.getMessage() : null);
// 异步保存,不阻塞当前线程
asyncLogService.saveAsync(record);
} finally {
// 清理ThreadLocal,防止内存泄漏
LogContext.clear();
}
}
}
3.3 ThreadLocal上下文管理
/**
* 日志上下文 - 基于ThreadLocal存储请求级日志数据
*/
public class LogContext {
private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
private static final ThreadLocal<String> PARAMS = new ThreadLocal<>();
public static void setStartTime(Long time) {
START_TIME.set(time);
}
public static Long getStartTime() {
return START_TIME.get();
}
public static void setParams(String params) {
PARAMS.set(params);
}
public static String getParams() {
return PARAMS.get();
}
/** 清理所有ThreadLocal变量,防止内存泄漏 */
public static void clear() {
START_TIME.remove();
PARAMS.remove();
}
}
四、异步日志记录机制
4.1 异步日志服务
/**
* 异步日志服务
* 使用线程池异步写入日志,不阻塞业务线程
*/
@Service
@Slf4j
public class AsyncLogService {
@Autowired
private LogRecordMapper logRecordMapper;
/** 专用日志线程池,与业务线程池隔离 */
private final ThreadPoolExecutor logExecutor = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(2048), // 任务队列
new ThreadFactoryBuilder().setNameFormat("log-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:由调用线程执行
);
/**
* 异步保存日志记录
*/
public void saveAsync(LogRecord record) {
logExecutor.execute(() -> {
try {
logRecordMapper.insert(record);
} catch (Exception e) {
log.error("日志保存失败: {}", e.getMessage(), e);
// 降级:写入本地日志文件
log.warn("日志降级写入本地文件: {}", record);
}
});
}
}
4.2 异步日志架构
flowchart LR
subgraph 业务线程
A[Controller方法] --> B[AOP切面拦截]
B --> C[构建LogRecord]
end
subgraph 异步线程池
C -->|提交任务| D[LogThreadPool]
D --> E{队列是否已满?}
E -->|否| F[异步写入MySQL]
E -->|是| G[CallerRunsPolicy]
G -->|降级| H[写入本地日志文件]
end
subgraph 监控告警
F --> I[队列监控]
I -->|队列积压| J[告警通知]
end
style D fill:#FF9800,color:#fff
style F fill:#4CAF50,color:#fff
style H fill:#f44336,color:#fff
五、SpEL表达式支持
在日志描述中支持SpEL表达式,可以动态获取方法参数值:
/**
* SpEL表达式解析器
* 支持在注解中使用如: @MyLog(description = "删除用户: #userId")
*/
public class SpelExpressionParser {
private static final org.springframework.expression.spel.standard.SpelExpressionParser
PARSER = new org.springframework.expression.spel.standard.SpelExpressionParser();
/**
* 解析SpEL表达式
* @param expression SpEL表达式字符串
* @param method 目标方法
* @param args 方法参数值
* @return 解析后的字符串
*/
public static String parse(String expression, Method method, Object[] args) {
if (StringUtils.isBlank(expression) || !expression.contains("#")) {
return expression;
}
// 创建解析上下文
EvaluationContext context = new StandardEvaluationContext();
ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
String[] paramNames = discoverer.getParameterNames(method);
if (paramNames != null) {
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
}
return PARSER.parseExpression(expression).getValue(context, String.class);
}
}
使用示例:
@MyLog(module = "用户管理", type = OperationType.DELETE,
description = "删除用户: #userId")
@DeleteMapping("/{userId}")
public Result<Void> deleteUser(@PathVariable Long userId) {
userService.deleteById(userId);
return Result.success();
}
六、数据脱敏处理
日志中记录的参数可能包含敏感信息(如手机号、身份证号),需进行脱敏处理:
/**
* 日志数据脱敏处理器
*/
public class LogDesensitizeHandler {
/** 脱敏规则注册表 */
private static final Map<String, DesensitizeRule> RULES = new HashMap<>();
static {
// 手机号脱敏: 138****1234
RULES.put("phone", new DesensitizeRule(3, 4, '*'));
// 身份证脱敏: 310***********1234
RULES.put("idCard", new DesensitizeRule(3, 4, '*'));
// 邮箱脱敏: t****@example.com
RULES.put("email", new DesensitizeRule(1, 0, '*'));
}
/**
* 对JSON字符串中的敏感字段进行脱敏
*/
public static String desensitize(String json) {
if (StringUtils.isBlank(json)) {
return json;
}
JSONObject obj = JSON.parseObject(json);
for (Map.Entry<String, DesensitizeRule> entry : RULES.entrySet()) {
String field = entry.getKey();
if (obj.containsKey(field)) {
obj.put(field, applyRule(obj.getString(field), entry.getValue()));
}
}
return obj.toJSONString();
}
private static String applyRule(String value, DesensitizeRule rule) {
if (value == null || value.length() <= rule.getKeepLeft() + rule.getKeepRight()) {
return value;
}
int maskLen = value.length() - rule.getKeepLeft() - rule.getKeepRight();
return value.substring(0, rule.getKeepLeft())
+ String.valueOf(rule.getMaskChar()).repeat(maskLen)
+ value.substring(value.length() - rule.getKeepRight());
}
}
结论与建议
核心设计要点
| 设计要点 | 实现方式 | 优势 |
|---|---|---|
| 无侵入性 | AOP + 自定义注解 | 业务代码零修改 |
| 异步写入 | 独立线程池 | 不阻塞业务线程 |
| 动态描述 | SpEL表达式 | 日志描述更灵活 |
| 数据安全 | 脱敏处理器 | 防止敏感信息泄露 |
| 降级容错 | CallerRunsPolicy + 本地文件 | 保证日志不丢失 |
最佳实践建议
- 线程池隔离:日志线程池应与业务线程池隔离,避免日志写入影响业务并发能力
- 队列监控:对日志队列积压进行监控告警,防止内存溢出
- ThreadLocal清理:务必在finally块中清理ThreadLocal,防止线程复用导致的数据污染
- 日志分级存储:热数据存MySQL,冷数据归档至ES或对象存储
- 采样策略:高频查询接口可配置采样率,避免日志量爆炸