资讯动态

Spring AOP 入门教程:从日志切面到核心原理与实战

发布时间:2026/10/14 5:42:04 来源:尧图企业网站定制
写 Service 层代码的时候估计大家都遇到过这样的情况每个方法进去要先打一条日志出来再统计一下耗时出异常还要记异常信息。一两个方法还能忍等接口一多满屏日志代码把核心业务逻辑全淹了。我第一次遇到这个痛点时用的还是最笨的办法——写个工具类逐个方法手动调结果代码重复得离谱还经常漏记录。后来专门花时间啃了一遍 Spring AOP才算把这个老毛病彻底根治。这篇博文就是顺着我当时的学习路径把 Spring AOP 从核心概念到入门案例完整梳理一遍内容包括它解决什么问题、五个核心术语怎么理解、注解怎么用、切点表达式怎么写、实际会踩到哪些坑适合刚学完 Spring IOC 想继续往 Spring 核心技术深入的同学也适合工作中被日志、权限、事务这类横切逻辑折磨过的开发者。看完之后你至少能独立写一个带日志切面的小项目并且遇到切面不生效、通知顺序错乱这些问题知道往哪个方向排查。1. 搞懂 AOP 之前先想清楚它到底解决什么问题1.1 从一段重复的日志代码说起每个项目逃不掉的一件事就是记日志。尤其是用户操作、订单变更这些关键链路不但要记入参出参还要记耗时和异常。我最早参与的一个后台管理系统里用户模块的 Service 方法大概有二十多个每个方法都要单独写一套日志逻辑代码长成这样public User findByUsername(String username) { logger.info(查询用户参数{}, username); long start System.currentTimeMillis(); try { User user userMapper.selectByUsername(username); logger.info(查询用户成功用户名{}耗时{} ms, username, System.currentTimeMillis() - start); return user; } catch (Exception e) { logger.error(查询用户失败用户名{}, username, e); throw new BusinessException(查询用户异常); } }一个方法如果带了日志、耗时、异常处理这些逻辑真正干活的代码往往只剩两三行。二十个方法下来重复代码铺天盖地而且很容易出现某个方法忘写日志、某个方法统计口径不一致的情况。会后端的新同学接手这段代码时先要被这堆样板代码淹没一圈才能摸到业务逻辑。更麻烦的是这种重复不是简单的复制粘贴能解决的。你在一个方法里改了日志格式就得到处同步漏一个地方日志体系就出现裂缝。本质上日志记录是一种“横着”加在业务流程里的逻辑它不属于业务本身但每个业务方法又都需要它。这种逻辑在 AOP 的语境里有个专门的名字叫做横切关注点。1.2 把公共逻辑从业务代码里抽出来面向对象编程擅长把纵向的业务模块拆成一个个类但对于日志、事务、权限这类横向铺开的逻辑OOP 的表达能力很有限。AOP 解决的就是这个不对等的问题它允许你把这些横切关注点集中写在一个独立模块里然后再声明“在哪些业务方法上执行”。用一个生活里的场景来类比。商场每个入口都有一道安检门顾客正常走进商场不需要自己在身上挂着“请检查”的牌子安检逻辑在门口统一完成。AOP 就是那道安检门业务方法是“走过去的人”而“过去之前要扫一眼”、“走过去之后放行”这些逻辑全部集中在了安检门这套机制里。换句话说切面代码和业务代码是分离的。业务方法里干干净净只做业务日志、耗时、异常记录这些事全部由切面在幕后替你干完。这就是 AOP 的核心思想纵向的业务逻辑交给 OOP横向的关注点交给 AOP两者配合代码的职责才能拆得清楚。1.3 AOP 的真实威力不止是日志很多教程把 AOP 挂在“日志管理”后面来讲容易让人产生一种错觉AOP 不就是个记录器吗其实日志只是最容易理解的一个应用场景AOP 在真实项目里的覆盖面相当广。事务管理Spring 的声明式事务 Transactional 本身就是一个切面实现的底层通过事务拦截器在方法前后开启、提交或回滚事务。权限校验用自定义注解标注接口再通过切面统一判断当前用户是否有权调用。性能监控统计方法耗时、记录慢请求是监控大盘上很多指标的数据来源。参数脱敏接口返回给前端之前统一把手机号、身份证这类敏感字段打码。接口幂等在方法执行前查一下有没有处理过相同请求执行完写一条标记防止重复提交。这些场景的共同点是逻辑本身和业务无关、但所有方法都需要。把它们放到切面里业务代码的复杂度能降一个量级也方便统一修改和治理。理解了这一点再看 Spring AOP 的各个概念就不会觉得抽象了。1.4 Spring AOP 和 AspectJ 是什么关系很多初学者会把 Spring AOP 和 AspectJ 混为一谈第一次见的时候我也被这两个名字绕了一下。简单说AspectJ 是一套完整的 AOP 框架它通过专门的编译器在编译期把切面代码织入目标类支持类初始化、字段读写、方法调用等各种细粒度的连接点。Spring AOP 是 Spring 框架自己实现的 AOP 组件它没有自己做一套完整的 AOP 规范而是直接借用了 AspectJ 的注解和切点表达式语法但底层实现方式完全不同——Spring AOP 在运行时通过动态代理机制生成代理对象把通知逻辑放进代理对象里执行目标方法时先经过代理也就先执行了切面逻辑。用一句话总结Spring AOP 是运行时代理AspectJ 是编译期织入。Spring AOP 轻量、和 Spring 容器无缝结合但只支持类方法级别的连接点AspectJ 功能更完整但配置成本高、需要额外编译器支持。绝大多数 Java 后端项目用 Spring AOP 就完全够了本文后面的案例也是基于 Spring AOP。2. 五个核心术语翻译成人话就这么记AOP 的术语很多源于英文直译初次接触会觉得拗口。我把它们按照程序运行的时间顺序串起来讲你就发现这些概念其实是一套连贯的东西。2.1 连接点和切点在哪“切”的问题连接点Join Point是程序执行过程中一个可以被拦截的特定位置。在 Spring AOP 里它指的就是目标方法的执行比如 UserService.addUser() 开始执行的那一刻、方法返回的那一瞬间都是连接点。你可以把连接点理解成一趟公交车的所有站点。切点Pointcut则是一个筛选规则用来声明“到底哪些连接点是我要拦截的”。公交车经过几十个站但不一定每站都停你定的规则可能是“只在公司门口那站停”。对应到代码里切点就是一个加了 Pointcut 注解的方法表达式Pointcut(execution(* com.example.service.UserService.*(..))) public void userPointcut() {}这个表达式翻译过来是匹配 com.example.service 包下 UserService 接口里所有方法、参数任意、返回类型任意。后面所有通知注解只需要引用 userPointcut() 这个切点方法改一处就能影响整个切面。2.2 切面和通知切完以后干什么切面Aspect是切点和通知的组合体是 AOP 模块的最小打包单位。一个切面包含两部分核心信息在哪些连接点上生效切点生效后执行什么逻辑通知。类上面标记 Aspect 注解这个类就被 Spring 认定为切面类。通知Advice是在切点指定的位置执行的代码。它回答了“切完以后干什么”的问题。通知本身还区分时机按照在目标方法执行前、正常返回后、抛出异常后、最终执行后、还是全程包裹可以分为五种类型。Spring 官方把通知定义为 AOP 联盟标准的一部分这五个时机也对应着五个注解后面案例里会逐个演示。2.3 目标对象、代理与织入整个流程怎么串起来目标对象Target就是被切面逻辑增强的那个原始业务对象比如我们写的 UserServiceImpl 实例。代理Proxy是 Spring 在运行时替我们生成的增强对象。Spring AOP 的底层机制是代理模式调用方拿到的并不是原始对象而是 Spring 生成的代理对象这个代理对象内部持有目标对象的引用并在执行方法前后插入切面逻辑。织入Weaving就是把切面逻辑“缝”到目标对象上、最终生成代理对象的过程。Spring AOP 在应用启动时、Bean 完成依赖注入后执行织入属于运行时织入。织入时机不同是 Spring AOP 和 AspectJ 最大的区别之一前面已经提过这里不重复。关于代理方式还有一个关键选择。JDK 动态代理是基于接口实现的要求目标类必须实现接口CGLIB 代理则直接生成目标类的子类不要求接口。两者各有取舍Spring Boot 2.x 之后默认使用 CGLIB这个点很容易在项目里埋下坑我在常见问题部分会专门讲。2.4 五种通知类型速查表把这五种通知放在一起对比执行时机和作用一目了然通知类型注解执行时机典型场景前置通知Before目标方法执行之前参数校验、权限判断、记录调用参数后置返回通知AfterReturning目标方法正常返回之后记录返回值、结果脱敏、标记成功异常通知AfterThrowing目标方法抛出异常之后异常记录、报警、统一异常转换最终通知After目标方法执行结束无论正常还是异常资源释放、清理线程变量环绕通知Around目标方法前后都可以干预还能控制方法是否执行耗时统计、事务、限流、缓存环绕通知能力最强它相当于把前置、后置、异常、最终四条通知合并成了一段代码并且可以通过 ProceedingJoinPoint.proceed() 手动控制目标方法什么时候执行、要不要执行。实际项目中性能统计和通用日志大多用环绕通知实现。3. 入门案例给用户服务加上日志切面概念讲得再多不如亲手跑一个例子。这个案例我带你在纯 Spring 环境下完整走一遍不用依赖 Spring Boot 的自动配置这样你才能看清 AOP 真正被激活的开关在哪里。3.1 依赖引入与工程结构新建一个 Maven 项目在 pom.xml 里加入两个核心依赖。第一个是 spring-context提供 Spring 容器基础能力第二个是 aspectjweaverSpring AOP 运行时会用 AspectJ 的切点表达式解析机制这个依赖必须有否则启动就会报缺类错误。dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version5.3.31/version /dependency dependency groupIdorg.aspectj/groupId artifactIdaspectjweaver/artifactId version1.9.19/version /dependency /dependencies如果你用的是 Spring Boot直接引入 spring-boot-starter-aop 这一个依赖就行自动配置会帮我们完成剩下的工作。纯 Spring 环境的好处是每一步都需要显式声明很适合第一次学习时理解原理。工程结构建议这样组织src/main/java/com/example ├── aspect/LogAspect.java ├── config/AppConfig.java ├── service/UserService.java ├── service/impl/UserServiceImpl.java └── AopDemoApplication.java3.2 定义业务接口和实现类先定义一个用户服务接口包含三个方法新增用户、删除用户、查询用户。这个接口有几个可选的切点连接点方便演示不同通知的拦截效果。package com.example.service; public interface UserService { void addUser(String username, String password); void deleteUser(Long id); String getUserById(Long id); }实现类用 Service 注册成 Spring Bean。注意这里为了演示方便直接用了 System.out 模拟业务逻辑你不用把注意力放在业务实现上重点观察代理对象带来的行为变化。package com.example.service.impl; import com.example.service.UserService; import org.springframework.stereotype.Service; Service public class UserServiceImpl implements UserService { Override public void addUser(String username, String password) { System.out.println(执行添加用户逻辑 username); } Override public void deleteUser(Long id) { System.out.println(执行删除用户逻辑 id); } Override public String getUserById(Long id) { System.out.println(执行查询用户逻辑 id); return 用户 id; } }3.3 编写切面类注解声明交给 Spring切面类是这个案例的核心用 Aspect 标记类为切面用 Component 让 Spring 扫描到它。第 12 行定义了一个切点 userPointcut切点表达式拦截 UserService 里所有方法。后面的每一个通知注解都引用这个切点方法做到了“一处定义多处复用”。package com.example.aspect; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; import java.util.Arrays; Aspect Component public class LogAspect { Pointcut(execution(* com.example.service.UserService.*(..))) public void userPointcut() {} Before(userPointcut()) public void beforeLog(JoinPoint joinPoint) { System.out.println([前置通知] 方法名 joinPoint.getSignature().getName() 参数 Arrays.toString(joinPoint.getArgs())); } AfterReturning(value userPointcut(), returning result) public void afterReturningLog(JoinPoint joinPoint, Object result) { System.out.println([后置返回通知] 方法名 joinPoint.getSignature().getName() 返回值 result); } Around(userPointcut()) public Object aroundLog(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); Object result pjp.proceed(); long end System.currentTimeMillis(); System.out.println([环绕通知] 方法名 pjp.getSignature().getName() 耗时 (end - start) ms); return result; } }代码里的 JoinPoint 是切面拿到的方法执行上下文。用 getSignature().getName() 可以拿到当前拦截的方法名用 getArgs() 可以拿到调用参数数组。这些 API 在写日志切面时几乎每天都要用到。环绕通知的参数类型是 ProceedingJoinPoint它是 JoinPoint 的子接口多了一个 proceed() 方法。proceed() 的作用是放行目标方法继续执行返回值就是目标方法的返回结果。环绕通知必须调用 proceed()否则目标方法永远不会执行业务逻辑会被切面“吃掉”——这是新手最容易犯的错误之一。3.4 配置类与启动验证定义一个配置类用 Configuration 标记通过 ComponentScan 扫描 com.example 包下的所有组件再用 EnableAspectJAutoProxy 显式开启 AOP 自动代理功能。package com.example.config; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; Configuration ComponentScan(com.example) EnableAspectJAutoProxy public class AppConfig { }EnableAspectJAutoProxy 是纯 Spring 环境下激活 AOP 的关键。没有这个注解切面类就算写好了也不会生效。Spring Boot 项目则不用手动添加因为 spring-boot-autoconfigure 会在检测到 AOP 相关依赖时自动开启这个功能。写一个入口类从 Spring 容器中取出 UserService调用三个方法观察切面行为。package com.example; import com.example.config.AppConfig; import com.example.service.UserService; import org.springframework.context.annotation.AnnotationConfigApplicationContext; public class AopDemoApplication { public static void main(String[] args) { AnnotationConfigApplicationContext context new AnnotationConfigApplicationContext(AppConfig.class); UserService userService context.getBean(UserService.class); userService.addUser(tom, 123456); String user userService.getUserById(1001L); System.out.println(查询结果 user); context.close(); } }启动 main 方法控制台输出如下[前置通知] 方法名addUser参数[tom, 123456] 执行添加用户逻辑tom [环绕通知] 方法名addUser耗时1 ms [后置返回通知] 方法名addUser返回值null [前置通知] 方法名getUserById参数[1001] 执行查询用户逻辑1001 [环绕通知] 方法名getUserById耗时0 ms [后置返回通知] 方法名getUserById返回值用户1001 查询结果用户1001注意看 addUser 这条链路的输出顺序前置信噪首先执行接着进入环绕通知环绕通知内部调用 proceed() 后才轮到目标方法目标方法执行完后回到环绕通知继续打印耗时最后才进入后置返回通知。前置信噪和后置返回通知都在环绕通知“包住”的范围内明白这个嵌套关系后面理解多切面的执行顺序就不会乱。3.5 切点表达式拆解会写执行表达式就成功了一半切点表达式是 AOP 入门最难啃的部分但其实格式是固定的。最常用的是 execution 表达式完整语法如下execution(权限修饰符 返回类型 类路径.方法名(参数类型) 异常类型)其中权限修饰符和异常类型可以省略。我在案例里用的表达式 execution(* com.example.service.UserService.*(..)) 逐段拆开看是这样的第一个 *代表任意返回类型也可以是 void、String 这样的具体类型。com.example.service.UserService类的全限定名。.*匹配该类的任意方法。(..)括号里的两个点表示参数任意可以是零个、一个或多个。再给几个常见写法方便你以后套用切点表达式作用execution(* com.example.service.UserService.addUser(..))只拦截 addUser 方法execution(* com.example.service.UserService.*(..))拦截 UserService 所有方法execution(* com.example.service...(..))拦截 service 包及其子包所有类的所有方法execution(public * com.example...(..))拦截 com.example 包下所有 public 方法within(com.example.service..*)匹配 service 包及其子包所有类内部的连接点annotation(com.example.annotation.LogRecord)匹配标有 LogRecord 注解的方法切点表达式的粒度直接决定切面的影响范围。表达式写得过宽会把不该拦截的方法也包进来写得过窄又容易漏掉需要增强的方法。写之前先明确“我要拦截哪些类、哪些方法、哪些参数”再翻译成 execution 表达式基本不会出错。4. 常见问题与排查技巧实录AOP 的概念本身不难真正让开发者头疼的是切面“悄无声息就不生效”或者“莫名奇妙多执行了一次”。这些问题我在项目里基本都踩过整理成一份排查清单遇到直接对照着查。4.1 切面完全没有执行的排查清单切面一点反应都没有先不要怀疑注解写错了按我下面这个顺序逐项排查依赖是否完整纯 Spring 项目确认引入了 aspectjweaverSpring Boot 项目确认引入了 spring-boot-starter-aop。切面类是否被 Spring 管理切面类上光有 Aspect 没用还必须加上 Component 或者通过 XML/配置类注册成 Bean。很多人漏了 Component导致 Spring 容器里根本没有这个切面对象。是否开启了自动代理纯 Spring 项目必须有 EnableAspectJAutoProxy或者 XML 里的 aop:aspectj-autoproxy/ Spring Boot 项目自动开启不需要手动加。目标方法是否为 publicSpring AOP 基于代理实现只能拦截 public 方法。protected 和 private 方法无法被代理增强这是一个硬限制。目标类是否为 finalCGLIB 代理通过生成子类实现final 类无法被继承final 方法无法被覆写自然也就加不了代理逻辑。上面的检查项都确认无误再怀疑代码逻辑本身。有一个简单的验证方法在切面里故意抛一个异常如果程序真的抛了说明切面确实执行了问题出在通知内容上如果异常也没抛说明切面根本就没被加载。4.2 内部方法调用导致切面失效这个坑非常隐蔽而且出现频率极高。看下面的代码Service public class UserServiceImpl implements UserService { Override public void addUser(String username, String password) { this.sendMail(tomexample.com); } public void sendMail(String email) { System.out.println(发送邮件给 email); } }如果切点匹配了 addUser 和 sendMail 两个方法你会发现调用 addUser 时sendMail 上的切面逻辑根本没有执行。原因在于 this.sendMail() 调用的是原始对象自身的方法走的是 this 引用而 Spring 代理对象拦截的是外部对代理对象的方法调用。简而言之代理只对“从外部调进来”生效类内部自己的方法互相调用是绕过了代理的。解决方式有三种。第一种最简单把 sendMail 拆到另外一个 Spring Bean 里通过注入另一个 Bean 调用因为跨 Bean 调用会经过代理。第二种在 UserServiceImpl 里注入自身代理用 Autowired 注入自己但要注意循环依赖问题Spring 三级缓存可以解决也不推荐随意用。第三种方式灵活一点在配置类开启 exposeProxy 属性然后通过 AopContext.currentProxy() 获取代理对象再调用EnableAspectJAutoProxy(exposeProxy true) public class AppConfig { } // 调用处 ((UserService) AopContext.currentProxy()).sendMail(tomexample.com);实际项目里我优先推荐方法拆分切面逻辑本来就只适合定义在独立的 Bean 方法上强行在内部方法上做 AOP本身就违背了职责分离的初衷。4.3 通知执行顺序和你想象的不一样单切面下通知执行顺序我以前也梳理过一遍这里直接总结成结论Around 前段 → Before → 目标方法 → Around 后段 → After → AfterReturning 或 AfterThrowingAfterReturning 拿到的是方法正常返回值AfterThrowing 针对的是异常抛出场景它俩是互斥分支。而 After 类似于 finally 块无论目标方法正常还是异常结束都会执行。这个顺序在控制台输出里就有体现前面案例里 addUser 的输出顺序完全吻合。多切面时执行顺序由 Order 注解或实现 Ordered 接口控制。数值越小优先级越高外层的切面先执行。假设我们有两个切面Order 分别为 1 和 2执行顺序是Order(1) 的前置逻辑 → Order(2) 的前置逻辑 → 目标方法 → Order(2) 的后置逻辑 → Order(1) 的后置逻辑。整个顺序像洋葱一样分层最外层切面最后收尾。如果多个切面同时处理同一个方法而且逻辑之间有先后依赖顺序设计就非常重要。比如先做权限校验、再做日志记录权限切面的 Order 就应该比日志切面小确保权限校验先执行。4.4 动态代理类型引发的类型转换问题Spring Boot 2.x 开始默认使用 CGLIB 代理也就是说即使目标类实现了接口最终生成的代理对象类型也不会是接口的 JDK 代理而是目标类的子类。这带来一个很典型的报错场景// 目标类实现 UserService 接口 UserServiceImpl impl context.getBean(UserServiceImpl.class);在 Spring Boot 默认配置下上面这行代码通常会报 BeanNotOfRequiredTypeException因为容器里的 bean 实际是 UserServiceImpl 的 CGLIB 代理子类直接按实现类类型注入时类型不匹配。解决方式有两个方向一是把变量类型改成接口 UserService这是最推荐的做法面向接口编程本来就是好习惯二是显式配置 spring.aop.proxy-target-classfalse切回 JDK 动态代理模式。CGLIB 代理还有一个限制final 方法无法增强。如果某个方法被标记成 final就算切点匹配到它代理也拦不住。排查问题时如果碰到“明明匹配了为什么没生效”的情况记得看一眼目标方法是不是 final 的。5. 几个实战后的判断供你参考案例跑通了常见问题也排查过一轮最后聊一点我自己的看法帮你少走弯路。5.1 AOP 适合做什么我也说说边界AOP 不是万能的它最适合处理那些“横跨多个业务方法、自身又和业务无关”的通用诉求。日志、事务、权限、幂等、缓存、审计这类需求用 AOP 是天然合理的。但我见过不少团队把 AOP 用过头把业务状态变更也塞进切面里结果代码的可读性急剧下降。别人看业务方法时根本不知道背后还挂了一堆切面逻辑出了问题排查半天才发现是某个切面把数据改了。AOP 应保持“横切不动业务”的边界切面逻辑尽量是增强性的、旁路性的而不是替代业务逻辑的。另外性能敏感的核心链路上AOP 也不是完全没有成本。每次方法调用都要经过代理层、匹配切点表达式虽然通常只有几十微秒但在高频调用的循环里会积累成可见的开销。滥用粗粒度切点比如对 dao 层所有方法做耗时统计可能比你自己手写统计代码的性能损耗还大。5.2 切面设计的几条原则基于我自己的项目经验切面设计如果能守住下面几个原则基本不会出大问题切点表达式宁可窄不可宽。先精确匹配到具体类或具体注解验证无误后再逐步放宽范围避免把框架内部调用也拦截进来产生日志噪音。环绕通知一定要确保 proceed() 被执行。业务逻辑出不来排查时要优先检查是不是环绕通知里条件判断把 proceed() 跳过了。切面逻辑保持轻量。不要把复杂计算、远程调用写进通知里切面的本意是横切增强不是新的业务入口。多切面协作时明确顺序用 Order 控制。没有顺序约束的多个切面就像没有红绿灯的路口迟早会撞车。优先使用自定义注解配合切点表达式语义更清晰。比如定义 LogRecord、RequiresPermission切点用 annotation 匹配代码的可读性会好很多也方便控制粒度。最后分享一个实际体会切点表达式已经开始影响方法调用行为时不要靠肉眼猜。可以在切面里临时打印一下目标方法的签名和入参确认切点到底匹配了谁。这个方法虽然朴素但比反复改表达式重启应用高效得多。AOP 学习的核心不在背注解而在理解代理机制和切点匹配逻辑把这两个点弄透后面的实战基本就是一马平川。

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

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

免费获取报价 →
↑