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


前言

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


一、AOP是什么?

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

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

二、Spring AOP

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

1.使用Spring进行切面的编程

使用Spring进行切面的编程

2. AOP基本概念

AOP基本概念
译文:

  • 切面(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 与只提供拦截的旧技术区分开来。切入点使通知能够独立于面向对象的层次结构。例如,可以将提供声明性事务管理的环绕通知应用于一组跨多个对象的方法(例如:服务层中的所有业务操作)。

3.AOP的功能和目标

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

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 动态代理要快。

5.@AspectJ支持

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

(1)声明一个切面

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 的组件扫描器的规则,一个定制的原型注释)。

(2)声明一个切入点

切入点确定感兴趣的连接点,从而使我们能够控制何时执行通知。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 可见性规则(您可以在相同的类型中看到私有切入点,层次结构中受保护的切入点,任何地方的公共切入点,等等)。可见性不影响切入点匹配。

(3)声明一个通知

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

  • 前置通知:使用 @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全局统一日志管理

1.环境说明

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

2.具体实现

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

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

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

@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.日志切面类

@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.存库日志记录
存库日志记录


总结

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

  • 12
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

编程火箭车

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值