ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring AOP增强类如何获取目标方法信息?JoinPoint实战详解

Spring AOP增强类如何获取目标方法信息?JoinPoint实战详解 1. 项目概述这个知识点解决什么问题在 Spring AOP 里凡是写过自定义切面也就是增强类的朋友几乎都会被同一个问题绊住在增强类的方法中怎么获取当前被拦截的目标方法的信息这里的“目标方法”不只是方法名还包括调用参数、返回类型、方法上的注解、目标对象实例甚至方法的抛出异常类型。刚开始接触 AOP 时我一度以为切面只是单纯“拦一下再放行”直到要做日志和权限控制才发现拿不到目标方法信息增强类就像个盲人摸象完全不知道怎么记录、怎么判断、怎么缓存。这个知识点解决的是 AOP 中最核心的信息传递问题Spring AOP 在创建代理并执行增强逻辑时把目标方法的信息封装成JoinPoint对象传递给我们定义的增强方法。弄懂了它你才能自由地从切面中读取方法名、参数、方法签名、注解等关键数据。这篇文章适合已经会写基础切面、但想进一步做通用日志、权限、审计、缓存等功能的同学也适合刚学 AOP 时被“JoinPoint 到底是什么”搞晕的初学者。我会把我实际踩过的坑、验证过的写法都放进来尽量给你一份可以直接参考的实操地图。1.1 一句话理解增强类与目标方法在 Spring AOP 的语境里增强类就是我们常说的切面类用Aspect注解标记目标方法则是切点表达式匹配到的、真正被拦截的业务方法。比如我定义一个切面LogAspect它拦截com.example.service.UserService里的所有方法那么getUserById就是目标方法LogAspect里的通知方法就是增强类的方法。目标方法的信息从哪来Spring 容器里实际运行的对象是代理对象当外部调用代理对象的方法时代理会先经过切面逻辑再把调用转发给真正的目标对象。JoinPoint就长在这个转发时刻它记录了“谁在调、调的是哪个方法、参数是什么、调用源是谁”。也就是说增强方法只需要声明一个JoinPoint参数Spring 就会把当前调用链路的信息自动塞进来不需要我们手动注册或查询。这个设计非常巧妙把方法级信息传递变成了框架的“内置能力”。1.2 获取目标方法信息的典型场景其实大部分 AOP 高级功能都离不开目标方法信息。第一个典型场景是操作日志我需要记录“用户张三在 10:25 调用了修改订单方法参数是订单号 1001”这个时候方法名和参数就是刚需。第二个场景是权限校验方法上如果打了RequirePermission(admin)注解切面就得反射拿到这个注解判断当前用户是否具备权限。第三个场景是数据审计修改类操作需要记录修改前后的数据切面需要知道被调方法对应的是哪张表、哪个实体类。第四个场景是缓存处理方法名加上参数作为缓存 key避免不同参数互相污染缓存。你看这些场景全部要在增强类内部访问目标方法。如果只知道“有个拦截点”但拿不到具体信息切面就只能做简单的耗时统计功能天花板很低。所以把这个信息获取链路搞清楚是 AOP 进阶的一个必经路口。2. 核心机制JoinPoint 与 Spring AOP 的工作方式要熟练获取目标方法信息得先理解 Spring AOP 是怎么把信息传进来的。很多人直接照着网上的代码写JoinPoint joinPoint参数却没想过它从哪来导致换一种通知类型就懵了。2.1 通知类型与通知方法参数Spring AOP 一共定义了五种通知类型Before前置通知、AfterReturning返回后通知、AfterThrowing异常通知、After最终通知和Around环绕通知。其中前四种通知方法里都可以直接声明一个JoinPoint参数Spring 会自动注入当前拦截点的信息而Around环绕通知比较特殊必须使用ProceedingJoinPoint它是JoinPoint的子接口额外提供了两个proceed()方法用来继续执行目标方法。这里有个容易踩坑的细节JoinPoint参数必须放在增强方法参数列表的第一位。如果你想通过表达式绑定注解对象或者其他参数需要把它们放在JoinPoint后面比如Before(value annotation(logRecord), argNames joinPoint, logRecord)这种写法。我刚开始就吃过亏把JoinPoint放在第二个位置Spring 启动后直接报“Binding parameter”相关错误提示找不到参数名。另外Around通知方法中proceed()方法是有返回值的它是真实业务方法的返回结果。如果你在环绕通知里把返回值吃掉了调用方的返回值就会变成null。这个后面我会详细说因为这是最容易产生诡异 bug 的地方。2.2 JoinPoint 里到底装了哪些信息JoinPoint接口本身方法不多但每个都很有分量。我用表格梳理一下方便你查阅方法名返回值含义getSignature()Signature获取目标方法的签名对象这是信息核心getArgs()Object[]调用目标方法时传入的参数数组getTarget()Object目标对象被代理的真实业务 beangetThis()Object当前代理对象Spring 中通常就是代理实例getStaticPart()StaticPart静态部分包含方法唯一标识等扩展信息日常用得比较少大多数时候我只用前三个尤其是getSignature()。Signature本身可以拿到方法名getName()、方法声明类名getDeclaringTypeName()、声明类型getDeclaringType()。但它还能进一步向下转型为MethodSignature这是 AOP 里更常用、信息更丰富的签名实现类可以拿到真正的Method对象、参数类型、返回值类型、参数名、异常类型列表等。getArgs()返回的是一个Object数组顺序和实参顺序一致。要注意的是如果目标方法没有参数这个数组是长度为 0 的空数组不是null所以遍历前不需要判空但最好判断一下长度。getTarget()返回的是真实目标对象getThis()返回的是代理对象。在默认情况下两者不是同一个东西尤其使用 CGLIB 代理时目标对象是业务类的实例代理对象是由 Spring 生成的子类实例。这一区别在“目标方法是私有方法时为什么切不到”这类问题上很关键后面排查部分再展开。3. 实操掌握五种获取目标方法信息的姿势这部分就是标题里的重头戏了。我会把五种获取方法挨个讲一遍每条都会配上可以直接运行的代码片段并说明取舍和注意事项。3.1 方法一通过 getSignature() 拿方法名和完整签名最基础、最直接的写法是调用joinPoint.getSignature()。假设有一个业务类UserService里面有个方法getUserById(Long id)我写一个前置通知来获取信息Aspect Component public class MethodInfoAspect { Before(execution(* com.example.service.UserService.*(..))) public void before(JoinPoint joinPoint) { Signature signature joinPoint.getSignature(); String methodName signature.getName(); String declaringTypeName signature.getDeclaringTypeName(); System.out.println(方法名 methodName); System.out.println(声明类名 declaringTypeName); System.out.println(短签名 signature.toShortString()); System.out.println(完整签名 signature.toString()); Object[] args joinPoint.getArgs(); if (args ! null args.length 0) { System.out.println(参数个数 args.length); for (int i 0; i args.length; i) { System.out.println(参数 i args[i]); } } } }getName()拿到的只是方法名比如getUserByIdgetDeclaringTypeName()拿到的是方法声明所在的类全限定名比如com.example.service.UserServicetoString()会输出比较完整的内容类似execution(void com.example.service.UserService.getUserById(java.lang.Long))。如果只是想快速打印请求摘要toShortString()已经够用它输出的是execution(UserService.getUserById(..))这种轻量格式。这段代码看似简单但它是后续所有技巧的基础。我建议你在第一个项目里先把它跑通打印出来的信息能帮你验证切点是否匹配到了预期方法也可以排查切面是否生效。3.2 方法二强转 MethodSignature 拿更完整的方法对象getSignature()返回的是接口类型实际运行时的对象通常就是MethodSignature实现类。所以大多数教程会直接强转Before(execution(* com.example.service..*(..))) public void getMethodInfo(JoinPoint joinPoint) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); Class? returnType signature.getReturnType(); Class?[] parameterTypes signature.getParameterTypes(); String[] parameterNames signature.getParameterNames(); System.out.println(方法对象 method); System.out.println(返回类型 returnType.getName()); System.out.println(参数类型 Arrays.toString(parameterTypes)); System.out.println(参数名 Arrays.toString(parameterNames)); }拿到Method对象之后你的自由度就大大增加了可以反射调用、读取方法注解、判断方法是否是final、是否带泛型等等。getParameterNames()能拿到参数名但这里有个前提项目编译时必须保留方法参数名否则返回的是arg0、arg1这类占位名。关于怎么配置编译参数我在后面的常见问题里专门说。这里要提醒一个容易忽略的点如果目标方法是接口的默认方法或者是泛型桥接方法method可能不是你肉眼看到的那个实现类方法。比如实现类没有重写但通过继承得到的方法或者 Java 编译器生成的桥接方法。处理这类情况时最好配合AopUtils.getMostSpecificMethod(method, targetClass)找到真正匹配目标对象的方法不然拿注解、判断修饰符时可能出错。3.3 方法三环绕通知中使用 ProceedingJoinPoint 控制流程环绕通知是 AOP 里功能最全的一种它可以在目标方法执行前后自定义逻辑而且能替换参数、捕获返回值、捕获异常。它的增强方法参数必须是ProceedingJoinPoint注意不是JoinPoint。Aspect Component public class AroundAspect { Around(execution(* com.example.service.OrderService.createOrder(..))) public Object around(ProceedingJoinPoint pjp) throws Throwable { MethodSignature signature (MethodSignature) pjp.getSignature(); String methodName signature.getMethod().getName(); Object[] args pjp.getArgs(); System.out.println(准备执行方法 methodName 参数 Arrays.toString(args)); // 可以在这里修改参数 for (int i 0; i args.length; i) { if (args[i] instanceof String) { args[i] ((String) args[i]).trim(); } } Object result null; try { // 执行目标方法并传入修改后的参数 result pjp.proceed(args); } catch (Exception e) { System.out.println(方法执行异常 e.getMessage()); // 这里可以决定是抛出还是吞掉 throw e; } System.out.println(方法执行完成返回值 result); return result; } }pjp.proceed()在没有参数时用原参数执行目标方法pjp.proceed(args)可以让你替换参数后执行。返回结果一定要原样返回除非你有特殊需求。我见过有人把返回值包装成一个操作日志对象后再返回结果导致调用方类型转换异常这种问题非常隐蔽。有一个高频 bugAround方法里忘记调用proceed()。这时候目标方法不会执行但如果你直接返回null调用方不会报错只会拿到一个null结果逻辑表现为“方法好像被吞了”排查起来很头疼。所以写环绕通知时第一件事就是确认proceed()是否被调用。3.4 方法四获取目标类与目标实例有时候信息全在目标对象上。比如我要记录某个操作涉及哪个类或者要拿到目标对象的属性可以用getTarget()Before(execution(* com.example.service..*(..))) public void getTargetInfo(JoinPoint joinPoint) { Object target joinPoint.getTarget(); Object proxy joinPoint.getThis(); System.out.println(目标对象类 target.getClass().getName()); System.out.println(代理对象类 proxy.getClass().getName()); if (target instanceof BizService) { BizService bizService (BizService) target; System.out.println(可访问业务属性 bizService.getBizName()); } }这里有个概念很容易混getTarget()是真正的目标对象而getThis()是 Spring 容器里的代理对象。当你打印类名时proxy.getClass()通常是一个包含$$的 CGLIB 子类比如com.example.service.BizService$$EnhancerBySpringCGLIB而target.getClass()才是我们写的BizService。如果想在切面里拿到目标对象的某些属性直接强转getTarget()通常没问题前提是你切面代码和目标类编译在一起。另外一个更稳妥的方法是用反射避免强转引发ClassCastException。3.5 方法五获取目标方法上的注解注解获取是最常用的能力之一也是很多人在网上搜了半天才搞明白的点。最笨但直观的办法是先从MethodSignature拿到Method再调用method.getAnnotation(...)Before(execution(* com.example.controller..*(..))) public void readAnnotation(JoinPoint joinPoint) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); LogRecord logRecord method.getAnnotation(LogRecord.class); if (logRecord ! null) { System.out.println(业务模块 logRecord.module()); System.out.println(操作类型 logRecord.operation()); } }如果目标方法没有加注解getAnnotation返回null所以判断时必须有null检查。这个方法的问题在于如果方法是通过接口定义的而接口方法上没有注解、实现类方法上有注解由于 Spring AOP 默认基于接口代理时拿到的Method可能是接口方法此时getAnnotation可能拿不到实现类的注解。解决方案是配合目标类使用AopUtils.getMostSpecificMethod找到具体方法或者直接使用 Spring 提供的AnnotationUtils.findAnnotation(method, annotationType)它能处理桥接方法和继承场景。更高级的写法是让注解信息直接作为增强方法的绑定参数出现。切点表达式用annotation关键字Before(annotation(logRecord)) public void bindAnnotation(JoinPoint joinPoint, LogRecord logRecord) { System.out.println(模块 logRecord.module()); System.out.println(操作 logRecord.operation()); // 使用 joinPoint 继续获取方法名、参数等 }注意这里的切点匹配的是“方法上有该注解”无论方法是普通方法还是自定义注解类型只要有这个注解就能切进来。这种写法不仅性能更好语义更清晰还省掉了反射取注解的步骤。我在通用日志组件里一直用这个方案强烈推荐。4. 完整示例从依赖到切面再到测试讲了那么多方法不如完整搭一个最小项目。我把从依赖到验证的整套步骤写出来你照着做一遍基本就能把所有获取方式串起来了。4.1 环境准备与依赖配置我以 Spring Boot 3.2 为例。创建一个 Maven 项目后在pom.xml里引入 AOP starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependencySpring Boot 的AopAutoConfiguration会自动开启EnableAspectJAutoProxy所以不用手动加这个注解。如果你是传统 Spring MVC 项目就需要自己在配置类上加上EnableAspectJAutoProxy。对于 Spring Boot 3还需要保证 JDK 17 以上版本。为了让getParameterNames()返回真实参数名我建议在pom.xml的build节点里显式配置编译参数plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin4.2 定义切点和切面我定义了一个OperationLogAspect它拦截带LogRecord注解的方法并把目标方法的信息统一打印出来模拟操作日志的采集过程。Aspect Component public class OperationLogAspect { Around(annotation(logRecord)) public Object around(ProceedingJoinPoint pjp, LogRecord logRecord) throws Throwable { MethodSignature signature (MethodSignature) pjp.getSignature(); String methodName signature.getMethod().getName(); String targetClassName pjp.getTarget().getClass().getSimpleName(); long startTime System.currentTimeMillis(); Object result null; try { result pjp.proceed(); } finally { long costTime System.currentTimeMillis() - startTime; System.out.println([日志记录] 模块 logRecord.module() , 操作 logRecord.operation() , 类 targetClassName , 方法 methodName , 参数 Arrays.toString(pjp.getArgs()) , 耗时 costTime ms); } return result; } }LogRecord注解定义如下Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface LogRecord { String module(); String operation(); }代码里用了Around(annotation(logRecord))其中logRecord是参数名它会在运行时被绑定为实际方法上的注解对象。如果编译器禁止参数名需要用Around(value annotation(logRecord), argNames pjp, logRecord)明确指定参数顺序。4.3 编写业务代码验证效果创建一个简单的OrderService在方法上加上自定义注解Service public class OrderService { LogRecord(module 订单模块, operation 创建订单) public String createOrder(String orderNo, Double amount) { return 订单创建成功 orderNo 金额 amount; } }写一个测试入口调用它可以是单元测试也可以是一个简单的 Spring Boot 启动类SpringBootApplication public class DemoApplication { public static void main(String[] args) { ConfigurableApplicationContext context SpringApplication.run(DemoApplication.class, args); OrderService orderService context.getBean(OrderService.class); orderService.createOrder(A1001, 99.9); } }启动后控制台会出现类似输出[日志记录] 模块订单模块, 操作创建订单, 类OrderService, 方法createOrder, 参数[A1001, 99.9], 耗时12ms这个例子虽然短但它把环绕通知、注解绑定、MethodSignature、getTarget()、getArgs()全串起来了。日常开发中我们还会把输出换成日志框架把耗时和参数序列化成 JSON 存到数据库或消息队列。5. 常见问题与排查技巧实录这部分是没法在任何官方文档里直接抄到的“实战经验本”。我把这些年遇到的典型问题和排查思路整理成速查表然后针对高频问题单独展开。现象常见原因排查方向切面完全不执行切点表达式写错、没有引入AOP模块、切面类未被Spring管理检查表达式、依赖、Component参数名是arg0/arg1编译时没有保留参数名配置-parameters参数方法注解拿不到接口代理导致得到的Method不是实现类方法用AopUtils或annotation绑定业务方法没执行Around中忘记调用proceed()检查环绕通知逻辑业务方法重复执行Around中多次调用proceed()检查循环、条件分支返回值变成null环绕通知没有返回proceed()的结果检查return语句增强方法参数绑定异常JoinPoint位置不对或argNames缺失调整参数顺序5.1 切面不生效的常见原因先看切点表达式很多新手会在execution里写错包名。比如包名是com.example.service表达式写成了com.exmaple永远匹配不上。我习惯在带连接符的项目中特别小心建议用Pointcut定义切点后先不写逻辑启动应用看是否报错或者加一层日志确认拦截路径。再看切面类有没有加Component或类似注解。Aspect只是告诉 Spring “这是切面”但不会自动注册为 Bean必须配合Component。Spring Boot 组件扫描没扫到也会导致不生效需要确认切面类所在包在启动类的子包下或者用Import显式注册。最后要提“自调用”问题。如果在目标类内部直接调用另一个方法比如UserService的createUser()里调用了this.updateUser()这次内部调用不会经过代理所以切面不生效。解决办法是注入自身代理或者拆到另外的类去调用。这是 AOP 的经典陷阱。5.2 参数名变成 arg0、arg1 怎么办如果你的切面用methodSignature.getParameterNames()拿到的是[arg0, arg1]说明编译产物中没有包含参数名信息。JDK 8 引入了-parameters编译参数Maven 里可以通过 compiler plugin 的parameterstrue/parameters开启。IDEA 中也可以在 Settings 里搜索 “Compiler” 中的 “Store information about method parameters” 并勾选然后重新编译。还有一个备选方案如果项目不方便重新编译可以通过method.getParameters()拿Parameter对象再通过ParamNameDiscoverer或者 ASM 解析字节码来获取参数名。但这个方法成本偏高不建议一开始就做最好的方式还是把编译参数配上。5.3 注解怎么就是拿不到原因多半出在“方法来自接口”上。当 Spring AOP 使用 JDK 动态代理时切面拿到的Method是接口方法实现类方法上的注解自然读不到。解决办法有两个一是把注解也放在接口方法上如果你的项目允许二是用AopUtils.getMostSpecificMethod()获取实现类方法后再读注解。我推荐一个更优雅的方案直接使用annotation(logRecord)绑定,让框架自己处理注解归属不依赖反射定位。还有一个细节自定义注解的Retention必须是RUNTIME如果写成了CLASS或SOURCE运行时反射是拿不到的。这个问题排查起来更隐蔽因为代码编译没问题但运行结果就是一坨 null。务必检查注解定义。5.4 环绕通知的“坑”proceed 到底调几次Around里proceed()每次调用都会触发目标方法执行。如果你在拦截方法里写了循环、条件分支可能导致业务方法执行多次。最典型的错误是try { pjp.proceed(); } finally { pjp.proceed(); // 又执行了一次 }还有人在捕获异常后为了“重试”而再次调用proceed()这虽然可行但要非常小心因为业务方法可能产生副作用。正确做法是只调用一次把返回值用变量保存切面逻辑放在try/finally或try/catch中最终把保存的返回值返回出去。另外proceed()声明的异常类型是Throwable所以切面方法签名最好也写成throws Throwable。6. 我对这个功能的理解与建议聊到这里Spring AOP 的“目标方法信息获取”已经从接口一层一层剥到了注解绑定。这些年用下来我的体会是信息获取方式没有绝对的标准答案关键看你的使用场景和代码风格。6.1 做一个通用的日志切面时信息收集要适度我看过不少项目把整个参数数组、返回对象甚至方法详情一股脑字符串拼接到日志里结果线上日志几秒就刷一个 GB。目标方法信息虽然丰富但真不是每次都值得完整记录。比如分页查询方法通常只记录页码和大小修改操作则要把核心业务 ID 记下来。我建议在切面里做“降噪设计”可以给注解增加一个ignoreParam属性或者只记录joinPoint.getArgs()中实现了toString的对象。信息收集越克制日志可用性越高。6.2 几个提高效率的切入点技巧如果你需要频繁使用目标方法信息有几个组合拳值得记下来。第一优先用annotation绑定注解既减少反射又更语义化第二参数名尽量靠编译参数开启而不是运行时去猜第三需要校验方法时用MethodSignature.getMethod()再配合AopUtils.getMostSpecificMethod处理桥接和代理场景第四环绕通知是通用逻辑的“黄金钩子”信息收集、异常处理、耗时统计都能在前后完成尽量把所有业务切面统一成一种通知类型避免Before和AfterReturning里重复拿信息。我现在的套路是外部接口统一入口用Around内部业务方法尽量用注解切点整个切面代码干净很多排查问题时也一目了然。这套东西看起来简单但真正到线上问题时细节都是被逼出来的。希望你少踩几个我踩过的坑一次就写好你的第一个信息增强切面。
返回列表