AOP 注解驱动无侵入日志采集:@MyLog + 异步线程池完整设计

作者:忆笙智云官方 | 发布时间:2026-06-13 10:30 | 更新时间:2026-06-13 10:30

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 + 本地文件 保证日志不丢失

最佳实践建议

  1. 线程池隔离:日志线程池应与业务线程池隔离,避免日志写入影响业务并发能力
  2. 队列监控:对日志队列积压进行监控告警,防止内存溢出
  3. ThreadLocal清理:务必在finally块中清理ThreadLocal,防止线程复用导致的数据污染
  4. 日志分级存储:热数据存MySQL,冷数据归档至ES或对象存储
  5. 采样策略:高频查询接口可配置采样率,避免日志量爆炸

相关资源链接