SpringBoot学习笔记——AOP全局统一日志管理

一、前言

我们开发的Web系统都会有日志模块,用来记录对数据有进行变更的操作。一般都会记录请求的URL,请求的IP,执行的方法,操作人员等等。其目的可能是为了保留操作痕迹,防抵赖,或是记录系统运行情况,再有就是审计要求。

二、AOP

2.1 AOP是什么?

360百科:在软件业,AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

将一些功能从业务逻辑代码中划分出来,通过对这些行为的分离,我们希望可以将它们独立到非指导业务逻辑的方法中,进而改变这些行为的时候不影响业务逻辑的代码。比如最常见的日志记录性能统计安全控制事务处理异常处理等等。

2.2 Spring AOP

这里主要为 Spring Framework 5.2.5 中AOP的介绍。

2.2.1 使用Spring进行切面的编程

SpringBoot学习笔记——AOP全局统一日志管理_第1张图片

2.2.2 AOP基本概念

SpringBoot学习笔记——AOP全局统一日志管理_第2张图片
译文:

  • 切面(Aspect):切面是一个关注点的模块化。事务管理是企业Java应用程序中横切关注点的一个很好的例子。在Spring AOP中,切面是通过使用常规类(基于模式的方法)或使用@Aspect注解的常规类来实现的。
  • 连接点(Join Point):程序执行过程中的一个点,如方法的执行或异常的处理。在Spring AOP中,连接点总是表示方法执行。
  • 通知(Advice):切面在特定连接点上采取的操作。不同类型的通知包括 "around”、“before” 、“after”。许多AOP框架,包括Spring,将通知建模为拦截器,并维护围绕连接点的拦截器链。
    • 前置通知(Before advice):在连接点之前运行但不能阻止执行流继续到连接点的通知(除非它抛出异常)。
    • 后置通知(After returning advice):通知在连接点正常完成后运行(例如,如果一个方法没有抛出异常而返回)。
    • 异常通知(After throwing advice):如果一个方法通过抛出异常退出,则要执行的通知。
    • 最终通知(After (finally) advice):无论连接点以何种方式退出(正常或异常返回),都将执行通知。
    • 环绕通知(Around advice):围绕连接点(如方法调用)的通知。这是最有力的通知。环绕通知可以在方法调用之前和之后执行自定义行为。它还负责选择是继续到连接点,还是通过返回它自己的返回值或抛出异常来简化通知的方法执行。
  • 切点(Pointcut):匹配连接点的术语。通知与切入点表达式相关联,并在与切入点匹配的任何连接点上运行(例如,执行具有特定名称的方法)。连接点由切入点表达式匹配的概念是AOP的核心,Spring默认使用AspectJ切入点表达式语言。
  • 引入(Introduction):代表类型声明其他方法或字段。Spring AOP允许您将新的接口(和相应的实现)引入任何被建议的对象。例如,您可以使用一个引入来让一个bean实现一个IsModified接口,以简化缓存。(引入在AspectJ社区中称为类型间声明。)
  • 目标对象(Target object):被一个或多个切面告知的对象。也称为“被通知对象”。因为Spring AOP是通过使用运行时代理来实现的,所以这个对象总是一个代理对象。
  • AOP代理(AOP proxy):为了实现切面契约(通知方法执行等)而由AOP框架创建的对象。在Spring框架中,AOP代理是JDK动态代理或CGLIB代理。
  • 织入(Weaving):将切面与其他应用程序类型或对象链接以创建通知的对象。这可以在编译时(例如,使用AspectJ编译器)、加载时或运行时完成。与其他纯Java AOP框架一样,Spring AOP在运行时执行编织。

说明:
环绕通知是最普遍的通知。因为Spring AOP和AspectJ一样,提供了各种各样的通知类型,所建议使用最不强大的通知类型来实现所需的行为。使用最特定的通知类型可以提供更简单的编程模型,减少出错的可能性。
所有的通知参数都是静态类型的,这样就可以使用适当类型的通知参数(例如:方法执行返回值的类型),而不是对象数组。
切入点匹配的连接点的概念是AOP的关键,它将AOP与只提供拦截的旧技术区分开来。切入点使通知能够独立于面向对象的层次结构。例如,可以将提供声明性事务管理的环绕通知应用于一组跨多个对象的方法(例如:服务层中的所有业务操作)。

2.2.3 AOP的功能和目标

Spring AOP是在纯Java中实现的。不需要特殊的编译过程。Spring AOP不需要控制类装入器层次结构,因此适合在servlet容器或应用程序服务器中使用。
Spring AOP目前只支持方法执行连接点(建议在Spring bean上执行方法)。虽然可以在不破坏核心Spring AOP api的情况下添加对字段拦截的支持,但是没有实现字段拦截。如果需要通知字段访问和更新连接点,请考虑AspectJ之类的语言。
Spring框架的AOP功能通常与Spring IoC容器一起使用。切面是通过使用普通的bean定义语法来配置的(尽管这允许强大的“自动代理”功能)。这是与其他AOP实现的一个重要区别。

2.2.4 AOP代理

Spirng的AOP的动态代理实现机制有两种,分别是:JDK动态代理和CGLib动态代理。简单介绍下两种代理机制:

  • JDK动态代理
    JDK动态代理是面向接口的代理模式,如果被代理目标没有接口那么Spring也无能为力,Spring通过java的反射机制生产被代理接口的新的匿名实现类,重写了其中AOP的增强方法。
  • CGLib动态代理
    CGLib是一个强大、高性能的Code生产类库,可以实现运行期动态扩展java类,Spring在运行期间通过 CGlib继承要被动态代理的类,重写父类的方法,实现AOP面向切面编程。

两者对比:

  1. JDK动态代理是面向接口,在创建代理实现类时比CGLib要快,创建代理速度快。而且JDK动态代理只能对实现了接口的类生成代理,而不能针对类。
  2. CGLib动态代理是通过字节码底层继承要代理类来实现(如果被代理类被final关键字所修饰,那么抱歉会失败),在创建代理这一块没有JDK动态代理快,但是运行速度比JDK动态代理要快。

2.2.5 @AspectJ 支持

@AspectJ指的是将切面声明为用注解注释的常规Java类的样式。@AspectJ样式是由AspectJ项目作为AspectJ 5发行版的一部分引入的。Spring使用AspectJ提供的用于切入点解析和匹配的库来解释与AspectJ 5相同的注释。但是AOP运行时仍然是纯Spring AOP,并且不依赖于AspectJ编译器或编织器。

声明一个切面

1.第一个示例展示应用程序上下文中的一个常规bean定义,它指向一个具有@Aspect注释的bean类。

<bean id="myAspect" class="org.xyz.NotVeryUsefulAspect">
    <!-- configure properties of the aspect here -->
</bean>

2.第二个示例展示NotVeryUsefulAspect类定义,它是由org.aspectj.lang.annotation.Aspect 注解。

package org.xyz;
import org.aspectj.lang.annotation.Aspect;

@Aspect
public class NotVeryUsefulAspect {

}

切面(使用@Aspect注解的类)可以具有与任何其他类相同的方法和字段。它们还可以包含切入点、通知和引入(类型间)声明。
通过组件扫描自动检测切面:可以将切面类注册为Spring XML配置中的常规bean,或者通过类路径扫描自动检测它们——与任何其他Spring管理的bean相同。但是,请注意@Aspect注解对于类路径中的自动检测是不够的。为此,您需要添加一个单独的@Component注解(或者,根据Spring的组件扫描器的规则,一个定制的原型注释)。

声明一个切入点

切入点确定感兴趣的连接点,从而使我们能够控制何时执行通知。Spring AOP只支持Spring bean的方法执行连接点,因此可以将切入点看作是与Spring bean上的方法执行相匹配的。切入点声明有两部分:一个签名,它包含名称和任何参数;一个是切入点表达式,它确定我们对哪个方法执行感兴趣。在AOP的@AspectJ注解风格中,切入点签名由一个常规方法定义提供,切入点表达式通过使用@Pointcut注解来表示(作为切入点签名的方法必须有一个void返回类型)。

@Pointcut("execution(* transfer(..))") // the pointcut expression
private void anyOldTransfer() {} // the pointcut signature

切入点指示器
Spring AOP支持以下用于切入点表达式的AspectJ切入点指示器(PCD):

  • execution:用于匹配方法执行连接点。这是使用Spring AOP时要使用的主要切入点指示器。
  • within:限制对某些类型中的连接点的匹配(使用Spring AOP时在匹配类型中声明的方法的执行)。
  • this:限制连接点(使用Spring AOP时方法的执行)的匹配,其中bean引用(Spring AOP代理)是给定类型的实例。
  • target:限制对连接点(使用Spring AOP时方法的执行)的匹配,其中目标对象(代理的应用程序对象)是给定类型的实例。
  • args:限制连接点的匹配(使用Spring AOP时方法的执行),其中的参数是给定类型的实例。
  • @target:限制连接点的匹配(使用Spring AOP时方法的执行),其中执行对象的类具有给定类型的注解。
  • @args:限制连接点的匹配(使用Spring AOP时方法的执行),其中实际传递的参数的运行时类型具有给定类型的注解。
  • @within:限制对具有给定注解的类型中的连接点的匹配(在使用Spring AOP时,使用给定注解在类型中声明的方法的执行)。
  • @annotation:限制对连接点的匹配,连接点的主体(在Spring AOP中执行的方法)具有给定的注解。

切入点表达式
可以使用&&、||和!组合切入点表达式。您还可以通过名称引用切入点表达式。

// 如果方法执行连接点表示任何公共方法的执行,则匹配。
@Pointcut("execution(public * *(..))")
private void anyPublicOperation() {} 

// 如果方法执行在交易模块中,则匹配。
@Pointcut("within(com.xyz.someapp.trading..*)")
private void inTrading() {} 

// 如果方法执行代表交易模块中的任何公共方法,则匹配。
@Pointcut("anyPublicOperation() && inTrading()")
private void tradingOperation() {} 

最佳实践是从较小的命名组件构建更复杂的切入点表达式,如前面所示。当通过名称引用切入点时,应用普通的Java可见性规则(您可以在相同的类型中看到私有切入点,层次结构中受保护的切入点,任何地方的公共切入点,等等)。可见性不影响切入点匹配。

声明一个通知

通知与切入点表达式相关联,并在切入点匹配的方法执行之前、之后或前后运行。切入点表达式可以是对指定切入点的简单引用,也可以是在适当位置声明的切入点表达式。

  • 前置通知:使用 @Before注解在切面中声明前置通知。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Aspect
public class BeforeExample {

    @Before("execution(* com.xyz.myapp.dao.*.*(..))")
    public void doAccessCheck() {
        // ...
    }

}
  • 后置通知
    返回后,当匹配的方法执行正常返回时,将运行通知。你可以使用 @AfterReturning注解来声明它。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterReturning;

@Aspect
public class AfterReturningExample {

    @AfterReturning("com.xyz.myapp.SystemArchitecture.dataAccessOperation()")
    public void doAccessCheck() {
        // ...
    }

}

需要在通知主体中访问返回的实际值。您可以使用@AfterReturning的形式绑定返回值来获得访问权限。

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterReturning;

@Aspect
public class AfterReturningExample {

    @AfterReturning(
        pointcut="com.xyz.myapp.SystemArchitecture.dataAccessOperation()",
        returning="retVal")
    public void doAccessCheck(Object retVal) {
        // ...
    }

}
  • 异常通知
    抛出通知后,当匹配的方法执行通过抛出异常退出时运行。您可以使用 @AfterThrowing注解来声明它。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterThrowing;

@Aspect
public class AfterThrowingExample {

    @AfterThrowing("com.xyz.myapp.SystemArchitecture.dataAccessOperation()")
    public void doRecoveryActions() {
        // ...
    }

}

通常,您希望仅在抛出给定类型的异常时才运行通知,并且常常需要在通知正文中访问抛出的异常。您可以使用throwing属性来限制匹配(如果需要,可以使用Throwable作为异常类型),并将抛出的异常绑定到一个通知参数。

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterThrowing;

@Aspect
public class AfterThrowingExample {

    @AfterThrowing(
        pointcut="com.xyz.myapp.SystemArchitecture.dataAccessOperation()",
        throwing="ex")
    public void doRecoveryActions(DataAccessException ex) {
        // ...
    }

}
  • 最终通知
    当匹配的方法执行退出时,将运行最终通知。它是使用 @After注解声明的。最终通知必须准备好处理正常和异常返回条件。它通常用于释放资源和类似的目的。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.After;

@Aspect
public class AfterFinallyExample {

    @After("com.xyz.myapp.SystemArchitecture.dataAccessOperation()")
    public void doReleaseLock() {
        // ...
    }

}
  • 环绕通知
    环绕 通知是通过使用 @Around注解来声明的。通知方法的第一个参数必须是类型为ProceedingJoinPoint。在通知的主体中,对过程ProceedingJoinPoint 调用proceed()会导致底层方法执行。proceed方法也可以传递一个Object[]。当方法执行时,数组中的值用作方法执行的参数。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.ProceedingJoinPoint;

@Aspect
public class AroundExample {

    @Around("com.xyz.myapp.SystemArchitecture.businessService()")
    public Object doBasicProfiling(ProceedingJoinPoint pjp) throws Throwable {
        // start stopwatch
        Object retVal = pjp.proceed();
        // stop stopwatch
        return retVal;
    }

}

三、AOP全局统一日志管理

3.1 环境说明

开发工具:IDEA 2019.3.1
框架版本:SpringBoot 2.2.6

3.2 具体实现

1.pom.xml中加入Spring AOP依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2.启用@AspectJ支持,这里默认已经开启。
@AspectJ支持
3.自定义日志注解类。

package com.liyafei.core.log;

import java.lang.annotation.*;

/**
 * 

Title: WebLog

*

Description: 自定义日志注解类

*

Company: Ongoing蜗牛

* * @author liyf * @date 2020年3月28日 */
@Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.PARAMETER, ElementType.METHOD }) public @interface WebLog { /** * 渠道 * @return 渠道标识 */ String channel() default "web"; /** * 功能名称 * @return 功能名称 */ String name() default ""; /** * 方法名称 * @return 方法名称 */ String action() default ""; /** * 是否保存(默认不保存) * @return 是否保存 */ boolean saveFlag() default false; }

@Retention 注解保留策略
RetentionPolicy 策略枚举类(RUNTIME 注解将由编译器记录在类文件中,并在运行时由VM保留,因此可以反射性地读取它们。)
@Target 注解目标位置(也就是该注解要用在什么地方)
ElementType 目标元素类型枚举类(PARAMETER:参数,METHOD:方法)

4.日志切面类

package com.liyafei.core.log;

import java.lang.reflect.Method;
import java.text.DateFormat;
import java.util.Date;

import javax.servlet.http.HttpServletRequest;

import com.liyafei.skillset.domain.SysUserLog;
import com.liyafei.skillset.service.SysUserLogService;
import com.liyafei.skillset.tools.utils.RequestUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import com.alibaba.fastjson.JSONObject;
/**
 * 

Title: WebLogAspect

*

Description: 日志切面类

*

Company: Ongoing蜗牛

* * @author liyf * @date 2020年3月28日 */
@Component @Aspect public class WebLogAspect { private static final Logger logger = LoggerFactory.getLogger(WebLogAspect.class); private final SysUserLogService sysUserLogService; public WebLogAspect(SysUserLogService sysUserLogService) { this.sysUserLogService = sysUserLogService; } /** * 连接点(切入点) * 切入点表达式:匹配 web包及子包 Controller类的任何公共方法 */ @Pointcut("execution(public * com.liyafei.skillset.web..*Controller.*(..))") public void webLog() { } /** * 通知:前置通知(Before advice),在连接点之前运行但不能阻止执行流继续到连接点的通知(除非它抛出异常)。 * 在日志文件或控制台输出请求信息 * * @param joinPoint */ @Before("webLog()") public void doBefore(JoinPoint joinPoint) { // 利用RequestContextHolder获取HttpServletRequest对象 ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest(); // 重组请求信息 StringBuffer sb = new StringBuffer(); sb.append("收到请求:"); sb.append("\r\n访问URI :" + httpServletRequest.getRequestURI().toString()); sb.append("\r\nSession :" + httpServletRequest.getSession().getId()); sb.append("\r\n访问IP :" + RequestUtil.getIP(httpServletRequest)); sb.append("\r\n响应类 :" + joinPoint.getSignature().getDeclaringTypeName()); sb.append("\r\n方法 :" + joinPoint.getSignature().getName()); Object[] objects = joinPoint.getArgs(); for (Object arg : objects) { if (arg != null) { sb.append("\r\n参数 :" + arg.toString()); } } // 打印请求信息 logger.info(sb.toString()); } /** * 通知:后置通知(After returning advice),通知在连接点正常完成后运行 * 处理请求日志信息 * * @param joinPoint */ @AfterReturning(pointcut = "webLog()", returning = "rvt") public void doAfterReturning(JoinPoint joinPoint, Object rvt) { // 处理日志信息 handleLog(joinPoint, null); } /** * 通知:异常通知(After throwing advice),方法通过抛出异常退出,则要执行的通知 * 处理请求异常日志信息 * * @param joinPoint * @param e */ @AfterThrowing(pointcut = "webLog()", throwing = "e") public void afterThrowing(JoinPoint joinPoint, Exception e) { // 处理日志信息 handleLog(joinPoint, e); } /** * 日志处理 * * @param joinPoint * @param e */ private void handleLog(JoinPoint joinPoint, Exception e) { // 利用RequestContextHolder获取HttpServletRequest对象 ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest(); // 获取执行的方法 Signature signature = joinPoint.getSignature(); if(!(signature instanceof MethodSignature)) { throw new IllegalArgumentException("暂不支持非方法注解"); } MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod(); if (method != null) { // 获取注解 WebLog controllerLog = method.getAnnotation(WebLog.class); if (controllerLog != null) { // 保存日志到数据库 if (controllerLog.saveFlag()) { SysUserLog sysUserLog = new SysUserLog(); // SessionId sysUserLog.setAccount(httpServletRequest.getRequestedSessionId()); // 渠道 sysUserLog.setChannel(controllerLog.channel()); // 功能名称 sysUserLog.setName(controllerLog.name()); // 响应类.方法 sysUserLog.setAction(signature.getDeclaringTypeName() + "." + method.getName()); // URI sysUserLog.setUrl(httpServletRequest.getRequestURI()); // 参数 sysUserLog.setParams(JSONObject.toJSONString(httpServletRequest.getParameterMap()).replace("\"", "")); // 请求IP sysUserLog.setIp(RequestUtil.getIP(httpServletRequest)); // 操作时间 sysUserLog.setLogTime(new Date()); // 异常信息 if (e != null) { sysUserLog.setErrMsg(e.getMessage()); } sysUserLogService.insert(sysUserLog); } } } // 发生异常时打印错误信息 if (e != null) { StringBuffer sb = new StringBuffer(); sb.append("时间:"); sb.append(DateFormat.getDateTimeInstance().format(new Date())); sb.append("方法:"); sb.append(joinPoint.getSignature() + "\n"); sb.append("异常信息:" + e.getMessage()); logger.error(sb.toString()); } } }

5.Controller请求处理层添加注解

/**
 * 系统用户 列表页
 *
 * @param sysUserCriteria
 * @param model
 * @return
 */
@WebLog(channel = "web", name = "系统用户列表", action = "/sysUser", saveFlag = true)
@GetMapping("")
public String list(SysUserCriteria sysUserCriteria, Model model) {
   model.addAttribute("sysUserCriteria", sysUserCriteria);
   return "sysUser/list";
}

6.存库日志记录
存库日志记录

四、总结说明

抛开杂念,静下心来,只看当下;
充电片刻,日积月累,步步向前。

你可能感兴趣的:(SpringBoot)