前言
之前注解篇时我说,通常情况下一个自定义注解一般对应一个切面,虽然项目里的切面和注解个数相同,但是好像有一个名字看起来并不对应,无所谓,先看了再说。
ExceptionLogAspect切面
我在里面做了具体注释,所以看起来比较长,但我觉得作者的代码思路还是很清晰的,就是里面一下就涉及了三个自写工具类的使用,会让我暂时少了一些具体逻辑的理解。
如果不想看代码里的注释可以先看看我的理解。
我们先来看这个类的类图吧
这个类被两个注解修饰,@Component和 @Aspect一起使用,可以方便地实现Spring AOP的功能,同时确保切面类被Spring容器管理。
@Component
:这个注解是Spring的一个核心注解,用于指示一个类是Spring容器管理的组件。当一个类被标记为@Component时,Spring会自动检测并注册它,使得它可以在应用程序的其他部分中被注入和使用。
@Aspect
:这个注解是Spring AOP(面向切面编程)的一部分,用于指示一个类是一个切面。切面是一个关注点(Cross-Cutting Concerns)模块化的类,它包含了一系列的通知(Advice),这些通知在特定的连接点(Join Points)上执行。
这个类有四个方法
handlelog(JoinPoint,Exception)
:根据传入的参数,返回一个填充好数据的 ExceptionLog的对象,ExceptionLog的属性部分我也放在图里面了。
logAfterThrowing(JoinPoint,Exception)
:用于指定在目标方法抛出异常后执行的通知。它被一个注解 @AfterThrowing(value = “logPointcut()”, throwing = “e”) 修饰,value属性指定了切点,throwing属性指定了异常对象,它可以在通知方法中作为参数使用。
logPointcut()
:它被 @Pointcut(“execution(* com.rawchen.controller….(…))”)修饰,表示切入点的声明,里面的参数表示拦截的范围。
最后实现的功能
一旦com.rawchen.controller包下的某个类的某个方法抛出异常,ExceptionLogAspect切面中的logAfterThrowing通知方法就会被触发。该方法上面一行的注解会找到切入点对象和异常信息传到方法里,然后开始执行逻辑。
@AfterThrowing(value = "logPointcut()", throwing = "e")
public void logAfterThrowing(JoinPoint joinPoint, Exception e) {
ExceptionLog log = handleLog(joinPoint, e);
exceptionLogService.saveExceptionLog(log);
}
ExceptionLog log = handleLog(joinPoint, e) 调用handleLog方法来创建一个新的ExceptionLog对象,并填充其属性,包括异常发生的URI、方法、IP地址、用户代理、描述信息、错误堆栈跟踪以及请求参数。
exceptionLogService.saveExceptionLog(log) 调用ExceptionLogService的saveExceptionLog方法来保存异常日志。这个方法将异常日志信息保存到数据库或其他存储介质中。
ExceptionLogService是一个服务层接口,它定义了保存异常日志的方法,这个接口在ExceptionLogAspect类中通过@Autowired注解注入了实现类。
完整代码注释
package com.rawchen.aspect;
import com.rawchen.annotation.OperationLogger;
import com.rawchen.annotation.VisitLogger;
import com.rawchen.entity.ExceptionLog;
import com.rawchen.service.ExceptionLogService;
import com.rawchen.util.AopUtils;
import com.rawchen.util.IpAddressUtils;
import com.rawchen.util.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.rawchen.util.JacksonUtils;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Map;
/**
* @Description: AOP记录异常日志
* @Date: 2020-12-03
*/
@Component
@Aspect
public class ExceptionLogAspect {
//@Autowired:这是一个Spring的注解,用于自动注入ExceptionLogService的实例。
//Spring会自动查找合适的组件并将其注入到当前类中。
@Autowired
ExceptionLogService exceptionLogService;
/**
* 配置切入点
* logPointcut():它的作用是作为切点的标识符。
* 在这个方法上标注了@Pointcut注解,因此它会被Spring AOP识别为切点定义。
*/
//@Pointcut("execution(* com.rawchen.controller..*.*(..))"):这是一个切点注解,用于定义哪些方法会被拦截。
//在这个例子中,execution(* com.rawchen.controller..*.*(..))
//表示拦截com.rawchen.controller包及其子包下的所有方法。
@Pointcut("execution(* com.rawchen.controller..*.*(..))")
public void logPointcut() {
}
/**
* 该方法它接收JoinPoint和Exception对象作为参数这个方法可用于创建一个异常日志对象,并将为其填充为合适的值后存储。
*/
//@AfterThrowing(value = "logPointcut()", throwing = "e"):这是一个通知注解,
//用于指定在目标方法抛出异常后执行的通知。value属性指定了切点,throwing属性指定了异常对象,
//它可以在通知方法中作为参数使用。
@AfterThrowing(value = "logPointcut()", throwing = "e")
public void logAfterThrowing(JoinPoint joinPoint, Exception e) {
ExceptionLog log = handleLog(joinPoint, e);
exceptionLogService.saveExceptionLog(log);
}
/**
* 设置ExceptionLog对象属性
*
* @return 填充好数据的ExceptionLog对象
*/
private ExceptionLog handleLog(JoinPoint joinPoint, Exception e) {
//获取了当前的ServletRequestAttributes对象,这个对象包含了当前HTTP请求的详细信息。
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
//获取当前的HttpServletRequest对象,这个对象代表了当前的HTTP请求。
HttpServletRequest request = attributes.getRequest();
//通过request对象的方法获取一些具体信息
//获取请求的URI(统一资源标识符)
String uri = request.getRequestURI();
//获取请求方法
String method = request.getMethod();
//通过作者自己写的工具类,来获取ip地址
String ip = IpAddressUtils.getIpAddress(request);
//获取请求的User-Agent头,这个头通常包含了客户端浏览器的信息。
String userAgent = request.getHeader("User-Agent");
//todo 使用swagger后,可以直接使用注解上的内容作为 ExceptionLog 的 description
//通过类里私有方法获取注解上的描述内容存储在字符串中
String description = getDescriptionFromAnnotations(joinPoint);
//使用作者自己写的StringUtils工具类的方法来获取异常的堆栈跟踪信息。
String error = StringUtils.getStackTrace(e);
//创建一个新的ExceptionLog对象,并使用前面获取的信息来填充它的属性。
ExceptionLog log = new ExceptionLog(uri, method, description, error, ip, userAgent);
//调用AopUtils工具类的方法来获取请求的参数。
Map<String, Object> requestParams = AopUtils.getRequestParams(joinPoint);
//将请求参数转换为JSON字符串,并截取前2000个字符,
//然后将其设置为ExceptionLog对象的param属性,只截取2000个字符是为了限制日志的长度,避免日志过长。
log.setParam(StringUtils.substring(JacksonUtils.writeValueAsString(requestParams), 0, 2000));
return log;
}
/**
* 如果抛出方法被自定义注解修饰,那就得到OperationLogger注解里或者VisitLog描述操作的那段字符串
*/
private String getDescriptionFromAnnotations(JoinPoint joinPoint) {
String description = "";
//这行代码从JoinPoint对象中获取方法签名,并将其转换为Method对象,这样就可以访问方法上的注解了。
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
OperationLogger operationLogger = method.getAnnotation(OperationLogger.class);
if (operationLogger != null) {
description = operationLogger.value();
return description;
}
VisitLogger visitLogger = method.getAnnotation(VisitLogger.class);
if (visitLogger != null) {
description = visitLogger.behavior();
return description;
}
return description;
}
}
OperationLogAspect
还是先看类图
注解已经解释过一遍了,而且这三个方法与前面的ExceptionLogAspect切面相差不大,同时一样在图里附上OperationLog类便于理解。
logPointcut()
:被 @Pointcut(“@annotation(operationLogger)”) 注解修饰,表示切入点的声明,它将会匹配所有带有OperationLogger注解的方法。
logAround(ProceedingJoinPoint joinPoint, OperationLogger operationLogger)
:被 @Around(“logPointcut(operationLogger)”) 注解修饰,表示该切入点的方法被环绕通知,环绕通知是一种动态拦截方法执行的通知,它允许你完全控制方法的执行流程。
环绕通知的logAround方法可以执行以下操作:
执行被环绕的方法:Object result = joinPoint.proceed(); 这行代码会执行原始的方法调用,并将返回值存储在result变量中。
在方法执行前后添加额外的逻辑:在这个例子中,它记录了方法执行的时间,并创建了一个操作日志对象。
返回方法的执行结果:return result; 这行代码将原始方法的执行结果返回给调用者。
handleLog(ProceedingJoinPoint joinPoint, OperationLogger operationLogger, int times)
:通过调用工具类进行信息解析,返回一个填充好数据属性的OperationLog对象,供服务层接口进行日志保存。
最后实现的功能
一旦被OperationLogger注解修饰的方法被调用,然后切入点进行匹配,传参到logAround方法,进行一个环绕通知来记录这个方法的执行时间,最后通过handleLog方法与其他信息一同被记录到日志当中。也是服务层接口通过注解注入实体类,服务层接口方法负责日志保存。
@Around("logPointcut(operationLogger)")
public Object logAround(ProceedingJoinPoint joinPoint, OperationLogger operationLogger) throws Throwable {
// 设置当前时间
currentTime.set(System.currentTimeMillis());
// 执行被环绕的方法
Object result = joinPoint.proceed();
// 计算方法执行时间
int times = (int) (System.currentTimeMillis() - currentTime.get());
// 清除当前时间
currentTime.remove();
// 创建操作日志对象
OperationLog operationLog = handleLog(joinPoint, operationLogger, times);
// 保存操作日志
operationLogService.saveOperationLog(operationLog);
// 返回方法的执行结果
return result;
}
完整代码注释
@Component
@Aspect
public class OperationLogAspect {
// 注入操作日志服务
@Autowired
OperationLogService operationLogService;
// 线程局部变量,用于记录当前时间
ThreadLocal<Long> currentTime = new ThreadLocal<>();
/**
* 配置切入点,用于匹配带有OperationLogger注解的方法
*/
@Pointcut("@annotation(operationLogger)")
public void logPointcut(OperationLogger operationLogger) {
}
/**
* 配置环绕通知,用于记录操作日志
*
* @param joinPoint 方法执行的连接点
* @param operationLogger 方法上的OperationLogger注解
* @return 方法的执行结果
* @throws Throwable 方法执行中可能抛出的异常
*/
@Around("logPointcut(operationLogger)")
public Object logAround(ProceedingJoinPoint joinPoint, OperationLogger operationLogger) throws Throwable {
// 设置当前时间
currentTime.set(System.currentTimeMillis());
// 执行被环绕的方法
Object result = joinPoint.proceed();
// 计算方法执行时间
int times = (int) (System.currentTimeMillis() - currentTime.get());
// 清除当前时间
currentTime.remove();
// 创建操作日志对象
OperationLog operationLog = handleLog(joinPoint, operationLogger, times);
// 保存操作日志
operationLogService.saveOperationLog(operationLog);
// 返回方法的执行结果
return result;
}
/**
* 获取HttpServletRequest请求对象,并设置OperationLog对象属性
*
* @param operationLogger 方法上的OperationLogger注解
* @param times 方法执行时间
* @return 操作日志对象
*/
private OperationLog handleLog(ProceedingJoinPoint joinPoint, OperationLogger operationLogger, int times) {
// 获取当前请求的ServletRequestAttributes
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 从ServletRequestAttributes中获取HttpServletRequest对象
HttpServletRequest request = attributes.getRequest();
// 获取当前用户名,通过JWT工具类解析
String username = JwtUtils.getTokenBody(request.getHeader("Authorization")).getSubject();
// 获取请求的URI
String uri = request.getRequestURI();
// 获取请求的HTTP方法
String method = request.getMethod();
// 获取OperationLogger注解的描述信息
String description = operationLogger.value();
// 获取请求的IP地址
String ip = IpAddressUtils.getIpAddress(request);
// 获取请求的User-Agent头
String userAgent = request.getHeader("User-Agent");
// 创建新的操作日志对象
OperationLog log = new OperationLog(username, uri, method, description, ip, times, userAgent);
// 通过工具类,获取请求参数,并将其转换为JSON字符串,然后截取前2000个字符作为日志参数,避免日志过长
Map<String, Object> requestParams = AopUtils.getRequestParams(joinPoint);
log.setParam(StringUtils.substring(JacksonUtils.writeValueAsString(requestParams), 0, 2000));
// 返回操作日志对象
return log;
}
}
VisitLogAspect切面
类图
这里面的logAround环绕通知方法,logPointcut方法以及handleLog的方法与上面两个切面类也是大同小异,现在主要看一下类图里前面三个方法。
checkIdentification(HttpServletRequest request)
:校验访客标识码,通过获取请求里面的校验码,有校验码就与数据库里的进行比对,比对成功后保存在Redis中,比对不成功就签发一个新的标识码;加入请求里根本没有校验码,也是签发一个新的。
saveUUID(HttpServletRequest request)
:签发校验码,根据时间戳、ip、userAgent生成UUID,并进行数据库与Redis的保存操作。
至于这个judgeBehavior(String behavior, String content, Map<String, Object> requestParams, Object result)
方法,它里面只是简单的字符串与注解里的参数进行匹配,再根据请求信息完成日志数据,所以说我放一张VisitLogger的注解使用次数反而更加好理解。
最后实现功能
- 访问日志记录:当一个方法被VisitLogger注解修饰时,该方法执行前后会被VisitLogAspect切面拦截,并在方法执行前后执行日志记录逻辑。
- 访客标识码验证:在方法执行前,切面会检查请求头中是否包含identification字段,这是访客的唯一标识码。如果未包含,切面会生成一个新的访客标识码并保存到数据库和Redis中。
- 行为判断和日志内容设置:根据VisitLogger注解的行为和内容描述,切面会判断并设置访问日志的备注和内容字段。
- 访问日志保存:在方法执行后,切面会创建一个VisitLog对象,并填充其属性,包括访客标识码、请求URI、HTTP方法、行为描述、内容描述、IP地址、执行时间、User-Agent头等。然后,它会将这个访问日志对象保存到服务层进行持久化。
- Redis使用:切面使用Redis服务来存储和验证访客标识码。它将访客标识码保存到Redis中的一个集合中,并在验证访客标识码时检查这个集合中是否包含该标识码。
- 数据库访问:切面会访问数据库来验证访客标识码是否已存在于数据库中,以及是否需要生成新的访客标识码。
UUID的作用
UUID(Universally Unique Identifier,通用唯一识别码)是一种用于生成唯一标识符的机制。
在这个项目里,UUID被用于生成访客的唯一标识码。当请求头中没有identification字段时,切面会生成一个新的UUID,并将其保存到数据库和Redis中。之后,在每次请求中,切面都会检查请求头中的identification字段,以验证访客标识码是否有效。
这种机制可以防止重复访问和刷访问量等恶意行为。
完整代码注释
@Component
@Aspect
public class VisitLogAspect {
// 注入访问日志服务
@Autowired
VisitLogService visitLogService;
// 注入访客服务
@Autowired
VisitorService visitorService;
// 注入Redis服务
@Autowired
RedisService redisService;
// 线程局部变量,用于记录当前时间
ThreadLocal<Long> currentTime = new ThreadLocal<>();
/**
* 配置切入点,用于匹配带有VisitLogger注解的方法
*/
@Pointcut("@annotation(visitLogger)")
public void logPointcut(VisitLogger visitLogger) {
}
/**
* 配置环绕通知,用于记录访问日志
*
* @param joinPoint 方法执行的连接点
* @param visitLogger 方法上的VisitLogger注解
* @return 方法的执行结果
* @throws Throwable 方法执行中可能抛出的异常
*/
@Around("logPointcut(visitLogger)")
public Object logAround(ProceedingJoinPoint joinPoint, VisitLogger visitLogger) throws Throwable {
// 设置当前时间
currentTime.set(System.currentTimeMillis());
// 执行被环绕的方法
Object result = joinPoint.proceed();
// 计算方法执行时间
int times = (int) (System.currentTimeMillis() - currentTime.get());
// 清除当前时间
currentTime.remove();
// 获取请求对象
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// 校验访客标识码
String identification = checkIdentification(request);
// 记录访问日志
VisitLog visitLog = handleLog(joinPoint, visitLogger, request, result, times, identification);
// 保存访问日志
visitLogService.saveVisitLog(visitLog);
// 返回方法的执行结果
return result;
}
/**
* 校验访客标识码
*
* @param request
* @return
*/
private String checkIdentification(HttpServletRequest request) {
// 获取请求头中的访客标识码
String identification = request.getHeader("identification");
// 如果请求头中没有访客标识码,则签发一个新的访客标识码
if (identification == null) {
// 签发新的访客标识码并保存到数据库和Redis
identification = saveUUID(request);
} else {
// 校验Redis中是否存在访客标识码
boolean redisHas = redisService.hasValueInSet(RedisKeyConfig.IDENTIFICATION_SET, identification);
// 如果Redis中不存在访客标识码,则检查数据库中是否存在
if (!redisHas) {
// 检查数据库中是否存在访客标识码
boolean mysqlHas = visitorService.hasUUID(identification);
// 如果数据库中存在,则保存至Redis
if (mysqlHas) {
redisService.saveValueToSet(RedisKeyConfig.IDENTIFICATION_SET, identification);
} else {
// 如果数据库中不存在,则签发一个新的访客标识码
identification = saveUUID(request);
}
}
}
return identification;
}
/**
* 签发UUID,并保存至数据库和Redis
*
* @param request
* @return
*/
private String saveUUID(HttpServletRequest request) {
//获取响应对象
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
//获取当前时间戳,精确到小时,防刷访客数据
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
String timestamp = Long.toString(calendar.getTimeInMillis() / 1000);
//获取访问者基本信息
String ip = IpAddressUtils.getIpAddress(request);
String userAgent = request.getHeader("User-Agent");
//根据时间戳、ip、userAgent生成UUID
String nameUUID = timestamp + ip + userAgent;
String uuid = UUID.nameUUIDFromBytes(nameUUID.getBytes()).toString();
//添加访客标识码UUID至响应头
response.addHeader("identification", uuid);
//暴露自定义header供页面资源使用
response.addHeader("Access-Control-Expose-Headers", "identification");
//校验Redis中是否存在uuid
boolean redisHas = redisService.hasValueInSet(RedisKeyConfig.IDENTIFICATION_SET, uuid);
if (!redisHas) {
//保存至Redis
redisService.saveValueToSet(RedisKeyConfig.IDENTIFICATION_SET, uuid);
//保存至数据库
Visitor visitor = new Visitor(uuid, ip, userAgent);
visitorService.saveVisitor(visitor);
}
return uuid;
}
/**
* 设置VisitLogger对象属性
*
* @param joinPoint
* @param visitLogger
* @param result
* @param times
* @return 填充好的日志对象
*/
private VisitLog handleLog(ProceedingJoinPoint joinPoint, VisitLogger visitLogger, HttpServletRequest request, Object result,
int times, String identification) {
// 获取请求的URI
String uri = request.getRequestURI();
// 获取请求的HTTP方法
String method = request.getMethod();
// 获取VisitLogger注解的行为描述
String behavior = visitLogger.behavior();
// 获取VisitLogger注解的内容描述
String content = visitLogger.content();
// 获取请求的IP地址
String ip = IpAddressUtils.getIpAddress(request);
// 获取请求的User-Agent头
String userAgent = request.getHeader("User-Agent");
// 获取请求参数
Map<String, Object> requestParams = AopUtils.getRequestParams(joinPoint);
// 根据访问行为,设置对应的访问内容或备注
Map<String, String> map = judgeBehavior(behavior, content, requestParams, result);
// 创建新的访问日志对象
VisitLog log = new VisitLog(identification, uri, method, behavior, map.get("content"), map.get("remark"), ip, times, userAgent);
// 设置日志的参数
log.setParam(StringUtils.substring(JacksonUtils.writeValueAsString(requestParams), 0, 2000));
// 返回访问日志对象
return log;
}
/**
* 根据访问行为,设置对应的访问内容或备注
*
* @param behavior
* @param content
* @param requestParams
* @param result
* @return
*/
private Map<String, String> judgeBehavior(String behavior, String content, Map<String, Object> requestParams, Object result) {
Map<String, String> map = new HashMap<>();
String remark = "";
if (behavior.equals("访问页面") && (content.equals("首页") || content.equals("动态"))) {
int pageNum = (int) requestParams.get("pageNum");
remark = "第" + pageNum + "页";
} else if (behavior.equals("查看博客")) {
Result res = (Result) result;
if (res.getCode() == 200) {
BlogDetail blog = (BlogDetail) res.getData();
String title = blog.getTitle();
content = title;
remark = "文章标题:" + title;
}
} else if (behavior.equals("搜索博客")) {
Result res = (Result) result;
if (res.getCode() == 200) {
String query = (String) requestParams.get("query");
content = query;
remark = "搜索内容:" + query;
}
} else if (behavior.equals("查看分类")) {
String categoryName = (String) requestParams.get("categoryName");
int pageNum = (int) requestParams.get("pageNum");
content = categoryName;
remark = "分类名称:" + categoryName + ",第" + pageNum + "页";
} else if (behavior.equals("查看标签")) {
String tagName = (String) requestParams.get("tagName");
int pageNum = (int) requestParams.get("pageNum");
content = tagName;
remark = "标签名称:" + tagName + ",第" + pageNum + "页";
} else if (behavior.equals("点击友链")) {
String nickname = (String) requestParams.get("nickname");
content = nickname;
remark = "友链名称:" + nickname;
}
map.put("remark", remark);
map.put("content", content);
return map;
}
}
后续
一扯到这个切面,里面的工具类,服务层接口,redis配置类全部一下子冒出来了,还好这个项目比较小,总算是看完了,现在我很难想象以后工作时真要看项目代码时,那得是件多煎熬的事啊,下一篇看看项目里的工具类代码咋写的。