JHHK

欢迎来到我的个人网站
行者常至 为者常成

bug菌_AOP日志拦截

目录

AOP 是什么

AOP(Aspect Oriented Programming,面向切面编程)通过预编译方式或运行期动态代理,把程序中的通用功能统一维护起来,是 OOP 的补充与完善。

它的核心是「切面」——从业务逻辑中分离出来的横切逻辑。只要一个需求与核心业务无关、但需要散落在多处,例如:

  • 日志打印及记录
  • 权限认证
  • 全局异常处理拦截
  • 返回值统一处理
  • 多数据源切换
  • 事务处理

这些都属于 AOP 的适用范围。换句话说,AOP 的价值在于分离关注点:核心关注点写业务,横切关注点交给切面,符合这个特征就可以用 AOP。

实现 AOP 的技术路线有两类:

方式 说明
动态代理 截取消息并对消息进行装饰,取代原有对象行为的执行
静态织入 引入特定语法创建「方面」,编译器在编译期织入代码

Spring AOP 走的是动态代理路线。

AOP 的核心术语

术语 含义
Joinpoint(连接点) 程序执行中的一个精确执行点,例如类中的某个方法。抽象概念,实现时不需要定义
Pointcut(切入点) 捕获连接点的结构,用于定义「拦截哪些方法的调用」
Advice(通知) 切入点的执行代码,即「方面」的具体逻辑
Aspect(方面) Pointcut + Advice 的组合,类似 OOP 中的类,但表达的是对象间的横向关系
Introduce(引入) 为对象引入附加的方法或属性,改变对象结构(部分工具称 mixin)

通知有 5 种类型:

  • 前置通知(Before):目标方法调用之前执行
  • 后置通知(After):目标方法完成后执行,不关心输出结果
  • 返回通知(AfterReturning):目标方法成功执行后调用
  • 异常通知(AfterThrowing):目标方法抛出异常后调用
  • 环绕通知(Around):包裹目标方法,可在调用前后执行自定义行为

本文的日志拦截用的就是环绕通知,因为需要在方法执行前后各取一次时间戳,并拿到返回值。

引入依赖与配置

原则上只要引入 Spring 框架的 AOP 依赖即可使用,但 Spring Boot 额外提供了 spring-boot-starter-aop 自动配置模块,用起来更省事。它由两部分组成:

  • spring-boot-autoconfigure 下的 org.springframework.boot.autoconfigure.aop.AopAutoConfiguration 提供 @Configuration 配置类与配置项
  • spring-boot-starter-aop 自身提供 spring-aop、aspectjrt、aspectjweaver 的依赖

加入依赖后就会自动触发 AOP 关联行为,包括构建 AutoProxyCreator、把横切关注点织入目标对象等。

<!--AOP-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

AopAutoConfiguration 只留了两个配置项,一般不需要配,但需要知道:

spring:
  aop:
    auto: true # 自动代理开关,false 表示关闭自动 AOP 配置
    proxy-target-class: false # true 基于类代理(CGLIB),false 默认基于接口代理(JDK)

实战:用自定义注解记录接口日志

目标:记录接口调用日志,包含接口返回码、接口耗时、请求时间、请求参数、IP、异常信息等,并落库。

一、建表

CREATE TABLE `log_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增id',
  `type` varchar(255) DEFAULT NULL COMMENT '日志类型',
  `response_code` varchar(255) DEFAULT NULL COMMENT '接口返回状态码',
  `operate_time` datetime DEFAULT NULL COMMENT '请求时间',
  `spend_time` bigint(255) DEFAULT NULL COMMENT '消耗时间',
  `url` varchar(255) DEFAULT NULL COMMENT 'url',
  `body` varchar(255) DEFAULT NULL COMMENT '请求体',
  `ip` varchar(255) DEFAULT NULL COMMENT 'ip',
  `query` varchar(255) DEFAULT NULL COMMENT '查询参数',
  `exception` varchar(255) DEFAULT NULL COMMENT '异常信息',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

二、日志实体

/**
 * 系统日志实体
 */
@TableName("log_info")
@Data
@ApiModel(value = "系统日志实体", description = "系统日志实体")
public class LogInfo extends BaseEntity {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "主键id自增列")
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    @ApiModelProperty(value = "操作类型")
    @TableField(value = "log_type")
    private String logType;

    @ApiModelProperty(value = "接口返回状态码")
    @TableField(value = "response_code")
    private Integer responseCode;

    @ApiModelProperty(value = "请求时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @TableField(value = "operate_time")
    private Date operateTime;

    @ApiModelProperty(value = "消耗时间")
    @TableField(value = "spend_time")
    private Long spendTime;

    @ApiModelProperty(value = "url")
    @TableField(value = "url")
    private String url;

    @ApiModelProperty(value = "请求体")
    @TableField(value = "body")
    private String body;

    @ApiModelProperty(value = "ip")
    @TableField(value = "ip")
    private String ip;

    @ApiModelProperty(value = "查询参数")
    @TableField(value = "query")
    private String query;

    @ApiModelProperty(value = "异常信息")
    @TableField(value = "exception")
    private String exception;

    @ApiModelProperty(value = "用户id(工号)")
    @TableField(value = "user_id")
    private String userId;
}

三、持久层

/**
 * 系统日志持久层
 */
@Component
public interface LogInfoMapper extends BaseMapper<LogInfo> {

    /**
     * 清除指定日期之前的日志
     *
     * @param date 时间
     */
    void clear(Date date);
}

四、业务层

public interface ILogInfoService extends IService<LogInfo> {

}
@Slf4j
@Service
public class LogInfoServiceImpl extends ServiceImpl<LogInfoMapper, LogInfo>
        implements ILogInfoService {

}

五、自定义注解

/**
 * 自定义注解类 @SysLog
 */
@Target(ElementType.METHOD)      // 注解作用于方法级别
@Retention(RetentionPolicy.RUNTIME) // 运行期生效,切面才能通过反射读取
@Documented                      // 生成文档
public @interface SysLog {

    // 声明注解成员
    String operation() default "";

    // 日志类型,默认查询类型
    LogTypeEnum logType() default LogTypeEnum.LOG_TYPE_QUERY;
}

六、Controller

/**
 * 用户管理分发器
 */
@RestController
@RequestMapping("/user")
@Api(tags = "用户管理模块", description = "用户管理模块")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 根据用户id查询用户信息
     */
    @SysLog(logType = LogTypeEnum.LOG_TYPE_QUERY)
    @GetMapping("/getUser-by-id")
    @ApiOperation(value = "根据用户id查询用户信息", notes = "根据用户id查询用户信息")
    public ResultResponse<UserEntity> getUserById(
            @RequestParam(name = "userId") @ApiParam("请输入用户id") String userId) {
        return new ResultResponse<>(userService.getById(userId));
    }
}

切面处理类拆解

先声明切面骨架:

/**
 * 系统日志:切面处理类
 */
@Slf4j
@Aspect
@Component
public class SysLogAspect {

    @Autowired
    private ILogInfoService iLogInfoService;

    @Around("execution(public * com.example.demo.controller.*.*(..))")
    public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {
        // 核心逻辑,下面逐个拆解
        return null;
    }
}

需要采集的数据可以分为四类:

  1. 查询参数、IP、URL、请求体
  2. 返回值(返回 code、是否异常)
  3. 请求时间、目标接口耗时
  4. 操作类型

一、从切入点获取 request

private HttpServletRequest getRequest() {
    ServletRequestAttributes servletRequestAttributes =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    return servletRequestAttributes.getRequest();
}

二、获取 IP

private String getIpAddress() {
    HttpServletRequest request = SpringServletContextUtils.getRequest();
    if (request != null) {
        try {
            return IpUtils.getIpAddr(request);
        } catch (Exception e) {
            log.error("unable to get ip address");
        }
    }
    return null;
}

三、获取请求体

请求体只能从 ContentCachingRequestWrapper 中读取,需要预先用过滤器或 Filter 包装 HttpServletRequest:

private void setBody(HttpServletRequest request, LogInfo log) {
    if (request instanceof ContentCachingRequestWrapper) {
        ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
        String body = StringUtils.toEncodedString(wrapper.getContentAsByteArray(),
                Charset.forName(wrapper.getCharacterEncoding()));
        log.setBody(body);
    }
}

四、构建日志对象

/**
 * 构建日志对象
 */
private LogInfo createOpLog() throws JsonProcessingException {
    HttpServletRequest request = this.getRequest();
    LogInfo log = new LogInfo();
    log.setQuery(request.getQueryString());
    this.setBody(request, log);
    log.setOperateTime(new Date());
    log.setUrl(request.getServletPath());
    log.setIp(getIpAddress());

    // 获取登录人 id
    SysUserEntity user = SpringServletContextUtils.getUser();
    if (!Objects.isNull(user)) {
        log.setUserId(user.getAccountId());
    }
    return log;
}

五、执行目标方法并计算耗时

private Object proceedController(ProceedingJoinPoint pjp, LogInfo log) throws Throwable {
    // 记录开始时间
    long spendTime = System.currentTimeMillis();

    // 调用目标接口并获取返回结果
    Object result = pjp.proceed(pjp.getArgs());

    // 计算接口耗时(单位:秒)
    spendTime = (System.currentTimeMillis() - spendTime) / 1000;
    log.setSpendTime(spendTime);

    return result;
}

六、获取返回码

private BaseResponse setResponseCode(LogInfo log, Object result) {
    // 判断返回体类型是否为 BaseResponse
    if (result != null && result instanceof BaseResponse) {
        return (BaseResponse) result;
    }
    return new BaseResponse();
}

七、从注解中取操作类型

private void getLogType(ProceedingJoinPoint pjp, LogInfo log) {
    // 通过反射获取织入点的方法
    MethodSignature signature = (MethodSignature) pjp.getSignature();
    Method method = signature.getMethod();

    // 获取方法上的注解
    SysLog sysLog = method.getAnnotation(SysLog.class);

    // 取注解中声明的日志类型
    log.setLogType(sysLog.logType().getValue());
}

八、环绕通知总装

@Around("execution(public * com.example.demo.controller.*.*(..))")
public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {

    // 初始化 log
    LogInfo log = this.createOpLog();

    // 执行目标接口并记录耗时
    Object result = this.proceedController(pjp, log);

    // 获取操作类型
    this.getLogType(pjp, log);

    // 获取返回值编码 code
    BaseResponse resData = this.setResponseCode(log, result);

    // 赋值返回编码
    log.setResponseCode(resData.getCode());

    // 非成功状态记录异常信息
    if (log.getResponseCode() != ResultEnum.SUCCESS.getKey()) {
        log.setException(resData.getMsg());
    }

    // 保存日志到数据库
    iLogInfoService.save(log);

    return result;
}

两种切点写法

一、切整个 Controller 层

@Around("execution(public * com.example.demo.controller.*.*(..))")

表达式含义拆解:

片段 含义
第一个 * 返回任意类型
com.example.demo.controller 包路径,针对所有控制器
第二个 * 任意类
第三个 * 任意方法
(..) 任意参数

好处是无需给接口加注解,整个控制层自动被拦截;缺点是日志范围不受控,不适合只想记录部分接口的场景。

二、切自定义注解

// 定义切点:在注解的位置切入代码
@Pointcut("@annotation(com.example.review.annotation.SysLog)")
public void logPointCut() {
}

@Around("logPointCut()")
public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {
    LogInfo log = this.createOpLog();
    Object result = this.proceedController(pjp, log);
    this.getLogType(pjp, log);

    BaseResponse resData = this.setResponseCode(log, result);
    log.setResponseCode(resData.getCode());
    if (log.getResponseCode() != ResultEnum.SUCCESS.getKey()) {
        log.setException(resData.getMsg());
    }

    iLogInfoService.save(log);
    return result;
}

需要精细化控制时,用这种方式把 @SysLog 标注在目标方法上即可。

三、两种写法的区别

两者的区别不在于切面逻辑,而在于「怎么挑出要被拦截的方法」:

  • execution(...) 是按代码结构匹配:包名、类名、方法名、返回值、参数列表,只要能写出表达式就能拦,不要求目标方法做任何改动。
  • @annotation(...) 是按方法上的标记匹配:谁带了 @SysLog 就拦谁,没带的一律放过,属于声明式切入。
维度 execution 切 Controller 层 @annotation 切注解
匹配依据 包路径 + 方法签名 方法上是否存在指定注解
对业务代码的侵入 零侵入,不用改任何接口 需要在目标方法上加 @SysLog
拦截粒度 粗,整个包下的方法全被拦 细,精确到单个方法
日志范围 不可控,增删接口会自动被记录 完全由开发者决定
新增接口的成本 自动生效,但也可能记录到不需要的接口 想记录就加注解,不加就不记录
典型场景 统一审计、全量调用统计 差异化业务日志(登录、导出、删除等)

实际项目里两者经常组合使用:用 execution 把范围圈定在 Controller 层避免误伤 Service,再用 @annotation 在圈定的范围内做二次筛选,写出来就是一个 && 表达式:

@Around("execution(public * com.example.demo.controller.*.*(..)) " +
        "&& @annotation(com.example.demo.annotation.SysLog)")
public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {
    // ...
}

四、跟自定义注解是什么关系

@SysLog 本身没有任何行为。它只是一个贴在方法上的元数据标签,JVM 不会因为看到它就去做任何事。真正干活的是 SysLogAspect,注解在这里承担三个角色:

  1. 开关:@Pointcut("@annotation(...)") 让切面只对带注解的方法生效,注解等同于「这条接口需要记录日志」的声明。
  2. 定位:切点表达式负责找到方法,而切面要区分具体是登录日志还是删除日志,就得靠注解成员。
  3. 传参:logType、operation 这些成员值,是切面通过反射从方法上读出来的:
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
SysLog sysLog = method.getAnnotation(SysLog.class);
log.setLogType(sysLog.logType().getValue());   // 注解成员 → 日志字段

这也解释了两者配合时的一个坑:前面 getLogType() 默认目标方法上一定有 @SysLog。如果只用 execution 切整个 Controller 层,遇到没加注解的方法,method.getAnnotation(SysLog.class) 会返回 null,直接 sysLog.logType() 就是空指针。所以:

  • 用 @annotation 切点 → 天然保证注解存在,可以放心取值;
  • 用 execution 切点 → 必须先判空,或给日志类型兜一个默认值。

还有两个前置条件必须满足,否则注解加了也不生效:

  • 注解的保留策略必须是 @Retention(RetentionPolicy.RUNTIME),否则运行期反射读不到;
  • 目标类必须由 Spring 容器管理(@Controller / @Service 等),因为 Spring AOP 靠代理实现,new 出来的对象和方法内部自调用都不会经过切面。

一句话概括:注解负责「声明要不要记、记成什么类型」,切点负责「找到这些声明」,切面负责「真正去记录」,三者缺一不可。

测试与小结

重启项目后通过 Swagger 调用 /user/getUser-by-id,接口本身正常返回且无报错。此时打开数据库查看 log_info 表,若切面生效,会新增一条记录,包含 URL、查询参数、IP、耗时、返回码与操作类型。

测试过程中有一个细节:本地调用时获取到的 IP 是 127.0.0.1 的 IPv6 形式,属于正常现象,可在 IP 工具方法末尾对回环地址做一次判断,再决定展示形式。

最后附上日志类型枚举,配合注解中的 logType 使用,便于日志分类统计与筛选:

/**
 * 日志类型枚举
 */
public enum LogTypeEnum implements IEnum {

    LOG_TYPE_LOGIN(1, "登录日志"),
    LOG_TYPE_LOGOUT(2, "登出日志"),
    LOG_TYPE_EXPORT(3, "导出日志"),
    LOG_TYPE_QUERY(4, "查询日志"),
    LOG_TYPE_EDIT(5, "修改日志"),
    LOG_TYPE_ADD(6, "新增日志"),
    LOG_TYPE_DELETE(7, "删除日志"),
    LOG_TYPE_IMPORT(8, "导入日志"),
    LOG_TYPE_LOGIN_OUT(9, "外部日志"),
    LOG_TYPE_UPLOAD_FILES(10, "文件上传(批量)"),
    LOG_TYPE_DELETE_FILES(11, "文件删除"),
    LOG_TYPE_SCOPE_FLUSH(12, "量化分刷新操作"),
    LOG_TYPE_DELETE_REDIS_BY_KEY(13, "redis指定删除"),
    LOG_TYPE_IMG_UPLOAD(14, "反馈问题及图片上传操作"),
    ;

    @EnumValue
    private Integer key;
    private String value;

    LogTypeEnum(Integer key, String value) {
        this.key = key;
        this.value = value;
    }

    @JsonValue
    @Override
    public Integer getKey() {
        return this.key;
    }

    public void setKey(Integer key) {
        this.key = key;
    }

    @Override
    public String getValue() {
        return this.value;
    }

    public void setValue(String value) {
        this.value = value;
    }

    /**
     * 将参数反序列化为枚举
     */
    @JsonCreator
    public static LogTypeEnum parse(Integer param) {
        return IEnum.parse(param, LogTypeEnum.class);
    }
}

整套方案的要点只有三条:

  1. 自定义注解决定「哪些接口需要记录」,用 @Retention(RUNTIME) 保证切面能反射读到;
  2. 环绕通知负责前后夹击,pjp.proceed() 之前取时间,之后算耗时并拿返回值;
  3. 请求体必须依赖 ContentCachingRequestWrapper,否则流被读一次就空了。

至此,接口日志的采集与落库形成闭环,后续无论是做审计、排查慢接口还是统计调用量,都可以基于 log_info 这张表继续扩展。





R
Valine - A simple comment system based on Leancloud.