AOP日志
通过AOP来实现日志框架。
AOP
AOP(Aspect Oriented Programming)是一种设计思想,是软件设计领域中的面向切面编程,它是面向对象编程的一种补充和完善,它以通过预编译方式和运行期动态代理方式实现,在不修改源代码的情况下,给程序动态统一添加额外功能的一种技术。
利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
简单回顾一下AOP编程中的相关概念:
-
横切关注点 是指一个能够解决分散在不同模块中同一类问题的非核心业务(即日志、安全校验、事务处理等不涉及主体业务逻辑)。
-
通知 是对方法的增强部分,每个横切关注点需要做的事情都需要写一个方法来实现,这个方法可以理解为对原方法的增强部分。当然,根据不同的业务需要,通知的位置也就有所不同,例如,安全校验,需要在业务方法执行前进行校验,对应的就是前置通知。类似的,日志在业务方法执行完后,根据执行结果来存储日志,对应的可能就是后置通知。
根据位置的不同就有如下五种通知:前置通知,返回通知,异常通知,后置通知,还有一种是以上四种方法的综合,环绕通知。
需要注意上述通知之间的区别,返回通知,对应的是方法正常执行后执行,异常通知,是方法异常结束后执行,后置通知则是会在这两个方法结束后执行(类似于finally)。
-
切面 就是封装通知的类,一个关注点可以对应一个切面。
-
目标:被代理的目标对象。
-
代理:向目标对象应用通知之后创建的代理对象,一般为动态代理。动态代理又可以分为两种:JDK动态代理(兄弟,通过实现和目标一样的接口来进行代理),和Cglib动态代理(没有借口,做不成兄弟,就只能拜为义父,通过继承来进行代理)
JDK动态代理,Proxy.newInstance(类加载器,接口,调用处理器),类加载器在代理模式中应用,使用和目标类相同的类加载器,生成实现相同接口的代理类。然后使用相同的类加载器,反射生成代理类的实例,新实例化的实例对象持有InvocationHandler
CGLib 动态代理,是通过 Enhancer 对象把代理对象设置为被代理类的子类来实现动态代理的。
-
接入点,每个方法中的各个关注点,即通知的各种位置对应不同的接入点。
-
切入点,定义连接的方式,Spring的AOP技术通过切入点定位到特定的连接点。切点通过org.springframework.aop.Pointcut接口进行描述,使用类和方法作为连接点的查询条件。
代码部分
声明一个注解,将有注解标注的位置作为切入点。
注解-切入点
定义一个注解接口,包含一些操作相关信息,以及控制请求
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
public String title(); //模块名称
public OperatorType operatorType() default OperatorType.MANAGE; //操作人员类别
public int businessType(); //业务类型(0 其他 1新增 2修改 3删除)
public boolean isSaveRequestData() default true; //是否保存请求的参数
public boolean isSaveResponseData() default true; //是否保留响应的参数
}
Target 表示注解可以声明在哪些目标元素之前。
ElementType 可以声明的位置 TYPE 类(Class)、接口(interface)、注解(annotaiton interface)、枚举变量(enum)、记录声明(record declaration) FIELD 字段声明,包括枚举实例 METHOD 方法 PARAMETER 参数声明,可以用于方法的参数上 CONSTRUCTOR 构造器 LOCAL_VARIABLE Local variable declaration ANNOTATION_TYPE Annotation interface declaration (Formerly known as an annotation type.) PACKAGE Package declaration TYPE_PARAMETER Type parameter declaration @since 1.8 TYPE_USE Use of a typeince 1.8 MODULE Module declaration. @since 9 RECORD_COMPONENT Record component @jls 8.10.3 Record Members * @jls 9.7.4 Where Annotations May Appear @since 16 Retention 中文翻译为保留,可以理解为在何时保留。
SOURCE 在编译时忽略,
CLASS:会被编译器记录进字节码文件中,但是不会在虚拟机运行时保留。默认值
RUNTIME:被编译器记录进字节码文件中,并且在虚拟机运行时保留。一般需要通过注解来实现相应的功能都得选择这个。
定义操作人员类别枚举类型。
public enum OperatorType {
OTHER,
MANAGE,
MOBILE
}
切面
声明一个切面,通过环绕通知来完成日志部分。
@Aspect //声明切面
@Component //保证这个切面类能放入IOC容器
@Slf4j
public class LogAspect {
@Resource
private AsyncOperLogService asyncOperLogService;
@Around(value = "@annotation(sysLog)")
public Object doAroundAdvice(ProceedingJoinPoint joinPoint, Log sysLog){
// 构建前置参数
SysOperLog sysOperLog = new SysOperLog();
LogUtil.beforeHandleLog(sysLog, joinPoint, sysOperLog);
Object proceed = null;
try {
proceed = joinPoint.proceed();
// 执行业务方法
LogUtil.afterHandleLog(sysLog, proceed, sysOperLog, 0, "") ;
// 构建响应结果参数
} catch (SpzxException e) {
// 处理 SpzxException 异常
LogUtil.afterHandleLog(sysLog, proceed, sysOperLog, 1, e.getMessage());
System.out.println(e.getClass());
throw e; // 继续抛出 SpzxException 异常
} catch (Throwable e) {
// 处理其他异常
e.printStackTrace(); // 打印异常信息
LogUtil.afterHandleLog(sysLog, proceed, sysOperLog, 1, e.getMessage());
System.out.println(e.getClass());
throw new RuntimeException(); // 抛出新的异常
} finally {
// 保存日志数据
asyncOperLogService.saveSysOperLog(sysOperLog);
}
// 返回执行结果
return proceed ;
}
}
切入点表达式
value 属性用于指定切入点表达式(Pointcut Expression),它决定了哪些方法会被拦截。
value 属性中可以包含以下几种写法:
- 方法签名:例如
execution(* com.example.service.*.*(..))
,表示拦截com.example.service
包下所有类的所有方法。 - 类名:例如
within(com.example.service.*)
,表示拦截com.example.service
包下的所有类的所有方法。 - 参数类型:例如
args(java.lang.String)
,表示拦截参数类型为String
的方法。 - 返回类型:例如
returning(java.lang.String)
,表示拦截返回类型为String
的方法。 - 异常类型:例如
throwing(java.lang.Exception)
,表示拦截抛出Exception
类型异常的方法。 - 注解:例如
@annotation(org.springframework.web.bind.annotation.RequestMapping)
,表示拦截带有@RequestMapping
注解的方法。 - 组合以上写法:例如
execution(* com.example.service.*.*(..)) && args(java.lang.String)
,表示拦截com.example.service
包下所有类的所有方法,且参数类型为String
的方法。
ProceedingJoinPoint
切入点,ProceedingJoinPoint继承JoinPoint,主要特点为:通过proceed()方法来控制何时继续执行被代理的方法。除此之外,还有继承自JoinPoint的getArgs(),允许访问连接点的方法参数,或者getSignature()方法获取连接点的方法签名对象,可以通过这些了解被调用方法的详细信息。
LogUtil
自定义一个Log的工具类,提供被代理方法被调用前以及被调用后的方法。
-
beforeHandLog,负责在被代理方法调用前,通过Log注解中的信息获取操作模块名称和定义的操作人员类型,然后通过JoinPoint切入点的getSignature获取方法签名对象,获取其方法对应的全类名,之后通过HttpServletRequest获取HTTP请求头的一些信息,例如方法类型、URI与调用接口的IP地址,最终根据Log注解中定义的参数确定是否加入请求参数。
-
afterHandleLog,负责在被代理方法调用后,设定该次请求的状态(正常,异常)以及对应的错误消息,并且根据Log注解中定义的参数确定是否加入响应参数。
public class LogUtil {
//status 0表示正常,1表示异常
public static void afterHandleLog(Log sysLog, Object proceed, SysOperLog sysOperLog, int status, String errorMsg) {
if(sysLog.isSaveResponseData()) {
sysOperLog.setJsonResult(JSON.toJSONString(proceed));//加载方法返回参数
}
sysOperLog.setStatus(status);
sysOperLog.setErrorMsg(errorMsg);
}
//操作执行之前调用
public static void beforeHandleLog(Log sysLog, ProceedingJoinPoint joinPoint, SysOperLog sysOperLog) {
//设置操作模块名称
sysOperLog.setTitle(sysLog.title());
sysOperLog.setOperatorType(sysLog.operatorType().name());
//获取目标方法信息 -> 获取声明方法的全类名
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
sysOperLog.setMethod(method.getDeclaringClass().getName() + "." + method.getName());
//获取请求相关参数
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
sysOperLog.setRequestMethod(request.getMethod());
sysOperLog.setOperUrl(request.getRequestURI());
sysOperLog.setOperIp(request.getRemoteAddr());
// 设置请求参数
if(sysLog.isSaveRequestData()) {
String requestMethod = sysOperLog.getRequestMethod();
if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
String params = Arrays.toString(joinPoint.getArgs());
sysOperLog.setOperParam(params);
}
}
sysOperLog.setOperName(AuthContextUtil.get().getUserName());
}
}
自动装配
因为我们将日志模块作为公共服务抽离出来,放在与其他Spring项目分离的模块中,如果需要在其他的业务服务中使用LogAspect这个切面类,需要将该切面扫描进Spring容器。Spring Boot默认会扫描和启动类所在包相同包中的bean以及子包中的bean。而LogAspect作为一个单独存在公共服务,显然无法直接在业务服务中使用。可以通过自定义直接来实现。
@Target({ElementType.TYPE}) //该注解只能声明在一个类前
@Retention(RetentionPolicy.RUNTIME)
@Import(value = LogAspect.class) // 通过Import注解导入日志切面类到Spring容器中
public @interface EnableLogAspect {
}
异步存储日志
在环绕通知finally语句块中最终调用LogService来存储日志内容,通过@Async实现异步。
注意:
-
使用@Async需要在Application中声明@EnableAsync
-
Spring容器启动初始化bean时,根据类中是否使用了@Async注解,创建切入点和切入点处理器,根据切入点创建代理,在调用@Async注解标注的方法时,会调用代理,执行切入点处理器invoke方法,将方法的执行提交给线程池,实现异步执行。
所以,需要注意的一个错误用法是,如果A类的a方法(没有标注@Async)调用它自己的b方法(标注@Async)是不会异步执行的,因为从a方法进入调用的都是它本身,不会进入代理。
-
当使用
@Async
注解但没有通过value
指定线程池时,Spring将自动创建一个默认的线程池来执行异步任务。这个默认线程池是一个SimpleAsyncTaskExecutor
,它不使用线程池来执行任务,而是在每次需要执行异步任务时创建一个新的线程。SimpleAsyncTaskExecutor
是一个基本的实现,适用于执行少量且简单的异步任务。然而,对于大规模或高并发的应用场景,推荐使用更复杂的线程池配置,如ThreadPoolTaskExecutor
,以提供更好的性能和资源管理。
@Service
public class AsyncOperLogServiceImpl implements AsyncOperLogService {
@Resource
private SysOperLogMapper sysOperLogMapper;
@Async //异步执行保存日志操作
@Override
public void saveSysOperLog(SysOperLog sysOperLog) {
sysOperLogMapper.insert(sysOperLog);
}
}
使用
在目标方法上标注注解即可启动日志。
@Log(title = "角色添加",businessType = 1)
@PostMapping("/saveSysUser")
public Result saveSysUser(@RequestBody SysUser sysUser) {
sysUserService.saveSysUser(sysUser);
return Result.build(null, ResultCodeEnum.SUCCESS);
}
其他
Record
record声明是一种简化数据类的声明方式,自Java 14开始,Record类主要用于存储不可变的数据,可以自动生成getter方法,还有传承自Object的九大方法。
record Person(int name, int age) {}
public static void main(String[] args) {
Person person = new Person(1, 2);
System.out.println(person);
System.out.println(person.age());
System.out.println(person.name());
}
通知方法
@Before 前置通知
@AfterReturning 后置通知
@AfterThrowing 异常通知
@After 最终通知
@Around 环绕通知
RequestContextHolder
RequestContextHolder
是Spring框架中的一个类,它用于存储当前请求的上下文信息。在Spring MVC中,当一个请求到达时,Spring会创建一个RequestContextHolder
对象来保存请求相关的数据,如请求参数、请求头等。这样,在处理请求的过程中,可以通过RequestContextHolder
获取到这些信息。
例如,要获取当前请求的HttpServletRequest对象,可以使用以下代码:
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
...
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
HttpServletRequest
当客户端通过HTTP协议访问服务器时,服务器会为每个HTTP请求创建一个HttpServletRequest对象,该对象包含了HTTP请求头中的所有必要信息。具体来说,HttpServletRequest提供了一系列的方法和属性,使得开发者能够获取客户端请求的各种数据,例如:
- 获取完整的请求URL,可以使用
getRequestURL()
方法。 - 获取请求行中的资源名部分,可以使用
getRequestURI()
方法。URI相比于URL,仅包括资源路径名,不包括协议、端口号等信息。 - 获取请求行中的参数部分,可以使用
getQueryString()
方法。 - 获取请求URL中的额外路径信息,可以使用
getPathInfo()
方法。 - 获取发出请求的客户机的IP地址,可以使用
getRemoteAddr()
方法。