资讯动态

Spring Boot AOP日志切面实战:从切点表达式到常见坑

发布时间:2026/10/14 23:10:28 来源:尧图企业网站定制
1. 为什么我把日志逻辑从业务代码里搬出来Spring Boot 项目里总有一些逻辑跟业务没有半毛钱关系但每个接口都得带上调用前打一条日志调用结束后再打一条日志统计一下耗时万一出异常还得把异常信息记下来。如果只在 Controller 里手动写前两个接口还行接口一多代码会变得又臭又长而且每换一个人写日志格式就换一个样。我第一次处理这类问题时用的最朴素方案是抽一个工具类再在每个方法里手动调用。后来新需求要求所有接口打印请求参数我把十几个 Controller 全部翻出来改了一遍改完还有两处漏网之鱼。就是从那次开始我把“面向切面编程AOP”真正用到了日常开发里。如果你想先快速看到一个能跑的 SpringBoot AOP 示例那这篇文章正合适。我会从一个最简单的日志切面入手把依赖引入、切面类写法、切点表达式、执行顺序、高频踩坑全部过一遍。内容不会写得很深但保证你照着写完就能生效同时知道它为什么生效。1.1 这个示例要解决什么问题我这里做了一个很简单的模拟项目叫“模拟项目X”里面只有一个用户接口根据传入的名字返回一个用户对象。需求是这样的每次有人调用这个接口都要记录请求参数、返回结果、执行耗时如果方法抛异常还要把异常信息记录到日志里。最传统的写法是在方法里逐行加日志GetMapping(/info) public User info(RequestParam String name) { long start System.currentTimeMillis(); log.info(请求开始参数{}, name); User user new User(); user.setName(name); user.setTime(LocalDateTime.now()); log.info(请求结束返回{}耗时{}ms, user, System.currentTimeMillis() - start); return user; }问题很明显日志代码和业务代码混在一起每个接口都要复制一遍。更麻烦的是以后想把日志格式统一调整比如把参数序列化成 JSON、把耗时改成秒就要把所有方法全部改一遍。这种重复操作不仅效率低还容易漏而 AOP 的价值就是把这类横切逻辑统一收口到一个地方。1.2 为什么 Spring Boot 里用 AOP 特别顺手Spring Boot 里使用 AOP几乎不用手动写代理代码。因为项目中的所有 Bean 都交给 Spring 容器管理容器在创建 Bean 的时候如果发现这个 Bean 匹配了某个切面就会自动用一个代理对象替换原来的对象。这个代理对象可以看成“门禁闸机”业务方法原本是一扇直接打开的门加了代理之后所有调用都要先过闸机切面里的逻辑就相当于闸机上的检查步骤。Spring 的 AOP 是基于代理实现的分为两种JDK 动态代理和 CGLIB 代理。JDK 动态代理要求目标类有接口通过反射生成一个实现相同接口的代理类CGLIB 则是通过生成目标类的子类来代理不要求有接口。Spring Boot 2.x 默认使用 CGLIB 代理所以哪怕你的 Controller 没有实现接口也可以正常被切面拦截。这一点后面排查问题时会用到。之所以说“顺手”还有一个原因是 Spring Boot 的自动装配。引入一个 starter 依赖之后切面相关的配置基本都帮你配好了不需要再写 XML 配置文件甚至不需要手动加EnableAspectJAutoProxy我们要做的只是写一个切面类放进去容器会自动识别。对大多数日常场景来说这就是一个“依赖一加、切面一写、立马生效”的过程。2. 环境准备与工程搭建2.1 引入依赖spring-boot-starter-aop 到底带来了什么我使用的是 Maven 工程只需要在pom.xml里加上一个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency注意如果你用的是 Spring Boot 的spring-boot-starter-parent做父工程那么这里不需要写版本号由父工程统一管理。如果你是自己管理的依赖版本建议选一个和 Spring Boot 版本匹配的稳定版本。这个 starter 依赖会引入 Spring AOP 模块和相关的基础库。Spring Boot 启动时AopAutoConfiguration会自动把 AOP 开关打开并注册支持 AOP 的BeanPostProcessor。换句话说只要依赖在启动类上不需要额外加EnableAspectJAutoProxy大多数场景下 Spring Boot 已经替你处理好了。整个工程结构建议保持下面这种样子src/main/java/com/example/demo ├── DemoApplication.java ├── aspect │ └── WebLogAspect.java ├── controller │ └── UserController.java └── model └── User.java启动类就是一个普通的 Spring Boot 启动类不用做任何特殊处理。2.2 准备一个被拦截的简单接口先建一个返回对象Userpublic class User { private String name; private LocalDateTime time; public String getName() { return name; } public void setName(String name) { this.name name; } public LocalDateTime getTime() { return time; } public void setTime(LocalDateTime time) { this.time time; } Override public String toString() { return User{ name name \ , time time }; } }然后写一个简单的 ControllerRestController RequestMapping(/api/user) public class UserController { GetMapping(/info) public User info(RequestParam(name) String name) { User user new User(); user.setName(name); user.setTime(LocalDateTime.now()); return user; } }这个接口本身很简单但它足够用来验证 AOP 是否生效。如果你的环境已经能正常跑通一个 Spring Boot Web 工程那现在只需要把依赖加进去再准备一个这样的接口后面要做的就是写切面。2.3 先搞清楚这几个基础概念真正写代码之前有必要把 AOP 的几个术语放在一起对一下。因为很多文章一开始就把切面、切点、通知混着说刚接触的时候很容易绕晕。概念英文作用切面Aspect一个把切点和通知组合在一起的类通常用Aspect标记切点Pointcut定义“哪些方法需要被拦截”通常用表达式描述通知Advice定义“拦截后做什么”包括前置、后置、环绕等连接点JoinPoint程序执行过程中的某个方法调用切点在运行时选中的具体位置类比一下切面是一个“门禁系统”切点负责判断“哪些人需要过闸机”通知则是“过闸机时检查证件”这个动作。连接点就是某一个具体的人通过闸机的那一刻。实际开发中你只需要写好切面类和切点表达式Spring 会在运行时自动判断哪些方法调用命中切点然后把通知逻辑织入进去。3. 写一个能跑的日志切面3.1 用 Around 一版搞定耗时与日志我推荐把第一次跑通的例子直接用Around写因为一个大方法就能覆盖“前置日志、后置日志、耗时统计、异常记录”四个需求出现问题时调试起来也直观。在项目的aspect包下新建一个类package com.example.demo.aspect; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Arrays; Aspect Component public class WebLogAspect { private static final Logger log LoggerFactory.getLogger(WebLogAspect.class); Pointcut(execution(public * com.example.demo.controller..*.*(..))) public void webLogPointcut() { } Around(webLogPointcut()) public Object around(ProceedingJoinPoint pjp) throws Throwable { long startTime System.currentTimeMillis(); String methodName pjp.getSignature().toShortString(); Object[] args pjp.getArgs(); log.info(请求开始{}参数{}, methodName, Arrays.toString(args)); try { Object result pjp.proceed(); long cost System.currentTimeMillis() - startTime; log.info(请求结束{}返回{}耗时{}ms, methodName, result, cost); return result; } catch (Throwable throwable) { long cost System.currentTimeMillis() - startTime; log.error(请求异常{}耗时{}ms异常{}, methodName, cost, throwable.getMessage()); throw throwable; } } }启动项目后访问一下/api/user/info?namezhangsan控制台会输出类似下面的日志请求开始UserController.info(String)参数[zhangsan] 请求结束UserController.info(String)返回User{namezhangsan, time2025-...}耗时23ms看到这个输出说明你的 SpringBoot AOP 已经跑通了。整个过程没有改动 Controller 里的任何业务代码日志逻辑全部集中在WebLogAspect里。这里有三点要注意Aspect和Component缺一不可。Aspect告诉 Spring 这个类是切面Component把它注册成普通 Bean否则 Spring 不会管理它。Around(webLogPointcut())里的名字要和切点方法名一致实际是引用了切点定义。pjp.proceed()是放行方法继续执行的关键。如果你把它漏掉了被拦截的接口会直接返回 null而且业务代码不会执行。这是新手最容易踩的坑之一。3.2 Before、After、AfterReturning 和 Around 怎么选很多教程切入 AOP 时会把五种通知类型都摆出来原则上是希望你掌握全部能力但实际项目里大部分需求其实用Around就够。不过我还是建议把它们的执行时机搞明白这样读别人的代码时才不会懵。通知类型执行时机典型场景Before目标方法执行之前参数校验、基础日志After目标方法执行之后相当于 finally清理资源、最终记录AfterReturning目标方法正常返回之后可以拿到返回值日志记录返回结果AfterThrowing目标方法抛出异常之后异常通知Around完全包裹目标方法可以控制执行流程耗时统计、权限控制、事务控制举一个组合式写法方便你对比。比如我想在方法执行前打日志正常返回后拿到返回值再打一条可以这样写Before(webLogPointcut()) public void before(JoinPoint joinPoint) { log.info(before 通知方法{}, joinPoint.getSignature().toShortString()); } AfterReturning(pointcut webLogPointcut(), returning result) public void afterReturning(JoinPoint joinPoint, Object result) { log.info(afterReturning 通知返回结果{}, result); }这里returning参数需要和方法的第二个参数名一致。如果方法返回voidresult会是 null不影响调用。Around和Before配合时要注意一点如果你在同一个切面里同时写了Before和Around并且切点相同那么日志会输出两遍看起来就像“重复”了。这不算错误只是你要清楚每一条日志来自哪个通知避免排查问题时被误导。想统一格式直接用Around一个通知就够了。3.3 让切面只对指定方法生效我上面的切点写的是execution(public * com.example.demo.controller..*.*(..))意思是拦截controller包以及子包下所有类的所有公共方法。但有时候你并不想给所有接口打日志只想标记几个重点接口这时候用自定义注解更合适。先定义一个注解package com.example.demo.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface WebLog { }然后在切面里增加一个基于注解的切点Pointcut(annotation(com.example.demo.annotation.WebLog)) public void webLogAnnotationPointcut() { } Around(webLogAnnotationPointcut()) public Object aroundAnnotation(ProceedingJoinPoint pjp) throws Throwable { // 和上面的 around 逻辑一样 return pjp.proceed(); }使用的时候只在想被拦截的方法上加上WebLog注解GetMapping(/info) WebLog public User info(RequestParam(name) String name) { // ... }这样做的好处是切点不依赖包路径以后业务类放在哪个包都不影响而且方法上有没有注解一目了然比看一长串表达式更直观。如果你接触过比较老的 AOP 项目你应该会喜欢这种写法它就是很多人说的“注解驱动开发”。4. 切点表达式拆解与常见配置4.1 execution 表达式逐段拆解很多教程会给出一长串 execution 表达式但不解释每一段是什么。我在这里拆开讲一下因为切点表达式写错是 AOP 不生效的第一大原因。先看我们用的这个execution(public * com.example.demo.controller..*.*(..))从左到右execution方法执行切点指示器表示在方法执行时匹配。public匹配方法的修饰符这里限定只匹配public方法。默认情况下可以省略但写上更明确。*匹配返回类型。这里的*表示返回任何类型如果是void方法就写void如果想匹配所有带返回值的方法可以写*。com.example.demo.controller..*匹配包和类。com.example.demo.controller是包名..表示该包以及子包最后一个*表示任意类名。.*(..)匹配方法名和参数。点号后面第一个*表示任意方法名括号里的..表示任意数量和类型的参数。这里特别容易混的是.和..。单个.只匹配当前包不匹配子包双..包含当前包和所有子包。如果你希望匹配controller包下直接定义的类可以写execution(public * com.example.demo.controller.*.*(..))如果你希望也包含controller.controller这类子包就用双..execution(public * com.example.demo.controller..*.*(..))切点表达式还可以做更多精细化匹配我整理了几个常用写法表达式含义execution(* com.example.demo.service.*.*(..))匹配 service 包下所有类的任意方法execution(* com.example.demo.service.OrderService.save*(..))匹配 OrderService 类中以 save 开头的方法execution(* com.example.demo.service.*.*(String, ..))匹配第一个参数是 String 的方法execution(* com.example.demo.service.*.*())匹配无参方法execution(public * com.example.demo.service.OrderService.*(..))精确匹配某个类所有公共方法需要提醒的是Spring AOP 是基于代理实现的它只能拦截通过 Spring 容器获取到的 Bean 的方法调用。像静态方法、私有方法、Java 原生类的构造方法这些场景AOP 是无法拦截的。如果业务代码里写了new出来的对象再调用它的方法AOP 也不会生效因为那个对象压根没经过代理。4.2 用自定义注解做切点比路径更优雅前面提到可以用注解做切点这里我再补一个带参数的例子。毕竟现实中你可能希望在注解里传入操作描述日志里能直接看到“这是一个查询操作”还是“这是一个删除操作”。定义一个可以填值注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface WebLog { String value() default ; }切面绑定注解参数Around(annotation(webLog)) public Object aroundAnnotation(ProceedingJoinPoint pjp, WebLog webLog) throws Throwable { String desc webLog.value(); log.info(开始执行操作{}方法{}参数{}, desc, pjp.getSignature().toShortString(), Arrays.toString(pjp.getArgs())); Object result pjp.proceed(); log.info(操作结束{}结果{}, desc, result); return result; }接口上用GetMapping(/delete) WebLog(删除用户) public String delete(RequestParam(id) Long id) { return ok; }这样日志里会直接输出“开始执行操作删除用户”维护起来非常直观。用注解切点最大的好处是“所见即所得”方法上有没有切面行为扫一眼就知道。4.3 多个切面的执行顺序与 Order一个项目里不会只有一个切面。比如一个切面负责日志一个切面负责接口耗时统计还有一个切面负责权限校验。多个切面同时命中同一个方法时执行顺序如果不控制日志会非常跳动。Spring AOP 通过Order注解控制多个切面的执行顺序。数字越小优先级越高。以下面的顺序为例Aspect Component Order(1) public class LogAspect { // ... } Aspect Component Order(2) public class TimeAspect { // ... }当目标方法被两个切面命中时执行顺序是这样的LogAspect Around 的前置部分 TimeAspect Around 的前置部分 目标方法 TimeAspect Around 的后置部分 LogAspect Around 的后置部分可以理解成一层一层剥洋葱最先执行的切面最外层方法执行完后它最后收尾。如果你在Before通知里写日志看到的是Order(1)的先打印而在AfterReturning通知里写日志看到的是Order(2)的先打印。理解了这一点排查多个切面互相影响时会轻松很多。在实际项目中我习惯把权限校验这类安全逻辑放在最外层把日志放在第二层把耗时统计再往里放。这样优先保证权限没过就不会执行业务方法日志也不会记录到太多无效调用。5. 高频问题与排查实录5.1 切面没有生效先查这五件事AOP 最让人头疼的问题就是“代码写了但日志一条都没有”。根据我的经验90% 都出在下面五个地方问题现象原因解决办法切面类没有加 Aspect完全没有日志输出Spring 不认为它是切面类上加上Aspect切面类没有交给容器管理完全没有日志输出没有ComponentSpring 不扫描它类上加上Component切点表达式写错完全没有日志输出包路径和方法签名不匹配确认包名、类名、方法名是否准确依赖没引入启动报错或切面不被识别缺少 spring-boot-starter-aop检查 pom 依赖目标方法没有被代理日志有时有有时没有方法被 final 修饰或通过new调用去掉 final使用容器注入的 Bean还有一个小细节切面类所在包必须被 Spring Boot 扫描到。如果你的启动类在com.example.demo而切面类写在com.example.aspect那默认是扫不到的需要手动配置ComponentScan或保持包在启动类所在包及其子包下。你可以临时给切面加一条启动日志验证 Spring 是否创建了切面对象PostConstruct public void init() { log.info(WebLogAspect 已加载); }启动后如果没看到这条日志说明切面类根本没被容器管理后面的一切都不用讨论了。5.2 自调用导致拦截失效这个坑我印象很深。假设你有一个OrderServiceService public class OrderService { public void create() { save(); } public void save() { System.out.println(保存订单); } }切点配置为拦截OrderService.save()。当你调用create()时里面的save()并不会走切面逻辑。原因在于create()里的this.save()调用的是当前对象的方法而不是 Spring 容器里的代理对象。AOP 拦截的本质是拦截“从外部调用代理对象的方法”内部直接this调用是绕过代理的自然没效果。解决办法通常有三个第一种把save()挪到另一个 Service 类里在OrderService中注入那个新 Service。这样调用外部 Bean 的方法时走的是代理对象AOP 生效。第二种手动启用 AOP 上下文暴露代理对象。在配置里增加spring: aop: expose-proxy: true然后在代码里调用((OrderService) AopContext.currentProxy()).save();第三种也是我比较推荐的在类中注入自身代理对象但要注意循环依赖问题。如果项目里已经开了允许循环依赖可以用这种方式否则可能启动失败。综合来看把被拦截的方法独立成另一个 Bean 最干净也最符合分层思想。5.3 异常被吞掉、返回值类型不对等现场Around写法灵活也最容易出问题最常见的三个现场我整理一下。现场一在Around里用了 try/catch但是 catch 之后没有把异常重新抛出导致上层接口“成功”返回实际业务没执行成功。正确做法是捕捉到异常后记录日志然后继续throw throwable让调用方知道这次调用是失败的。现场二目标方法返回void而Around方法里把pjp.proceed()的结果直接 return这没有影响。但如果目标方法返回基本类型比如int而建议方法返回的Object是 nullSpring 在做类型适配时可能抛空指针异常。所以在环绕通知里要确保最终返回值不能随意置空。现场三想拿 HttpServletRequest却不知道该从哪里取。切面方法里没有 request 参数没关系可以通过RequestContextHolder获取ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request attributes.getRequest();这个工具的原理是 Spring MVC 在处理请求时把当前线程和请求绑定在一起所以在切面里能拿到同一个线程的 request。如果异步线程里拿不到通常是线程切换导致的不要把切面逻辑直接放进异步线程里。6. 几个让我少走弯路的经验第一个经验是新手阶段不要同时使用太多通知。我在刚开始写 AOP 时总想把Before、AfterReturning、Around全部用上结果日志里同一段信息被重复记录看起来乱成一团。如果你也是刚接触建议从Around一个开始逻辑清晰排查容易。等跑通之后再根据场景拆成更细的通知。第二个经验是切点表达式尽量用白名单思维。不要一上来就写一个..*.*(..)把所有方法都拦截掉尤其是带有Transactional注解的 Service 方法。切面里如果处理不当比如在环绕通知里没有正确放行事务逻辑也会被连带影响。更稳妥的做法是先用包路径限定范围或者用自定义注解标记重点方法之后再逐步扩大。第三个经验是AOP 不只是日志的好朋友。你完全可以用同样的套路做接口耗时统计、权限校验、参数脱敏、异常翻译。等日志切面跑通之后试着再加一个定时统计耗时的切面你会发现 AOP 的思路是通用的把横切逻辑集中起来业务代码保持干净。我在实际项目中踩过最深的坑是“看起来生效了但顺序不对”。多个切面同时拦截同一个方法时如果不注意Order日志顺序会让人误以为业务异常。后来我给所有切面都规矩地加了Order再也没为顺序问题头疼过。如果你正准备在项目里大规模使用 AOP希望这篇从简单例子到常见问题的记录能帮你少走一点弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑