前言

关于 Spring AOP 介绍及注解使用,请查看我的另一篇文章 Spring AOP介绍及注解使用

本文示例代码已上传 GitHub:spring-boot-aop-audit

使用

  1. 添加 Spring AOP 依赖:

    <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
  2. 添加自定义注解:

    @Documented
    @Target({ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface AuditLog {
    
     /**
      * 日志描述
      * @return
      */
     String description() default "";
    
     /**
      * 审计日志模块(登录/用户模块/角色模块等)
      * @return
      */
     AuditLogModuleEnum module() default AuditLogModuleEnum.USER;
    
     /**
      * 审计日志类型(登录/新增/修改/删除等)
      * @return
      */
     AuditLogOperationTypeEnum type() default AuditLogOperationTypeEnum.QUERY;
    }

有三个参数组成,description 指操作描述;module 是个枚举,用于说明属于哪个模块;type 也是个枚举,用于说明操作类型,比如「登录」、「新增」、「删除」等。

  1. 添加功能模块枚举
public enum AuditLogModuleEnum {

    /**
     * 定义审计日志模块
     */
    LOGIN(0, "登录"),
    USER(1, "用户模块"),
    ROLE(2, "角色模块"),
    PERMISSION(3, "权限模块");

    AuditLogModuleEnum(int moduleCode, String moduleName) {
        this.moduleCode = moduleCode;
        this.moduleName = moduleName;
    }

    @Getter
    private final int moduleCode;

    @Getter
    private final String moduleName;
}
  1. 添加操作类型枚举
public enum AuditLogOperationTypeEnum {

    /**
     * 定义审计日志操作类型枚举
     */
    LOGIN(1, "登录"),
    LOGOUT(2, "注销登录"),
    INSERT(3, "新增"),
    UPDATE(4, "修改"),
    DELETE(5, "删除"),
    QUERY(6, "查询");

    AuditLogOperationTypeEnum(int typeCode, String typeName) {
        this.typeCode = typeCode;
        this.typeName = typeName;
    }

    @Getter
    private final int typeCode;

    @Getter
    private final String typeName;
}
  1. 定义注解切面
@Aspect
@Component
@Slf4j
public class AuditLogAspect {

    private final IOperationLogService operationLogService;

    @Autowired
    public AuditLogAspect(IOperationLogService operationLogService) {
        this.operationLogService = operationLogService;
    }

    /**
     * 定义注解切点,注解拦截
     */
    @Pointcut("@annotation(com.lanweihong.aop.annotation.AuditLog)")
    public void auditLog() {
    }

    /**
     * 前置通知,方法调用前被调用
     * 除非抛出一个异常,否则这个通知不能阻止连接点之前的执行流程
     */
    @Before("auditLog()")
    public void doBefore() {
        log.info("进入 @Before ...");
    }

    /**
     * 环绕增强
     * 先执行 pjp.proceed() 然后进入 @Before,然后执行主方法,回到 @Around 的 pjp.proceed() 后
     * @param pjp ProceedingJoinPoint
     * @return
     */
    @Around("auditLog()")
    public Object doAround(ProceedingJoinPoint pjp) {
        log.info("进入 @Around ......");
        Object result = null;
        try {
            result = pjp.proceed();

        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        log.info("退出 @Around ......");
        return result;
    }

    /**
     * 后置通知,如果切入点抛出异常,则不会执行
     */
    @AfterReturning(pointcut = "auditLog()", returning = "keys")
    public void doAfterReturning(JoinPoint joinPoint, Object keys) {
        // 获取 RequestAttributes
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        Assert.notNull(requestAttributes, "获取 RequestAttributes 为空");
        // 获取 HttpServletRequest
        HttpServletRequest request = (HttpServletRequest)requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
        // 获取注解参数信息
        AuditLogDO operationLogDO = new AuditLogDO();
        // 从切入点通过反射获取切入点方法
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        // 获取执行方法,e.g. public void com.lanweihong.aop.controller.UserController.test()
        Method method = signature.getMethod();
        // 获取方法参数
        Object[] args = joinPoint.getArgs();

        // 获取注解
        AuditLog operationLog = method.getAnnotation(AuditLog.class);
        if (null != operationLog) {
            operationLogDO.setModule(operationLog.module().getModuleCode());
            operationLogDO.setOperationType(operationLog.type().getTypeCode());
            // 解析表达式
            String desc = parseDescriptionExpression(operationLog.description(), args);
            operationLogDO.setDescription(desc);
        }

        // TODO 请自己获取用户信息
        operationLogDO.setUserId(null)
                .setUserName("");

        Assert.notNull(request, "获取 HttpServletRequest 为空");
        // 获取 IP
        String ip = RequestUtils.getIpAddr(request);
        String userAgent = request.getHeader("user-agent");
        operationLogDO.setIp(ip)
                .setUserAgent(userAgent);
        operationLogService.addOperationLog(operationLogDO);
    }

    /**
     * 异常返回通知,切入点抛出异常后执行
     * @param joinPoint 切入点
     * @param e 异常
     */
    @AfterThrowing(pointcut = "auditLog()", throwing = "e")
    public void doAfterThrowing(JoinPoint joinPoint, Throwable e) {
        log.error("进入 @AfterThrowing...错误:" + e.getMessage());
    }

    /**
     * 解析 SpEL 表达式,从参数中获取数据并生成描述
     * @param descriptionExpression 描述表达式(使用 SpEL 表达式)
     * @param args 参数
     * @return
     */
    private String parseDescriptionExpression(String descriptionExpression, Object[] args) {
        SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
        Expression expression = spelExpressionParser.parseExpression(descriptionExpression, new TemplateParserContext());
        return expression.getValue(new StandardEvaluationContext(args), String.class);
    }
}

其中获取 IP 用到的工具类 RequestUtils

public class RequestUtils {

    /**
     * 定义获取 ip 的 header
     */
    private static final String[] IP_HEADER_CANDIDATES = {
            "X-Forwarded-For",
            "Proxy-Client-IP",
            "WL-Proxy-Client-IP",
            "HTTP_X_FORWARDED_FOR",
            "HTTP_X_FORWARDED",
            "HTTP_X_CLUSTER_CLIENT_IP",
            "HTTP_CLIENT_IP",
            "HTTP_FORWARDED_FOR",
            "HTTP_FORWARDED",
            "HTTP_VIA",
            "REMOTE_ADDR" };

    /**
     * 获取 IP 地址
     * @param request HttpServletRequest
     * @return IP
     */
    public static String getIpAddr(HttpServletRequest request) {
        for (String header : IP_HEADER_CANDIDATES) {
            String ip = request.getHeader(header);
            if (null != ip && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
                return ip;
            }
        }
        String ip = request.getRemoteAddr();
        return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
    }
}
  1. 新增审计实体类
@Getter
@Setter
@Accessors(chain = true)
@ToString
@Table(name = "audit_log")
public class AuditLogDO extends BaseEntity implements Serializable {
    private static final long serialVersionUID = 8364702890093931325L;

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "user_name")
    private String userName;

    @Column(name = "description")
    private String description;

    @Column(name = "operation_type")
    private Integer operationType;

    @Column(name = "module")
    private Integer module;

    @Transient
    private String moduleName;

    @Column(name = "ip")
    private String ip;

    @Column(name = "user_agent")
    private String userAgent;
}
  1. 新增相关的 Dao/Mapper/Service;为省略篇幅,这里就略过了,完整代码在 GitHub 中可看到,重点不是这个。

  2. 在 Controller 方法上添加注解 @AuditLog 使用:

@RestController
@RequestMapping("/api/users")
@Slf4j
public class UserController {

    @GetMapping("/{userid}")
    public JsonResult<String> getUserById(@PathVariable(value = "userid") Long userId) {
        // TODO...
        return JsonResult.ok();
    }

    @AuditLog(description = "新增用户,用户名:#{[0].userName}", type = AuditLogOperationTypeEnum.INSERT)
    @PostMapping
    public JsonResult<String> addUser(@Valid @RequestBody UserParam param) {
        // TODO...
        return JsonResult.ok();
    }

    @AuditLog(description = "修改用户,用户Id:#{[0]},用户名:#{[1].userName}")
    @PutMapping("/{userid}")
    public JsonResult<String> updateUser(@PathVariable(value = "userid", required = false) Long userId, @Valid @RequestBody UserParam param) {
        // TODO...
        return JsonResult.ok();
    }

    @AuditLog(description = "删除用户,用户 id:#{[0]}", type = AuditLogOperationTypeEnum.DELETE)
    @DeleteMapping("/{userid}")
    public JsonResult<String> deleteUser(@PathVariable(value = "userid") Long userId) {
        // TODO...
        return JsonResult.ok();
    }
}

其中,description 参数值用到 SpEL ,可以使用参数变量填充。简单介绍用法:#{} 表示引用参数变量,[] 表示方法的参数组使用的第 N 个参数,如 addUser 方法的就可以使用 #{[0].userName} 来获取方法参数中的第一个参数的 userName 属性。关于 SpEL 的用法,可去查看官方文档 Spring Expression Language (SpEL)

  1. 启动项目,测试请求。

启动项目后,使用 Postman 发送请求测试,查看数据库记录,发现已添加有审计日志。

数据库审计日志记录

总结

使用 Spring AOP 实现的审计日志功能很简单,在不改变原来的业务逻辑代码情况下,使用 AOP 可以很轻松优雅的实现审计功能。
本文仅介绍最简单的用法,实际开发中,有些需求是要记录数据的变化值(旧值与新值)的,这些都需要自己去拓展了。

还有,本例中未针对事务回滚做相应的配置。

文章目录