ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

动态代理与RPC Stub:从InvocationHandler到迷你RPC客户端

动态代理与RPC Stub:从InvocationHandler到迷你RPC客户端 面试聊到动态代理十个人里至少有八个会背一遍 InvocationHandler 的 demo实现接口、重写 invoke、Proxy.newProxyInstance 一把梭然后补一句这不就是给方法调用加个拦截嘛。这个理解不算错但把动态代理的定位看小了。真正让动态代理发挥核心价值的地方是 RPC 框架里那个让无数人觉得玄乎的东西——Stub。你写下HelloService hello rpc.reference(HelloService.class)然后像调本地方法一样调hello.sayHello(world)你手里的hello没有一行业务实现代码它是 JVM 在运行期凭空长出来的一个代理对象。这篇文章我会顺着这条线从代理的本质拆到手写一个迷你 RPC 客户端再聊生产环境里和 Stub 有关的那些坑。1. 把动态代理当 InvocationHandler 小把戏会卡在哪里1.1 代理对象不是回调器而是一个运行时生成的替身很多人写完动态代理 demo 之后对它的理解是这样的我调用目标方法时invoke 会收到通知然后在这里做点额外操作。这种回调器视角不能说错但会漏掉最关键的一点——动态代理生成出来的对象本身就是一个完整的、有类型的、可被当成真实对象使用的替身。看一个最小例子public interface HelloService { String sayHello(String name); } HelloService hello (HelloService) Proxy.newProxyInstance( HelloService.class.getClassLoader(), new Class?[]{HelloService.class}, (proxy, method, args) - { if (sayHello.equals(method.getName())) { return Hello, args[0]; } return null; }); System.out.println(hello.sayHello(world));这里hello是不是一个真正的HelloService对于调用方来说是。你可以把它传进任何接收HelloService的方法JVM 的类型检查完全通过。但它的类是com.sun.proxy.$Proxy0这个名字说明了一切这个类不存在于任何源文件里是 JVM 在运行期临时长出来的。把这个认知迁移到 RPC 场景Stub 的本质就清楚了。Stub 不是一个普通的工具类它和远程服务实现了同一个接口调用方拿到它的感觉应该是这就是那个远程服务。由于远程接口在编译期只是抽象方法没有任何实现所以 Stub 只能靠运行期生成——这正是动态代理的用武之地。1.2 invoke 方法签名里的四个参数每个都是考点InvocationHandler.invoke(Object proxy, Method method, Object[] args)这三个参数看着简单但每一个都有容易踩歪的地方。第一method是接口上的那个方法不是实现类的方法。在 RPC 场景下没有实现类所以这个方法对象就是你发送给服务端的凭证。服务端要确定调用哪个方法靠的就是method.getName()加上method.getParameterTypes()。注意重载场景只传方法名是不够的必须把参数类型数组一起传给服务端否则同名方法会匹配错。第二proxy参数指向动态生成的代理对象自身。有一点必须警惕如果在 invoke 里通过 proxy 去调用方法会再次进入 invoke造成无限递归。proxy.sayHello(x)在 invoke 内部是绝对禁止的。第三args 数组是方法入参的运行时快照。泛型擦除后这里拿到的是 Object 数组具体类型信息已经没了。所以如果要正确序列化通常得靠method.getGenericParameterTypes()把真正带泛型的类型拿出来而不是依赖 args 元素的运行时类型。还有一个特别容易被忽略的地方equals、hashCode、toString这三个方法也会走进 invoke。因为代理类实现了接口而这些 Object 方法并没有在接口里声明可当你对代理对象调用它们时JDK 仍然会转发到 invoke。很多人写日志时发现toString没有返回期望内容或者两个代理对象 hashCode 异常就是因为没在 invoke 里处理 Object 方法。2. RPC 的 Stub 为什么要凭空长出来从 rmic 到运行期代理2.1 Stub 在调用链里的位置与职责先理清概念。一次 RPC 调用的完整链路是调用方 → Stub客户端桩→ 序列化 → 网络传输 → 服务端反序列化 → 服务端业务实现 → 结果再原路返回。Stub 是最靠近调用方的那个对象它的职责有三个把本地方法调用翻译成一次远端过程调用的请求把调用等待的结果变回方法返回值把远端异常重新抛给调用方。翻译的关键动作是方法名 参数类型 参数值 接口名一起打包成可传输的请求体。所以 Stub 的形态必须满足一个硬性要求它得和远程服务拥有完全一致的公开接口。这样调用方的代码才能无感地把它当成本地对象。这个硬性要求意味着 Stub 本质上是一份接口的运行时实现而接口本身没有实现Stub 又不能在编译期就确定于是只能运行期生成。2.2 编译期生成 Stub 的老路rmic 和那堆手写代码在动态代理出现之前最早的 RPC 框架里 Stub 是编译期生成的。Java RMI 是典型代表早期 JDK 提供一个叫rmic的工具根据你写好的远程接口Xxx extends Remote生成一个Xxx_Stub和一个Xxx_Skel类。这些类是实实在在的 Java 源码会在编译期生成并打包进产物里。这种做法的问题很明显第一每次改接口都要重新跑一遍 rmic构建流程里多出一步很容易忘第二生成的 Stub 代码和具体 JVM 版本、RMI 协议实现深度绑定JDK 升级时可能出现兼容性问题第三很多类库作者并不想把生成代码这一步引入自己的构建体系。于是后来 Java 平台本身开始拥抱运行期生成。J2SE 1.2 之后RMI 的 JRMP 协议就能基于动态代理来生成 Stub 了不再强制依赖 rmic 预生成类。这是整个 RPC 发展史上的一次重要转向Stub 的生成时机从编译期挪到了运行期。2.3 编译期和运行期两条路线如今都在用现在的主流框架其实是两条腿走路。gRPC 走的是编译期生成路线用 protoc 根据.proto文件生成阻塞桩、异步桩类而 Dubbo、Feign 这类偏 Java 生态的 RPC/HTTP 客户端更倾向于运行期用动态代理或字节码库来生成 Stub。为什么有框架仍然选择编译期生成因为协议定义的越界、多语言 SDK 一致性这些需求靠一个.proto文件集中控制更可靠。而为什么 Java 生态很多框架偏爱运行期代理因为它对调用方最透明、接入成本最低——一个接口一个注解代理自动生成调用方不需要关注任何生成产物。运行期生成的本质不是真的去写.java文件再编译而是让字节码库在内存里直接定义出新的类。JDK 动态代理生成的是实现接口的类CGLIB 生成的是目标类的子类ByteBuddy/Javassist 则更底层可以直接操纵字节码。方法只有一个就是把方法调用变成一个可编程的流转过程。3. 手写迷你 RPC Stub把一次远程调用拆给你看3.1 Proxy.newProxyInstance 三参数的选择逻辑Proxy.newProxyInstance(ClassLoader loader, Class?[] interfaces, InvocationHandler h)三个参数每一个的选择都有讲究。loader 参数决定了生成的$Proxy0类由哪个类加载器加载。一个铁律是这个 loader 必须能加载到 interfaces 里的所有接口否则生成的代理类无法实现这些接口运行时直接抛IllegalArgumentException。最稳妥的做法是直接用serviceInterface.getClassLoader()而不是用当前工具类的 loader。如果工具类被打进了某个共享 jar而接口在应用自己的类加载器里用错了 loader 会出现非常隐蔽的ClassCastException。interfaces 参数是接口数组可以传多个接口。RPC 框架里一般只传这一个远程服务接口但 Spring AOP 中会传目标对象实现的所有接口。这个数组的顺序会影响getProxyClass生成的类元信息但在纯调用场景影响不大。h 参数就是前面反复提的 InvocationHandler它承载了方法调用后干什么的全部逻辑。在 RPC 里它就是协议转换、网络调用、响应解析三件事的总控。3.2 RpcInvoker 的核心实现下面是一个足够运行的迷你 RPC 客户端 Stub 生成器。为简化理解序列化层我用 JSON 字符串示例网络层用最朴素的 Socket生产环境里这块要换成高性能序列化框架和连接池。public class MiniRpcClient { public static T T createStub(ClassT serviceInterface, String host, int port) { return (T) Proxy.newProxyInstance( serviceInterface.getClassLoader(), new Class?[]{serviceInterface}, new RpcInvoker(serviceInterface, host, port)); } } class RpcInvoker implements InvocationHandler { private final Class? serviceInterface; private final String host; private final int port; RpcInvoker(Class? serviceInterface, String host, int port) { this.serviceInterface serviceInterface; this.host host; this.port port; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 第一步处理 Object 方法避免走网络 if (method.getDeclaringClass() Object.class) { if (toString.equals(method.getName())) { return MiniRpcProxy System.identityHashCode(proxy); } else if (hashCode.equals(method.getName())) { return System.identityHashCode(proxy); } else if (equals.equals(method.getName())) { return proxy args[0]; } throw new UnsupportedOperationException(method.getName()); } // 第二步组装请求注意参数类型数组不能丢 RpcRequest request new RpcRequest(); request.setInterfaceName(serviceInterface.getName()); request.setMethodName(method.getName()); request.setParameterTypes(method.getParameterTypes()); request.setParameterValues(args); request.setReturnType(method.getReturnType()); // 第三步序列化、发送、等待响应 String json JsonUtil.toJson(request); String responseJson SocketTransport.call(host, port, json); // 第四步解析结果并反序列化 RpcResponse response JsonUtil.fromJson(responseJson, RpcResponse.class); if (response.hasException()) { throw response.getException(); } return response.getResult(); } }请求里必须带interfaceName因为服务端通常部署了多个服务必须靠接口全限定名定位到具体的服务实现。method.getName()和parameterTypes合在一起才能唯一确定要调用的方法——这是重载方法能不能命中的关键。parameterValues是真正的入参服务端拿到后要按parameterTypes完成类型转换。这个流程跑通之后你再看任何 RPC 框架的源码会发现骨架都是一样的只是把SocketTransport换成了 Netty把 JSON 换成了 Hessian/ProtoBuf/Kryo把简单 try-catch 换成了集群容错和负载均衡。动态代理在这里扮演的角色始终只有一个把本地方法调用翻译成一个标准化的消息对象。3.3 泛型返回值和参数类型getGenericReturnType 才是正解上面的代码有一个隐藏 bugmethod.getReturnType()对泛型返回类型会丢信息。public interface UserService { User findById(Long id); ListUser list(); }假设服务端返回的是ListUser的 JSON 数组客户端要对这段 JSON 反序列化成ListUser就必须知道元素的真实类型是User。可method.getReturnType()只返回List.classList里的泛型参数User在运行时已经被擦除了。真正能拿到完整类型信息的是method.getGenericReturnType()。当返回类型是带泛型的ListUser时它会返回一个ParameterizedType你可以从这个对象里提取原始类型和实际类型参数再交给 Jackson 的TypeReference完成反序列化Type returnType method.getGenericReturnType(); // 如果是 ParameterizedType说明是 ListUser 这种带泛型的结构 if (returnType instanceof ParameterizedType) { ParameterizedType pt (ParameterizedType) returnType; Class? rawType (Class?) pt.getRawType(); Type[] actualArgs pt.getActualTypeArguments(); // 用 rawType actualArgs 构造 TypeReference }参数侧同理method.getGenericParameterTypes()能拿到带泛型的入参类型。RPC 框架里对外暴露服务时这一步几乎是必做的。我在早期写网关级应用时就踩过这个坑接口声明ListOrder返回结果反序列化出来是ListLinkedHashMap调用方一取order.getOrderNo()就ClassCastException。排查到最后一层才发现是序列化时把泛型信息丢了。4. 当接口不存在CGLIB 如何通过子类化长出 Stub4.1 无接口场景从哪来JDK 动态代理有一个硬前提必须面向接口。如果目标服务没有接口只有具体类JDK 动态代理就直接歇菜了。但现实里这种情况不在少数。最典型的是历史遗留系统。早年很多人写服务不抽接口直接把实现类暴露给别人调用后来要接 RPC总不能逼所有调用方先大重构。还有一类是第三方依赖你拿到的是一个没有接口的类想把它变成远程服务只能基于这个类本身做代理。另外像配置中心、规则引擎这类偏底层的调用也经常直接以类为边界。4.2 CGLIB 的关键限制与 MethodInterceptorCGLIB 的做法是在运行期生成目标类的一个子类子类重写目标类的所有非 final 方法方法体里转调MethodInterceptor.intercept。使用方式很直白Enhancer enhancer new Enhancer(); enhancer.setSuperclass(RemoteBizService.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) - { // Object 方法走本地 if (method.getDeclaringClass() Object.class) { return proxy.invokeSuper(obj, args); } // 远程调用逻辑和 InvocationHandler 里一样 return remoteCall(method, args); }); RemoteBizService stub (RemoteBizService) enhancer.create();CGLIB 的限制是它的子类策略天然带来的目标类不能是 final被代理的方法不能是 final、private 或者 static。final 方法无法在子类里重写private 方法对子类不可见static 方法属于类而不属于对象。还有一点容易被忽略——CGLIB 重写的是 public 方法protected 方法也会被重写但如果你在同一个包内调用 package-private 方法行为会变得比较微妙线上出问题时不建议往这个方向钻。CGLIB 还有一个广为流传的优点它有自己的 FastClass 机制可以在字节码层面建立方法索引避免反射调用。这也是早期 Spring AOP 对无接口类采用 CGLIB 的核心原因之一。不过后来 JDK 对反射做了 inflation 优化之后两者的差距在大多数业务场景里已经没那么悬殊了。对比项JDK 动态代理CGLIB实现方式运行期生成接口实现类运行期生成目标类的子类硬性限制必须面向接口类/方法不能 final不能代理 static/private方法调用反射JDK 有优化字节码调用 FastClass性能更好典型场景Feign、MyBatis Mapper、RMI StubSpring AOP 无接口 Bean、部分 RPC 泛化调用4.3 Dubbo、Spring、Feign 的代理选型逻辑这几个框架在代理选型上各有逻辑恰恰说明用哪种代理取决于框架的约束条件。Dubbo 的初始设计是接口驱动服务提供方和消费方共享接口 jar所以默认基于 JDK 动态代理生成 Stub。但 Dubbo 的 proxy 工厂是自适应 SPI 的底层默认用 Javassist 生成代理类的字节码来替代一部分 JDK 反射转发目的是减少反射调用的开销。如果你显式配置proxy jdk它就会走标准 JDK 动态代理。这种可切换的设计说明框架开发者并不迷信某一种代理而是把自己需要的能力接口支持、类支持、性能、兼容性做成一个权衡开关。Spring AOP 的默认逻辑更直观目标对象有接口就用 JDK 动态代理没有接口就退回到 CGLIB。Spring Boot 2.x 之后默认spring.aop.proxy-target-classtrue也就是大多数情况直接用 CGLIB因为子类代理在很多场景下更省心——不用为了接 AOP 去额外抽接口。Feign 就简单多了它要求调用方定义接口所以直接基于 JDK 动态代理一个接口一个代理对象InvocationHandler 里完成 HTTP 请求组装和响应解析。5. 代理 Stub 在生产环境的翻车现场超时、类加载与张冠李戴的 RPC5.1 30 秒超时RPC 调用到底卡在哪一段搜索 RPC 相关问题经常能看到这类报错cannot finish rpc call in 30 seconds。这个报错的字面意思是客户端发起调用后30 秒内没有走完整个 RPC 流程。注意它不是某一个环节的固定报错而是整个调用链路的总超时。排查这类问题核心思路是把调用链路切成几段分别定位。第一段是连接建立服务端 IP/端口不通、防火墙拦截会造成 connect timeout第二段是请求发送大量超时请求堆积时客户端写缓冲也可能阻塞第三段是服务端处理包括线程池排队、业务逻辑慢、GC 停顿第四段是响应回传大响应体在慢网络上会拖垮总耗时。我自己遇到过最典型的一次服务端接口本身只要 50 毫秒但客户端调用偶尔超时。后来在 Stub 的 InvocationHandler 入口和出口都打了点发现卡在连接池等待——因为某个上游把连接池最大连接数调小了高峰期请求一进来就排队排队时间一长总耗时自然超过阈值。这类问题在动态代理层看不到任何异常因为代理自己没有感知但它能把每个环节的耗时完整暴露出来。所以我建议在 Stub 的 invoke 里做一条轻量的耗时日志至少把invoke 进入时间、序列化完成时间、收到响应时间记下来比事后对着网络拓扑猜要高效得多。5.2 类加载器打架$Proxy0 到底归谁管类加载器问题是运行期生成类逃不开的话题。Proxy.newProxyInstance生成的代理类会被定义在我们传入的 loader 对应的命名空间里。如果接口由某个 Web 应用的子类加载器加载而我们传入的是 JDK 系统类加载器代理类就会因为看不到接口而无法实现它启动时直接抛IllegalArgumentException: com.sun.proxy.$Proxy0 is not an interface或者运行期抛ClassCastException。我在给一个集成在 Tomcat 里的项目写通用调用工具时踩过这个坑。工具类放在公共 lib 里接口放在业务 war 里用MiniRpcClient.class.getClassLoader()去创建代理结果明明接口类型是对的强转却失败。正确做法是永远从接口身上取 loaderserviceInterface.getClassLoader()在 OSGi、Java 模块化这类强隔离场景里这个问题会更严重通常还要配合线程上下文类加载器来处理。一个简单的经验法则生成代理对象前先确认传入的 loader 真的能加载 targetClass能加载再传不能加载就去线程上下文里找。5.3 代理对象的 equals/hashCode/toString 身份陷阱前面提过Object 的三个方法也会进 invoke。如果开发者在 InvocationHandler 里没有对这仨做专门处理默认行为是什么所有对代理对象的 equals、hashCode、toString 调用都会进入自定义逻辑比如被当成远程方法发送到服务端然后大概率会报找不到方法或者返回 null。更隐蔽的是你只是想把代理对象放进一个HashSet或者打印一行日志结果 hashCode 或 toString 被代理转发到了远程调用上。线上出现诡异的调用量飙升但业务请求量没涨很多时候就是日志框架调用了代理的 toString把每次日志都变成了一次远程调用。标准做法是在 invoke 的入口处先用method.getDeclaringClass() Object.class拦下来走本地语义。这一点不管 JDK 动态代理还是 CGLIB 拦截器都必须做区别只是 CGLIB 里equals/hashCode/toString的识别方式和 JDK 代理几乎一致。5.4 别让rpc failed把你带偏分清不同的 RPC 报错最后说一个很多人忽略的问题搜索rpc 失败时你会看到一堆长得很像但完全不同的报错。error: rpc failed; curl 56 ... server closed abruptly是 Git 通过 HTTP 传输对象时的 RPC 协议错误本质是 HTTP 层连接被服务端中断和 Java 的 RPC 框架没有关系。failed to create pod sandbox: rpc error: code ...是 Kubernetes 调用 CRI 运行时接口失败。ORA-28576: lost rpc connection to external procedure agent是 Oracle 外部过程代理连接丢失。这些报错里出现的rpc只是同一个缩写在不同协议栈里的复用。遇到任何 RPC 报错先别急着改配置第一步永远是回答一个问题这个 RPC 到底是谁到谁的调用回答清楚了再沿着协议层、连接层、超时层去定位。这条经验不只在动态代理 Stub 的场景有用在任何分布式问题排查里都值得刻进肌肉记忆。根据我个人调试动态代理的经验还有一个特别顺手的小工具JDK 允许把生成的代理类 dump 到磁盘。JVM 启动参数里加-Dsun.misc.ProxyGenerator.saveGeneratedFilestrueJDK 9 之后属性名可能调整为jdk.proxy.ProxyGenerator.saveGeneratedFiles代理类就会以com/sun/proxy/$Proxy0.class的形式落盘。反编译看一眼生成的类比对着文档猜行为高效得多。另外我习惯在写完包含动态代理的工具类后单测里故意测三件事传一个无接口类、在 invoke 里故意调用 Object 方法、把接口换成带泛型的方法——这三件事能挡住我在生产环境里踩过的大部分坑。
返回列表