面向切面编程这个词,第一次听见的人多半会觉得它玄。我第一次在交接文档里看到“日志统一走 AOP,不要在业务里手写”这句话时,反应很直接:多一层看不见的东西,出了问题更难查。后来那个系统的日志需求从“记录入参出参”一路长到“记录耗时、链路标识、操作人、异常堆栈、慢调用告警”,业务代码里散落着上百行重复的打印语句,改一次格式要翻几十个文件,我才明白那句交接文档写得有多克制。
这篇就聊透一件事:AOP 到底在解决什么问题,以及用它实现一个日志记录,从设计到落地会踩哪些坑。内容面向已经写过业务代码、但对切面机制只有模糊印象的后端同学,也适合那些“切面已经写好了但就是不生效”、正在抓耳挠腮的人。我不会只给你一段能跑的代码,重点是每个选择背后的理由——为什么用环绕通知而不是前置通知、为什么自调用会失效、为什么日志序列化能把一个稳定运行三个月的服务拖垮。
1. 日志代码为什么会烂掉——AOP 要治的到底是什么病
1.1 一段人人都写过的重复代码
先看一段真实场景里的代码,你应该不会陌生。某个订单创建接口,最初的版本干干净净,三行业务逻辑收工。第二天产品说要记录谁下的单、什么时候下的,于是加了入参打印。第三天运维说要看接口慢不慢,于是前后各加一个时间戳。第四天风控要求记录异常堆栈。第五天线上的日志里出现了用户手机号,安全同事找上门,于是又加一层脱敏。
到这一步,这个方法的开头已经有十几行和“创建订单”这件事毫无关系的代码。更麻烦的是,这套十几行在同一个项目的四十多个接口里重复出现,格式还各不相同:有的用 log.info,有的用 System.out,有的拼字符串,有的传数组。你想统一加上链路标识,就得改四十个地方;你想统一改成 JSON 格式方便检索,还是要改四十个地方。这类代码最典型的特点不是“难”,而是“散”——散在几十个文件里,每次需求变动都像在做一次全量手术。
我见过更极端的做法:有人写了个 LogUtil 静态工具类,把所有打印逻辑收进去。这确实减少了重复,但每个方法开头仍然要手动调用一次 LogUtil.start()、结尾调用 LogUtil.end()。业务代码里的“噪音”并没有消失,只是从十行变成两行。而且只要有一个新同事忘了调,那条链路的日志就是断的,排查问题时你会怀疑人生。工具类解决的是“重复”,没解决“侵入”。
1.2 横切关注点:换个说法你就懂了
AOP 的教科书定义是“面向切面编程”,这个翻译本身就容易让人走神。我更喜欢用一句大白话来概括:有些代码,天生就不属于任何一个业务方法,却要在几乎每个业务方法里出现,AOP 就是给这类代码找一个集中安放的地方。
这类代码在业内叫“横切关注点”。为什么叫横切?你把系统按业务垂直切分,会切出订单、用户、支付、库存这些模块,每个模块内部是纵向的一条线。而日志、权限校验、事务控制、性能监控、限流、幂等,这些东西不归属于任何一个模块,它们像一把刀横着切过所有模块。纵向的切分靠分层和模块化解决,横向的切分就是 AOP 要干的事。
用生活类比会更直观。一栋楼里的每个房间都要通电,电线自然是从房间内部走,但如果你在每个房间里都单独接一根主线到楼下的配电箱,那这栋楼的布线会荒唐到没法维护。正确的做法是留一条贯穿全楼的竖向管井,各层从管井里取电。业务代码就是各个房间,AOP 就是那口管井。房间该怎么装修还是怎么装修,取电这件事被抽走了。
这个类比里有一个关键点值得强调:管井是横向贯穿的,但它并不改变房间的功能。同样,AOP 的定位是“增强”,不是“替代”。如果你发现某个切面里写了业务判断(比如“如果订单金额大于一千就走特殊流程”),那基本上是把业务逻辑错放到了切面里,早晚要还债。切面只该处理那些与业务语义无关的、纯技术性的通用动作。
1.3 AOP 的核心术语,用人话对一遍
这些术语看着吓人,实际含义很朴素。我按“从需求到落地”的顺序把它们串一遍,比孤立地背定义好记得多。
| 术语 | 大白话解释 | 在日志场景里对应什么 |
|---|---|---|
| 切面(Aspect) | 横切逻辑的封装单元,一个类 | 那个负责打日志的 LogAspect 类 |
| 连接点(JoinPoint) | 程序执行过程中“可以被插入”的点 | 方法调用、方法返回、抛异常的时刻 |
| 切点(Pointcut) | 从所有连接点里挑出你要的那些 | 只匹配 service 包下带某注解的方法 |
| 通知(Advice) | 在某连接点上具体做什么 | 打印入参、算耗时、记异常 |
| 织入(Weaving) | 把切面逻辑真正塞进目标方法的过程 | 由代理对象完成,运行时发生 |
这里最容易混淆的是“连接点”和“切点”。一句话区分:连接点是全集,切点是子集。理论上方法调用、字段访问、异常抛出都算连接点,但 Spring AOP 只支持方法级别的连接点,字段访问它管不了。这一点在你选型时非常重要——如果你需要拦截字段赋值,Spring AOP 做不到,得上完整的 AspectJ。
另一个需要澄清的是“通知类型”。很多人以为只有环绕通知一种,实际上有五种,各有各的适用场景。日志记录这件事,前后置通知和环绕通知都能做,但选错了会很别扭。
- 前置通知(Before):目标方法执行前触发,拿不到返回值,也改不了执行流程。适合做“只想知道要调用了”的轻量记录。
- 后置返回通知(AfterReturning):方法正常返回后触发,能拿到返回值,但方法抛异常时不会触发。
- 后置异常通知(AfterThrowing):方法抛异常时触发,能拿到异常对象,适合做异常日志。
- 后置通知(After):类似 finally,无论成功失败都触发。
- 环绕通知(Around):功能最强,能拿到入参、能控制是否放行、能改返回值、能捕获异常、能计算耗时。日志场景里我九成时候用这个。
1.4 为什么是“切面”而不是“工具类”
有人会问:既然只是统一打日志,用工具类 + 统一规范 + 代码评审强制约束,不也行吗?能行,但成本结构不一样。
工具类方案的成本是“持续的人力成本”。每新增一个接口,都要有人记得写;每次修改格式,都要有人全量扫一遍;每次代码评审,都要有人盯着有没有漏。这类成本不会因为时间推移而下降,反而随着人流动、项目膨胀而上升。
切面方案的成本是“一次性的机制成本”。你要设计切点、写好通知、调试织入、处理各种边界,前期投入确实更大。但机制一旦建成,后面新增接口只要符合切点规则就自动纳入,格式改一次全局生效,人的记性不再是可靠性的一部分。
我在一个后台系统里做过对比:同一个团队,切面方案上线后,日志缺失的线上问题从每月两三次降到零。不是因为大家变仔细了,而是因为“忘记写”这件事在机制上变得不可能。这是我最看重的价值——把可靠性从依赖人转移到依赖机制上。
2. 代理:AOP 的地基,绕不开的原理
2.1 静态代理为什么不够用
要理解 Spring AOP 怎么工作,必须先理解代理模式。因为 Spring AOP 的底层实现只有一个词:动态代理。不通这一层,你后面遇到的所有“切面不生效”“强转报错”“自调用无效”,都会变成玄学。
代理的基本思路很朴素:不直接调用目标对象,而是调用一个“替身”,替身内部先做额外的事,再转给真的目标对象。静态代理就是手写这个替身类,一个接口对应一个实现类的替身,一个都不能少。
问题立刻显现。假设系统里有 200 个 service 接口,你要给所有的都加日志,就得手写 200 个代理类。业务接口新增一个方法,代理类也得跟着改。这种方案在生产上根本不可行,它的价值只在于帮你理解“代理”这个概念——调用方看到的对象,和真正干活的不是同一个。
动态代理解决的就是“代理类不用手写”这件事。运行时由框架根据目标对象动态生成代理类的字节码,加载到 JVM 里,代理类里统一插入横切逻辑。一个代理生成器可以代理任意多的类,横切逻辑只写一份。这就是 AOP 能够规模化的根本原因。
2.2 JDK 动态代理与 CGLIB 的选型对照
Java 生态里动态代理有两条主流路线,差异不是“哪个更好”,而是“哪个更适合你的场景”。
JDK 动态代理由 JDK 自带,核心是 InvocationHandler 接口。它的限制很明确:只能代理接口。原理是运行时生成一个实现了目标接口的新类,方法调用被转发到 InvocationHandler 的 invoke 方法。因为生成的是接口实现,所以代理对象只能强转为接口类型,强转为具体实现类会抛 ClassCastException。
CGLIB 走的是另一条路:生成目标类的子类,覆盖父类的方法,在方法里插入拦截逻辑。它不要求目标类实现接口,普适性更好。代价是不能代理 final 类和 final 方法(无法被继承和覆盖),private 方法也拦不住(子类根本访问不到)。
实际选型时,我的判断顺序是这样的:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 目标类实现了接口,且调用方面向接口编程 | JDK 动态代理 | 无第三方依赖,生成的开销小 |
| 目标类没有接口,或是遗留代码不方便抽接口 | CGLIB | 唯一可行方案 |
| 目标方法被声明为 final | 都不行 | 需要重构,或改用编译期织入 |
| 需要拦截 private 方法 | 都不行 | 换 AspectJ 编译期织入 |
| 需要拿到方法上的注解做判断 | 两者都行 | 通过 Method 对象反射获取 |
这里有个版本变化值得提醒:早期的 Spring 在“有接口就用 JDK 代理,没接口才用 CGLIB”之间自动选择,所以很多人被“为什么我的代理对象强转实现类会炸”坑过。后来主流框架的默认策略改成了统一使用 CGLIB,行为一致性好很多。如果你的项目经历过版本升级,这块行为差异要专门验证一遍,尤其是老代码里存在强转实现类的地方。
2.3 织入时机决定你能做什么
“织入”这个词听着抽象,其实就是问一句:切面代码是什么时候被塞进目标方法的?答案有三种,能力和代价完全不同。
编译期织入:在 javac 阶段就把切面逻辑写进字节码。能力最强,能拦截 private 方法、字段访问、构造器,性能也最好,几乎没有运行时开销。代价是需要特殊的编译器,构建链路要改,IDE 配置也麻烦,团队接受度通常不高。
类加载期织入:在类被加载进 JVM 时修改字节码。能力接近编译期,不需要改构建流程,但需要配置 Java Agent,启动参数要加东西,某些环境下会有兼容性问题。
运行期织入:也就是 Spring AOP 走的路。不改字节码,而是在运行时生成代理对象。好处是零侵入、纯 Java 实现、开发调试方便。坏处是只能拦截“经过代理”的方法调用,方法内部的 this 调用拦不到,private 方法拦不到,final 方法拦不到。
绝大多数业务场景,比如日志、事务、权限、限流,运行期代理完全够用。我个人的原则是:能用 Spring AOP 解决的就别上 AspectJ,除非你有明确的、代理模式无法满足的需求。为了拦截一个 private 方法而引入整套编译期织入,收益和成本完全不成比例。
2.4 手写一个最小 AOP 框架
理解原理最有效的方式是自己实现一遍。下面这段代码不到一百行,但把 AOP 的核心机制完整跑通了:自定义注解 + JDK 动态代理 + 反射调用。你看完之后再回头看 Spring 的注解,会发现它们只是把这套东西做得更通用、更健壮。
先定义一个标记注解:
import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface TraceLog { String value() default ""; }再写一个业务接口和实现,其中部分方法带注解:
public interface OrderService { String create(String userId, int amount); int query(String orderId); } public class OrderServiceImpl implements OrderService { @TraceLog("创建订单") @Override public String create(String userId, int amount) { if (amount <= 0) { throw new IllegalArgumentException("金额必须大于0"); } return "ORDER-" + userId + "-" + amount; } @Override public int query(String orderId) { return 1; } }核心是代理工厂,注意它在 invoke 里做了三件事:判断是否需要拦截、计算耗时、异常记录:
import java.lang.reflect.*; import java.util.Arrays; public class TraceProxyFactory { @SuppressWarnings("unchecked") public static <T> T wrap(T target) { Class<?> clazz = target.getClass(); return (T) Proxy.newProxyInstance( clazz.getClassLoader(), clazz.getInterfaces(), new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Method real = clazz.getMethod(method.getName(), method.getParameterTypes()); TraceLog ann = real.getAnnotation(TraceLog.class); if (ann == null) { return method.invoke(target, args); } String name = ann.value().isEmpty() ? method.getName() : ann.value(); long start = System.nanoTime(); try { Object result = method.invoke(target, args); long cost = (System.nanoTime() - start) / 1_000_000; System.out.printf("[%s] 入参=%s 耗时=%dms 返回=%s%n", name, Arrays.toString(args), cost, result); return result; } catch (InvocationTargetException e) { long cost = (System.nanoTime() - start) / 1_000_000; System.out.printf("[%s] 入参=%s 耗时=%dms 异常=%s%n", name, Arrays.toString(args), cost, e.getTargetException().getMessage()); throw e.getTargetException(); } } }); } }用起来是这样:
public class Demo { public static void main(String[] args) { OrderService service = TraceProxyFactory.wrap(new OrderServiceImpl()); System.out.println(service.create("u1001", 99)); System.out.println(service.query("ORDER-1")); } }跑一遍输出,你会发现query方法因为没注解,什么日志都没有;create方法被打上了完整日志;而如果把create的金额传 0,异常也会被记录并原样抛出。
这段代码里藏着三个后面会反复提到的关键点。第一,method.invoke抛出的异常会被包成InvocationTargetException,必须拆包再抛,否则业务代码收到的异常类型全变了。第二,日志只在走代理的情况下才产生,如果你在OrderServiceImpl内部用this.create(...)调用自己,代理根本不知情。第三,代理对象是Proxy生成的,service instanceof OrderServiceImpl会是 false。这三条,本质上就是 Spring AOP 所有“坑”的源头。
3. 实战:给日志切面做需求拆解与落地
3.1 先把需求列成表,再动手写
我见过太多切面是“边写边加”长出来的,最后变成一个三百行、谁都改不动的怪物。动手前花二十分钟把需求列清楚,能省下后面几天的返工。下面是我在项目里常用的一张需求清单,你可以直接照着填。
| 维度 | 要确认的问题 | 常见答案 |
|---|---|---|
| 拦截范围 | 拦哪些方法 | 只拦 controller 层,或只拦带注解的方法 |
| 记录内容 | 出入参记不记、记全量还是摘要 | 入参记全量并截断,返回值只记关键字段 |
| 耗时统计 | 是否需要、精度要求 | 需要,毫秒级够用 |
| 异常记录 | 记异常类型还是完整堆栈 | 记类型和消息,堆栈由全局异常处理统一打 |
| 敏感数据 | 手机号、身份证、密码怎么处理 | 字段名白名单脱敏 |
| 采样策略 | 全量记还是按比例 | 高频接口按 10% 采样,慢调用 100% 记录 |
| 异步与否 | 是否允许阻塞业务线程 | 允许,但要设上限;高频接口必须异步 |
这张表里最容易被忽略的是“采样”和“异步”。日志这东西有个奇怪的性质:平时没人看,一旦线上出问题就指望它救命。所以团队往往倾向于“全量记录、越详细越好”,结果就是流量高峰期日志写入把磁盘打满,或者序列化开销把接口 RT 拖高几十毫秒。我待过的一个系统就因为切面里同步写日志文件,导致大促期间接口平均耗时上涨 30%,最后靠降级开关才稳住。
3.2 环境与依赖:版本对齐比什么都重要
Spring 体系下做 AOP,只需要一个 starter,但依赖是否正确引入,决定了你后面半小时是顺利推进还是原地打转。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>这个 starter 会带入 AOP 的核心实现和切点表达式解析器。有个非常经典的坑:只引入了spring-context而没有 AOP 相关依赖,注解@Aspect能编译通过(因为注解本身在别的包里),但运行时切面完全静默,什么日志都没有,也不报错。遇到“切面不生效且无任何异常”的情况,第一个要查的就是依赖是否齐全。
再一个容易被忽略的是日志实现。切面里你应该面向 SLF4J 的Logger编程,而不是绑定具体实现。原因是日志门面可以随部署环境切换后端,测试环境用控制台输出、生产环境用文件加异步,业务代码一行都不用改。
import org.slf4j.Logger; import org.slf4j.LoggerFactory; private static final Logger log = LoggerFactory.getLogger(LogAspect.class);注意这里的static final和类名参数。static是为了避免每个实例都创建一次 logger,final是因为 logger 本身无状态可复用于多线程。类名传进去是为了让日志输出里带来源,方便按类名过滤,这个小细节在日志量大的时候能救命。
3.3 自定义注解:给切面一个精准的开关
我不推荐用包名做切点(比如拦整个 service 包下所有方法),除非你已经很确定包结构不会变。更好的做法是自定义一个注解,想要日志就标上,不想要就不标。这样做有三个好处:范围精确可控、注解上可以带参数(比如模块名、是否记录返回值)、日后新增方法时是“显式选择”而不是“意外命中”。
import java.lang.annotation.*; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface OpLog { /** 操作名称,用于日志标识 */ String value() default ""; /** 是否记录返回值 */ boolean recordResult() default true; /** 是否记录入参 */ boolean recordArgs() default true; }三个属性里,recordArgs和recordResult看着多余,实际很有必要。有些接口的返回值是一个巨大的列表对象,序列化一次可能产生几十 KB 日志,还会触发懒加载。有了开关,这类接口可以单独关掉返回值记录,不用为此再写一个切面。
@Retention(RUNTIME)是必须的,因为切面要在运行时通过反射读取注解。写成CLASS或默认值的话,注解在运行期不可见,切点匹配直接落空,而且不会报错。
3.4 切面主干:环绕通知里的顺序问题
下面这段是切面的主体骨架。我会把每个 try/catch/finally 位置的选择理由都标出来,因为顺序写错是导致“日志记了一半”的主要原因。
import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; import org.springframework.stereotype.Component; @Aspect @Component public class OpLogAspect { private static final Logger log = LoggerFactory.getLogger(OpLogAspect.class); @Around("@annotation(opLog)") public Object around(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable { String name = opLog.value().isEmpty() ? pjp.getSignature().getName() : opLog.value(); String argsStr = opLog.recordArgs() ? safeArgs(pjp.getArgs()) : "-"; long start = System.nanoTime(); Object result = null; try { result = pjp.proceed(); return result; } catch (Throwable t) { long cost = (System.nanoTime() - start) / 1_000_000; log.warn("[{}] 入参={} 耗时={}ms 异常={}: {}", name, argsStr, cost, t.getClass().getSimpleName(), t.getMessage()); throw t; } finally { long cost = (System.nanoTime() - start) / 1_000_000; String resultStr = (opLog.recordResult() && result != null) ? safeToString(result) : "-"; log.info("[{}] 入参={} 耗时={}ms 返回={}", name, argsStr, cost, resultStr); } } }这段代码有四个地方值得单独说。
第一,return pjp.proceed();必须把返回值原样返回。新手最常犯的错是只写pjp.proceed();不 return,结果所有被切的方法返回值都变成 null,接口调用方拿到一堆空对象,而且报错位置离切面很远,排查起来极其痛苦。
第二,catch里记录异常后必须throw t,不能吞掉。切面的职责是“记录”,不是“处理”。一旦吞掉,全局异常处理器收不到异常,原本该返回 500 的接口会返回 200,前端拿到一个看起来成功其实失败的结果。这类 bug 的破坏力比不记日志大得多。
第三,finally里再算一次耗时并记录正常日志。为什么不把正常日志写在 try 里?因为 try 块里如果中途抛异常,后面的日志就跳过了。放 finally 能保证无论成功失败都有一次完整记录,异常分支额外多一条带异常信息的日志。
第四,先proceed再记录结果,这个顺序不能反。有人为了“先记录再执行”把日志写前面,那返回值就只能靠猜。环绕通知里,proceed就是那道分界线,之前的代码看得到入参看不到结果,之后的代码两边都能看到。
3.5 参数与返回值序列化:最容易出事故的一段
pjp.getArgs()返回的是Object[],直接Arrays.toString出来的东西长这样:[com.example.dto.OrderReq@1f2a3b, 1]。这种日志在生产环境等于没记,你只能看到地址。正确做法是序列化成 JSON。
private static final ObjectMapper MAPPER = new ObjectMapper(); private String safeArgs(Object[] args) { if (args == null || args.length == 0) { return "[]"; } try { return buildMapper().writeValueAsString(args); } catch (Exception e) { return "[序列化失败: " + e.getClass().getSimpleName() + "]"; } }但序列化本身会带来三个新问题,每一个都真实存在。
循环引用。两个实体互相持有对方引用,序列化时无限递归,直接 StackOverflowError。这个异常发生在切面里,会导致业务方法执行完了但日志记录失败,如果异常没被捕获,业务结果也会被吞掉。必须包 try/catch,并且在实体类上做好关系字段的忽略。
懒加载异常。用了 ORM 框架的情况下,某些关联字段是懒加载的,序列化时触发数据库查询,而这个查询可能发生在事务已经关闭之后,直接抛 LazyInitializationException。表现就是“接口本来好的,加了日志切面就报错”。解决办法是序列化HttpServletRequest、MultipartFile、ResponseEntity这类框架对象要先过滤掉,实体对象只取需要的字段。
超大对象。一个查询接口返回三千条记录的列表,序列化成 JSON 可能是几 MB,一条日志就能把日志文件撑爆,还会占用大量 CPU。必须加长度截断。
private static final int MAX_LEN = 2000; private String truncate(String s) { if (s == null) { return "null"; } return s.length() <= MAX_LEN ? s : s.substring(0, MAX_LEN) + "...(截断,总长" + s.length() + ")"; }截断长度取多少?我的经验值是 2000 字符左右。太短了看不清问题现场,太长了日志文件膨胀得厉害。这个值最好做成配置项,不同环境可以不同——测试环境可以放大到 10000 方便调试,生产环境压到 1000 控制体积。
3.6 脱敏与截断:日志不能成为新的泄露源
这是我最想强调的一段。日志系统经常成为安全审计里最薄弱的一环,因为大家默认“日志只有内部人看”,但实际上日志会被采集、传输、汇总、长期存储,接触面远比想象中广。一段包含身份证号、手机号、银行卡号的入参日志,等同于一份泄露的数据副本。
脱敏有两种做法:基于字段名和基于正则。字段名方案更可靠,正则容易误伤。
private static final Set<String> SENSITIVE_KEYS = new HashSet<>(Arrays.asList("password", "pwd", "idcard", "mobile", "phone", "bankcard")); private String mask(String json) { if (json == null) { return null; } for (String key : SENSITIVE_KEYS) { // 匹配 "password":"xxx" 形式,保留首尾字符 json = json.replaceAll( "(?i)(\"" + key + "\"\\s*:\\s*\")([^\"]{2,})(\")", "$1****$3"); } return json; }这段代码不算完美,但它能挡住九成以上的意外泄露。要注意(?i)让匹配不区分大小写,因为实际字段命名有password、passWord、PASSWORD各种写法。另外正则替换在超大字符串上开销不小,所以顺序应该是先截断再脱敏,别反过来。
我踩过的一个坑是:脱敏逻辑写在切面里,但另一个同事的功能绕过了切面直接调用 service,结果那条链路里的日志把明文密码带出去了。这说明脱敏不能只靠切面,关键还是要在序列化配置层面做全局约束——比如给敏感字段在实体上打注解,让 ObjectMapper 统一处理。切面只做最后一道兜底。
3.7 切点表达式怎么写才不误伤
切点表达式的语法有一套固定格式,但真正难的是“写得准”。下面几个写法在实际项目里非常常用,我按使用频率排一下。
// 1. 匹配带指定注解的方法(最推荐) @Around("@annotation(opLog)") // 2. 匹配注解标注的类的所有方法 @Around("@within(org.springframework.stereotype.Service)") // 3. 匹配指定包下所有类的所有方法(注意 .. 是递归) @Around("execution(* com.example.order.service..*.*(..))") // 4. 组合:包范围 + 注解,避免误伤 @Around("execution(* com.example.order.service..*.*(..)) && @annotation(opLog)") // 5. 排除某个包,用取反 @Around("execution(* com.example..service..*.*(..)) && !within(com.example.internal..*)")..在包路径里表示“当前包及其所有子包”,在参数列表里表示“任意个任意类型参数”。这两个含义出现在同一行里的时候很容易看错,execution(* com.example..service..*.*(..))这行里有三处..,分别对应包的递归、包的递归、参数的任意。
我的原则是:切点宁窄勿宽。切宽了,日志量暴增不说,还可能拦到框架内部的类,导致意料之外的异常。特别是别用execution(* *(..))这种全匹配去图省事,它会拦截所有 bean 的所有方法,包括那些你根本不想碰的基础组件,性能损耗和排查困难都是灾难级的。
4. 三个高频翻车点:顺序、失效、性能
4.1 多切面顺序:@Order 的真实语义
真实项目里切面很少只有一个。日志切面、权限切面、事务切面、限流切面往往同时存在,它们的执行顺序直接影响到你看到的现象。
@Order注解或者实现Ordered接口可以控制顺序,规则是数值越小,优先级越高。但“优先级高”在不同通知类型上的表现方向是相反的,这一点非常容易搞混。
| 通知类型 | 优先级高的切面表现 |
|---|---|
| 前置通知(Before) | 先执行 |
| 环绕通知(进入部分) | 先执行 |
| 后置通知(After) | 后执行 |
| 后置返回(AfterReturning) | 后执行 |
| 环绕通知(退出部分) | 后执行 |
可以把它想象成一个洋葱。优先级高的切面在最外层,进去时先穿过它,出来时最后穿过它。所以“进入”方向是先执行,“退出”方向是后执行。
这个特性在日志场景里有用武之地。如果我希望日志里的耗时包含权限校验和限流的时间(因为用户感受到的响应时间就是全部加起来),那日志切面应该在整个调用链的最外层,优先级设得最高,给的 order 值最小。反过来,如果我只想统计业务逻辑本身的耗时,就要把日志切面放到最内层。
有个反直觉的细节:@Order对事务切面特别关键。事务切面的优先级如果不当,可能出现“日志记录成功了但事务回滚了”的情况,你会看到一条日志说订单创建成功,但数据库里并没有这条数据。这种人会疯的日志,多半是切面顺序没配对。
4.2 自调用失效的根因与三种解法
这是 AOP 使用中最高频的问题,没有之一。现象是:切面写好了,外部调用有日志,但同一个类里 A 方法调用 B 方法,B 方法上的注解完全不生效。
根因在设计上就注定了。代理对象的调用链是这样的:调用方 → 代理对象 → 目标对象。代理对象在把调用转给目标对象之前插入切面逻辑。而当目标对象内部的 B 方法被 A 方法调用时,调用方是“目标对象自己”,走的是目标对象的内部调用路径,完全没有经过代理对象,代理自然没有机会插入逻辑。
用前面的类比:代理是门口的门童,你从大门进来他会登记。但如果你已经在楼里了,从三楼走到四楼,门童根本看不到你。
解法有三种,各有取舍。
第一种,拆类。把需要被拦截的方法挪到另一个 bean 里,通过注入调用。这是最干净的做法,符合单一职责,也不依赖任何框架特性。缺点是改动量大,需要重新梳理职责边界。
第二种,自注入。在类里注入自己的代理对象,然后通过它调用。
@Service public class OrderService { @Lazy @Autowired private OrderService self; public void createOrder() { self.doCreate(); // 走代理,注解生效 } @OpLog("执行创建") public void doCreate() { // 业务逻辑 } }@Lazy是必须的。不加会形成循环依赖:A 依赖 A 的代理,A 的代理依赖 A 本身,容器启动直接报错。加了@Lazy后注入的是个延迟初始化的代理包装,启动期不触发真实依赖解析。
第三种,AopContext 获取当前代理。开启暴露代理后,在方法里直接取。
// 启动类上加 @EnableAspectJAutoProxy(exposeProxy = true) public void createOrder() { ((OrderService) AopContext.currentProxy()).doCreate(); }这个方案最省事,但我不太推荐作为常规手段。一是需要全局开启配置,有轻微性能影响;二是强耦合框架 API,代码里出现AopContext的地方基本等同于“这里有个设计问题被绕过去了”;三是这种调用方式在异步线程里拿不到,容易埋雷。
我的建议是:如果只有一两处,用自注入;如果发现自调用到处都是,说明类的职责太胖了,该拆。
4.3 性能开销:实测数据与收敛手段
“加个切面而已,能有多慢”,这是我听过最多的误判。切面的开销不是固定的,它跟你切点覆盖的范围、日志内容的复杂度强相关。我在一台四核的开发机上做过一组粗糙但足够说明问题的测试,调用一个空方法一千万次,对比不同配置的耗时:
| 配置 | 单次额外耗时(约) | 说明 |
|---|---|---|
| 无切面 | 0 ns | 基线 |
| 空环绕通知,不做任何操作 | 20 ns | 代理转发的固定开销 |
| 记录方法名,不序列化参数 | 200 ns | 反射取签名 |
| 序列化两个简单对象入参 | 3 微秒 | Jackson 初始化后的开销 |
| 序列化含 100 条记录的列表 | 500 微秒 | 明显感知 |
| 同步写文件日志 | 20 微秒 | 受磁盘影响,波动大 |
数字不是重点,重点是量级差异:序列化和 IO 比代理转发本身贵出一到两个数量级。所以优化切面性能,方向从来不是“少用代理”,而是“少做序列化和 IO”。
几个我实际用过的收敛手段:
第一,按需序列化。把recordArgs和recordResult做成注解属性,默认关闭,只有需要排查问题的接口才打开。这个改动通常能砍掉八成以上的开销。
第二,日志级别前置判断。log.debug(...)这种写法,参数拼接会在方法调用前完成,即使日志级别不够也白算一遍。改成if (log.isDebugEnabled())包起来能省下序列化的钱。生产环境把详细参数日志放在 debug 级别,info 级别只留方法名和耗时,这个约定很实用。
第三,异步写日志。日志框架基本都支持异步 appender,把 IO 从业务线程挪到独立线程。要注意配置队列长度和丢弃策略,队列满了默认是阻塞的,那异步就白做了。生产环境我会设一个够大的队列并把discardThreshold打开,宁可丢日志也不能拖垮接口。
第四,只记慢调用。先判断耗时是否超过阈值,超了才序列化参数和返回值。这个策略在高频接口上效果极好,正常请求几乎零开销,慢请求仍有完整现场。缺点是那些“不慢但出错”的调用看不到参数,所以通常和异常分支的全量记录配合使用。
5. 排查手册:切面不生效怎么一步步定位
5.1 从现象倒推的排查路径
切面问题最恶心的地方是它通常不报错,只是“安静地什么都不做”。我整理了一套固定的排查顺序,从最常见的可能性往下走,一般三步之内能定位。
第一步,确认代理对象是否真的存在。在业务方法被调用的地方打个断点,或者打印obj.getClass().getName()。如果输出里带$$或$Proxy之类的标记,说明代理生成成功;如果就是原始类名,说明这个 bean 根本没被代理。
第二步,确认 bean 是否被容器管理。手动new出来的对象永远不会有切面,这是最基础也最容易犯的错。另一个隐蔽情况是通过@Bean方法返回的实例、或者从工具类里拿到的静态实例,如果没走容器,同样没有代理。
第三步,确认切点表达式是否真的匹配。这一步有技巧:把切点临时改成最宽的范围(比如@annotation(...)改成execution(* com.example..*.*(..))),如果切面突然生效了,说明表达式写错了;如果还是不生效,问题在代理层面而不是表达式层面。这个二分法能快速缩小范围。
第四步,确认方法本身是否可被拦截。方法是 private 的、final 的、static 的,或者在不在代理链条上的代码里被调用的,全都拦不到。这些情况下切面配置再正确也没用。
第五步,确认是否有多个切面互相干扰。有时候切面“生效了”,只是被别的切面的异常中断了,或者顺序错了导致日志被覆盖。这时候开 debug 日志看拦截器链的执行情况最直接。
5.2 日志切面自身会踩的坑
切面本身的 bug 比“不生效”更危险,因为它会让一个原本正常的接口出故障。下面这些我都遇到过或者被同事求助过。
返回值丢失。环绕通知忘了 return,全站接口返回值变 null。这个问题的可怕之处在于编译不报错、启动不报错,只有调用方会崩溃。写环绕通知时,return pjp.proceed()应该是条件反射。
异常被吞。catch 之后没 rethrow,业务错误变成静默失败。判断方法很简单:在切面 catch 块里只做两件事——记日志、原样抛出,其他一概不做。
日志里出现循环引用。表现为日志里出现$ref或者直接栈溢出。前者是序列化器检测到循环后的降级处理,后者是没检测到。不管哪种,日志可读性都归零了。处理方式是给实体关系字段加忽略注解,或者用专门的 DTO 做日志对象,别拿实体直接序列化。
异步日志里的上下文丢失。用了线程池异步写日志后,MDC 里的链路标识在新线程里拿不到,所有日志的 traceId 都是空的。解决方式是用可传递的上下文容器,或者干脆在提交任务前把 MDC 内容取出来当参数传过去。
切面把事务搞乱了。日志切面如果同时开了事务注解,或者顺序配置不当,可能出现日志记录成功了但数据回滚了。判断方法是在切面里打印事务状态,或者临时把日志切面挪到事务切面内层验证。
5.3 问题速查表
这张表我打印出来贴在工位上很久了,大多数问题看一眼就能对上号。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 切面完全无输出,也无报错 | 缺少 AOP 依赖 | 补 starter,重启验证 |
| 切面完全无输出,依赖正常 | 切点表达式不匹配 | 临时放宽范围做二分验证 |
| 部分方法有日志,部分没有 | 这些方法被自调用 | 拆类或自注入 |
| 加了注解但必须重启才生效 | 注解保留策略不是 RUNTIME | 改为@Retention(RUNTIME) |
| 切面执行了但拿不到返回值 | 环绕通知没 return | 补return pjp.proceed() |
| 接口莫名返回 null | 同上 | 同上 |
| 日志里全是对象地址 | 没做序列化 | 用 Jackson 转 JSON |
日志里出现大量$ref | 对象存在循环引用 | 忽略关系字段或改用 DTO |
| 加了切面后接口变慢明显 | 同步序列化 + 同步 IO | 采样、异步、按需记录 |
| 切面里报 LazyInitializationException | 序列化触发了懒加载 | 先过滤框架对象,只取字段 |
| 事务回滚了但日志显示成功 | 切面顺序错误 | 调整 order,日志切面置于事务内层 |
5.4 几条踩出来的经验
写到这里,把几条不太容易从文档里看到、但实际很有用的经验单独列一下。
给切面加一个全局开关。在配置里放一个布尔值,切面第一行判断,关闭时直接proceed返回。线上出问题时能一键止血,这比临时改代码发版快得多。我在一个系统里加了这个开关后,至少两次避免了因为日志切面异常导致的故障扩大。
日志里带上方法签名而不是只带方法名。getSignature().toShortString()会输出类似OrderService.create(..)的格式,重载方法多的时候能区分清楚。只记方法名的话,save到底存的是订单还是用户,你得靠猜。
不要记录文件流和请求体。InputStream、MultipartFile、HttpServletRequest这几类对象一旦被序列化,轻则报错重则把流读空了,业务代码拿到的就是空数据。在序列化前做类型判断,遇到这类对象直接输出[skip]。
切面类不要写业务判断。这句话重复一次也不为过。一旦切面里出现if (order.getType() == 3),这个切面就绑定了业务语义,日后需求一变必然要改,而且改的人得先看懂这个“隐藏逻辑”。切面的干净程度直接决定了它能不能活过三个迭代。
给自己留一条验证链路。切面写完后,写一个最简单的测试方法,带上注解,跑一次看日志是否正确。不要等联调时才发现问题,那时候变量太多,排查成本高好几倍。
我个人在实际项目里的体会是,AOP 这个技术的价值,八成不在“会写切面”,而在“知道什么不该放进切面”。日志、耗时、异常这三件事放进去是合适的,因为它们对业务无感知、对结果无影响。而一旦你在切面里开始判断参数、修改流程、兜底返回值,它就从“管井”变成了“暗线”,短期看着省事,长期是维护灾难。我见过一个切面在三年里长到七百多行,最后没人敢动,所有新需求都绕着它走——那不是 AOP 的错,是最初把它当成万能胶的代价。