Spring AOP詳解及其用法(二)

引言

在我的上一篇博客中主要介紹了有關Spring Aop的概念,並翻譯了官方網站中關於幾種通知的使用,並沒有涉及在項目中如何使用的實戰。那麼這篇博文筆者就講一講Spring AOP在異常處理和日誌記錄中的具體使用。這篇文章是在筆者之前寫過的一篇博文Spring Boot整合Mybatis項目開發Restful API接口的基礎上進行的,在此基礎上,還需在項目的pom.xml文件的<dependencies>標籤中引入spring-boot-starter-aop的依賴

         <dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-aop</artifactId>
		</dependency>
1 配置項目支持切面註解及Aop通知
1.1 通過註解的方式開啓

在配置類中添加@EnableAspectJ註解

@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 在applicationContext.xml文件中配置
<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 註解的方式
@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類

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中將ExpressionAspect類配置爲bean,並在applicationContext.xml中配置aop切點表達式和通知

<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註解導入applicationContext.xml資源文件

@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

控制檯顯示日誌如下:

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":"[email protected]",
		"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層中的接口類,然後再看一下

@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 接口後的日誌信息:

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":"[email protected]",
		"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方法增加拋出異常部分代碼:

@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方法修改如下

ServiceResponse<String> addUserTO(UserTO userTO)throws Exception;

MybatisController類中的addUserInfo方法和userRegister方法修改如下,增加捕獲異常處理:

@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
入參爲:

{
        "deptNo": 1001,
        "userAccount": "chaogai",
        "password": "chaogai234",
        "nickName": "晁蓋",
        "emailAddress": "[email protected]",
        "birthDay": "1958-01-18",
        "phoneNum": "15121003400",
        "updatedBy":"system"
}

控制檯日誌信息如下:

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":"[email protected]",
	"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語句了。示例代碼如下:

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中的引發主鍵衝突異常代碼一樣,接口響應信息如下:

{
    "status": 500,
    "message": "Inner Server Error"
}

如果需要把出參格式設置爲html格式,開發人員可以將HttpServletResponse的contentType屬性值改爲"text/html;charset=utf8"即可,這樣就不需要對message內容進行轉義處理了; 此外,讀者也可以在handleException方法中處理自定義的異常類。

這裏要注意handleException方法的返回類型必須是void,否則不會生效,返回的是spring-boot-starter-web模塊中默認的全局異常處理器;例如,當筆者將handleException方法的返回類型改爲ServiceResponse

@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模塊中默認的異常處理器,響應信息中提供了時間戳、響應狀態、錯誤類型、異常信息和接口路徑等內容

{
    "timestamp": "2020-03-21T08:31:18.798+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "\r\n### Error updating database.  Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry 'chaogai' for key 'pk_userInfo'\r\n### The error may involve com.example.mybatis.dao.IUserDao.addUserInfo-Inline\r\n### The error occurred while setting parameters\r\n### SQL: insert into userinfo             (user_account,              password,              phone_num,              dept_no,              birth_day,              nick_name,              email_address,              updated_by)             values(               ?,               ?,               ?,               ?,               ?,               ?,               ?,               ?)\r\n### 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建表定義腳本
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類

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接口
package com.example.mybatis.dao;

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

@Repository
public interface IApiLogDao {

    void addApiLog(LogInfoTO logInfoTO);
}
  1. 完成與IApiLogDao接口對應的IApiLogDao.xml
<?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

package com.example.mybatis.service;

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

    void addApiLog(LogInfoTO logInfoTO) throws Exception;

}
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 完成自定義切面類代碼
@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類的實現類,也就是它的子類。

@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異常
注意:環繞通知方法必須要有返回對象,否則數據無法響應給前端
環繞通知是一個比較重的方法,它多少會影響到目標方法的正常執行,官網也提醒讀者慎用環繞通知!其實這個功能同樣也能在正常返回通知、和異常通知方法中實現,只是要定義一個全局的invokeStartTimeInvokeEndTime參數,讀者不妨一試。
若要使用xml配置切面,需要將LogRecordAspect類頭上的註解以及與切點和通知有關的註解去掉,並在applicationContext.xml文件中配置LogRecordAspect類的bean,並將LogRecordAspect類作爲切面類配置在aop:config標籤下,配置示例如下:

 <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
我們發現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.*(..)),切點爲服務層中的所有方法;

  1. 第二個切面LogRecordAspect主要寫了一個Around通知,切點表達式使用了聯合指示器within(com.example.mybatis.controller.*Controller) && @annotation(org.springframework.web.bind.annotation.RequestMapping)
    切控制器類中所有帶有@RequestMapping註解的方法,也就是每一個API。這時需要將之前項目中使用@GetMappingPostMapping註解全部用@RequestMapping註解替換過來,並使用註解中的valuemethod屬性區分不同的請求URL和請求類型;

  2. 本文嘗試了使用@ControllerAdvice裝飾的類處理全局異常,開發人員可以自定義異常,然後在 @ExceptionHandler註解的 value屬性中指定自定義的異常類;

  3. 如果項目需求中需要拿到客戶的登錄IP或域名等信息或者免去通過運行時參數類型與方法中定義的參數類型不一致的麻煩獲取請求方法上的Url部分的麻煩,則最好通過實現HandlerInterceptor接口自定義的攔截器中preHandle方法和afterCompletion方法中實現,通過第一個HttpServletRequest類型入參可以拿到很多有用的參數。自定義攔截器的僞代碼如下:

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來判斷接口調用是否成功
    }

參考文獻:
《深入淺出SpringBoot2.x》電子文檔之第4章約定編程Spring AOP,作者楊開振;
《SpringBoot+Vue全棧開發》電子文檔之4.4 ControllerAdvice,作者王松;
本文項目源代碼已全部更新提交到本人的碼雲地址: https://gitee.com/heshengfu1211/mybatisProjectDemo
有需要的讀者可自行前往克隆下載

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章