ARTICLE DETAIL

资讯详情

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

一文搞懂AOP:切面、通知与动态代理原理

一文搞懂AOP:切面、通知与动态代理原理 最近好几个准备跳槽的同行跑来问我同一个问题到底什么是AOP有些人已经背过了“面向切面编程”这个定义但真把一段业务代码放到他面前让他说清楚切面应该切哪里、底层又是怎么把通知织进去的就含糊了。AOPAspect Oriented Programming面向切面编程解决的核心问题就是把日志、权限校验、事务管理、性能统计这类散落在各个业务方法里的公共逻辑从业务代码中剥离出来统一放到“切面”里处理。这篇文章我不打算给你堆术语而是直接从一段让人头疼的重复代码讲起把AOP的整套思路、底层代理机制、使用场景和常见面试题一次说清楚适合刚开始学Spring的初学者也适合要系统梳理AOP原理准备面试的同学。1. 从复制粘贴的日志代码到横切关注点1.1 基础设施代码正在淹没核心业务很多同学第一次感受到AOP的价值都源于一个非常朴素却极其真实的场景——重复代码太多了。想象你正在维护一个订单服务里面至少十几个公开方法。每个方法进来第一件事打日志中间可能要做权限判断最后还得包一层事务。如果不借助任何框架能力你写出来的代码大概是这个样子public Order createOrder(OrderDTO dto) { // 日志 System.out.println([createOrder] start, params: dto); // 权限校验 if (!SecurityUtil.hasPermission(ORDER_CREATE)) { throw new ForbiddenException(无权限); } long start System.currentTimeMillis(); try { // 真正的业务逻辑 Order order doCreate(dto); // 耗时统计 System.out.println([createOrder] cost: (System.currentTimeMillis() - start) ms); return order; } catch (Exception e) { // 异常日志 System.out.println([createOrder] error: e.getMessage()); throw e; } }这样的代码写一个还好写十个呢更麻烦的是后面如果你想调整日志格式或者把权限校验的逻辑换一套就得打开十几个文件逐个改动。要是哪天某个方法漏贴了日志代码排查线上问题的时候就只能干瞪眼。我见过一个老项目一个Controller类里所有方法前面的日志打印几乎都是复制粘贴出来的加一个入参就要全局搜索替换接口签名改完之后被漏掉的那几个方法就成了下次事故的导火索。1.2 横切关注点OOP的纵向模块化解不了横向问题要理解AOP得先分清一个概念横切关注点Cross-cutting Concerns。对象导向编程OOP的模块化是纵向的它按照业务领域给系统切块订单是一块用户是一块商品是一块。日志、权限、事务这些逻辑呢它们不属于任何一个单一领域而是横向贯穿了订单、用户、商品所有模块。你没办法把它完整地塞进订单模块里因为用户模块也要打印日志商品模块也要做权限校验。这种逻辑在架构里有个专门的名字叫横切关注点。它们有个共同特点如果不做处理要么把所有业务代码都“污染”一遍要么就得靠人肉纪律来保证每个方法都手动调用公共方法。OOP处理不了这个问题的根因是它的继承和组合机制只会把公共代码下沉到父类或工具类但“在哪一个方法上调用”这件事还是得开发者自己控制。1.3 提取公共方法的局限在哪有同学说我把日志封装成LogUtil.info()然后在每个方法里调一行不也解决了吗确实抽公共方法能解决实现方式重复的问题但解决不了调用位置重复的问题。你仍然要在每个方法里显式写一行调用代码。这时候AOP换了个思路既然“在哪些方法上做日志”这件事是可以描述的那我能不能把这些描述告诉框架让框架在运行的时候自动往目标方法上加逻辑业务代码彻底不用关心AOP的回答是“可以”。它把“在哪些方法上增强”定义成切点把“增强什么逻辑”定义成通知把“切点和通知的结合体”定义成切面再由框架在合适的时机完成织入。这一整套机制下来业务方法的核心代码干干净净公共逻辑统一治理。理解了这个初衷后续的所有概念都只是在不同层面回答同一个问题怎么把横切逻辑和业务逻辑安全地分离。2. 五个核心术语用一个登机流程说清楚2.1 连接点、切点、通知、切面、织入AOP有五个核心术语初学者很容易搞混。先记住一句话切点决定切哪里通知决定干什么切面是把二者组装起来的成品织入是组装的时机。我把这五个术语用机场安检来打比方。你从值机到登机中间经过安检口安检员会检查你的证件和随身行李。在这个场景里连接点Join Point所有可能被检查的位置比如值机、托运、安检、登机口。在AOP里连接点就是程序执行过程中的某个点Spring AOP中通常指方法调用。切点Pointcut实际上要拦截的位置比如“只有国际航班的安检口才检查签证”。切点是一个表达式用来筛选连接点。通知Advice安检员的具体检查动作比如核对护照、扫描行李。对应到代码里就是你要在目标方法前后执行的逻辑。切面Aspect把“国际航班安检口”切点和“核对护照”通知组合起来的完整模块。织入Weaving在什么时候把安检员安排到安检口上岗。对应AOP就是在运行期把通知逻辑应用到目标方法的过程。概念关系可以用一个通俗的公式表达切面等于切点加通知。当你说自己写了一个切面时你其实同时指定了两样东西——在哪些方法上生效切点以及生效时执行什么逻辑通知。2.2 一个下单方法的完整对照还是用订单系统举例。假设我要统计所有下单方法的耗时最简单的方式是定义一个切面切点指向OrderService类里的所有public方法通知使用环绕通知在目标方法前后各取一次时间Aspect Component public class OrderCostAspect { Around(execution(* com.example.service.OrderService.*(..))) public Object recordCost(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); try { // 调用真正的目标方法 Object result pjp.proceed(); return result; } finally { long cost System.currentTimeMillis() - start; System.out.println(方法 pjp.getSignature().getName() 耗时 cost ms); } } }这个类就是切面。Around注解里的字符串就是切点表达式execution(* com.example.service.OrderService.*(..))的意思很直白匹配com.example.service包下OrderService这个类的任意方法对参数不做限制。被Around修饰的recordCost方法就是通知它会在匹配到的目标方法执行前后发挥作用。pjp.proceed()是接住目标方法的执行流程你现在在它前边写的代码会在目标方法之前运行后边的代码在目标方法返回或抛出异常之后运行。连接点在这里指的是OrderService里的所有方法因为按照切点表达式它们都是可能被拦截的方法切点筛出的实际拦截范围就是这个类里的全部方法。当Spring容器启动完毕、OrderService被调用时框架完成织入把recordCost这个通知逻辑和createOrder等目标方法绑定到一起耗时统计就开始生效了。整个过程你在OrderService里看不到任何一行统计逻辑的代码这正是AOP想要的效果。2.3 通知类型的细节差异通知有五种类型写切面时最常用的是Around但其他几种也有各自的适用场景。简单区分Before目标方法执行前执行。适合做权限校验、参数校验但如果这里抛出异常会影响目标方法的执行。AfterReturning目标方法正常返回后执行可以在注解里通过returning指定返回值参数。适合记录成功日志或对返回结果做加工。AfterThrowing目标方法抛出异常后执行适合记异常日志注意它不一定能捕获所有异常因为有些框架会先把异常处理掉。After目标方法结束后执行类似finally块无论正常返回还是抛异常都会执行。Around可以完全控制目标方法的调用时机前后逻辑都能写。理论上其他四种都能用它实现但语义上不如专用注解清晰。一段真实的经验是能用AfterReturning或AfterThrowing表达的场景尽量不要全都用Around否则整个切面都是ProceedingJoinPoint.proceed()的包装读起来很累。比如异步任务执行完要更新状态这种逻辑用AfterReturning一眼就能看出意图。3. 底层原理JDK动态代理和CGLIB为什么必须在运行期织入3.1 没有动态代理普通类无法被运行时增强AOP的概念本身并不依赖Java但Spring AOP能在运行期给普通Bean增添行为靠的是Java的动态代理能力。一个最常见的误解是AOP就是反射、注解加上拦截器。这个说法不够准确反射和注解只是描述元数据的手段真正让“调用目标方法前先执行一段通知”这件事成立的是容器里那些Bean根本不是原始对象而是代理对象。当Spring创建被切面匹配到的Bean时它检测到该Bean需要被增强于是不直接返回原始对象而是根据目标类的情况生成一个代理对象放入容器。外部调用方从容器里拿到的、注入到其他Bean里的都是这个代理对象。代理对象内部先执行通知逻辑再通过invoke或invokeSuper把实际调用转发给原始对象。因为绝大多数Spring应用都通过容器管理Bean调用方不直接new对象所以这种运行时织入策略可以无侵入地生效。3.2 JDK动态代理只认接口JDK动态代理是Java标准库自带的代理方式核心是java.lang.reflect.Proxy和InvocationHandler。它有个硬性限制只能为接口生成代理。如果你的目标类实现了接口比如OrderService实现了OrderServiceInterface那么JDK动态代理会创建一个新的类也实现这个接口内部持有一个InvocationHandler。外部通过接口方法调用时所有方法都会被转发给handler的invoke方法。public class JdkProxyDemo { public static void main(String[] args) { OrderServiceInterface target new OrderServiceImpl(); OrderServiceInterface proxy (OrderServiceInterface) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxyObj, method, methodArgs) - { System.out.println(前置通知); Object result method.invoke(target, methodArgs); System.out.println(后置通知); return result; } ); proxy.createOrder(); } }这种实现方案在Spring早年被广泛使用因为它不依赖第三方库生成的代理类结构也清晰。但问题同样明显如果目标类压根没实现接口就没办法用JDK动态代理了。早年很多同事写Service层习惯先写接口再写实现其中一个原因正是为了满足JDK动态代理的需要。3.3 CGLIB用继承生成子类代理CGLIBCode Generation Library走的是另一条路它通过动态生成目标类的子类来实现代理。代理类是目标类的子类在代理类中重写父类能被重写的方法在重写的方法里插入通知逻辑再调用super的原始方法。这种方式不要求目标类实现接口适用范围更广。但代价也很明确目标类不能是final因为final类无法被继承。目标方法不能是final因为final方法无法在子类中重写。对私有方法不适用因为子类也看不到父类的私有方法。这里有一个早期Spring版本踩过的坑CGLIB代理对目标类的构造方法调用策略有过多次变更Spring 4.0之后才开始使用objenesis绕过构造方法来创建代理对象如果没有正确配置某些带复杂构造器参数的类在生成代理时容易报错。好在新版本框架基本处理好了这些问题但对原理的理解仍然能帮你在排查诡异问题时快速定位方向。3.4 Spring到底怎么选择代理方式的Spring AOP的代理选择逻辑经历过明显变化。早期的规则是目标类实现了接口就用JDK动态代理没有实现接口就直接用CGLIB。后来Spring Boot时代全面默认使用CGLIB作为代理实现除非你在配置里显式指定proxyTargetClassfalse并要求目标类有接口。为什么Spring Boot更偏爱CGLIB最现实的原因是很多类确实没实现接口比如写Controller时大部分人只写类不写接口而且同一个接口如果有多个实现JDK动态代理就只能按接口代理Spring仍然需要额外判断哪个实现是被增强的目标。一个经常被忽视的坑是如果你依赖了aop相关依赖但目标类没有接口且类或方法带有final修饰在旧版本Spring中切面日志不会生效但也不会报错看起来像是“切面压根没跑”。遇到这种情况第一反应应该就是去查目标类的final修饰符。Spring AOP和AspectJ的关系也值得在这里说清楚。Spring AOP只支持方法级别的拦截而且只能在运行时通过代理实现切面逻辑AspectJ则是一个更完整的AOP框架可以修改字节码在编译期、类加载期完成织入支持字段访问、构造函数调用等更细粒度的连接点。Spring官方只是借用了AspectJ的注解语法比如Aspect、Around这些注解定义在aspectjweaver里实际运行时仍用自己的代理机制这种模式通常被称为“Spring AOP with AspectJ annotations”。这两个概念面试不要混为一家。4. 实际项目中用得最多的三种自定义切面4.1 统一日志与耗时统计最常见的场景还是日志。写一个贴合团队需求的日志切面需要完整输出调用链路类名、方法名、入参、返回值、耗时。在实现时有一个经验值得分享入参不要直接打印整个对象数组因为某些对象重写了toString且包含敏感信息要么统一序列化并脱敏要么只选择安全的基础类型字段。一个相对完善的写法大致如下Aspect Component public class AccessLogAspect { private static final Logger log LoggerFactory.getLogger(AccessLogAspect.class); Around(annotation(org.springframework.web.bind.annotation.RequestMapping)) public Object logController(ProceedingJoinPoint pjp) throws Throwable { // 获取方法签名 MethodSignature signature (MethodSignature) pjp.getSignature(); long start System.currentTimeMillis(); try { Object result pjp.proceed(); log.info([访问日志] 请求: {}.{} 耗时: {}ms 返回: {}, signature.getDeclaringType().getSimpleName(), signature.getName(), System.currentTimeMillis() - start, JSON.toJSONString(result)); return result; } catch (Throwable e) { log.error([访问日志] 请求: {}.{} 异常: {}, signature.getDeclaringType().getSimpleName(), signature.getName(), e.getMessage()); throw e; } } }这里切点表达式用到了annotation(...)凡是标注RequestMapping的方法都会被拦下来相比execution匹配所有Controller方法它更灵活也天然对新加的Controller生效。注意catch到异常后一定要重新抛出否则业务上的异常会被切面吞掉调用方得到的就永远是成功的响应了。4.2 基于自定义注解的权限校验权限校验几乎是AOP除日志之外最经典的落地场景。思路是先自定义一个注解比如RequirePermission(order:create)然后在需要控制的接口方法上标注它切面负责在目标方法执行前检查当前登录用户的权限集合Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String value(); }Aspect Component public class PermissionAspect { Before(annotation(requirePermission)) public void checkPermission(JoinPoint joinPoint, RequirePermission requirePermission) { // 从当前登录上下文获取用户 User currentUser LoginContext.getCurrentUser(); if (currentUser null || !currentUser.getPermissions().contains(requirePermission.value())) { throw new ForbiddenException(无权限访问: requirePermission.value()); } } }这里注意annotation(requirePermission)这个写法的细节它表示把方法上的注解对象作为同名参数传入通知方法如果你写成annotation(com.example.annotation.RequirePermission)通知方法里就接收不到这个实例了。权限校验放在Before里很合理因为一旦校验失败就不应该执行核心业务逻辑。真正生产环境还会配合Spring Security或Shiro但自定义注解加切面的思路在黑名单管理、操作审计场景仍然很好用尤其是需要兼容老系统的权限改造时。4.3 分布式锁与接口幂等事务、缓存、异步这些能力在Spring里很多本身就是靠AOP实现的。比如Transactional会注册一个事务相关的切面Cacheable注册了缓存切面。你自己动手写一个分布式锁切面会发现在扣库存、积分赠送这些操作上特别有价值通过注解指定锁的key切面在方法执行前加锁执行后释放锁业务方法内部不需要关心加锁失败怎么处理。Around(annotation(distributedLock)) public Object handleLock(ProceedingJoinPoint pjp, DistributedLock distributedLock) throws Throwable { String lockKey SpELParser.parse(distributedLock.key(), pjp); boolean locked lockClient.tryLock(lockKey, distributedLock.waitTime(), distributedLock.leaseTime()); if (!locked) { throw new BizException(系统繁忙请稍后再试); } try { return pjp.proceed(); } finally { lockClient.unlock(lockKey); } }这个切面的核心价值不只是“加锁释放锁”而是把加锁逻辑和业务逻辑解耦了。新人来写业务时只要在方法上加一个注解、配好key的参数表达式就能获得统一的分布式锁能力不用再关心锁客户端API怎么调、异常后锁有没有释放。这也印证了AOP最核心的价值主张把横切关注点沉淀成团队能力而不是每次都在业务里重复低水平实现。5. 切面不生效这五个坑我踩过5.1 同类内部方法调用导致切面失效这是AOP新手遇到最多的诡异问题。同一个类里方法A加上了切面方法A内部又调用方法BB也加了切面结果B的切面没执行。原因是Spring AOP的代理机制容器里注入的是代理对象但方法A是在目标对象内部执行的它内部的this指向的仍然是原始对象调用this.b()时根本没有经过代理对象通知自然无法接入。解决方式有三种。第一种是把B单独拆到另一个Bean里去通过注入那个Bean来调用这是最推荐的做法也符合“不同横切关注点拆开”的设计原则第二种是Transactional这类注解开启exposeProxytrue后使用AopContext.currentProxy()拿到当前代理对象再调用第三种是放弃同类调用在切面代码里通过pjp.getTarget()反射调用同样会再次触发拦截很多场景下会造成死循环这个方案更适用于高级扩展而非常规业务。5.2 切点表达式写宽或写窄execution表达式的语法看起来简单写起来容易出错。几个关键节点execution(* com.example..*Service.*(..))和execution(* com.example.*Service.*(..))的区别不只是从单个包变成子包匹配的问题。com.example..表示com.example及其所有子孙包com.example.只匹配com.example这个包下直接声明的类。如果你希望管理模块下所有Service接口的实现类方法通常要写成execution(* com.example.manager..*Service.*(..))这类的形式。还有一个细节是返回类型匹配。*代表任意返回类型但如果你写成void就只能匹配返回值是void的方法void在切点里也是合法的返回类型描述符。我在实际项目里犯过的错是想匹配User getXxx()这样的方法但切点写成了get*然后发现连getAllUsers()这种不带参数的方法也没被拦截后来才意识到切点匹配的是完整的方法名通配而不是方法名开头。排查这类问题优先使用Spring的切点匹配日志或者临时在通知方法里打印joinPoint.getSignature()看实际匹配到的方法列表。5.3 final修饰与代理模式冲突接上之前讲过的底层机制如果你明确配置了CGLIB代理但目标类是final或者目标方法是final代理生成阶段就会出问题。Spring Boot 2.x以后默认强制CGLIB所以一个被标记为final的Service类在启动时可能直接抛出Final method cannot be mocked或代理创建失败这类错误。有些情况下错误不明显框架会退化为普通Bean切面静默失效这时候排查方向就会跑到切点表达式上去。建议项目里明确一个约束被AOP管理的类不添加final修饰不在切点匹配的方法上加final。5.4 多切面的执行顺序当一个方法同时被多个切面命中比如日志切面、权限切面、分布式锁切面它们的执行顺序如果没有约定就会出现“权限没校验就先打印了日志”“锁还没释放就发出了埋点事件”这类奇特的现象。Spring用Order注解或实现Ordered接口控制顺序数值越小越先执行。一个实践中合理的顺序是事务和分布式锁这类资源型切面排最外层权限校验其次参数校验再次日志统计最内层贴近业务。这个顺序的含义是先拿锁、再校验权限、处理业务、最后记日志避免在无权限访问时白白加锁或产生误导性的成功日志。5.5 Spring AOP只能拦截容器管理的Bean自己用new关键字创建的对象肯定不会被Spring AOP管理。Spring AOP的动态代理是在Bean创建和初始化阶段完成的只有从容器里拿Bean才能拿到代理对象。如果你在工具类里直接new OrderServiceImpl()然后调用带切面的方法切面一定不会生效。这一点很基础但真出问题时容易被忽略尤其是配合一些老的静态工厂写法。6. 面试高频IoC和AOP的原理怎么答才能加分6.1 面试官到底在问什么热搜里“ioc和aop的原理面试”指的就是Spring两大核心特性IoC控制反转和AOP。面试官问这个问题的目的通常有三个层次第一看你能不能从定义层面说清楚它们分别解决什么问题第二看你有没有真正理解Spring容器如何把Bean创建好、如何把代理对象注入进来第三看你在项目里有没有实际用过AOP以及能不能说出动态代理的底层细节。很多人背了大量概念仍然拿不到好评价问题就出在第三个层次。一个合格的回答应该包含完整链条IoC让开发人员从自己new对象、自己管理依赖变成把控制权交给容器由容器负责创建和注入依赖AOP则是在这个基础上为容器创建的Bean增加运行时增强能力核心实现是动态代理。打个比方IoC回答的是“对象从哪里来”的问题AOP回答的是“对象的行为能不能在不动代码的情况下被扩展”的问题。6.2 实战向答题框架参考第一层先用一句话给定义面向切面编程核心是把日志、事务、权限等横切关注点从业务逻辑中抽离出来通过切面和通知对方法进行统一增强。第二层讲清楚为什么需要它OOP解决纵向模块化问题但无法解决日志这类横向贯穿多个模块的重复逻辑AOP通过横切解决这个问题。第三层说底层原理Spring AOP在运行时通过JDK动态代理或者CGLIB生成代理对象把通知织入到目标类的方法执行前后。JDK动态代理要求目标类实现接口CGLIB通过生成子类来代理Spring Boot默认使用CGLIB。如果目标类或方法是finalCGLIB会失败。第四层说明Spring AOP与AspectJ的关系自己基于动态代理实现方法级别拦截只是复用了AspectJ的注解语法完整的AspectJ还能在编译期修改字节码支持更细粒度的连接点。第五层结合自己项目给例子可以说项目里使用自定义注解加切面实现了接口幂等性判断或者用切面统一记录Controller访问日志、统计耗时。说具体一点比如切点怎么写的、通知选的哪一种、遇到过什么坑这比背出所有概念更能让面试官确认你是真的用过。围绕“aop使用场景”这个热搜点重点讲一两个最能体现切面价值的独立场景没必要把日志、权限、事务、缓存全背一遍。6.3 “aop面向界面编程”这个说法是哪来的对了热搜里还有一个“aop面向界面编程”的说法可能是输入法问题或者早期翻译版本造成的误解。AOP里的A是Aspect不是Interface。Aspect在这里是切面不是界面或接口。一定要记住正确说法是“面向切面编程”面试时嘴瓢说出“面向切片”“面向切块”都还可以接受说“面向界面”会让面试官怀疑你概念来源有问题。7. 我的一点实操总结AOP这个概念其实不难难的是把“动态代理”和“织入时机”这种抽象的运行机制和实际开发结合起来。我自己的实践经验是刚开始用AOP时会克制一点只做日志和权限校验两个最直观的场景等理解了代理失效的边界、切点表达式的匹配规则之后再逐步把分布式锁、幂等控制、审计记录这些更复杂的能力用注解加切面的方式沉淀下去。用AOP的正确姿势永远是“切面替业务扛住横切逻辑”而不是“把一切逻辑都往切面里塞”。项目里出现过把非常厚重的业务计算往通知里写导致排查问题时切面代码成了第二业务代码的教训这是最需要避免的。如果你正在学习Spring或者准备面试按照这篇文章的顺序把问题想清楚先想它解决什么问题再看它怎么实现最后找一个自己的项目场景亲手写一个切面试试这套知识就真正内化了。
返回列表