Java动态代理实战:基于反射与Proxy的日志记录统一改造

📅 发布时间:2026/10/17 5:07:09
Java动态代理实战:基于反射与Proxy的日志记录统一改造
很多人在学 Java 反射的时候都绕不过“动态代理”这一块。但反射的概念、Proxy 类的用法背得再熟一落到真实业务里还是不知道从哪下手。我之前在一个模拟订单系统里正好做了一次动态代理日志记录的改造把原本散落在各个业务方法里的日志输出全部收敛到了一个统一代理层里。那次改造做完之后我对动态代理的理解才算真正从“会读代码”变成了“会写设计”。这篇文章就把这次实践完整拆开讲一遍从为什么用动态代理做日志到 JDK 代理和 CGLIB 代理的选型再到完整的可复现代码和我在实际里踩过的坑一步一步说清楚。如果你正准备在项目里引入统一的日志埋点、权限校验或者事务控制又不想改动业务代码本身那动态代理几乎是性价比最高的切入点。理解了这个场景你对反射、代理模式、AOP 底层原理的理解都会跟着上一个台阶。1. 为什么偏偏用动态代理做日志记录1.1 日志散落在业务代码里的真实痛点我们最开始的需求其实特别简单每次调用核心业务方法时能自动记录一下方法名、入参、耗时、有没有抛异常。听起来没什么特别的但真正的问题是——这些核心方法分散在十几个 Service 类里每个类有七八个方法加起来上百个方法。如果在每个方法里手动加上日志逻辑那就是一百多处的重复代码。改一个格式就要全局搜索替换漏改一处日志就对不齐。更麻烦的是日志代码和业务逻辑搅在一起。比如一个处理订单状态的方法本来逻辑就够复杂了你还得在里面理清楚哪一行是业务逻辑哪一行是日志输出。时间一长代码的可读性会变得非常差。我见过有些项目里方法里一半以上的行数都在做日志和参数拼接真正的业务逻辑反而被淹没了。当时的诉求很明确能不能做到业务代码保持原样日志自动出现在方法调用的前后这就是动态代理的典型使用场景。1.2 代理模式在这里解决的本质问题代理模式的核心思路是在访问真实对象之前加一层中间层。调用者只面向代理对象代理对象拦截了本次调用可以在调用前、调用后、异常时插入统一逻辑。动态代理则意味着这个代理对象不需要手写而是在运行时通过 JDK 或者字节码生成库自动创建出来的。回到我们这个场景里看一下日志逻辑是横切关注点业务逻辑是核心关注点。动态代理把横切关注点从核心关注点里剥离出来让业务方法只关心一件事——做它自己的业务。日志、统计、异常监控这些统一交给了代理层。这其实和装修房子是一个道理——你不会让水电工顺手把墙也刷了每一类工作应该有明确的边界。动态代理就是帮你把日志这些“装修工人”和业务逻辑这些“主体工程”彻底分开。1.3 动态代理与 AOP 剖面的关系很多人看到这里会想这不就是 AOP 的事吗用个注解就搞定了何必手写动态代理从结果看确实相似但理解手写动态代理的价值在于它是 AOP 的底层基础。Spring AOP 内置了对于 JDK 动态代理和 CGLIB 代理的封装你要明白它在容器里到底帮你做了什么才能在你真正需要自定义拦截逻辑的时候不至于两眼一抹黑。而且轻量级的场景其实根本不需要引入一套完整的 AOP 框架。我这次的目标只是日志记录代理的类也就十几个手动实现一个代理切面完全可控而且整个过程透明出了任何问题都能迅速定位到具体是代理层写的还是业务层写的。2. JDK 动态代理和 CGLIB 代理的选型对比2.1 两种代理的底层机制差异Java 里实现动态代理有两条主流路线。第一条是 JDK 自带的方式要求目标对象必须实现接口。它的原理是在运行时通过反射机制生成一个实现了相同接口的代理类所有对接口方法的调用都会被转发到 InvocationHandler 的 invoke 方法里。你可以把 JDK 动态代理理解成“按接口签约”——代理对象只要签了这套接口的“合同”就能代替真实对象出场。第二条是 CGLIB它不要求目标对象实现接口。它的原理是在运行时直接生成目标类的一个子类子类里覆盖了父类的所有非 final 方法然后通过方法拦截器拦截调用。这个你可以理解成“继承家业”——代理类直接继承目标类把目标类的方法重写一遍在重写逻辑前后塞入代理逻辑。这两种路线不是谁取代谁的关系而是适用对象不同的互补关系。如果你的类没实现接口或者你希望代理的是类本身而没有现成的接口抽象层那 CGLIB 几乎是唯一选择。2.2 这里为什么优先选 JDK 动态代理我们这次日志记录的目标对象是 Service 层的核心业务类。在设计分层架构的时候Service 层本身就是面向接口编程的——我这边每个 Service 都先定义了接口然后才有对应实现类。比如订单服务和用户服务都有清晰的接口定义。这样的结构下使用 JDK 动态代理就非常顺畅了。接口本身就是天然的门面代理对象只需要实现同样的接口就能在方法调用链路上插入日志逻辑。不需要额外引入任何第三方依赖JVM 原生支持反射调用的成本在绝大多数业务场景下都完全可接受。如果你维护的代码里有些类没有接口或者接口体系很混乱再考虑引入 CGLIB。但一个接口都懒得定义的系统硬要套代理模式其实也意义不大那种代码往往结构本身就需要优先梳理。2.3 两种代理的性能与兼容性细节性能上JDK 代理在方法调用时需要经过反射CGLIB 因为直接生成了子类字节码方法调用上会比 JDK 代理快一些。但是记住这个快也是相对的。对于一个方法内部要做数据库查询、外部接口调用、复杂计算的场景来说反射损耗的那一点点时间比例小到可以忽略不计。需要注意的反而是另一个细节CGLIB 是通过生成子类来工作的如果目标类是 final 的或者某个方法是 final 的、static 的那 CGLIB 就无能为力了。JDK 代理也有它的边界只有接口方法会被拦截如果业务代码里有接口之外自己单独加的方法那么代理对象根本看不到这个方法的调用。我把两种方式的关键区别整理成了表格方便直接对照。我在做技术选型时习惯把这类对比放到一起也方便团队新成员快速掌握对比维度JDK 动态代理CGLIB 代理底层机制运行时生成接口实现类运行时生成目标类的子类目标要求目标必须实现接口目标类不能是 final 类方法要求仅拦截接口中定义的方法非 final 非 static 的方法均可第三方依赖无需额外依赖需要引入 cglib 库或通过 Spring 内置调用性能基于反射相对较慢字节码增强相对较快维护成本结构简单链路清晰生成逻辑复杂调试定位难度更高2.4 这次方案选型的取舍理由这次改造我选了 JDK 动态代理理由有四个。第一目标 Service 全部有接口完全贴合 JDK 动态代理的适用条件。第二整个项目还没引入任何额外字节码库没必要为日志记录引入 CGLIB 这个第三方依赖。第三代理调用链路要足够的简单透明方便团队里成员直接顺着代码阅读下去就懂。第四日志记录对性能的要求并不极端反射开销在可以接受的范围之内如果哪天真的对性能有严苛要求了再升级也不迟。有些项目组喜欢不论什么场景都默认上 CGLIB理由是 Spring Boot 内置了它就顺手用起来了。但我要提醒一句别人的默认未必适合你的场景。技术选型的第一原则永远是匹配现有代码结构先看看自己手里的牌再决定打法。3. 完整日志记录代理的设计与实现3.1 需要被代理的业务接口与实现类先说清楚我们的目标形态。订单服务是我们系统的核心业务服务之一它定义了查询订单和创建订单两个核心方法。我拿这个接口作为演示示例你的业务里换成自己的模块就好。createOrder 方法接收一个订单参数返回值是一个包含订单号和结果的 Map。queryOrder 方法按订单号查询订单信息。这两个方法将来都要被代理层拦截分别记录调用信息。为了更贴近真实项目订单参数实体里也可以加一些字段。不过这里的前置代码重点还是接口的形态字段本身不是关键关键是接口的存在。没有接口的话后面 JDK 动态代理的第一步就玩不转了。我们给这个订单服务定义一个接口public interface OrderService { /** * 创建订单 * * param order 订单信息 * return 创建结果 */ MapString, Object createOrder(MapString, Object order); /** * 查询订单 * * param orderId 订单编号 * return 订单详情 */ MapString, Object queryOrder(String orderId); }对应的接口实现类里我们把原先曾经出现过的日志输出语句全部去掉。这也是以后你引入这种改造方案时最容易出问题的一步——去掉日志的时候要格外小心别再留下半行未闭合的字符串拼接或者多余的 System.out不然编译期就直接报错了。public class OrderServiceImpl implements OrderService { Override public MapString, Object createOrder(MapString, Object order) { // 模拟业务处理耗时 try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } MapString, Object result new HashMap(8); result.put(orderId, ORDER_ System.currentTimeMillis()); result.put(status, CREATED); result.put(orderInfo, order); return result; } Override public MapString, Object queryOrder(String orderId) { // 模拟业务查询耗时 try { Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } MapString, Object result new HashMap(8); result.put(orderId, orderId); result.put(status, QUERY_SUCCESS); return result; } }3.2 日志记录 InvocationHandler 的核心代码接下来就是整篇文章里最核心的东西——日志记录处理器。它实现了 InvocationHandler 接口对所有接口方法的调用进行统一拦截。invoke 方法接收到三个参数第一个 proxy 是生成的代理对象本身第二个 method 是被调用的目标方法在反射层面的对象第三个 args 是本次调用的实际入参。在方法真正执行之前我们通过 method.getName() 和 method.getDeclaringClass().getName() 拼出“类名.方法名”的完整调用标志。再配合 Arrays.toString 把参数数组转换成可读的字符串。这一步为了防 NPE推荐用 String.valueOf 进行安全包装下面代码里就是这么处理的。方法执行时用一个 startTime 记录开始时间通过 method.invoke(target, args) 真正触发目标对象的业务逻辑。执行完成后计算耗时并且对返回值做了安全处理——如果返回值是 null就输出一段提示否则输出 toString。异常处理是这段代码里的关键设计。我在 catch 代码块里记录异常信息之后没有把吞掉之后假装无事发生而是重新 throw 抛给上层调用方。这个问题真实项目里经常被忽略。代理层如果吞掉了异常业务方的 try-catch 等于白写了整个异常链路就从这里断掉了。日志守护了“记录”的本分但异常的处理义务还是应该由业务方法决定。我写这个小工具的时候还额外做了一个实现模拟一个带自定义注解的方法通过 method.isAnnotationPresent 判断是否要记录日志。这样一来日志记录不光是全量的还能做到按注解精准控制范围。这一段代码内部的完整实现放在 3.4 节里展示。3.3 创建代理对象的工厂封装有了 InvocationHandler我们还需要一个工厂类专门负责创建代理对象。这个类的作用是约定一个统一的入口后续调用方只需要从这个工厂里拿到代理对象不需要知道背后反射和 Proxy 的原理。工厂方法的核心就一行Proxy.newProxyInstance。第一个参数是目标对象的类加载器第二个参数是目标对象实现的所有接口第三个参数是我们自研的日志记录处理器。类加载器这里要注意不要随便用自定义的线程上下文加载器而是要绝对保证和目标类使用的是同一个类加载器。我之前遇到过有人图省事直接传 ClassLoader.getSystemClassLoader()结果目标类是委托给应用服务器加载的两边类加载器不统一运行期报了 ClassCastException排查起来格外绕。这个细节看似简单实际翻车率不低。把工厂代码放出来方便直接拿来用public class LogProxyFactory { /** * 创建日志代理对象 * * param target 目标业务对象 * param T 接口类型 * return 代理对象 */ SuppressWarnings(unchecked) public static T T createLogProxy(T target) { if (target null) { throw new IllegalArgumentException(target object cannot be null); } return (T) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogInvocationHandler(target) ); } }3.4 完整日志记录处理器代码详解下面这段代码就是承载这次核心日志记录能力的处理器完成体我加上了一些注释方便阅读public class LogInvocationHandler implements InvocationHandler { private static final Logger log LoggerFactory.getLogger(LogInvocationHandler.class); /** 目标业务对象 */ private final Object target; public LogInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 如果是 Object 类自身的通用方法直接放行避免无意义的日志干扰 if (method.getDeclaringClass() Object.class) { return method.invoke(target, args); } String methodName method.getDeclaringClass().getSimpleName() # method.getName(); long startTime System.currentTimeMillis(); // 方法进入时记录入参信息 log.info([PROXY] {} start, params: {}, methodName, String.valueOf(args)); try { Object result method.invoke(target, args); long costTime System.currentTimeMillis() - startTime; // 方法正常返回时记录出参信息和耗时 log.info([PROXY] {} finish, cost: {}ms, result: {}, methodName, costTime, String.valueOf(result)); return result; } catch (InvocationTargetException e) { // 反射调用会把业务异常包装成 InvocationTargetException这里必须取出真实的异常 long costTime System.currentTimeMillis() - startTime; Throwable cause e.getTargetException(); log.error([PROXY] {} error, cost: {}ms, exception: , methodName, costTime, cause); throw cause; } catch (Exception e) { // 代理层自身的其它异常同样记录并抛出 long costTime System.currentTimeMillis() - startTime; log.error([PROXY] {} exception, cost: {}ms, exception: , methodName, costTime, e); throw e; } } }这段代码里有几个值得注意的细节我需要单独说明一下。第一个是 method.getDeclaringClass() Object.class 的判断。这个判断非常讲究。代理对象除了业务接口的方法之外从 Object 继承来的 toString、hashCode、equals 也会被拦截因为 JVM 在传递方法调用的时候并不自动区分哪些方法是接口定义、哪些是 Object 类固有方法。如果不提前放行每次打印代理对象时都会跑一遍日志逻辑打出来的日志又乱又蠢。第二个是 InvocationTargetException 的处理。反射调用 method.invoke 的时候如果目标业务方法内部抛了异常JVM 会把这个异常包装成 InvocationTargetException 往外抛。如果你直接 catch Exception 然后记日志打出来的异常堆栈全是反射内部的包装痕迹真正的业务异常堆栈就被埋掉了。所以必须调用 getTargetException() 或者 getCause() 取出真实异常再重新抛出。第三个是 String.valueOf(args) 的细节。很多人习惯写 Arrays.toString(args)但 args 可能为 nullJDK 的 Arrays.toString 就会抛 NPE。String.valueOf 内部会做 null 判断输出 null 字样日志照样打程序不会断。三个细节分别对应了程序的稳定、可读、健壮少了任何一个都会在后续的运行里暴露出问题。4. 实操过程与核心环节实现4.1 从普通调用改成代理调用的具体步骤改造其实没有那么多玄虚的步骤核心就是把原先 new 实现类的地方改成经过工厂类获取代理对象。我们模拟项目最开始在主程序里直接构造了 OrderServiceImpl然后调用了两次方法。现在要改成通过 LogProxyFactory 拿代理对象。这里的改造等于引出了“面向接口而非面向实现”的落地姿势。因为我这里已经把接口定义好了调用方只需要持有 OrderService 这个类型具体背后是真实实现类还是代理对象调用方根本不关心也感知不到。修改后的入口主程序如下public class ProxyLogDemo { public static void main(String[] args) { // 1. 创建目标业务对象 OrderService target new OrderServiceImpl(); // 2. 通过代理工厂创建代理对象 OrderService proxy LogProxyFactory.createLogProxy(target); // 3. 调用代理对象的方法触发日志记录 MapString, Object order new HashMap(8); order.put(productId, P-1001); order.put(quantity, 2); MapString, Object createResult proxy.createOrder(order); System.out.println(下单结果: createResult); MapString, Object queryResult proxy.queryOrder(ORDER_20250101_001); System.out.println(查询结果: queryResult); } }运行这段代码后的输出是这样的我保留关键日志语义仅供趋势参考具体时间戳等信息以运行时打印为准[INFO ] [PROXY] OrderServiceImpl#createOrder start, params: [{productIdP-1001, quantity2}] [INFO ] [PROXY] OrderServiceImpl#createOrder finish, cost: 301ms, result: {orderIdORDER_1735660800000, statusCREATED, orderInfo{productIdP-1001, quantity2}} 下单结果: {orderIdORDER_1735660800000, statusCREATED, orderInfo{productIdP-1001, quantity2}} [INFO ] [PROXY] OrderServiceImpl#queryOrder start, params: [ORDER_20250101_001] [INFO ] [PROXY] OrderServiceImpl#queryOrder finish, cost: 152ms, result: {orderIdORDER_20250101_001, statusQUERY_SUCCESS} 查询结果: {orderIdORDER_20250101_001, statusQUERY_SUCCESS}日志里清清楚楚地记录了什么方法被调用、入参是什么、耗时多少、返回结果是什么。而 OrderServiceImpl 内部完全看不到任何日志代码的痕迹。业务实现类保持纯净这就是这次改造最终要达到的效果。4.2 日志切面里精确控制记录范围的扩展有朋友看完会说现在的设计是每个方法都记录日志但我的业务系统里并不是每个方法都需要记日志。比如有些纯查询方法日志价值不大有些高频方法日志打太频繁反而影响性能。这个扩展点我来补上。我在处理器里加入了一个自定义注解 LogRecord只有标了该注解的方法代理层才执行日志记录逻辑。没标注解就只做普通转发不记任何日志。注解定义很简单Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface LogRecord { String value() default ; }然后修改一下 OrderService 接口希望 createOrder 方法记录日志queryOrder 暂时不记public interface OrderService { LogRecord(创建订单日志) MapString, Object createOrder(MapString, Object order); MapString, Object queryOrder(String orderId); }对应的 LogInvocationHandler 里的 invoke 逻辑也要跟着调整Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.getDeclaringClass() Object.class) { return method.invoke(target, args); } boolean needLog method.isAnnotationPresent(LogRecord.class); if (!needLog) { // 无注解直接放行 return method.invoke(target, args); } String methodName method.getDeclaringClass().getSimpleName() # method.getName(); long startTime System.currentTimeMillis(); LogRecord logRecord method.getAnnotation(LogRecord.class); String bizDesc logRecord.value(); log.info([PROXY] {}, desc: {}, start, params: {}, methodName, bizDesc, String.valueOf(args)); try { Object result method.invoke(target, args); long costTime System.currentTimeMillis() - startTime; log.info([PROXY] {}, desc: {}, finish, cost: {}ms, result: {}, methodName, bizDesc, costTime, String.valueOf(result)); return result; } catch (InvocationTargetException e) { long costTime System.currentTimeMillis() - startTime; Throwable cause e.getTargetException(); log.error([PROXY] {}, desc: {}, error, cost: {}ms, exception: , methodName, bizDesc, costTime, cause); throw cause; } catch (Exception e) { long costTime System.currentTimeMillis() - startTime; log.error([PROXY] {}, desc: {}, exception, cost: {}ms, exception: , methodName, bizDesc, costTime, e); throw e; } }这样改造后日志记录范围的控制从代理类的全局拦截下放到了注解层面。谁想记录日志就在对应接口方法上打个标签代码里一看就知道。团队协作的时候这个方案的可读性比逻辑判断拦截方式要好很多。4.3 方法耗时统计里隐藏的坑耗时统计看起来就是 startTime 和 endTime 相减非常简单。但这里实际上藏着几个容易忽略的细节。第一个坑是 System.currentTimeMillis() 获取的是 wall-clock 时间也就是墙上时钟时间。如果系统时间服务器做了时间校准导致系统时间往前跳或者往后跳测出来的耗时就会忽大忽小。对于做日志记录来说这个误差可以接受但如果你要拿这个数据做性能基准建议换成 System.nanoTime() 来测。它获取的是 JVM 内部的单调时钟不受系统时间调整影响。第二个坑是耗时数字的类型。我用的是 long 类型这在绝大多数场景下是够用的。如果你要用这个耗时做排序、告警阈值判断建议保留为 long不要因为方便展示就转化成 int一旦方法耗时超过 int 上限你就要额外做溢出了。第三个坑是代理层测的耗时包含了什么。它包含了反射调用本身的损耗也包含了业务方法内部的所有时间消耗。如果你想知道纯业务方法的耗时最简单的方式是在业务方法内部单独埋点但那就和动态代理的目的背离了。所以这里要接受一个误差代理层的耗时永远稍微大于业务方法真实耗时偏差通常只有微妙级别对日志记录这不重要。4.4 反射调用的异常链路如何保持完整前面已经强调了 InvocationTargetException 的处理这一节再展开说说它背后涉及到的反射机制。当目标方法内部抛出了一个业务异常比如订单库存不足你把它抛成自定义的 BusinessException。这个异常会被反映到 method.invoke 的调用栈上因为 invoke 底层通过 native 方法调用了目标方法JVM 不会直接把异常原样传出来而是统一包装成 InvocationTargetException。很多新手第一次写代理都会在这里栽跟头。他们就直接捕获 InvocationTargetException 然后 log.error 记一下一行 toString 了事跑起来之后发现业务方根本接不住原来的异常类型。整个调用方预期的是抛 BusinessException结果收到的是 InvocationTargetException类型体系全乱了。正确的处理方式是取出 cause 再重新抛出并且在方法签名上声明 throws Throwable。InvocationHandler 接口本身就是这么设计——允许实现类往外抛任意异常。这个签名设计也是给代理层处理异常留下了最大程度的自由度。在你的业务代码里当调用方 catch 住异常的时候拿到的异常类型和堆栈就能恢复成目标方法抛出的原样信息日志排查链路也不会断。5. 常见问题与排查技巧实录5.1 生成的代理对象类型转换失败这个问题最常见的诱因有三个目标类没有实现任何接口类加载器使用混乱代理对象转换到了错误的目标类型。如果你拿一个没有实现任何接口的类传给 Proxy.newProxyInstance第二个参数传 getClass().getInterfaces() 拿到的就是空数组代理类生成阶段直接抛 IllegalArgumentException。这种情况要考虑改用 CGLIB。类加载器方面要保证代理类所在模块能看见目标接口。最简单的做法就是用目标对象的类加载器几乎不会出错。乱用底层类加载器或者业务侧自定义类加载器很容易在运行时生成出来的代理类无法对接口定义做链接验证结果就是 ClassCastException。排查这类问题时第一步就是打印目标对象的类型和接口数组先定位是哪一段链路出了问题再顺着编译器的错误信息一个个验证。5.2 业务方法事务不回滚这是动态代理应用到一个项目里之后最容易引发的连锁反应。如果你的事务是通过 Spring 的事务管理器控制的而你的动态代理在事务控制外层又包了一层就可能出现事务不生效的情况。底层原因其实不复杂事务切面本身也是通过动态代理在工作。当你的代理对象先于事务管理器创建的代理对象出现时事务管理器看到的可能不是经过代理增强的对象或者是代理的顺序不对导致注解上配置的传播行为没有被正确识别。在我这个简单日志代理场景里问题不大因为我没有引入任何事务控制代理链路非常简单。但如果你准备在一个已经使用事务注解的项目里叠加自定义代理就要特别注意增强顺序的问题。最好的实践是不要随意在一个被 Spring 托管并且已经使用 AOP 的类上再手动包一层 JDK 动态代理这会让代理嵌套层数剧增排查成本成倍上升。5.3 日志中把敏感信息打出来了日志记录最容易被忽略的就是安全问题。如果你直接打印参数的 toString而参数对象里有银行卡号、手机号、密码字段那这些明文信息就会进入日志系统。一旦日志文件被同步到第三方平台或者被低权限人员查询就是一次数据泄露事故。我在实现时留了一个可配置的开关也可以在处理器里增加字段过滤。最简单的过滤方式是不打印全部参数而是打印参数全类名加参数个数比如 paramTypes[OrderQueryRequest], paramCount1。如果需要追踪指定的定位信息再额外提取订单号或者用户 ID 这样的非敏感字段。关键的原则是日志不是越详细越好而是越安全越有用。没有敏感字段的日志才可以放心流转到各类日志采集分析系统。5.4 代理对象调用开销被低估很多人以为反射调用很慢、慢到不能商用但实际没有这么夸张。在今天的 JVM 上反射调用是经过了高度优化的随着 HotSpot 版本升级反射调用的性能已经接近普通方法调用的性能。真正影响性能的是方法调用过于频繁且方法本身特别简单导致反射开销占比变得明显。如果某个高频方法只是简单的取值计算比如每秒被调用几十万次再追加一层动态代理日志那确实可能成为热点。但如果你代理的是 Service 层、方法内部要查询数据库、调用远程接口这个开销比例就完全不值一提了。不过我还是加了一个优化思路高频且纯查询的方法可以通过注解控制不记录日志返回结果也不打印只记录方法名和耗时。这个思路在 4.2 的代码里已经给出了实现方向。5.5 异常日志打印不完整有经验的排查人员会注意到一个细节日志里出现的异常只打印了异常消息缺少完整的堆栈——这种情况十有八九是你调用了 e.getMessage()而不是把整个异常对象打进去。我们的代码里用的是 log.error(...exception: , cause)这种方式把异常对象作为最后一个参数传入日志框架会自动把异常堆栈完整渲染出来。如果这里少了堆栈信息排查线上问题时只能看到“NullPointerException”六个字然后完全不知道发生在哪一行排查效率会断崖式下降。我还遇到过一种极端情况某些异常对象的 message 为空但堆栈里有丰富信息。更说明完整堆栈的重要性了别贪省事只打 getMessage()。5.6 代理类内部方法互相调用时日志失效这是动态代理和 AOP 体系里一个非常经典的问题。假设 OrderServiceImpl 的 createOrder 方法里直接调用了 this.queryOrder(内部查询)这个内部调用是一个普通 Java 方法调用完全不会经过代理对象。因为动态代理的拦截机制只对代理对象的外部调用生效。因此如果你在代理层做了日志记录你会看到外部调用 createOrder 时有日志但 createOrder 内部调用的 queryOrder 却没有日志。这会导致日志记录不完整排查链路时中间缺了一段。这个问题的解法从设计层面来说分两种。一种是你明确要求业务方法内部不要自调用把公共查询逻辑抽取到独立的支撑服务里让两个 Service 对外分别被代理。另一种就是接受这种现状向团队说明清楚日志记录是面向外部调用链的内部 this 调用不在这层管控范围内。我个人的建议是优先接受现状并把它写进团队技术规范。强行设计一套自调用也能拦截的方案付出的复杂度远超收益除非你真的面临严格到每个内部调用都必须审计的场景否则没必要过度设计。6. 这次实践给我的几个额外体会代码之外我再说一点从实际操练里得来的经验。动态代理这套工具真正上手用起来之后会让你对框架底层的理解发生一次质的改变。以前 JVM 生成代理类、反射调用方法这些概念都是散的做过一个完整日志代理之后它们就被组装成了一条完整的链路再去看 Spring AOP 的源码也能看出门道来不再是一堆抽象概念在脑子里飞来飞去。日志记录只是动态代理的最小场景之一。同样的拦截思路再往前延伸一点你可以实现接口调用的耗时监控、参数校验、权限过滤、幂等控制、缓存旁路。每一次扩展都用同一套代理模式去思考代码的组织方式会慢慢形成一种反射式设计的肌肉记忆。对于团队里基础比较薄弱的新同事我会建议他先把这篇文章里这个几十行代码的日志代理跑通仔细理解每一行的含义然后就着它去阅读 Spring 里 ProxyFactory 的实现。由小到大出来的知识比直接扑向庞大框架要扎实很多很多。最后分享一个小细节那次改造做完我顺手在代理处理器里加了一个静态开关生产环境默认开启压测环境可以直接关闭全部日志输出为的是在压测的时候排除一切 IO 干扰。后来这个开关在两次大促压测里帮了大忙——压测报告里的耗时曲线干净了很多没有日志写入抖动带来的毛刺。像这样的开关建议你以后自己写代理增强层时也考虑一个。