之前有出过一期关于springboot面向切面AOP的测试博客,简单了解了一下各个方法的运行顺序。之前一直想找个机会搞一搞关于日志方面的东西,今天终于抽出了点时间出来。
毫无疑问,这个项目也是一个测试项目,但这个项目绝对不会太水,有许多的小细节都会讲到。先简单介绍一下项目大纲,首先数据库方面有两张表:用户表和日志表;对应java的功能用户表有增删改查功能,日志表只有新增功能,没有前端页面,所有操作都走的queryString方式传参(简单的演示项目,没必要搞太多);
创建项目的过程就不一一截图了,这里展示一下依赖和项目配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.0</version>
<relativePath/>
</parent>
<groupId>org.kangjia</groupId>
<artifactId>springboot_log_demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot_log_demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring.boot.web.version>2.6.0</spring.boot.web.version>
<mysql.version>5.1.38</mysql.version>
<druid.version>1.2.5</druid.version>
<mybatis.spring-boot.version>2.1.4</mybatis.spring-boot.version>
<aop.version>1.7.4</aop.version>
</properties>
<dependencies>
<!-- springboot web依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.web.version}</version>
</dependency>
<!--数据库驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>${mysql.version}</scope>
</dependency>
<!-- 连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>${druid.version}</version>
</dependency>
<!-- mybatis整合springboot依赖 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.spring-boot.version}</version>
</dependency>
<!-- aop依赖 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>${aop.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
网上有其他博客介绍还需要导入spring-boot-starter-aop的依赖,我这里是不需要的,因为spring-boot-starter-web依赖里已经包含了aop依赖
项目配置有
#服务端口
server.port=8090
#项目访问路径
server.servlet.context-path=/
#项目编码
server.tomcat.uri-encoding=utf-8
# log4j配置全局扫描级别
log4j.rootLogger=info
# log4j配置局部扫描级别
logging.level.org.kangjia=debug
#数据库配置
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&useSSL=true
spring.datasource.username=root
spring.datasource.password=root
#扫描mapper.xml文件
mybatis.mapper-locations=classpath:mapper/*.xml
package org.kangjia.config.aop;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface MyProjectLog {
/** 操作人账号 */
String operationAccount() default "";
/** 操作人 */
String operationUser() default "";
/** 操作模块 */
Module operationModule() default Module.test;
/** 操作类型 */
Type operationType() default Type.test;
/** 操作简述 */
String value() default "";
/** 操作状态 */
State operationState() default State.success;
/**
* 操作模块枚举,实际项目中的各个模块名称都写到这里
*/
public enum Module{
test((byte)1),user((byte)2);
private final byte value;
Module(byte value){this.value = value;}
public byte value(){return this.value;}
}
/**
* 操作类型枚举,实际项目中的各个模块的操作都写到这里
*/
public enum Type{
test((byte)0),add((byte)1),edit((byte)2),del((byte)3),select((byte)4),export((byte)5),impor((byte)6);
private final byte value;
Type(byte value){this.value = value;}
public byte value(){return this.value;}
}
/**
* 操作状态枚举
*/
public enum State{
success((byte)1),error((byte)0);
private final byte value;
State(byte value){this.value = value;}
public byte value(){return this.value;}
}
}
这里建议大家像这样使用枚举,传参时枚举比常量的优势体现的最明显。
package org.kangjia.config.aop;
import com.alibaba.druid.util.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.kangjia.entity.BusinessLog;
import org.kangjia.service.BusinessLogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Date;
/**
* 利用AOP把日志写入数据库
* @author ren
* @date 2021年10月08日 14:58:56
*/
@Aspect
@Component
public class MyLogListener {
private static final Logger log = LoggerFactory.getLogger(MyLogListener.class);
private BusinessLog businessLog = new BusinessLog();
@Autowired
private BusinessLogService businessLogService;
//植入Advice的触发条件
@Pointcut("@annotation(org.kangjia.config.aop.MyProjectLog)")
private void addTaskAction(){}
//环绕增强
@Around("addTaskAction()")
public Object aroundInterviewTask(ProceedingJoinPoint joinPoint) throws Throwable {
Object proceed = joinPoint.proceed(joinPoint.getArgs());
return proceed;
}
//前置增强
@Before("addTaskAction()")
public void beforeInterviewTask(JoinPoint joinPoint) {
}
//方法正常退出时执行
@AfterReturning(returning="result",value="addTaskAction()")
public void afterReturningInterviewTask(JoinPoint joinPoint, Object result){
businessLog.setOperationState(MyProjectLog.State.success.value());
}
//异常抛出增强
@AfterThrowing(throwing="ex",value="addTaskAction()")
public void afterThrowingInterviewTask(JoinPoint joinPoint, Throwable ex){
businessLog.setOperationState(MyProjectLog.State.error.value());
}
//final增强,不管是抛出异常或者正常退出都会执行
@After("addTaskAction()")
public void afterInterviewTask(JoinPoint joinPoint) throws Exception {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
MyProjectLog myProjectLog = method.getAnnotation(MyProjectLog.class);
HttpServletRequest request = null;
Object[] args = joinPoint.getArgs();
String[] parameterNames = methodSignature.getParameterNames();
for(int i = 0;i<parameterNames.length;i++){
if("request".equals(parameterNames[i])){
request = (HttpServletRequest)args[i];
break;
}
}
String operationAccount = myProjectLog.operationAccount();
if(StringUtils.isEmpty(operationAccount)){
//从请求的token中取(一般项目中都会在请求中带token的),这里测试写一个死值
operationAccount = "admin";
}
businessLog.setOperationAccount(operationAccount);
String operationUser = myProjectLog.operationUser();
if(StringUtils.isEmpty(operationUser)){
//从请求的token中取(一般项目中都会在请求中带token的),这里测试写一个死值
operationUser = "admin";
}
businessLog.setOperationUser(operationUser);
businessLog.setOperationModule(myProjectLog.operationModule().value());
businessLog.setOperationType(myProjectLog.operationType().value());
businessLog.setOperationDate(new Date());
String operationSketch = myProjectLog.value();
if(StringUtils.isEmpty(operationSketch)){
//从请求中取(在方法里我已经放进去了)
operationSketch = request.getAttribute("logCache").toString();
}
businessLog.setOperationSketch(operationSketch);
businessLog.setSessionId(request.getSession().getId());
//这里因为项目部署到服务器上运行,所以对与代码而言客户端ip就是远程ip,服务ip就是本机ip,服务端口就是本地端口
businessLog.setClientIp(getRemortIP(request));
businessLog.setServerIp(getLocalAddr());
businessLog.setServerPort(request.getLocalPort());
businessLog.setRequestUrl(request.getRequestURL().toString());
businessLogService.insert(businessLog);
}
/**
* 获取客户端ip
* @param request
* @return
*/
private String getRemortIP(HttpServletRequest request) {
String ip = null;
if (request != null && request.getHeader("x-forwarded-for") == null) {
ip = request.getRemoteAddr();
}else{
ip = request.getHeader("x-forwarded-for");
}
if("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip) || "localhost".equals(ip)){
ip = getLocalAddr();
}
return ip;
}
/**
* 获取服务端ip(本机ip)
* @return
*/
private String getLocalAddr() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
e.printStackTrace();
}
return null;
}
}
这里的代码意思就是从方法的注解和参数中取到要保存的数据然后调用保存日志的方法。这里最重要的一个细节,Object[] args = joinPoint.getArgs();获取到的只是参数列表,数组里的每个对象值是参数值,但类型不是原参数的类型,所以我这里只能获取参数名称然后找到指定参数进行强转。
package org.kangjia.controller;
import org.kangjia.config.aop.MyProjectLog;
import org.kangjia.entity.User;
import org.kangjia.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
/**
* (User)表控制层
*
* @author ren
* @since 2021-11-27 16:08:39
*/
@RestController
@RequestMapping("user")
public class UserController {
@Autowired
private UserService userService;
private final static Logger log = LoggerFactory.getLogger(UserController.class);
/**
* 定义测试接口,模拟用户的前端请求
* @return
*/
@GetMapping("test")
@MyProjectLog(value = "操作测试方法",operationModule = MyProjectLog.Module.test)
public String test(HttpServletRequest request){
log.debug("操作测试方法");
return "test ok!!!";
}
/**
* 定义测试接口,模拟用户的前端请求
* @return
*/
@GetMapping("demo")
@MyProjectLog(value = "操作案例方法",operationModule = MyProjectLog.Module.test)
public String demo(HttpServletRequest request){
log.debug("操作案例方法");
return "demo ok!!!";
}
/**
* 通过主键查询单条数据
* @param id 主键
* @return 单条数据
*/
@GetMapping("selectOne")
@MyProjectLog(operationModule = MyProjectLog.Module.user,operationType = MyProjectLog.Type.select)
public User selectOne(HttpServletRequest request,Integer id) {
request.setAttribute("logCache","查询id为"+id+"的用户信息");
log.debug("查询id为{}的用户信息",id);
return this.userService.queryById(id);
}
/**
* 新增单条数据
* @param userName
* @param password
* @return
*/
@GetMapping("add")
@MyProjectLog(operationModule = MyProjectLog.Module.user,operationType = MyProjectLog.Type.add)
public User addUser(HttpServletRequest request,String userName,String password) {
request.setAttribute("logCache","新增一条用户信息,用户名称为:"+userName+",密码为:"+password);
log.debug("新增一条用户信息,用户名称为:{},密码为:{}",userName,password);
User u = new User();
u.setUserName(userName);
u.setPassword(password);
User user = this.userService.insert(u);
return user;
}
/**
* 通过主键修改单条数据
* @param id
* @param userName
* @param password
* @return
*/
@GetMapping("edit")
@MyProjectLog(operationModule = MyProjectLog.Module.user,operationType = MyProjectLog.Type.edit)
public User editUser(HttpServletRequest request,Integer id,String userName, String password){
request.setAttribute("logCache","修改id为"+id+"的用户信息,用户名称改为:"+userName+",密码改为:"+password);
log.debug("修改id为{}的用户信息,用户名称改为:{},密码改为:{}",id,userName,password);
User u = new User();
u.setId(id);
u.setUserName(userName);
u.setPassword(password);
User user = this.userService.update(u);
return user;
}
/**
* 通过主键删除单条数据
* @param id 主键
* @return 单条数据
*/
@GetMapping("del")
@MyProjectLog(operationModule = MyProjectLog.Module.user,operationType = MyProjectLog.Type.del)
public String delUser(HttpServletRequest request,Integer id) {
request.setAttribute("logCache","删除id为"+id+"的用户信息");
log.debug("删除id为{}的用户信息",id);
this.userService.deleteById(id);
return "del ok!!!";
}
}
这里有个小tips,对于静态的日志简述直接赋值给注解的value属性,动态的日志简述我是放到了request中,这也是为什么AOP那里我要编码在目标方法执行完成后的方法里。还有一个细节,如果在控制层的方法里捕获了异常,AOP切面处理那里就不能单纯的在afterThrowingInterviewTask方法里设置失败状态了,或许你也可以往request里放失败的状态值,然后在正常退出的方法里从request中取值。
以上就是关于springboot使用AOP技术实现日志记录的全过程。这样做的优点是数据库表自由化,可以根据需求随心所欲创建日志表,没有Logback日志格式的约束。缺点也很明显,方法中除了使用log打印日志以外有时还需要把动态的日志简述放到request中,对与捕获异常的方法更麻烦。如果最终可以把log的某个级别的日志打印内容直接取到就完美了,奈何目前还没想到好的解决方案,以后如果想通了或许会设计出更好的方案。