☰
Java动态代理实战:老系统无侵入式日志记录方案
2026/10/17 2:42:59 网站建设 项目流程

给一个老系统加日志,最让人头疼的往往不是“加”这个动作,而是“不能改”。我之前在某个维护中的项目里遇到过这么个需求:核心业务模块的每个方法都得有操作日志,包括入参、返回值、耗时、异常信息,但项目当时的现状是Service层代码已经迭代了三年多,谁也不敢为了加日志去大面积改动业务源码,一怕改出回归,二怕上线窗口不够用。那时候动态代理就成了最合适的选择,我花了一个下午把日志拦截器写好,通过代理工厂统一替换服务对象,业务代码一行都没动,后续所有核心方法自动带上了完整日志。

这篇文章会把我用Java动态代理实现日志记录的完整思路、核心代码、踩过的坑和方案选型过程一次说清楚。不堆概念,全部是能在真实项目里直接落地的做法。适合正在给老项目做可观测性改造、或者想彻底搞懂动态代理原理的开发者。

1. 动态代理原理入门:为什么它能不动业务代码就加日志

1.1 从手动打日志到静态代理:常规方案的三层困境

先聊聊最直觉的方案——在业务方法里手动加日志。项目初期代码量小,每个方法加几行logger.info没毛病,但一旦业务膨胀到几百个方法,这条路就走不通了。第一是侵入性太强,日志代码和业务代码完全搅在一起,你想看一段业务逻辑,满屏都是log输出,阅读成本急剧上升。第二是容易遗漏,人不是机器,写新方法的时候可能顺手写了日志,改老方法的时候大概率忘了。第三是回归风险,改代码就意味着重新测试、重新发布,对老系统来说,每一次改动都像在雷区里走路。

后来有人想到用静态代理模式解决。给每个业务实现类配一个对应的代理类,由代理去调真实对象,在代理里加日志。这个思路的逻辑是对的,把日志关注点和业务关注点拆开了。但它的问题也同样明显——类数量爆炸。每个接口都得多维护一个代理类,里面全是重复模板代码,而且接口一旦加新方法,代理类也得同步改。维护代理类的成本很快会超过维护日志本身。

这两条常规路径都不顺,于是大家的注意力自然转向了运行时动态生成代理对象,也就是动态代理。它能在不写死代理类的情况下,在运行时给任意接口生成一个代理实例,所有方法调用都会被统一的拦截器接管。日志逻辑只要写在拦截器里一份,所有被代理的方法就都能复用。

1.2 JDK动态代理的两个核心角色:Proxy和InvocationHandler

JDK动态代理的核心机制是运行时生成字节码,不需要编译期手写代理类。它只围绕两个关键角色工作。

一个是InvocationHandler,一个接口,里面只有一个invoke方法。代理对象上任何方法的调用,最终都会转发到这个invoke方法里。invoke方法能拿到三个关键参数:proxy(代理对象本身)、method(被调用的Method对象)、args(实际参数列表)。有了这三样,你可以在目标方法执行前后做几乎任何事情——记录入参、统计耗时、捕获异常、修改返回值。

另一个是Proxy,JDK提供的工具类,通过newProxyInstance静态方法为指定接口动态生成代理对象。这个方法接收三个参数:类加载器(ClassLoader)、接口数组(interfaces)、InvocationHandler实现。生成的代理对象实现了传入的全部接口,但接口方法内部的逻辑统一转发给InvocationHandler处理。

打个生活化的比方:真实对象是公司老板,代理对象是前台。外面的人找老板办事,必须先经过前台登记。前台知道谁来了、办了什么事、办了多久,甚至能决定放不放行。老板完全感知不到前台的存在,业务代码本身没有被改动。

注意:JDK动态代理有个硬性限制——只能代理接口。如果目标对象没有实现任何接口,或者你想代理的是某个具体类,JDK动态代理会直接抛异常。这个限制对方案选型影响很大,下一节细说。

2. 动态代理方案选型:JDK动态代理、CGLIB与字节码增强怎么选

2.1 JDK动态代理的限制决定你的技术路线

JDK动态代理用起来轻量,不引入额外的依赖,JDK自带就能搞定。但前面说了,它只支持接口代理。真实项目里,总会有一些不那么“标准”的代码——第三方库返回的是具体类,老代码压根没抽象出接口,或者某些内部工具类直接用类来承载业务能力。遇到这些情况,硬用JDK动态代理就会很尴尬。

我见过不少人为了适配JDK动态代理,去给具体类硬抽接口,然后改所有引用处。这个动作在数据量大的老代码里非常危险,因为改了类型关系,牵一发动全身。更合理的做法是:入口处保留具体类,换用支持类代理的方案。这时候CGLIB就登场了。

2.2 CGLIB:基于继承的代理机制

CGLIB的核心原理是生成目标类的子类,通过重写父类方法来实现拦截。它底层用ASM字节码框架在运行时动态生成子类字节码,再通过MethodInterceptor回调来接管被重写方法的逻辑。

一个最简单的CGLIB代理例子:

Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(UserServiceImpl.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> { long start = System.nanoTime(); try { Object result = proxy.invokeSuper(obj, args); long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); System.out.println("方法耗时: " + costMs + "ms"); return result; } catch (Throwable e) { System.out.println("方法异常: " + e.getMessage()); throw e; } }); UserServiceImpl proxyService = (UserServiceImpl) enhancer.create();

这段代码里,enhancer.setSuperclass指定父类,enhancer.setCallback设置拦截回调,proxy.invokeSuper负责调用父类的真实方法。看起来很简单,但CGLIB的边界条件比JDK动态代理更严格:

  • 目标类不能是final,否则没法生成子类
  • 目标方法不能是final或static,否则没法重写
  • 目标方法不能是private,否则子类不可见
  • 如果目标类是接口或者抽象类,也受各自限制

CGLIB在Spring早期的版本里作为默认的动态代理实现,因为当时很多Service并没有严格遵循接口设计。后来Spring才调整策略,判断目标对象有接口就默认走JDK动态代理,没有接口才走CGLIB。

2.3 选型对照表与建议

我根据自己的项目经验,把几个常见方案整理成对照表:

方案代理机制能力范围依赖适用场景
JDK动态代理接口 + Proxy类仅接口方法JDK自带面向接口设计的Service层、Controller代理
CGLIB继承 + 子类重写非final类的非private方法cglib依赖无接口的具体类、Spring AOP默认
字节码增强(JavaAgent)修改类字节码几乎全部类和方法ASM / ByteBuddy全链路监控、框架级打点、无侵入埋点

我的建议很简单:优先用JDK动态代理,但前提是项目里的核心业务对象都面向接口。如果代码里有大量没有接口的具体类,或者你正在接入Spring容器这种会自动决策的框架,那就直接选CGLIB路线。至于字节码增强,除非是要做全链路监控或者给第三方jar包里的类埋点,否则日常业务日志用不上,杀鸡不用牛刀。

3. 实操:手写一个可落地的动态代理日志记录器

3.1 场景定义与需求拆解

假设项目里有一个UserService接口,包含几个典型的用户操作:

public interface UserService { User getUserById(Long userId); boolean updateUserName(Long userId, String name); List<User> batchQueryUsers(List<Long> userIds); }

我的日志需求有三条:每个方法调用都自动记录入参和返回值;方法执行耗时必须体现在日志里;方法抛异常时要有完整异常堆栈。同时业务代码本身不允许改动,调用方只需要持有代理对象。

这三个需求正好是动态代理的强项:入参和返回值可以在invoke方法里拿到;耗时用System.nanoTime统计;异常在catch块里完整记录后重新抛出。

3.2 核心实现:LogInvocationHandler

先写最核心的InvocationHandler:

public class LogInvocationHandler implements InvocationHandler { private static final Logger log = LoggerFactory.getLogger(LogInvocationHandler.class); private final Object target; private final String serviceTag; private final ThreadLocal<Integer> depthHolder = new ThreadLocal<>(); public LogInvocationHandler(Object target, String serviceTag) { this.target = target; this.serviceTag = serviceTag; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // Object类的方法直接放行,不记录日志 if (method.getDeclaringClass() == Object.class) { return method.invoke(target, args); } // 防止递归日志刷屏 int depth = getDepth(); if (depth > 2) { return method.invoke(target, args); } depthHolder.set(depth + 1); long start = System.nanoTime(); String traceId = UUID.randomUUID().toString().substring(0, 8); try { log.info("[{}][{}] enter method={}, args={}", serviceTag, traceId, method.getName(), toJson(args)); Object result = method.invoke(target, args); long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); log.info("[{}][{}] exit method={}, result={}, cost={}ms", serviceTag, traceId, method.getName(), toJson(result), costMs); return result; } catch (Throwable e) { long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); log.error("[{}][{}] error method={}, cost={}ms", serviceTag, traceId, method.getName(), costMs, unwrap(e)); throw unwrap(e); } finally { depthHolder.remove(); } } private Throwable unwrap(Throwable e) { // method.invoke会把业务异常包在InvocationTargetException里,需要解开 if (e instanceof java.lang.reflect.InvocationTargetException) { Throwable cause = e.getCause(); return cause != null ? cause : e; } return e; } private int getDepth() { Integer depth = depthHolder.get(); return depth == null ? 0 : depth; } }

几个重要设计点解释一下。第一,method.getDeclaringClass() == Object.class的判断是必需的,因为toString、hashCode、equals这些Object方法也会被拦截,如果不放行,打印日志时调用代理对象的toString会再次进入invoke,形成死循环。第二,depthHolder用来防止代理方法内部递归触发重复日志,这在方法互相调用的服务里很关键,后面第五部分会细讲。第三,异常日志必须打完整堆栈,log.error的最后一个参数传异常对象,这样日志文件里能保留完整栈信息,排查问题才能定位到具体行号。

3.3 代理工厂与集成方式

有了Handler还不够,还要一个统一的代理工厂,避免业务代码每次都去手动拼Proxy.newProxyInstance:

public final class LogProxyFactory { private LogProxyFactory() {} public static <T> T wrap(Class<T> interfaceType, T target) { return wrap(interfaceType, target, "SERVICE"); } public static <T> T wrap(Class<T> interfaceType, T target, String serviceTag) { if (interfaceType == null || !interfaceType.isInterface()) { throw new IllegalArgumentException("interfaceType必须是接口"); } if (target == null) { throw new IllegalArgumentException("target不能为null"); } LogInvocationHandler handler = new LogInvocationHandler(target, serviceTag); return (T) Proxy.newProxyInstance( target.getClass().getClassLoader(), new Class<?>[]{interfaceType}, handler); } }

使用的时候只需要在创建服务对象的地方做一次替换:

UserService realService = new UserServiceImpl(); UserService userService = LogProxyFactory.wrap(UserService.class, realService, "USER-SERVICE");

之后所有注入到Controller或者别的Service里的userService引用,全部换成代理对象。原有调用代码不需要任何改动。这是动态代理最吸引人的地方:日志逻辑在代理工厂里集中维护,业务代码完全不感知。

在Spring项目里,更常见的做法是将代理对象的创建放在Bean初始化阶段,通过BeanPostProcessor或者FactoryBean来实现,这样整个容器内部自动替换,业务代码连创建对象的代码都不用改。不过那是另一个话题了,手写的方案在理解原理上更直观。

4. 动态代理日志记录的4个关键细节:参数序列化、耗时、异常与递归

4.1 参数序列化:别直接调toString

给日志系统写序列化方法时,最容易踩的坑就是直接调args.toString()。有些对象没重写toString,打出来是com.example.model.User@1a2b3c这种毫无价值的信息。更危险的是,某些实体的toString方法里会触发懒加载查询,或者在toString里引用了其他复杂对象,调用它可能直接抛异常。

我习惯的做法是优先用JSON序列化,失败再降级:

private String toJson(Object obj) { if (obj == null) { return "null"; } if (obj.getClass().isArray()) { return Arrays.deepToString((Object[]) obj); } if (obj instanceof Collection || obj instanceof Map) { try { return ObjectMapperHolder.get().writeValueAsString(obj); } catch (Exception e) { return String.valueOf(obj); } } try { return ObjectMapperHolder.get().writeValueAsString(obj); } catch (Exception e) { return String.valueOf(obj); } }

在使用JSON序列化时还需要考虑循环引用问题。比如两个实体互相持有对方引用,直接序列化会抛异常,需要在ObjectMapper里配置深度限制,或者在序列化时忽略掉某些字段。我的做法是在序列化之前判断一下对象类型,如果已经出现在当前序列化链路上就不再展开。

4.2 耗时统计:用System.nanoTime而不是currentTimeMillis

日志记录中耗时统计必不可少。但不少初学者会用System.currentTimeMillis()来算耗时,这个API返回的是JVM当前墙钟时间,它的值会受到系统时间调整、NTP时间同步等因素影响。如果线上服务器做了时钟同步,你可能会看到方法耗时出现负数这种诡异现象。

正确选择是System.nanoTime()。它返回的是JVM内部单调时钟的值,专门用来测量时间间隔,不受墙钟时间调整影响。用nanoTime计算耗时的标准写法:

long start = System.nanoTime(); // 执行目标方法 long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);

在日志输出时建议统一转成毫秒,不然纳秒级别的数字可读性太差,排查问题的时候一眼看过去全是十几位数字,没有任何意义。

4.3 异常日志:捕获之后必须重新抛出

代理里处理异常时,最容易犯的错误是catch住异常记录完日志就return了,不再继续抛出。这个错误后果极其严重——业务代码可能根据异常做回滚、补偿或者返回错误提示,而你吞掉异常等于告诉调用方“方法正常执行完”,会导致事务不回滚、数据不一致、接口返回错误结果,线上出问题都找不到原因。

正确写法我在前面的Handler代码里已经展示:catch住异常,记录完整堆栈,然后throw unwrap(e)重新抛出。

还有一个细节,method.invoke(target, args)底层的实现会把业务方法抛出的异常包一层InvocationTargetException,直接打印e.getMessage()往往得不到真正有用的业务异常信息。所以我在Handler里单独写了unwrap方法,把InvocationTargetException拆开,取真正的业务异常再打印和抛出。

4.4 防止日志递归刷屏

动态代理日志最隐蔽的坑就是递归。假如被代理的方法内部又调用了另一个同样被代理的方法,而代理Handler内部记录日志时又触发了对代理对象的方法调用,就可能在日志系统里形成无限递归。

我遇到过一种场景:业务方法里调了另一个服务方法,而第一个方法的返回值里包含另一个服务的对象,日志序列化这个返回值的时候,序列化器尝试访问对象属性的getter,而getter内部又调用了代理方法,日志直接刷到OutOfMemoryError。

解决思路有三个。一是在Handler里用ThreadLocal记录递归深度,超过设定阈值就不再记录,这个方案最简单可靠,我在上面代码里已经实现了。二是日志记录代码里坚决不调用任何可能经过代理的方法。三是序列化对象时配置忽略某些属性或限制深度。建议第一和第三个结合使用,双保险。

5. 动态代理日志记录常见问题排查实录

5.1 代理不生效:final类、final方法导致的静默失败

用CGLIB时,如果目标类是final的,Enhancer会在运行时抛异常,这个错误还算明显。但如果目标类不是final、只是某个方法被final修饰,CGLIB不会报错,而是直接让代理对该方法的拦截不生效。问题就来了:日志时有时无,排查半天发现是final方法绕过了拦截。

我的排查建议是,上线前写一个最小化验证用例,把核心方法全部打一遍,检查哪些方法没有日志输出。如果有遗漏,去确认方法是不是final、private或者static。

5.2 ClassCastException:代理对象不能强转成实现类

JDK动态代理生成的代理类,和业务实现类是兄弟关系,它们都实现了同一个接口,但代理类不是业务实现类的子类。如果你在代码里写了这样的强转:

UserServiceImpl impl = (UserServiceImpl) LogProxyFactory.wrap(UserService.class, realService);

运行时会直接抛ClassCastException。这个问题的根源在于对代理对象类型边界的理解有偏差。代理对象的类型是Proxy与接口的交集,不是目标实现类本身。我在项目里曾经因为这个问题排查了一整晚,后来把所有引用处从实现类改成接口类型,问题立刻消失。

5.3 日志重复:重复代理导致的重复记录

如果一个已经包了代理的服务对象又被包了一层代理,日志就会出现双份。我在某个聚合服务里遇到过这种场景:服务A被外层代理包装后注入到服务B,服务B又被代理包装后注入到上层,最终一个方法调用打出了四份日志。

解决办法是在代理工厂里做防重复包装,可以记录当前对象是否已经是代理对象,如果是就直接返回,不再二次包装。更稳妥的方案是在项目启动时统一完成代理创建,之后所有注入都使用代理对象,避免在不同代码层反复包装。

5.4 类内部自调用绕过代理

动态代理有个著名的边界问题:如果类内部的某个方法通过this去调用另一个方法,这个内部调用不会经过代理。因为代理对象只是给外部调用方使用的,内部this调用的目标仍然是原始对象,不是代理对象。

这意味着如果你用动态代理给Service加事务或日志,A方法调用本类的B方法时,B方法的日志不会记录、事务注解不会生效。我之前给老项目做日志改造时就踩过这个坑,排查了很久,最后定位到是因为自调用绕过了代理。

有两个解决方案。一是把内部自调用改成注入代理对象,通过代理对象去调B方法。二是把需要代理的方法拆分到独立对象里,通过组合代替继承避免自调用。第一种方案改动小,但要注意注入的必须是代理对象,否则还是白搭。

6. 从手写动态代理到生产级方案:Spring AOP与MDC

6.1 动态代理与Spring AOP的关系

Spring AOP底层就是动态代理,规则是默认优先使用JDK动态代理,目标对象没有接口时自动切换CGLIB。理解了前面这些内容,再去看Spring AOP的@Around、@Before、@After注解,会发现它们本质上都是包在不同InvocationHandler里的拦截逻辑,Spring只是把创建代理的过程封装成了声明式API。

如果项目里已经在用Spring框架,我不建议还去手写代理工厂。直接用Spring AOP更合适,声明一个切面,业务代码零侵入。操作步骤很简单:定义一个@Aspect类,用@Around定义环绕通知,在方法执行前后记录日志。

@Aspect @Component public class LogAspect { private static final Logger log = LoggerFactory.getLogger(LogAspect.class); @Around("execution(* com.example.service..*.*(..))") public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.nanoTime(); try { Object result = joinPoint.proceed(); log.info("method={}, args={}, result={}, cost={}ms", joinPoint.getSignature().getName(), Arrays.toString(joinPoint.getArgs()), result, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); return result; } catch (Throwable e) { log.error("method={} error", joinPoint.getSignature().getName(), e); throw e; } } }

6.2 生产级日志落地建议:MDC串联请求链路

手写动态代理得到的每一条日志是独立的,前后没有关联。在真实的分布式系统里,需要把同一次用户请求经过的所有方法串联起来,这时候单靠动态代理就不够了,还得引入MDC(Mapped Diagnostic Context)机制。

方案是在请求入口生成一个traceId,放进SLF4J的MDC上下文里,日志pattern里配置%X{traceId},这样所有日志输出都会自动带上这个traceId。动态代理Handler里只需要在invoke时读取MDC里的traceId,就能把链路信息带进日志。这个方法不需要对业务代码做改造,和动态代理天然搭配。

在生产环境落地时,我会把三个层次组合使用:请求入口用Filter生成traceId写入MDC;Service层用动态代理或Spring AOP统一记录参数与耗时;异常统一由全局异常处理器捕获并记录。这三层各司其职,日志可读性会大幅提升。

最后再给一个建议:日志内容也要注意安全边界。动态代理会把方法入参和返回值全部打出来,如果参数里包含手机号、身份证这类敏感字段,日志文件本身就可能成为数据泄露的入口。生产环境要么在序列化时做字段脱敏,要么在代理Handler里配置哪些方法不记录参数。我在项目里落地时,特意在工厂类里加了一个方法名单列表,对特殊方法只记录方法名和耗时,不记录参数与返回值。这个细节平时没人注意,等真出安全审计的时候,能帮你省掉很多麻烦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询