Java中高级程序员必须要掌握的Spring Aop编程(下篇)

2022-09-21 07:13:07 浏览数 (1)

0 引言

在我的上一篇文章中主要介绍了有关Spring Aop的概念,并翻译了官方网站中关于几种通知的使用,并没有涉及在项目中如何使用的实战。那么这篇博文笔者就讲一讲Spring AOP在异常处理和日志记录中的具体使用。这篇文章是在笔者之前写过的一篇博文Spring Boot整合Mybatis项目开发Restful API接口(https://blog.csdn.net/heshengfu1211/article/details/85490612)的基础上进行的,在此基础上,还需在项目的pom.xml文件的标签中引入spring-boot-starter-aop的依赖

代码语言:javascript复制
  <dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-aop</artifactId>
  </dependency>
1 配置项目支持切面注解及 Aop 通知

1.1 通过注解的方式开启

在配置类中添加 @EnableAspectJ 注解

代码语言:javascript复制
 @SpringBootApplication
//@ImportResource(locations = {"classpath:applicationContext.xml"})
@MapperScan(basePackages={"com.example.mybatis.dao"})
@EnableAspectJAutoProxy(proxyTargetClass = false,exposeProxy = true)
public class MybatisApplication{

   public static void main(String[] args) {

   	SpringApplication.run(MybatisApplication.class, args);
   }

}

1.2 通过 xml 配置文件开启

代码语言:javascript复制
 <beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context
       http://www.springframework.org/schema/context/spring-context.xsd
       http://www.springframework.org/schema/aop
       http://www.springframework.org/schema/aop/spring-aop.xsd">
    <context:component-scan base-package="com.example.mybatis" />
    <aop:aspectj-autoproxy proxy-target-class="false" expose-proxy="true" /> 
</beans>
2 写一个切面并配置切点和通知

2.1 注解的方式

代码语言:javascript复制
@Aspect
@Component
@Order(value = 1)
public class ExpressionAspect {

    private final static Logger logger = LoggerFactory.getLogger(ExpressionAspect.class);

    private long startTime = 0;
    private long endTime = 0;
    @Before(value = "execution(* com.example.mybatis.service.impl.*Service.*(..))")
    public void beforeAdvice(JoinPoint joinPoint){
        logger.info("进入前置通知方法....");
        Object[] args = joinPoint.getArgs();
        //打印参数
        for(int i=0;i<args.length;i  ){
            if(!(args[i] instanceof HttpServletRequest)&&!(args[i] instanceof HttpServletResponse))
            logger.info("args[" i "]={}", JSON.toJSONString(args[i], SerializerFeature.PrettyFormat));
        }
        startTime = System.currentTimeMillis();
    }

    @AfterReturning(value = "execution(* com.example.mybatis.service.impl.*Service.*(..))",returning = "returnVal")
    public void afterReturnAdvice(JoinPoint joinPoint,Object returnVal){
        logger.info("进入后置通知方法...");
        endTime = System.currentTimeMillis();
        Signature signature = joinPoint.getSignature();
        String signatureName = signature.getName();
        logger.info("signatureName={}",signatureName);
        logger.info("{}方法执行耗时={}",signatureName,(endTime-startTime) "ms");
        Object _this = joinPoint.getThis();
        Object target = joinPoint.getTarget();
        logger.info("_this==target:{}",_this==target);
        logger.info("_thisClassName={}",_this.getClass().getName());
        logger.info("targetClassName={}",target.getClass().getName());
        if(returnVal!=null){
            logger.info("returnValClassName={}",returnVal.getClass().getName());
            logger.info("returnVal={}",JSON.toJSONString(returnVal,SerializerFeature.PrettyFormat));
        }

    }

    @AfterThrowing(value = "execution(* com.example.mybatis.service.impl.*Service.*(..))",throwing = "ex")
    public void afterThrowingAdvice(JoinPoint joinPoint,Exception ex){
        logger.info("进入异常通知方法...");
        Object targetObject = joinPoint.getTarget();
        Signature signature = joinPoint.getSignature();
        logger.error("exception occurred at class " targetObject.getClass().getName() 
                "n signatureName=" signature.getName(),ex);
        logger.info("ExceptionClassName={}",ex.getClass().getName());
        logger.info("message:{}",ex.getMessage());

    }

    @After(value = "execution(* com.example.mybatis.service.impl.*Service.*(..))")
    public void afterAdvice(JoinPoint joinPoint){
        logger.info("进入最终后置通知方法....");
        logger.info("signatureName={}",joinPoint.getSignature().getName());
    }
}
2.2 XML 的方式

(1) 写一个普通的pojo类

代码语言:javascript复制
 public class ExpressionAspect {

   private final static Logger logger = LoggerFactory.getLogger(ExpressionAspect.class);

   private long startTime = 0;
   private long endTime = 0;
   
   public void beforeAdvice(JoinPoint joinPoint){
       logger.info("进入前置通知方法....");
       Object[] args = joinPoint.getArgs();
       //打印参数
       for(int i=0;i<args.length;i  ){
           if(!(args[i] instanceof HttpServletRequest)&&!(args[i] instanceof HttpServletResponse))
           logger.info("args[" i "]={}", JSON.toJSONString(args[i], SerializerFeature.PrettyFormat));
       }
       startTime = System.currentTimeMillis();
   }

   public void afterReturnAdvice(JoinPoint joinPoint,Object returnVal){
       logger.info("进入后置通知方法...");
       endTime = System.currentTimeMillis();
       Signature signature = joinPoint.getSignature();
       String signatureName = signature.getName();
       logger.info("signatureName={}",signatureName);
       logger.info("{}方法执行耗时={}",signatureName,(endTime-startTime) "ms");
       Object _this = joinPoint.getThis();
       Object target = joinPoint.getTarget();
       logger.info("_this==target:{}",_this==target);
       logger.info("_thisClassName={}",_this.getClass().getName());
       logger.info("targetClassName={}",target.getClass().getName());
       if(returnVal!=null){
           logger.info("returnValClassName={}",returnVal.getClass().getName());
           logger.info("returnVal={}",JSON.toJSONString(returnVal,SerializerFeature.PrettyFormat));
       }

   }

   public void afterThrowingAdvice(JoinPoint joinPoint,Exception ex){
       logger.info("进入异常通知方法...");
       Object targetObject = joinPoint.getTarget();
       Signature signature = joinPoint.getSignature();
       logger.error("exception occurred at class " targetObject.getClass().getName() 
               "n signatureName=" signature.getName(),ex);
       logger.info("ExceptionClassName={}",ex.getClass().getName());
       logger.info("message:{}",ex.getMessage());

   }

   public void afterAdvice(JoinPoint joinPoint){
       logger.info("进入最终后置通知方法....");
       logger.info("signatureName={}",joinPoint.getSignature().getName());
   }
}

(2) 在xml 配置文件中定义切面切面 bean 和切点通知

代码语言:javascript复制
  <bean id="expressionAspect" class="com.example.mybatis.aspect.ExpressionAspect">
  </bean>
<aop:config>
  <aop:aspect id="executionAspect" ref="expressionAspect" order="1">
  <aop:pointcut id="executionPointCut" expression="execution(* com.example.mybatis.service.impl.*Service.*(..))" />
  <aop:before method="beforeAdvice" pointcut-ref="executionPointcut" />
  <aop:after-returning method="afterReturnAdvice" returning="returnVal" pointcut-ref="executionPointcut" />
  <aop:after-throwing method="afterThrowingAdvice" throwing="ex" pointcut-ref="executionPointcut" />
  <aop:after method="afterAdvice" pointcut-ref="executionPointcut" />
  </aop:aspect>
</aop:config>

(3) 在启动类中通过@ImportResource注解导入xml配置文件

代码语言:javascript复制
@SpringBootApplication
@ImportResource(locations = {"classpath:applicationContext.xml"})
@MapperScan(basePackages={"com.example.mybatis.dao"})
public class MybatisApplication{

	public static void main(String[] args) {

		SpringApplication.run(MybatisApplication.class, args);
	}
}
3 测试通知效果

3.1 测试前置通知、返回通知和最终通知

使用1.1或1.2中任意一种方式配置切面和通知后,启动程序后在Postman中调用查找单个用户信息接口http://localhost:8081/springboot/user/userInfo?userAccount=chaogai

控制台显示日志如下:

代码语言:javascript复制
 2020-03-15 23:31:53.279  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : 进入前置通知方法....
2020-03-15 23:31:53.326  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : args[0]="chaogai"
2020-03-15 23:31:53.371  INFO 21976 --- [nio-8081-exec-3] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} inited
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : 进入最终后置通知方法....
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : signatureName=queryUserInfoByAccount
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : 进入后置通知方法...
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : signatureName=queryUserInfoByAccount
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : queryUserInfoByAccount方法执行耗时=287ms
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : _this==target:false
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : _thisClassName=com.example.mybatis.service.impl.UserService$$EnhancerBySpringCGLIB$$53b469ec
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : targetClassName=com.example.mybatis.service.impl.UserService
2020-03-15 23:31:53.614  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : returnValClassName=com.example.mybatis.model.ServiceResponse
2020-03-15 23:31:53.642  INFO 21976 --- [nio-8081-exec-3] c.e.mybatis.aspect.ExpressionAspect      : returnVal={
	"data":{
		"birthDay":"1958-01-18",
		"deptName":"生产部",
		"deptNo":1001,
		"emailAddress":"chaogai234@163.com",
		"id":59,
		"nickName":"晁盖",
		"password":"chaogai234",
		"phoneNum":"15121003400",
		"updatedBy":"heshengfu",
		"updatedTime":"2019-12-22 11:20:30.0",
		"userAccount":"chaogai"
	},
	"message":"ok",
	"status":200
}

分析:从控制台中打印的信息可以看出:代理类为SpringCGLIB,这可能是因为切点表达式中匹配的连接点目标类为Service层的实现类,而不是接口

现在我们使用注解的方式将切面中切点表达式中匹配的连接点目标类效果改为Servcie层中的接口类,然后再看一下效果

代码语言:javascript复制
 @Before(value = "execution(* com.example.mybatis.service.*Service.*(..))")
   public void beforeAdvice(JoinPoint joinPoint){
     //方法逻辑同2.1中代码
   }
   //其他通知切点表达式统一改为execution(* com.example.mybatis.service.*Service.*(..)),方法逻辑不变

调用http://localhost:8081/springboot/user/userInfo?userAccount=chaogai 接口后的日志信息:

代码语言:javascript复制
  2020-03-16 00:10:26.309  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : 进去前置通知方法....
2020-03-16 00:10:26.375  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : args[0]="chaogai"
2020-03-16 00:10:26.430  INFO 18784 --- [nio-8081-exec-1] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} inited
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : 进入最终后置通知方法....
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : signatureName=queryUserInfoByAccount
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : 进入后置通知方法...
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : signatureName=queryUserInfoByAccount
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : queryUserInfoByAccount方法执行耗时=402ms
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : _this==target:false
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : _thisClassName=com.example.mybatis.service.impl.UserService$$EnhancerBySpringCGLIB$$3d74024e
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : targetClassName=com.example.mybatis.service.impl.UserService
2020-03-16 00:10:26.777  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : returnValClassName=com.example.mybatis.model.ServiceResponse
2020-03-16 00:10:26.807  INFO 18784 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : returnVal={
	"data":{
		"birthDay":"1958-01-18",
		"deptName":"生产部",
		"deptNo":1001,
		"emailAddress":"chaogai234@163.com",
		"id":59,
		"nickName":"晁盖",
		"password":"chaogai234",
		"phoneNum":"15121003400",
		"updatedBy":"heshengfu",
		"updatedTime":"2019-12-22 11:20:30.0",
		"userAccount":"chaogai"
	},
	"message":"ok",
	"status":200
}

以上结果说明我的猜想并不对,查资料发现在Spring5中AOP的动态代理已经强制使用了SpringCGLIB

3.2 测试异常通知

为了 测试异常通知,我们修改IUserService接口和接口实现类UserService类中的addUser方法,使之抛出异常UserService类addUserTO方法增加抛出异常部分代码:

代码语言:javascript复制
  @Override
    public ServiceResponse<String> addUserTO(UserTO userTO) throws Exception{
        ServiceResponse<String> response = new ServiceResponse<>();
            userBusiness.addUserInfo(userTO);
            response.setMessage("ok");
            response.setStatus(200);
            response.setData("success");
        return response;
    }

相应的IUserService接口中的addUserTO方法修改如下:

代码语言:javascript复制
  ServiceResponse<String> addUserTO(UserTO userTO)throws Exception;

MybatisController类中的addUserInfo方法和userRegister方法修改如下,增加捕获异常处理:

代码语言:javascript复制
  @RequestMapping(value="add/userInfo",method=RequestMethod.POST)
    public ServiceResponse<String> addUserInfo(@RequestBody UserTO userTO){
        //生产环境存储用户的密码等敏感字段是要加密的
        try {
            return userService.addUserTO(userTO);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

 @PostMapping("/register")
    public ServiceResponse<String> userRegister(UserTO userTO){
        checkRegisterParams(userTO);
        try {
            return userService.addUserTO(userTO);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

修改完重启项目后,调用post请求添加用户接口http://localhost:8081/springboot/user/add/userInfo入参为:

代码语言:javascript复制
  {
        "deptNo": 1001,
        "userAccount": "chaogai",
        "password": "chaogai234",
        "nickName": "晁盖",
        "emailAddress": "chaogai234@163.com",
        "birthDay": "1958-01-18",
        "phoneNum": "15121003400",
        "updatedBy":"system"
}

控制台日志信息如下:

代码语言:javascript复制
  2020-03-16 01:04:08.328  INFO 3664 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : 进入前置通知方法....
2020-03-16 01:04:08.418  INFO 3664 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : args[0]={
	"birthDay":"1958-01-18",
	"deptNo":1001,
	"emailAddress":"chaogai234@163.com",
	"nickName":"晁盖",
	"password":"chaogai234",
	"phoneNum":"15121003400",
	"updatedBy":"system",
	"userAccount":"chaogai"
}
2020-03-16 01:04:08.476  INFO 3664 --- [nio-8081-exec-1] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} inited
2020-03-16 01:04:08.793  INFO 3664 --- [nio-8081-exec-1] o.s.b.f.xml.XmlBeanDefinitionReader      : Loading XML bean definitions from class path resource [org/springframework/jdbc/support/sql-error-codes.xml]
2020-03-16 01:04:08.852  INFO 3664 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : 进入最终后置通知方法....
2020-03-16 01:04:08.852  INFO 3664 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : signatureName=addUserTO
2020-03-16 01:04:08.852  INFO 3664 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : 进入异常通知方法...
2020-03-16 01:04:08.858 ERROR 3664 --- [nio-8081-exec-1] c.e.mybatis.aspect.ExpressionAspect      : exception occurred at class com.example.mybatis.service.impl.UserService
 signatureName=addUserTO

org.springframework.dao.DuplicateKeyException: 
### Error updating database.  Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry 'chaogai' for key 'pk_userInfo'

以上说明AOP异常通知拦截到了Service层中抛出异常方法的执行

4 全局异常处理

为了统一处理异常,并返回json数据,开发人员可以在Service层和Controller层统统把异常抛出去,然后写一个使用@ControllerAdvice注解装饰的全局异常处理类,这样就不需要在项目中的每个接口中都写那么多冗余的try-catch语句了。示例代码如下:

代码语言:javascript复制
  package com.example.mybatis.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@ControllerAdvice
public class CustomerExceptionHandler {
    private Logger logger = LoggerFactory.getLogger(CustomerExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public void handleException(Exception ex, HttpServletResponse response) throws IOException {
        response.setContentType("application/json;charset=utf8");
        logger.error("Inner Server Error,Caused by: " ex.getMessage());
        PrintWriter writer = response.getWriter();
        writer.write("{"status":500,"message":"Inner Server Error"}" );
        writer.flush();
        writer.close();

    }

}

当我们再次调用添加用户信息接口,且入参与4.2中的引发主键冲突异常代码一样,接口响应信息如下:

代码语言:javascript复制
  {
    "status": 500,
    "message": "Inner Server Error"
 }

如果需要把出参格式设置为html格式,开发人员可以将HttpServletResponse的contentType属性值改为"text/html;charset=utf8"即可,这样就不需要对message内容进行转义处理了; 此外,读者也可以在handleException方法中处理自定义的异常类。

这里要注意handleException方法的返回类型必须是void,否则不会生效,返回的是spring-boot-starter-web模块中默认的全局异常处理器;例如,当笔者将handleException方法的返回类型改为ServiceResponse时

代码语言:javascript复制
  @ExceptionHandler(Exception.class)
    public ServiceResponse<String> handleException(Exception ex, HttpServletResponse response) throws IOException {
        ServiceResponse<String> serviceResponse = new ServiceResponse<>();
        serviceResponse.setStatus(500);
        serviceResponse.setMessage("Inner Server Error, Caused by: " ex.getMessage());
        return serviceResponse;
    }

调用引发异常的添加用户信息接口时返回如下Json数据格式,说明自定义的异常处理返回类型数据失效了,而是使用了spring-boot-starter-web模块中默认的异常处理器,响应信息中提供了时间戳、响应状态、错误类型、异常信息和接口路径等内容

代码语言:javascript复制
  {
    "timestamp": "2020-03-21T08:31:18.798 0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "rn### Error updating database.  Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry 'chaogai' for key 'pk_userInfo'rn### The error may involve com.example.mybatis.dao.IUserDao.addUserInfo-Inlinern### The error occurred while setting parametersrn### SQL: insert into userinfo             (user_account,              password,              phone_num,              dept_no,              birth_day,              nick_name,              email_address,              updated_by)             values(               ?,               ?,               ?,               ?,               ?,               ?,               ?,               ?)rn### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry 'chaogai' for key 'pk_userInfo'n; ]; Duplicate entry 'chaogai' for key 'pk_userInfo'; nested exception is com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry 'chaogai' for key 'pk_userInfo'",
    "path": "/springboot/user/add/userInfo"
}
5 Spring Aop 环绕通知在记录接口调用日志场景中的应用

很多时候,我们的业务场景中有记录系统中各个接口的调用次数,每次调用时间等需求,并将这些数据持久化到数据库提供给系统管理员查看和定位耗时较长的接口。那我们现在就来尝试使用Spring Aop来解决这个问题

5.1 mysql客户端中执行api_call_logs建表定义脚本
代码语言:javascript复制
  create table api_call_logs(
  log_id varchar(32) primary key comment '日志id',
  rest_type varchar(20) not null comment '请求类型',
  rest_url varchar(200) not null comment '请求URL',
  start_time datetime not null comment '接口开始调用时间',
  expense_time bigint not null comment '接口调用耗时',
  result_flag char(1) not null comment '接口调用结果标识,0:调用失败; 1:调用成功'
)engine=InnoDB default CHARSET=utf8;
5.2 定义与api_call_logs表对应的DTO类

新建LogInfoTO类

代码语言:javascript复制
  package com.example.mybatis.model;

import org.apache.ibatis.type.Alias;

import java.io.Serializable;

@Alias("LogInfoTO")
public class LogInfoTO implements Serializable {
    private String logId; //日志id

    private String restType; //请求类型

    private String restUrl; //请求URL

    private String startTime; //接口开始调用时间

    private Long expenseTime; //接口调用耗时,单位ms

    private Integer resultFlag; //接口调用结果标识,0:调用失败;1:调用成功
    //...省略setter和gettter方法
}
5.3 完成Dao层代码

(1)新建IApiLogDao接口

代码语言:javascript复制
  package com.example.mybatis.dao;

import com.example.mybatis.model.LogInfoTO;
import org.springframework.stereotype.Repository;

@Repository
public interface IApiLogDao {

    void addApiLog(LogInfoTO logInfoTO);
}

(2)完成与IApiLogDao接口对应的IApiLogDao.xml

代码语言:javascript复制
  <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatis.dao.IApiLogDao">
    <insert id="addApiLog" parameterType="LogInfoTO">
        insert into api_call_logs(log_id,
                          rest_type,
                          rest_url,
                          start_time,
                          expense_time,
                          result_flag)
                    values( #{logId},
                         #{restType},
                         #{restUrl},
                         #{startTime},
                         #{expenseTime},
                         #{resultFlag})
    </insert>
</mapper>
5.4 完成Service层代码

新建IApiLogService接口类及其实现类ApiLogService

IApiLogService.java

代码语言:javascript复制
  package com.example.mybatis.service;

import com.example.mybatis.model.LogInfoTO;
public interface IApiLogService {

    void addApiLog(LogInfoTO logInfoTO) throws Exception;

}

ApiLogService.java

代码语言:javascript复制
  package com.example.mybatis.service.impl;

import com.example.mybatis.dao.IApiLogDao;
import com.example.mybatis.model.LogInfoTO;
import com.example.mybatis.service.IApiLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class ApiLogService implements IApiLogService {

    @Autowired
    private IApiLogDao apiLogDao;
    
    @Override
    public void addApiLog(LogInfoTO logInfoTO) throws Exception {

        apiLogDao.addApiLog(logInfoTO);
    }
}
5.5 完成自定义切面类代码
代码语言:javascript复制
  @Component
@Aspect
@Order(value = 2)
public class LogRecordAspect {

    private final static Logger logger = LoggerFactory.getLogger(LogRecordAspect.class);

    private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @Autowired
    private IApiLogService apiLogService;
    /**
     *切点:所有控制器的Rest接口方法
     */
    @Pointcut("within(com.example.mybatis.controller.*Controller) && @annotation(org.springframework.web.bind.annotation.RequestMapping)")
    public void pointCut(){

    }

    @Around("pointCut()")
    public Object aroundAdvice(ProceedingJoinPoint joinPoint){
        Object result = null;
        Signature signature = joinPoint.getSignature();
        Class clazz = signature.getDeclaringType();
        RequestMapping requestMapping1 = (RequestMapping) clazz.getDeclaredAnnotation(RequestMapping.class);
        String path1 = requestMapping1.value()[0];
        if(!StringUtils.isEmpty(path1)){
            if(!path1.startsWith("/")){
                path1 = "/" path1;
            }
        }
        String signatureName = signature.getName();
        logger.info("signatureName={}",signatureName);
        //获取擦数类型
        Object[] args = joinPoint.getArgs();
        Class[] paramTypes = new Class[args.length];
        if(clazz== MybatisController.class){
            if(signatureName.equals("userLogin")){
                paramTypes[0] = UserForm.class;
                paramTypes[1] = HttpServletRequest.class;
            }else if(signatureName.equals("loginOut")){
                paramTypes[0] = HttpServletRequest.class;
            }else{
                for(int i=0;i<args.length;i  ){
                    paramTypes[i] = args[i].getClass();
                }
            }
        }else if(clazz== ExcelController.class){
            if(signatureName.equals("exportExcel")){
                paramTypes[0] = String.class;
                paramTypes[1] = HttpServletResponse.class;
            }else if(signatureName.equals("exportSearchExcel")){
                paramTypes[0] = UserForm.class;
                paramTypes[1] = HttpServletResponse.class;
            }else if(signatureName.equals("importExcel")){
                paramTypes[0] = MultipartFile.class;
                paramTypes[1] = HttpServletRequest.class;
            }
        }else{
            for(int i=0;i<args.length;i  ){
                paramTypes[i] = args[i].getClass();
            }
        }

        //获取接口请求类型和请求URL
        try {
            Method method = clazz.getDeclaredMethod(signatureName,paramTypes);
            RequestMapping requestMapping2 =  method.getDeclaredAnnotation(RequestMapping.class);
            String path2 = requestMapping2.value()[0];
            if(!path2.startsWith("/")){
                path2 = "/" path2;
            }
            String restType = "";
            RequestMethod[] requestTypes = requestMapping2.method();
            if(requestTypes.length==0){
                restType = "GET";
            }else{
                restType = requestTypes[0].toString();
            }
            String restUrl = new StringBuilder(contextPath).append(path1).append(path2).toString();
            LogInfoTO logInfoTO = new LogInfoTO();
            logInfoTO.setLogId(UUID.randomUUID().toString().replace("-",""));
            logInfoTO.setRestType(restType);
            logInfoTO.setRestUrl(restUrl);
            long invokeStartTime = System.currentTimeMillis();
            String startTime = sdf.format(new Date());
            logInfoTO.setStartTime(startTime);
            int resultFlag;
            long invokeEndTime;
            ServiceResponse<String> exceptionResponse = new ServiceResponse<>();
            //执行接口方法逻辑
            try{
                if(args.length==0){
                    result = joinPoint.proceed();
                }else{
                    result = joinPoint.proceed(args);
                }
                invokeEndTime = System.currentTimeMillis();
                resultFlag = 1;
            }catch (Throwable ex){
                invokeEndTime = System.currentTimeMillis();
                resultFlag = 0;
                logger.error("invoke signature method error",ex);
                exceptionResponse.setStatus(500);
                exceptionResponse.setMessage("Inner Server Error,Caused by: " ex.getMessage());

            }
            long expenseTime = invokeEndTime - invokeStartTime;
            logInfoTO.setResultFlag(resultFlag);
            logInfoTO.setExpenseTime(expenseTime);
            try {
                apiLogService.addApiLog(logInfoTO);
            } catch (Exception ex2) {
                logger.error("add apiLog failed",ex2);
            }
            if(resultFlag==0){ //如果调用接口逻辑方法发生异常,则返回异常对象
                return exceptionResponse;
            }
        } catch (NoSuchMethodException e) {
           logger.error("",e);
        }
        return result;

    }

}

这里需要注意的是通过运行时获取方法的参数类型时,获取的参数类型可能是方法定义参数类型的子类,这时如果通过args[i].class得到的参数类型并不是方法定义中参数的类型,例如用户登录接口方法中d第二个入参的参数类型是javax.servlet.http.HttpServletRequest类型,而运行时却变成了org.apache.catalina.connector.RequestFacade类,这个类是HttpServletRequest类的实现类,也就是它的子类。

代码语言:javascript复制
  @RequestMapping(value = "/login",method = RequestMethod.POST)
    public ServiceResponse<UserTO> userLogin(UserForm formParam, HttpServletRequest request){
        if(StringUtils.isEmpty(formParam.getUserAccount())||StringUtils.isEmpty(formParam.getPassword())){
            throw new IllegalArgumentException("用户账号和密码不能为空!");
        }else{
            ServiceResponse<UserTO> response = userService.userLogin(formParam);
            if(response.getStatus()==200){
                request.getSession().setAttribute("userInfo",response.getData());
            }
            return response;
        }
    }

这时如果在程序运行时通过args[i].class获取参数的类型会报ClassNotFoundException异常 注意:环绕通知方法必须要有返回对象,否则数据无法响应给前端环绕通知是一个比较重的方法,它多少会影响到目标方法的正常执行,官网也提醒读者慎用环绕通知!其实这个功能同样也能在正常返回通知、和异常通知方法中实现,只是要定义一个全局的invokeStartTime和InvokeEndTime参数,读者不妨一试。

若要使用xml配置切面,需要将LogRecordAspect类头上的注解以及与切点和通知有关的注解去掉,并在applicationContext.xml文件中配置LogRecordAspect类的bean,并将LogRecordAspect类作为切面类配置在aop:config标签下,配置示例如下:

代码语言:javascript复制
   <bean id="logRecordAspect" class="com.example.mybatis.aspect.LogRecordAspect"></bean>
 <aop:config>
        <aop:aspect id="logAspect" ref="logRecordAspect" order="2">
            <aop:pointcut id="executionPointcut" expression="within(com.example.mybatis.controller.*Controller) and @annotation(org.springframework.web.bind.annotation.RequestMapping)" />
            <aop:around method="aroundAdvice" pointcut-ref="executionPointcut"></aop:around>
        </aop:aspect>
  </aop:config>

同时,记得在配置类中使用@ImportResource注解导入applicationContext.xml

5.6 环绕通知记录接口调用日志测试

通过调用查询单个用户和添加用户信息测试环绕通知的效果, 在postman中调用完接口后,查看数据库中的api_call_logs表中的记录

我们发现api_call_logs表中增加了日志调用的记录数据,多以使用 Spring AOP 完成项目中接口的调用日志记录是一种可行的方案

6 小结

本文通过定义两个切面来对Spring AOP进行项目实战的理解,帮助读者掌握Spring AOP在Spring Boot项目中的具体用法

(1) 第一个切面ExpressionAspect类写了3中通知,分别是Before通知、AfterReturning、AfterThrowing和After,切点表达式统一使用了execution(* com.example.mybatis.service.*Service.*(..)),切点为服务层中的所有方法;

(2)第二个切面LogRecordAspect主要写了一个Around通知,切点表达式使用了联合指示器within(com.example.mybatis.controller.*Controller) && @annotation(org.springframework.web.bind.annotation.RequestMapping)切控制器类中所有带有@RequestMapping注解的方法,也就是每一个API。这时需要将之前项目中使用@GetMapping和PostMapping注解全部用@RequestMapping注解替换过来,并使用注解中的value和method属性区分不同的请求URL和请求类型;如果使用@GetMapping和PostMapping注解的话则联合指示器&&需换成@annotation(org.springframework.web.bind.annotation.GetMapping)||@annotation(org.springframework.web.bind.annotation.PostMapping)

(3)本文尝试了使用@ControllerAdvice装饰的类处理全局异常,开发人员可以自定义异常,然后在 @ExceptionHandler注解的 value属性中指定自定义的异常类;

(4)如果项目需求中需要拿到客户的登录IP或域名等信息或者免去通过运行时参数类型与方法中定义的参数类型不一致的麻烦获取请求方法上的Url部分的麻烦,则最好通过实现HandlerInterceptor接口自定义的拦截器中preHandle方法和afterCompletion方法中实现,通过第一个HttpServletRequest类型入参可以拿到很多有用的参数。自定义拦截器的伪代码如下:

代码语言:javascript复制
  public class WebCorsInterceptor implements HandlerInterceptor {
    private static final Logger logger = LoggerFactory.getLogger(WebCorsInterceptor.class);
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //在这里实现接口调用前的逻辑处理
    return true;

   }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
		
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
		//在这里实现接口调用后的逻辑,通过判断ex是否为空判断接口调用是否发生异常;也通过response中的status状态码值是否>=200且<300来判断接口调用是否成功
    }
7 参考资料

[1] 深入浅出SpringBoot2.x》电子文档之第4章约定编程Spring AOP,作者杨开振;

[2]《SpringBoot Vue全栈开发》电子文档之4.4 ControllerAdvice,作者王松;

本文项目源代码已全部更新提交到本人的码云地址:

https://gitee.com/heshengfu1211/mybatisProjectDemo

有需要的读者可自行前往克隆下载。本文是作者去年3月份写的一篇关于Spring AOP 实战文章,最初发表在CSDN博客。原创不易,希望阅读过本文并觉得对你有帮助的读者点亮在看,谢谢

---END---

0 人点赞