SpringBoot应用中使用AOP记录接口访问日志
本文主要讲述AOP在项目中的应用,通过在controller层建一个切面来实现接口访问的统一日志记录。
AOP
AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。利用AOP可以对业务逻辑的各个部分进行隔离,将那些与业务无关的、却被业务模块共同调用的功能抽取出来,形成一个独立的模块,从而在需要的时候动态地将这些功能"织入"到业务模块中,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
AOP的相关术语
通知(Advice)
通知描述了切面要完成的工作以及何时执行。比如我们的日志切面需要记录每个接口调用时长,就需要在接口调用前后分别记录当前时间,再取差值。
- 前置通知(Before):在目标方法调用前调用通知功能;通常用于执行一些预处理操作,如权限检查、参数验证等。
- 后置通知(After):无论目标方法是否正常返回,都会执行的通知;通常用于资源清理和释放等操作。
- 返回通知(AfterReturning):在目标方法正常返回后调用通知功能;通常用于获取方法的返回值,并做一些后续处理。
- 异常通知(AfterThrowing):在目标方法抛出异常后调用通知功能;通常用于进行异常处理和日志记录。
- 环绕通知(Around):通知包裹了目标方法,在目标方法调用之前和之后执行自定义的行为;通过 ProceedingJoinPoint 参数,可以选择是否执行目标方法。
连接点(JoinPoint)
指程序执行的某个特定位置,比如方法调用、异常抛出等。这些位置就是切面能够插入自己的逻辑的地方。连接点是程序执行过程中的一个精确的点。比如方法调用、方法执行、异常抛出等。
切点(Pointcut)
切点(Pointcut)是一个表达式,用于匹配连接点。切点定义了通知功能被应用的范围。比如日志切面的应用范围就是所有接口,即所有controller层的接口方法。
切面(Aspect)
切面(Aspect)是一个模块化的横切关注点,定义了在何处以及在何时执行特定的行为。切面由切点(Pointcut)和通知(Advice)组成。(通常定义一个切面类)
引入(Introduction)
在无需修改现有类的情况下,向现有的类添加新方法或属性,实现接口,甚至可以将横切关注点的逻辑添加到现有的类中。
指的是在切面中声明一些新的成员(方法或属性)或为现有的类添加实现新的接口。使得某个类在不直接修改其代码的情况下,获得新的行为或特性。
引入通常通过使用@DeclareParents
注解来实现。有两个关键参数:value
和 defaultImpl
。
@DeclareParents(value = "com.example.service.*+", defaultImpl = AdditionalFunctionalityImpl.class)
value
可以使用通配符来匹配一组类,比如"com.example.service.*"
匹配com.example.service
包下的所有类。"com.example.service..*"
匹配com.example.service
包及其子包下的所有类。"com.example.service.SomeService+"
匹配SomeService
类及其所有子类。
defaultImpl
:这个参数用于指定要引入的接口的默认实现类。当value中目标类被引入该接口时,如果目标类没有实现该接口,将使用这个默认实现类的方法。
织入(Weaving)
把切面应用到目标对象并创建新的代理对象的过程。织入可以在不同的阶段进行,具体包括编译时、类加载时和运行时。
AOP 的工作原理
Spring 容器在启动时,会扫描所有 Bean,识别出其中的切面。
程序在执行到切点所描述的连接点时,AOP 框架会动态地将切面的通知"织入"到目标对象中,从而实现横切性功能的整合。
这种动态织入的方式,使得开发人员可以专注于业务逻辑的开发,而不必过多地关注这些横切性需求。
Spring中使用注解创建切面
相关注解
- @Aspect:用于定义切面
- @Before:通知方法会在目标方法调用之前执行
- @After:通知方法会在目标方法返回或抛出异常后执行
- @AfterReturning:通知方法会在目标方法返回后执行
- @AfterThrowing:通知方法会在目标方法抛出异常后执行
- @Around:通知方法会将目标方法封装起来
- @Pointcut:定义切点表达式
切点表达式
指定了通知被应用的范围,表达式格式:
execution(方法修饰符 返回类型 方法所属的包.类名.方法名称(方法参数)
//com.tiny.controller包中所有类的public方法都应用切面里的通知
execution(public * com.tiny.controller.*.*(..))
//com.tiny.service包及其子包下所有类中的所有方法都应用切面里的通知
execution(* com.tiny.service..*.*(..))
//com.tiny.service.PmsBrandService类中的所有方法都应用切面里的通知
execution(* com.tiny.service.PmsBrandService.*(..))
添加AOP切面实现接口日志记录
添加切面类WebLogAspect
定义了一个日志切面,在环绕通知中获取日志需要的信息,并应用到controller层中所有的public方法中去。
package com....;
/**
* 请求日志及访问限制
*/
@Aspect
@Component
@Slf4j
public class RequestAspect {
@Autowired
private HttpServletRequest request;
/**
* 是否记录所有日志到数据库中
*/
private static final Boolean IS_ADD_MYSQL = false;
/**
* 定义切点
*/
@Pointcut("execution(* com.master.chat..controller..*.*(..))")
public void requestApi() {
}
@Before("requestApi()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
addOperaterInfo(joinPoint.getArgs());
}
@After("requestApi()")
public void doAfter() throws Throwable {
}
@Around("requestApi()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
long beginTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long time = System.currentTimeMillis() - beginTime;
Object[] args = joinPoint.getArgs();
String parameter = getParameter(args);
saveSyslog(joinPoint, parameter, result, time);
log.info(getRequestParams(), getRequestArgs(parameter, time));
return result;
}
/**
* 添加操作人信息
*
* @param args
*/
private void addOperaterInfo(Object[] args) {
if (ValidatorUtil.isNullIncludeArray(args)) {
return;
}
Stream<?> stream = ArrayUtils.isEmpty(args) ? Stream.empty() : Arrays.stream(args);
CommonCommand command = (CommonCommand) stream.filter(arg -> arg instanceof CommonCommand).findFirst().orElse(null);
if (ValidatorUtil.isNull(command)) {
return;
}
UserDetail userDetail = JwtTokenUtils.getLoginUser();
if (ValidatorUtil.isNotNull(userDetail)) {
command.setOperaterId(userDetail.getId());
command.setOperater(userDetail.getUsername());
}
}
/**
* 构建系统请求日志信息
*
* @param parameter 请求参数
* @param result 返回结果
* @param time 请求耗时
*/
private void saveSyslog(ProceedingJoinPoint joinPoint, String parameter, Object result, long time) {
if (!IS_ADD_MYSQL) {
return;
}
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
UserDetail userDetail = JwtTokenUtils.getLoginUser();
UserAgent userAgent = UserAgentUtil.parse(request.getHeader(Header.USER_AGENT.getValue()));
String ip = IPUtil.getIpAddr(request);
String urlStr = request.getRequestURL().toString();
String domain = StrUtil.removeSuffix(urlStr, URLUtil.url(urlStr).getPath());
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
methodName = className + StringPoolConstant.DOT + methodName + StringPoolConstant.LEFT_BRACKET + StringPoolConstant.RIGHT_BRACKET;
SysLog sysLog = SysLog.builder()
.sysUserId(ValidatorUtil.isNotNull(userDetail) ? userDetail.getId() : 0L)
.username(ValidatorUtil.isNotNull(userDetail) ? userDetail.getUsername() : "游客")
.ip(ip).domain(domain)
.browser(userAgent.getBrowser().getName()).os(userAgent.getOs().getName())
.method(methodName).requestMethod(request.getMethod()).uri(request.getRequestURI())
.build();
AsyncManager.me().execute(AsyncFactory.addSysLog(sysLog, parameter, result, time));
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取请求日志参数
*
* @param args 请求参数
* @return
*/
private String getRequestParams() {
// 构建成一条长 日志,避免并发下日志错乱
StringBuilder requestLog = new StringBuilder(300);
requestLog.append("\n");
requestLog.append("请求IP:{}\n");
requestLog.append("请求路径:{}\n");
requestLog.append("请求方式:{}\n");
requestLog.append("请求参数:{}\n");
requestLog.append("请求耗时:{}ms");
return requestLog.toString();
}
/**
* 获取请求日志参数
*
* @param args 请求参数
* @return
*/
private Object[] getRequestArgs(String parameter, Long time) {
List<Object> responseArgs = new ArrayList<>();
responseArgs.add(IPUtil.getIpAddr(request));
responseArgs.add(request.getRequestURI());
responseArgs.add(request.getMethod());
responseArgs.add(parameter);
responseArgs.add(time);
return responseArgs.toArray();
}
/**
* 根据方法和传入的参数获取请求参数
*
* @param args 请求参数
* @return
*/
private String getParameter(Object[] args) {
Stream<?> stream = ArrayUtils.isEmpty(args) ? Stream.empty() : Arrays.stream(args);
List<Object> logArgs = stream
.filter(arg -> (!(arg instanceof HttpServletRequest)
&& !(arg instanceof HttpServletResponse)
&& !(arg instanceof MultipartFile[])
&& !(arg instanceof MultipartFile)
&& !(arg instanceof Principal)))
.collect(Collectors.toList());
return logArgs.size() == 0 ? StringPoolConstant.EMPTY : JSON.toJSONString(logArgs);
}
}
进行接口测试
运行项目并访问:
可以看到控制住台中会打印如下日志信息:
2024-05-18 21:24:56.367 [http-nio-8088-exec-17] INFO com.chat.framework.aspect.RequestAspect -
请求IP:127.0.0.1
请求路径:/chat/app/chat/message
请求方式:POST
请求参数:[{"chatNumber":"1791817611953176576","model":"SPARK","needsOperator":true,"operater":"177********","operaterId":1,"prompt":"怎么快速上手一个Java项目","systemPrompt":"怎么快速上手一个Java项目","userId":1}]
请求耗时:22ms