业务介绍
通过aop切面技术,借助ApiOperation的value()值,记录用户的具体操作信息,比如
操作描述、请求参数、返回结果、请求结果、用户信息、操作时间、执行时长、异常信息等信息
日志表设计
CREATE TABLE `operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`operation_desc` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作描述',
`request_param` longtext CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT '请求参数',
`success` char(1) DEFAULT NULL COMMENT '0成功 1失败',
`response_param` longtext CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT '返回参数',
`user_id` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作员id',
`user_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作员名称',
`method` varchar(512) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作方法',
`url` varchar(512) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '请求url',
`ip` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '请求ip',
`create_time` datetime DEFAULT NULL COMMENT '操作时间',
`execution_duration` bigint(20) DEFAULT NULL COMMENT '执行时长(ms)',
`error_message` longtext COMMENT '异常信息',
PRIMARY KEY (`id`),
KEY `create_time` (`create_time`,`execution_duration`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='操作日志表';
AOP实现
采用环绕通知记录正常操作日志以及异常日志:
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.zyp.annotation.NoLog;
import com.zyp.exception.OperationLogException;
import com.zyp.model.entity.OperationLog;
import com.zyp.util.IDUtil;
import com.zyp.util.Result;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.web.multipart.MultipartFile;
/**
* 切面处理类,操作日志异常日志记录处理
* @author leishen
*/
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
/**
* 异常信息最大长度
*/
private static final Integer MAX_ERROR_LENGTH=1000;
/**
* 定义切入点
*/
@Pointcut("execution(* com.zyp.controller..*.*(..))")
//@Pointcut("@annotation(io.swagger.annotations.ApiOperation)")
public void pointCut() {
}
/**
* 环绕通知
* @param joinPoint
*/
@Around(value = "pointCut()")
public Object saveOperationLog(ProceedingJoinPoint joinPoint){
//返回值
Object object=null;
// 获取RequestAttributes
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request = (HttpServletRequest) requestAttributes
.resolveReference(RequestAttributes.REFERENCE_REQUEST);
// 从切面织入点处通过反射机制获取织入点处的方法
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 获取切入点所在的方法
Method method = signature.getMethod();
//判断类上面有此注解,有的话则不记录操作日志
NoLog noLog1 = method.getDeclaringClass().getAnnotation(NoLog.class);
//判断方法上面有此注解,有的话则不记录操作日志
NoLog noLog = method.getAnnotation(NoLog.class);
if (noLog != null || noLog1!=null ) {
try {
object = joinPoint.proceed();
}catch (Throwable throwable){
log.error(throwable.getMessage(),throwable);
ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
String value;
if (apiOperation != null) {
value=apiOperation.value()+"请求失败!";
}else{
value="请求失败!";
}
object=new Result<>().error(value);
}
}else{
//创建日志实体类
OperationLog operationLog = new OperationLog();
// 获取请求的方法名
String methodName = method.getName();
// 获取请求的类名
String className = joinPoint.getTarget().getClass().getName();
methodName = className + "." + methodName;
// 请求方法
operationLog.setMethod(methodName);
//获取ApiOperation注解,要求每个方法都要有这个注解
ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
if (apiOperation!=null) {
//操作描述
String value = apiOperation.value();
operationLog.setOperationDesc(value);
// 请求参数
String requestParam = getParam(joinPoint);
operationLog.setRequestParam(requestParam);
//用户id
String userId = StringUtils.isNotEmpty(request.getHeader("userId")) ? request.getHeader("userId") : null;
operationLog.setUserId(userId);
//用户名称
String userName = StringUtils.isNotEmpty(request.getHeader("userName")) ? request.getHeader("userName") : null;
operationLog.setUserName(userName);
// 主键ID
operationLog.setId(IDUtil.getId());
// 请求URI
String requestURI = request.getRequestURI();
operationLog.setUrl(requestURI);
//设置ip
operationLog.setIp(this.getIpAddress(request));
// 创建时间
operationLog.setCreateTime(new Date());
try {
//目标方法执行开始
Long start = System.currentTimeMillis();
object = joinPoint.proceed();
//目标方法执行结束
Long end = System.currentTimeMillis();
operationLog.setSuccess("0");
//计算执行时长 保留两位小数
//BigDecimal executionDuration=calExecutionDuration(start,end);
operationLog.setExecutionDuration(end-start);
// 返回结果
operationLog.setResponseParam(JSON.toJSONString(object));
}catch (Throwable throwable){
log.error(throwable.getMessage(),throwable);
//失败
operationLog.setSuccess("1");
if (throwable instanceof Exception) {
Exception e = (Exception) throwable;
operationLog.setErrorMessage(stackTraceToString(e.getClass().getName(), e.getMessage(), e.getStackTrace()));
throw new OperationLogException(value+"请求失败!");
}
}finally {
//加入任务队列
LogTask.addTask(operationLog);
}
}
}
return object;
}
/**
* 计算执行时长 保留两位小数
* @param start
* @param end
* @return
*/
private BigDecimal calExecutionDuration(Long start, Long end) {
BigDecimal decimal = null;
if (start!=null && end!=null) {
long l = end - start;
BigDecimal decimal1 = new BigDecimal(1000 + "");
decimal= new BigDecimal(l + "").divide(decimal1, 2, RoundingMode.HALF_UP);
}
return decimal;
}
/**
* 封装请求参数
* @param joinPoint
* @return
*/
private String getParam(JoinPoint joinPoint) {
List<Object> allArgs = Arrays.asList(joinPoint.getArgs());
List<Object> args = allArgs.stream().map(arg -> {
if (!(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse)
&& !(arg instanceof BindingResult) && !(arg instanceof MultipartFile)) {
return arg;
} else {
return null;
}
}).filter(arg -> arg != null).collect(Collectors.toList());
if (CollectionUtils.isNotEmpty(args)) {
return JSON.toJSONStringWithDateFormat(args.get(0),"yyyy-MM-dd", SerializerFeature.WriteDateUseDateFormat);
}else{
return null;
}
}
/**
* 转换异常信息为字符串
*
* @param exceptionName 异常名称
* @param exceptionMessage 异常信息
* @param elements 堆栈信息
*/
public static String stackTraceToString(String exceptionName, String exceptionMessage, StackTraceElement[] elements) {
StringBuffer stringBuffer = new StringBuffer();
for (StackTraceElement stet : elements) {
stringBuffer.append(stet + "\n");
}
String message = exceptionName + ":" + exceptionMessage + "\r\n" + stringBuffer;
if (message.length()>MAX_ERROR_LENGTH) {
message=message.substring(0,MAX_ERROR_LENGTH);
}
return message;
}
/**
* 获取访问者的IP
*/
private String getIpAddress(HttpServletRequest request) {
String ip = null;
// X-Forwarded-For:Squid 服务代理
String ipAddresses = request.getHeader("X-Forwarded-For");
if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
// Proxy-Client-IP:apache 服务代理
ipAddresses = request.getHeader("Proxy-Client-IP");
}
if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
// WL-Proxy-Client-IP:weblogic 服务代理
ipAddresses = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
// HTTP_CLIENT_IP:有些代理服务器
ipAddresses = request.getHeader("HTTP_CLIENT_IP");
}
if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
// X-Real-IP:nginx服务代理
ipAddresses = request.getHeader("X-Real-IP");
}
// 有些网络通过多层代理,那么获取到的ip就会有多个,一般都是通过逗号(,)分割开来,并且第一个ip为客户端的真实IP
if (ipAddresses != null && ipAddresses.length() != 0) {
ip = ipAddresses.split(",")[0];
}
// 还是不能获取到,最后再通过request.getRemoteAddr();获取
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
ip = request.getRemoteAddr();
}
return ip.equals("0:0:0:0:0:0:0:1") ? "127.0.0.1" : ip;
}
}
队列批量插入日志
import com.google.common.collect.Lists;
import com.zyp.model.entity.OperationLog;
import com.zyp.service.OperationLogService;
import com.zyp.util.SpringContextUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
/**
* @author leishen
*/
@Component
@Slf4j
public class LogTask implements Runnable {
private static final ConcurrentLinkedQueue<OperationLog> QUEUE=new ConcurrentLinkedQueue<>();
/**
* 添加任务
* @param operationLog
*/
public static void addTask(OperationLog operationLog){
QUEUE.offer(operationLog);
}
@Override
public void run() {
//最大数量
int batchSize=200;
while (true){
List<OperationLog> taskList= Lists.newArrayList();
//若队列有数据
if (!QUEUE.isEmpty()) {
//如果队列中的数据小于200,就等待10s
if (QUEUE.size()<200) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
log.error(e.getMessage());
}
}
//开始时间
LocalDateTime start = LocalDateTime.now();
try {
log.info("日志表插入开始,当前时间{},当前队列条数{}", start, QUEUE.size());
//队列非空且小于200加入任务
while (!QUEUE.isEmpty() && taskList.size() < batchSize){
taskList.add(QUEUE.poll());
}
LocalDateTime end = LocalDateTime.now();
log.info("从队列取出数据,当前时间{},取出条数{}条,耗时{}s", end, taskList.size(), Duration.between(start, end).getSeconds());
OperationLogService operationLogService = SpringContextUtils.getBean(OperationLogService.class);
operationLogService.saveBatch(taskList);
end = LocalDateTime.now();
log.info("日志表插入结束,当前时间{},插入条数{}条,耗时{}s", end, taskList.size(), Duration.between(start, end).getSeconds());
taskList.clear();
}catch (Exception e){
log.error(e.getMessage(),e);
OperationLogService operationLogService = SpringContextUtils.getBean(OperationLogService.class);
taskList.forEach(i->{
operationLogService.save(i);
});
taskList.clear();
LocalDateTime end = LocalDateTime.now();
log.info("日志表插入结束,当前时间{},插入条数{}条,耗时{}s", end, taskList.size(), Duration.between(start, end).getSeconds());
}
}else{
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
log.error(e.getMessage());
}
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
log.error(e.getMessage());
}
}
}
}
不记录日志的实现
通过自定义注解NoLog,若方法上面有此注解,该方法执行则不记录日志;若类有此注解,则该类的里面的方法执行都不记录日志
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 不记录日志到注解
* @author leishen
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD,ElementType.TYPE})
public @interface NoLog {
}
启动记录日志的线程
import com.zyp.aspect.LogTask;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 在项目启动完毕后做一些动作
* @author leishen
*/
@Component
@Order(1)
public class Runner implements ApplicationRunner {
/**
* @param args 此处的项目启动参数是经过处理的
* @throws Exception
*/
@Override
public void run(ApplicationArguments args) throws Exception {
//启动记录日志的线程
new Thread(new LogTask()).start();
}
}
自定义日志异常
import lombok.Data;
/**
* 自定义记录日志异常
* @author leishen
*
* 继承RuntimeException而不是Exception,避免抛出UndeclaredThrowableException
*/
@Data
public class OperationLogException extends RuntimeException{
private String desc;
private Exception exception;
/**
* 自定义异常信息
* @param desc
*/
public OperationLogException(String desc) {
this.desc=desc;
}
/**
* 自定义异常信息
* @param desc
*/
public OperationLogException(String desc,Exception exception) {
this.desc=desc;
this.exception=exception;
}
}
全局处理异常封装结果
import com.zyp.util.Result;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.stream.Collectors;
/**
* 定义全局处理异常
* @author leishen
*/
@RestControllerAdvice
@Slf4j
@Configuration
public class ExceptionHandlerConfig {
/**
* 全局处理异常
* @param e
* @return
*/
@ExceptionHandler(Exception.class)
public Result handlerException(Exception e){
log.error(e.getMessage());
return new Result().error(e.getMessage());
}
/**
* 全局处理异常
* @param e
* @return
*/
@ExceptionHandler(OperationLogException.class)
public Object handlerException(OperationLogException e){
return new Result().error(e.getDesc());
}
/**
* 处理 form data方式调用接口校验失败抛出的异常
* @param e
* @return
*/
@ExceptionHandler(BindException.class)
public Result handlerException(BindException e){
BindingResult bindingResult = e.getBindingResult();
String result = bindingResult.getFieldErrors().stream().map(o -> o.getDefaultMessage()).collect(Collectors.joining(","));
return new Result().error(result);
}
/**
* 处理 json 请求体调用接口校验失败抛出的异常
* @param e
* @return
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handlerException(MethodArgumentNotValidException e){
BindingResult bindingResult = e.getBindingResult();
String result = bindingResult.getFieldErrors().stream().map(o -> o.getDefaultMessage()).collect(Collectors.joining(","));
return new Result().error(result);
}
/**
* 处理requestParam/PathVariable参数校验的异常
* @param e
* @return
*/
@ExceptionHandler(ConstraintViolationException.class)
public Result handlerException(ConstraintViolationException e){
String result = e.getConstraintViolations().stream().map(o -> o.getMessage()).collect(Collectors.joining(","));
return new Result().error(result);
}
/**
* spring Validation默认校验完所有参数才会返回结果,可将failFast设置为true,一旦校验失败就会结果校验并返回结果
* @return
*/
@Bean
public Validator validator(){
ValidatorFactory factory= Validation.byProvider(HibernateValidator.class).
configure().failFast(false).buildValidatorFactory();
return factory.getValidator();
}
}
其他类
结果封装类:
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.io.Serializable;
/** 响应数据
*
* @author Mark sunlightcs@gmail.com
* @since 1.0.0
*/
@ApiModel(value = "响应")
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 编码:0表示成功,其他值表示失败
*/
@ApiModelProperty(value = "编码:200表示成功,其他值表示失败")
private int code = 0;
/**
* 消息内容
*/
@ApiModelProperty(value = "消息内容")
private String msg = "success";
/**
* 响应数据
*/
@ApiModelProperty(value = "响应数据")
private T data;
public Result<T> ok(T data) {
this.setData(data);
return this;
}
public Result<T> ok(T data,String msg){
this.setData(data);
this.setMsg(msg);
return this;
}
public boolean success(){
return code == 0 ? true : false;
}
public Result<T> error() {
this.code = 500;
this.msg = "失败";
return this;
}
public Result<T> error(int code) {
this.code = code;
this.msg = "失败";
return this;
}
public Result<T> error(int code, String msg) {
this.code = code;
this.msg = msg;
return this;
}
public Result<T> error(String msg) {
this.code = 500;
this.msg = msg;
return this;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
日志实体类:
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
/**
* <p>
* 操作日志表
* </p>
*
* @author syl
* @since 2023-01-30
*/
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value="OperationLog对象", description="操作日志表")
public class OperationLog{
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "主键")
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@ApiModelProperty(value = "操作描述")
private String operationDesc;
@ApiModelProperty(value = "请求参数")
private String requestParam;
@ApiModelProperty(value = "0成功 1失败")
private String success;
@ApiModelProperty(value = "返回参数")
private String responseParam;
@ApiModelProperty(value = "操作员id")
private String userId;
@ApiModelProperty(value = "操作员名称")
private String userName;
@ApiModelProperty(value = "操作方法")
private String method;
@ApiModelProperty(value = "请求url")
private String url;
@ApiModelProperty(value = "请求ip")
private String ip;
@ApiModelProperty(value = "操作时间")
private Date createTime;
@ApiModelProperty(value = "执行时长(ms)")
private Long executionDuration;
@ApiModelProperty(value = "异常信息")
private String errorMessage;
}
mapper层
import com.zyp.model.entity.OperationLog;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* <p>
* 操作日志表 Mapper 接口
* </p>
*
* @author syl
* @since 2023-01-30
*/
public interface OperationLogMapper extends BaseMapper<OperationLog> {
}
mapper.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.zyp.mapper.OperationLogMapper">
<!-- 通用查询映射结果 -->
<resultMap id="BaseResultMap" type="com.zyp.model.entity.OperationLog">
<id column="id" property="id" />
<result column="operation_desc" property="operationDesc" />
<result column="request_param" property="requestParam" />
<result column="success" property="success" />
<result column="response_param" property="responseParam" />
<result column="user_id" property="userId" />
<result column="user_name" property="userName" />
<result column="method" property="method" />
<result column="url" property="url" />
<result column="ip" property="ip" />
<result column="create_time" property="createTime" />
<result column="execution_duration" property="executionDuration" />
<result column="error_message" property="errorMessage" />
</resultMap>
<!-- 通用查询结果列 -->
<sql id="Base_Column_List">
id, operation_desc, request_param, success, response_param, user_id, user_name, method, url, ip, create_time, execution_duration, error_message
</sql>
</mapper>
service层
import com.zyp.model.entity.OperationLog;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 操作日志表 服务类
* </p>
*
* @author syl
* @since 2023-01-30
*/
public interface OperationLogService extends IService<OperationLog> {
}
service实现类
import com.zyp.model.entity.OperationLog;
import com.zyp.mapper.OperationLogMapper;
import com.zyp.service.OperationLogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
/**
* <p>
* 操作日志表 服务实现类
* </p>
*
* @author syl
* @since 2023-01-30
*/
@Service
public class OperationLogServiceImpl extends ServiceImpl<OperationLogMapper, OperationLog> implements OperationLogService {
}