Java 圈里一聊到 AOP,默认语境基本都是 Spring AOP,这本身没什么毛病,Spring 在 Java 服务端的话语权摆在那里。但最近问 Solon 的人明显变多了,尤其是那些要选型、想从 Spring Boot 往国产轻量框架迁移的团队,上来第一句基本都是:“Solon 的 AOP 和 Spring AOP 是不是一回事?我的切面代码能直接搬过去吗?”这个问题我在好几个技术群里都见人问过,但网上很少有把两套方案掰开揉碎讲清楚的文章。今天就从原理、写法、边界、性能和选型几个维度,把这两套 AOP 的差别彻底聊透,想弄明白的可以直接拿这篇当参考。
1. 机制本质拆解:动态代理与字节码增强
1.1 Spring AOP 是怎么实现“切”的
Spring AOP 的核心是运行时动态代理,具体分两条路:JDK 动态代理和 CGLIB。JDK 代理要求目标对象至少实现一个接口,它在运行时通过Proxy.newProxyInstance()为接口生成一个代理类,所有调用都先进InvocationHandler.invoke(),再由你去决定是直接放行还是塞入切面逻辑;CGLIB 则不走接口,它直接以目标类为父类,在运行时生成一个子类,然后重写目标方法,调用时通过MethodInterceptor转发。
Spring 的切面能生效,依赖一个关键组件:AbstractAutoProxyCreator这类BeanPostProcessor。Bean 正常经历实例化、属性填充、初始化之后,Spring 会拿容器里所有 Advisor(切面通知的载体)挨个做匹配,匹配上了就用ProxyFactory创建代理对象。所以你最终从容器里拿到的 Bean,很多时候根本不是你写的那个类的原始实例,而是一个代理包装过的对象。面试喜欢问的“Spring AOP 为什么默认对接口生效”,本质上就是这么来的。
Spring Boot 2.x 以后做了一个比较重要的默认值调整:spring.aop.proxy-target-class=true,也就是默认走 CGLIB 方案,哪怕目标类实现了接口,也尽量用子类代理,避免接口代理遇到的那些强转和接口演化问题。但底层依然是“运行时生成代理对象”那一套,区别只是实现手法不同。
1.2 Solon AOP 的 ASM 增强是怎么一回事
Solon 是国产的轻量级 Java 框架,设计目标就是“更小、更快、更少依赖”。在 AOP 这块,它没有沿用 Spring 的动态代理思路,而是直接采用ASM 字节码增强。Solon 在构建容器里的 Bean 时,如果发现这个类需要被切面处理,会直接用 ASM 读取并改写类字节码,生成增强后的类,再交给 JVM 加载。
说直白一点:Spring 是“先有原对象,再包一层代理壳”;Solon 是“在创建对象之前,就把类本身‘改造’成增强版”。这种方案有几个天然优势。第一,不需要接口,类上直接做文章;第二,少了动态代理那一长串的组装逻辑;第三,因为增强类的生成发生在 Bean 构建阶段,整个调用链在运行期会短不少。我当时第一次看 Solon 源码里 AOP 相关的实现时,第一反应是“这玩意儿怎么敢这么直接”,但跑起来之后确实稳。
1.3 织入时机差异带来的连锁反应
“织入时机”是这两套 AOP 最根本的分水岭。Spring 把 AOP 织入放在了 Bean 生命周期靠后的阶段:Bean 先实例化、再初始化,最后才被后处理器包成代理。这样做的代价,就是它必须处理一堆“对象还没包代理就被别人引用”的尴尬情况。
最典型的就是循环依赖。Spring 为了解决循环依赖加上 AOP 代理之间的矛盾,搞出了著名的三级缓存:一级缓存放成品 Bean,二级缓存放早期暴露的裸对象,三级缓存放ObjectFactory。当一个 Bean 在属性填充阶段被提前暴露出去时,其他 Bean 拿到的是还没有做 AOP 包装的原始对象;等这个 Bean 最终完成初始化、准备生成代理时,Spring 还得回头检查早期引用,想办法把代理对象“替换”回去。这一套机制非常精巧,但也非常绕,很多人看 Spring 源码就卡在getSingleton()和三级缓存这一段。
Solon 没有这个包袱。因为它在 Bean 构建阶段就直接生成增强类,Bean 第一次出现在容器里时就已经是“完成 AOP 形态”的了。不存在先给一个裸对象、后面再偷偷换代理的问题,自然也不需要搞二级、三级缓存去兜底。这不是说 Solon 就完全不会有循环依赖,而是它的容器在循环依赖场景下,不需要再叠加一层 AOP 代理的复杂度,设计上确实清爽一大截。
2. 写代码时能感知到的区别:API 与使用方式
2.1 Spring 的 AspectJ 风格:Pointcut 表达式是灵魂
Spring AOP 的对外 API 完全沿用了 AspectJ 的注解风格,最核心的三个注解是@Aspect、@Pointcut、@Around/@Before/@After。定义切面时你要写 Pointcut 表达式,比如:
@Aspect @Component public class LogAspect { @Around("execution(* com.example.service..*.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); try { return pjp.proceed(); } finally { System.out.println("[spring aop] 耗时: " + (System.currentTimeMillis() - start) + " ms"); } } }这条execution(* com.example.service..*.*(..))看着吓人,拆开也就四部分:*表示返回值任意,com.example.service..*匹配 service 包及其子包下的所有类,最后一个*匹配任意方法名,(..)表示参数列表任意。Spring AOP 的表达式体系非常完整,还有within、target、args、@annotation等一堆写法,能实现很精细的切入点控制。代价是这套东西学习成本高,新手经常被表达式搞晕,甚至因为表达式写错导致切面静默失效。
Spring Boot 下连@EnableAspectJAutoProxy都不用手动加,自动装配会帮你开好。但如果自己搭 Spring 原生环境,忘了加这个注解,@Aspect类就完全不生效,这是新手最容易踩的坑之一。
2.2 Solon 的极简切面:AopAspect 与 @Around
Solon 的 AOP 风格要直接得多。它不搞 Pointcut 表达式那一套,本质上更接近“容器级拦截”。定义一个切面,最常见的做法是实现AopAspect接口,然后在around方法里写逻辑:
@Aspect @Component public class TimeAspect implements AopAspect { @Override public Object around(AopInvocation inv) throws Throwable { long start = System.currentTimeMillis(); try { return inv.invoke(); } finally { System.out.println("[solon aop] 耗时: " + (System.currentTimeMillis() - start) + " ms"); } } }inv.invoke()就等价于 Spring 里的pjp.proceed(),继续往下走目标方法。Solon 里也可以用注解式切面,在一个容器管理的组件类中直接声明带@Around的方法,逻辑类似。由于没有表达式语言,Solon 的切面匹配逻辑主要靠类型和注解去圈定:切面本身用@Aspect标注,普通的业务类用@Component或@Bean交给容器管理,Solon 在构建时会自动判断哪些 Bean 需要应用切面。写起来少了一层抽象,但也意味着你想实现“只切某个包下的某些方法”这种精细控制时,需要自己做判断,或者用自定义注解来标记目标。
2.3 一个代码层面的最小对比示例
为了直观,我直接列一个最小的对比表。同样一个“方法耗时统计”需求,在两边分别怎么写:
| 维度 | Spring AOP | Solon AOP |
|---|---|---|
| 切面类注解 | @Aspect+@Component | @Aspect+@Component |
| 核心拦截注解 | @Around/@Before/@After | @Around/AopAspect接口 |
| 切入点表达 | AspectJ Pointcut 表达式 | 无表达式,靠类型和注解匹配 |
| 调用下一个方法 | pjp.proceed() | inv.invoke() |
| 获取目标信息 | pjp.getSignature()/getTarget() | AopInvocation提供对应访问方式 |
| 启动开关 | Boot 自动;原生环境需@EnableAspectJAutoProxy | 无需额外开关,容器构建时默认生效 |
能看出,Solon 的 AOP API 明显更“短平快”,没有把 AspectJ 那套理论搬过来,而是围绕“拦截方法调用”这件事做了最简实现。对从 Spring 迁过来的同学来说,适应成本主要在切点表达式的部分:Spring 里你用表达式精确控制范围,Solon 更考验你用注解设计来达到同样的隔离效果。
3. 边界条件大比武:什么能拦,什么拦不到
3.1 Spring AOP 的失效清单
Spring AOP 用得太深的人,多半都在“拦截失效”上栽过跟头。我把常见的拦不到的场景列出来,你对照着排查:
- 同类内部自调用:
this.doSomeThing()这种调用不会经过代理对象,切面直接失效。这是 Spring AOP 最经典的坑之一。 - 静态方法:无论 JDK 代理还是 CGLIB,静态方法都不在实例方法拦截范围内。
- final 方法:CGLIB 靠继承重写方法,final 方法无法重写,自然拦不到。
- private 方法:子类无法覆写父类的 private 方法。
- 构造方法:代理生成发生在 Bean 实例化之后,构造函数本身不会被 AOP 拦截。
- 非 Spring 管理的对象:直接
new出来的对象跟容器没有任何关系,切面不存在。 - JDK 代理的接口限制:如果走 JDK 动态代理,代理对象只能转成接口类型,代码里强行 cast 到实现类会抛
ClassCastException。这也是为什么 Spring Boot 2.x 以后默认proxy-target-class=true的原因之一,为了少踩这种雷。
3.2 Solon AOP 的边界与限制
Solon 的 AOP 边界和 Spring 有很多重叠的地方,但也有一个明显差异。它同样只对容器管理的 Bean 生效,直接new出来的对象不会经过增强。final 类、final 方法、static 方法、构造方法,Solon 的字节码增强方案同样拦不到——因为它生成的也是子类,子类没法覆写 final 成员。
真正的差异在“同类内部自调用”上。Spring 因为代理是包裹在原对象外面的,this指向的还是原始目标对象,所以内部调用会绕过代理。Solon 的增强发生在类层面,Bean 拿到手本质上是增强子类的实例,内部方法之间互相调用时,this指向的就是增强后的对象,因此我实测下来,Solon 里同类内部调用通常也能被切面拦到。但这里要加个限定:这个结论基于我使用的 Solon 2.x 版本,AOP 实现属于框架内部细节,不同版本可能有调整,你评估时最好在自己项目里做一次小实验。
3.3 从三级缓存看两套 AOP 的实现哲学
这一节其实是面试中特别爱被连带追问的点。Spring 的三级缓存,表面上解决的是循环依赖,实际上它把“循环依赖”和“AOP 代理”两件事强行耦合在了一起。三级缓存的三层设计不是拍脑袋想出来的:一级缓存给最终成品,二级缓存存放早期暴露的原始对象,三级缓存提供ObjectFactory,让提前引用的对象有机会在未来被替换成代理。如果 Spring 没有 AOP,二级缓存基本就够了;正因为有 AOP,它才需要再包一层ObjectFactory来做延迟决策。
Solon 的做法从源头上规避了这类复杂度。因为 Bean 构建的时候就已经是增强类,不需要“先裸对象、后包装”的过渡,容器内部根本不需要设计一条“早期引用升级为代理”的路径。所以说到底,这两套 AOP 的本质差异,不只是“代理 vs 字节码”这种技术选型,而是背后两套完全不同的生命周期设计哲学:Spring 在兼容性上做了太多妥协,Solon 则因为年轻,可以用更干净的方式重新设计。
4. 性能感知、资源占用与面试常考点
4.1 启动构建与运行时调用的开销对比
性能是选型时绕不开的话题。先说启动阶段。Spring 在 Bean 初始化完成后,会让所有Advisor去逐个匹配业务 Bean,匹配上了再做动态代理组装。当项目有几百个 Bean、十几个切面时,这部分工作虽然是必要的,但确实会占掉一部分启动时间。CGLIB 生成子类也需要额外开销,Spring 虽然在后续版本做了不少缓存优化,但整体性能模型还是偏重。Solon 的 AOP 是在 AopContext 构建 Bean 时直接走 ASM 生成增强类,类加载完成即用,省掉了运行时动态代理的组装和匹配环节,启动阶段的开销结构上更轻。
运行阶段的差异主要体现在调用链长度。Spring AOP 一次方法调用,要经过代理对象、拦截器链、反射或MethodProxy分发,链路长,环节多。Solon 的增强类是编译期生成的直接调用结构,inv.invoke()的调度开销更小。不过说实话,在真实业务系统里,方法调用的耗时大头在 SQL、RPC 和业务逻辑,AOP 本身的 CPU 开销占比非常低。除非你是做基础中间件、追求极致性能,否则这两者在运行性能上的差距,远没有启动速度和内存占用来得直观。
4.2 AOP 使用场景全景:日志、事务、权限等
抛开框架差异,AOP 本身解决的是一大类横切关注点问题,也就是把各个业务模块里都存在的“公共逻辑”抽出来,集中处理。最常见的场景有:
- 操作日志:记录接口调用、方法入参出参、操作人、耗时,这是 AOP 最经典的应用之一。
- 事务控制:Spring 的
@Transactional底层就是通过 AOP 代理实现的,大家写代码时用的是注解,实际上方法进入前开启事务、方法退出后提交或回滚,全是切面逻辑在干活。 - 权限校验:Spring Security 里的
@PreAuthorize,本质也是对带注解的方法做拦截,校验当前用户是否具备权限。 - 参数校验与幂等控制:对入参做统一校验,或者通过方法级 AOP 加分布式锁、做重复提交拦截。
- 性能监控与链路追踪:对关键方法做埋点,上报耗时、异常率,这些交给 AOP 做可以做到对业务代码零侵入。
- 缓存处理:Spring 的
@Cacheable也是 AOP 的功劳,方法执行前先查缓存,命中直接返回,不命中才执行方法。
这些场景在 Spring 和 Solon 里都能做。Spring 的优势是生态里已经沉淀了大量现成的 AOP 扩展,比如@Transactional、@Cacheable属于开箱即用;Solon 也提供类似编程能力,但封装度不如 Spring 全家桶那么细,很多场景需要你手动写切面逻辑来组装。
4.3 技术选型建议:什么时候用 Spring,什么时候用 Solon
聊选型不能踩一捧一。如果你所在团队已经深度绑定了 Spring 生态,团队里人人熟悉 Spring 的注解和排查思路,那继续用 Spring Boot 是最稳妥的选择,换框架带来的迁移成本大概率超过性能收益。如果项目要接入大量第三方开源库,比如 Spring AI、各种 Spring Cloud 组件,那基本没有选择空间,生态就是硬门槛。
什么情况适合认真考虑 Solon?我的个人判断是:从零开始的中小型项目、内部工具、边缘服务、资源受限环境(比如小内存的容器、嵌入式设备),Solon 的“轻”是真的香。它的学习曲线对 Spring 开发者来说也不陡,IoC 和 AOP 的核心概念一致,只是 API 简化了不少。另外“国产化”“自主可控”这类需求在特定领域也是硬性加分项。但要说缺点也很明显:社区体量、资料丰富度、第三方集成能力,至少在现阶段和 Spring 不在一个量级。选型本质上是在“生态成熟度”和“轻量高效”之间做权衡。
5. 实操对比与踩坑记录
5.1 动手演示:一个计时切面在两边怎么落
我直接用一个最简单的方法耗时切面做演示,你没跑过 Solon 的话,可以把这段代码贴到你的项目里试试。
Spring 侧,完整步骤是三步:定义切面类、写@Around、保证类被 Spring 扫描到。
@Aspect @Component public class SpringTimeAspect { @Around("execution(* com.example.service.*.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.nanoTime(); try { return pjp.proceed(); } finally { System.out.println("Spring AOP cost: " + (System.nanoTime() - start) + " ns"); } } }Solon 侧,同样定义切面类,业务代码完全不用动:
@Aspect @Component public class SolonTimeAspect implements AopAspect { @Override public Object around(AopInvocation inv) throws Throwable { long start = System.nanoTime(); try { return inv.invoke(); } finally { System.out.println("Solon AOP cost: " + (System.nanoTime() - start) + " ns"); } } }两边跑起来效果是一样的:service 包里所有方法执行时都会打印耗时,业务代码零侵入。区别只是在定义方式上——Spring 通过表达式划定范围,Solon 用类型和注解机制完成同类效果。如果你只是做日志、计时这类简单切面,迁移成本非常低,基本上就是改pjp.proceed()为inv.invoke()。
5.2 我踩过的几个典型的坑
第一个坑是 Spring 自调用失效。以前我在一个 service 里写了两个方法,A 方法调 B 方法,B 上加了@Transactional,结果事务死活不生效。排查半天才发现问题不在事务配置,而是this调用绕过了代理。解决办法无非三种:把 B 拆到另一个 Bean、用AopContext.currentProxy()、或者干脆用TransactionTemplate手动管理事务。这个场景在面试里也常考,背后就是“代理对象和目标对象不是同一个对象”这个本质。
第二个坑是 Solon 的 AOP 只对容器 Bean 生效。有一次我在 Solon 项目里直接用new创建了一个对象,然后在里面调用了带切面的方法,发现日志完全不打印,当时还以为是框架 bug。后来翻了一下文档才反应过来:AOP 本身就是容器给你的能力,脱离容器创建的对象,框架管不着。这点和 Spring 的逻辑完全一致,属于概念层面的问题,不是框架缺陷。
第三个坑是切面顺序。Spring 里多个切面同时命中一个方法时,执行顺序由@Order或者Ordered接口控制,顺序不对会导致日志、事务、校验之间的执行顺序和你设想的不一样。Solon 里同样存在顺序问题,虽然 API 表达上没有 Spring 那么多约定,但多切面场景下你需要主动关注切面之间的优先级。我建议任何项目里,把日志这类横切逻辑和事务这类强约束逻辑拆成独立的切面,然后用序值固定顺序,避免“看起来都生效了,但行为不符合预期”的尴尬。
5.3 常见问题速查表
最后把最常被人问的几个问题整理成一张表,方便收藏备查。
| 问题 | Spring AOP | Solon AOP |
|---|---|---|
| 同类内部调用能被拦截吗? | 不能,this调用绕过代理 | 通常可以(按版本实际表现为准) |
| 被代理类必须实现接口吗? | JDK 代理需要,CGLIB 不需要(Boot 默认 CGLIB) | 不需要,直接类增强 |
| static / private / final 方法能拦吗? | 不能 | 不能 |
| 构造方法能被拦截吗? | 不能 | 不能 |
| 非容器创建的对象能拦截吗? | 不能 | 不能 |
| 切入点支持复杂表达式吗? | 支持 AspectJ 全套 | 不支持表达式,靠类型和注解匹配 |
| 需要额外开关吗? | Spring 原生需要@EnableAspectJAutoProxy,Boot 自动 | 不需要额外开关 |
这张表可以当做一个排查手册来用。如果项目里切面没生效,先看对象是不是容器管理的,再看方法修饰符是不是 final/static/private,最后再考虑是不是自调用问题。这套排查顺序在 Spring 和 Solon 里都通用。
根据我个人的迁移经验,给想换框架的团队一个建议:动手之前先把项目里所有用 AOP 的地方列出来,逐一评估切面类型和依赖的 Spring 特性。如果只是日志、鉴权、简单监控这类切面,Solon 迁移成本很低;如果大量依赖@Transactional、@Cacheable、Spring Security 方法级安全这类 Spring 封装好的能力,一定先把替代方案想清楚再动手,否则切到一半会发现很多事情不是不能做,而是得自己重新造轮子。最后再补充一个实用的验证技巧:不管用哪个框架,写 AOP 的时侯先在切面里打一条日志,然后走一次业务调用,确认日志出来了再继续写后面的逻辑。这一步能帮你尽早发现“切面到底有没有生效”的问题,省掉后面排查的力气。