05 | 动态代理:面向接口编程,屏蔽 RPC 处理流程

简介: 本讲深入解析动态代理在 RPC 中的核心作用:通过动态代理实现接口方法的透明拦截,将本地调用无缝转为远程通信。结合 JDK 动态代理实例,揭示代理类生成与调用原理,并对比 Javassist、Byte Buddy 等框架在性能与易用性上的差异,帮助理解 RPC 如何实现“像调用本地一样调用远程”的编程体验。(238字)

上一讲我分享了网络通信,其实要理解起来也很简单,RPC 是用来解决两个应用之间的通信,而网络则是两台机器之间的「桥梁」,只有架好了桥梁,我们才能把请求数据从一端传输另外一端。其实关于网络通信,你只要记住一个关键字就行了——可靠的传输。
那么接着上一讲的内容,我们再来聊聊动态代理在 RPC 里面的应用。
如果我问你,你知道动态代理吗? 你可能会如数家珍般地告诉我动态代理的作用以及好处。那我现在接着问你,你在项目中用过动态代理吗?这时候可能有些人就会犹豫了。那我再换一个方式问你,你在项目中有实现过统一拦截的功能吗?比如授权认证、性能统计等等。你可能立马就会想到,我实现过呀,并且我知道可以用 Spring 的 AOP 功能来实现。
没错,进一步再想,在 Spring AOP 里面我们是怎么实现统一拦截的效果呢?并且是在我们不需要改动原有代码的前提下,还能实现非业务逻辑跟业务逻辑的解耦。这里的核心就是采用动态代理技术,通过对字节码进行增强,在方法调用的时候进行拦截,以便于在方法调用前后,增加我们需要的额外处理逻辑。
那话说回来,动态代理跟 RPC 又有什么关系呢?
远程调用的魔法
我说个具体的场景,你可能就明白了。
在项目中,当我们要使用 RPC 的时候,我们一般的做法是先找服务提供方要接口,通过 Maven 或者其他的工具把接口依赖到我们项目中。我们在编写业务逻辑的时候,如果要调用提供方的接口,我们就只需要通过依赖注入的方式把接口注入到项目中就行了,然后在代码里面直接调用接口的方法 。
我们都知道,接口里并不会包含真实的业务逻辑,业务逻辑都在服务提供方应用里,但我们通过调用接口方法,确实拿到了想要的结果,是不是感觉有点神奇呢?想一下,在 RPC 里面,我们是怎么完成这个魔术的。
这里面用到的核心技术就是前面说的动态代理。RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。
通过这种「偷梁换柱」的手法,就可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验,整体流程如下图所示:

实现原理
动态代理在 RPC 里面的作用,就像是个魔术。现在我不妨给你揭秘一下,我们一起看看这是怎么实现的。之后,学以致用自然就不难了。
我们以 Java 为例,看一个具体例子,代码如下所示:
package cn.mrcode.study.rpc.s05;

/**

  • 要代理的接口
    */
    public interface Hello {
    String say();
    }
    package cn.mrcode.study.rpc.s05;

/**

  • 真实调用对象
    */
    public class RealHello {
    public String invoke() {
     return "i'm proxy";
    
    }
    }
    package cn.mrcode.study.rpc.s05;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

/**

  • JDK 代理类生成
    */
    public class JDKProxy implements InvocationHandler {
    private Object target;

    JDKProxy(Object target) {

     this.target = target;
    

    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

     return ((RealHello) target).invoke();
    

    }
    }
    package cn.mrcode.study.rpc.s05;

import java.lang.reflect.Proxy;

/**

  • 测试例子
    */
    public class TestProxy {
    public static void main(String[] args) {

     // 构建代理器
     JDKProxy proxy = new JDKProxy(new RealHello());
    
     final ClassLoader classLoader = ClassLoader.getSystemClassLoader();
    

    // ClassLoader classLoader = ClassLoaderUtils.getCurrentClassLoader();

     // 该 ClassLoaderUtils 类没有找到,不知道是否是其他的工具类
    
     // 把生成的代理类保存到文件
     System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
    
     // 生成代理类
     Hello test = (Hello) Proxy.newProxyInstance(classLoader, new Class[]{Hello.class}, proxy);
     // 方法调用
     System.out.println(test.say());
    

    }
    }
    这段代码想表达的意思就是:给 Hello 接口生成一个动态代理类,并调用接口 say() 方法,但真实返回的值居然是来自 RealHello 里面的 invoke() 方法返回值。你看,短短 50 行的代码,就完成了这个功能,是不是还挺有意思的?
    那既然重点是代理类的生成,那我们就去看下 Proxy.newProxyInstance 里面究竟发生了什么?
    一起看下下面的流程图,具体代码细节你可以对照着 JDK 的源码看(上文中有类和方法,可以直接定位),我是按照 1.7.X 版本梳理的。

在生成字节码的那个地方(是 java.lang.reflect.Proxy.ProxyClassFactory#apply 中处理的生成字节码文件 ),也就是 ProxyGenerator.generateProxyClass() 方法里面,通过代码我们可以看到,里面是用参数 saveGeneratedFiles (sun.misc.ProxyGenerator#saveGeneratedFiles 这个变量取值就是上面写的那一串) 来控制是否把生成的字节码保存到本地磁盘。同时为了更直观地了解代理的本质,我们需要把参数 saveGeneratedFiles 设置成 true,但这个参数的值是由 key 为 sun.misc.ProxyGenerator.saveGeneratedFiles 的 Property 来控制的,动态生成的类会保存在工程根目录下的 com/sun/proxy 目录里面(没有继承公共的代理类,则默认的包名是这个)。现在我们找到刚才生成的 $Proxy0.class ,通过反编译工具打开 class 文件,你会看到这样的代码:

package com.sun.proxy;

import cn.mrcode.study.rpc.s05.Hello;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;

public final class $Proxy0 extends Proxy implements Hello {
private static Method m1;
private static Method m2;
private static Method m3;
private static Method m0;

public $Proxy0(InvocationHandler var1) throws  {
    super(var1);
}

public final boolean equals(Object var1) throws  {
    try {
        return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
    } catch (RuntimeException | Error var3) {
        throw var3;
    } catch (Throwable var4) {
        throw new UndeclaredThrowableException(var4);
    }
}

public final String toString() throws  {
    try {
        return (String)super.h.invoke(this, m2, (Object[])null);
    } catch (RuntimeException | Error var2) {
        throw var2;
    } catch (Throwable var3) {
        throw new UndeclaredThrowableException(var3);
    }
}

public final String say() throws  {
    try {
        return (String)super.h.invoke(this, m3, (Object[])null);
    } catch (RuntimeException | Error var2) {
        throw var2;
    } catch (Throwable var3) {
        throw new UndeclaredThrowableException(var3);
    }
}

public final int hashCode() throws  {
    try {
        return (Integer)super.h.invoke(this, m0, (Object[])null);
    } catch (RuntimeException | Error var2) {
        throw var2;
    } catch (Throwable var3) {
        throw new UndeclaredThrowableException(var3);
    }
}

static {
    try {
        m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
        m2 = Class.forName("java.lang.Object").getMethod("toString");
        m3 = Class.forName("cn.mrcode.study.rpc.s05.Hello").getMethod("say");
        m0 = Class.forName("java.lang.Object").getMethod("hashCode");
    } catch (NoSuchMethodException var2) {
        throw new NoSuchMethodError(var2.getMessage());
    } catch (ClassNotFoundException var3) {
        throw new NoClassDefFoundError(var3.getMessage());
    }
}

}

我们可以看到 $Proxy0 类里面有一个跟 Hello 一样签名的 say() 方法,其中 super.h 绑定的是刚才传入的 JDKProxy 对象(构造器中传入的 InvocationHandler var1),所以当我们调用 Hello.say() 的时候,其实它是被转发到了 JDKProxy.invoke() 。到这儿,整个魔术过程就透明了。
实现方法
其实在 Java 领域,除了 JDK 默认的 InvocationHandler 能完成代理功能,我们还有很多其他的第三方框架也可以,比如像 Javassist、Byte Buddy 这样的框架。
单纯从代理功能上来看,JDK 默认的代理功能是有一定的局限性的,它要求被代理的类只能是接口。原因是因为生成的代理类会继承 Proxy 类,但 Java 是不支持多重继承的。
这个限制在 RPC 应用场景里面还是挺要紧的,因为对于服务调用方来说,在使用 RPC 的时候本来就是面向接口来编程的,这个我们刚才在前面已经讨论过了。使用 JDK 默认的代理功能,最大的问题就是性能问题。它生成后的代理类是使用反射来完成方法调用的,而这种方式相对直接用编码调用来说,性能会降低,但好在 JDK8 及以上版本对反射调用的性能有很大的提升,所以还是可以期待一下的。
相对 JDK 自带的代理功能,Javassist 的定位是能够操纵底层字节码,所以使用起来并不简单,要生成动态代理类恐怕是有点复杂了。但好的方面是,通过 Javassist 生成字节码,不需要通过反射完成方法调用,所以性能肯定是更胜一筹的。在使用中,我们要注意一个问题,通过 Javassist 生成一个代理类后,此 CtClass 对象会被冻结起来,不允许再修改;否则,再次生成时会报错。
Byte Buddy 则属于后起之秀,在很多优秀的项目中,像 Spring、Jackson 都用到了 Byte Buddy 来完成底层代理。相比 Javassist,Byte Buddy 提供了更容易操作的 API,编写的代码可读性更高。更重要的是,生成的代理类执行速度比 Javassist 更快。
虽然以上这三种框架使用的方式相差很大,但核心原理却是差不多的,区别就只是通过什么方式生成的代理类以及在生成的代理类里面是怎么完成的方法调用。同时呢,也正是因为这些细小的差异,才导致了不同的代理框架在性能方面的表现不同。因此,我们在设计 RPC 框架的时候,还是需要进行一些比较的,具体你可以综合它们的优劣以及你的场景需求进行选择。
总结
今天我们介绍了动态代理在 RPC 里面的应用,虽然它只是一种具体实现的技术,但我觉得只有理解了方法调用是怎么被拦截的,才能厘清在 RPC 里面我们是怎么做到面向接口编程,帮助用户屏蔽 RPC 调用细节的,最终呈现给用户一个像调用本地一样去调用远程的编程体验。
既然动态代理是一种具体的技术框架,那就会涉及到选型。我们可以从这样三个角度去考虑:
● 因为代理类是在运行中生成的,那么代理框架生成代理类的速度、生成代理类的字节码大小等等,都会影响到其性能—— 生成的字节码越小,运行所占资源就越小。
● 还有就是我们生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时 生成的代理类的执行效率就需要很高效。
● 最后一个是从我们的使用角度出发的,我们肯定希望选择一个使用起来很方便的代理类框架,比如我们可以考虑:API 设计是否好理解、社区活跃度、还有就是依赖复杂度等等
最后,我想再强调一下。动态代理在 RPC 里面,虽然看起来只是一个很小的技术点,但就是这个创新使得用户可以不用关注细节了。其实,我们在日常设计接口的时候也是一样的,我们会想尽一切办法把细节对调用方屏蔽,让调用方的接入尽可能的简单。这就好比,让你去设计一个商品发布的接口,你并不需要暴露给用户一些细节,比如,告诉他们商品数据是怎么存储的。
课后思考
请你设想一下,如果没有动态代理帮我们完成方法调用拦截,用户该怎么完成 RPC 调用?
答:动态代理主要是帮我们完成方法调用拦截,里面是我们对远程方法的调用逻辑(如序列化请求、反序列化请求等),如果没有代理,就需要用户自己手动来调用这些方法来实现拦截里面做的一些操作

相关文章
|
1天前
|
机器学习/深度学习 搜索推荐 算法
19 | 广告系统:广告引擎如何做到在 0.1s 内返回广告信息?
广告系统是互联网核心营收支柱,支撑Google、Facebook等巨头超80%收入。其背后依赖高性能广告引擎,实现高并发、低延迟的“千人千面”精准投放。本文深入解析广告引擎架构,涵盖标签检索、向量匹配、打分排序与索引优化四大关键技术,揭示如何在0.1秒内完成从请求到广告返回的全过程,打造高效智能的广告生态体系。(238字)
|
1天前
|
机器学习/深度学习 搜索推荐 算法
20 | 推荐引擎:没有搜索词,「头条」怎么找到你感兴趣的文章?
本文深入解析了资讯类App推荐引擎的底层技术,重点探讨其在无搜索词场景下如何通过“下拉刷新”实现个性化内容召回。核心在于用户与文章画像构建,并结合基于内容与协同过滤的召回算法。基于内容的召回依赖标签匹配与向量检索,适合冷启动;协同过滤则通过用户或物品相似性推荐,挖掘潜在兴趣。实际系统多采用混合召回策略,结合多路结果并分层排序,在保证多样性的同时提升推荐精准度与效率。
|
1天前
|
存储 缓存 NoSQL
17 | 存储系统:从检索技术角度剖析 LevelDB 的架构设计思想
LevelDB是Google开源的高性能键值存储系统,基于LSM树优化,采用跳表、读写分离、SSTable分层与Compaction等技术,结合BloomFilter、缓存机制与二分查找,显著提升数据读写与检索效率,广泛应用于工业级系统中。(239字)
|
1天前
|
机器学习/深度学习 数据采集 自然语言处理
18 | 搜索引擎:输入搜索词以后,搜索引擎是怎么工作的?
本文介绍了搜索引擎的核心架构与工作原理,重点解析了爬虫、索引和检索三大系统。通过分词、纠错、推荐等查询分析技术,结合倒排索引与位置信息索引法,搜索引擎能精准理解用户意图并高效返回相关结果。特别地,以“极客时间”为例,深入讲解了短语检索中最小窗口排序与多关键词相关性判断机制,揭示了搜索背后的技术逻辑。(238字)
|
1天前
|
存储 固态存储 关系型数据库
特别加餐 | 高性能检索系统中的设计漫谈
本文深入解析高性能系统中的四大核心设计思想:索引与数据分离、减少磁盘IO、读写分离与分层处理。通过典型案例对比与扩展分析,揭示其本质与通用经验,帮助开发者在实际场景中优化检索效率、提升系统性能,打造高效稳定的架构。
|
1天前
|
存储 搜索推荐 定位技术
14 | 空间检索(下):「查找最近的加油站」和「查找附近的人」有何不同?
本文探讨了动态调整查询范围的高效检索方案,重点介绍如何利用四叉树和前缀树优化“查找最近的k个目标”场景。针对GeoHash固定范围查询的局限性,提出通过非满四叉树实现动态分裂与回溯查询,在保证效率的同时节省存储空间;并引出前缀树对GeoHash字符串编码的高效索引方法。最后拓展至高维场景,简述k-d树的适用性与挑战,为近邻搜索提供系统性解决方案。
|
1天前
|
机器学习/深度学习 算法 搜索推荐
11|精准 Top K 检索:搜索结果是怎么进行打分排序的?
搜索引擎排序核心在于打分与Top K检索。本文详解TF-IDF、BM25及机器学习打分方法,阐述如何综合词频、文档长度、查询词权重等因素提升排序质量,并介绍利用堆排序优化大规模数据下Top K结果返回效率,助力构建高效精准检索系统。
|
1天前
|
存储 算法 关系型数据库
06丨数据库检索:如何使用 B+ 树对海量磁盘数据建立索引?
本节深入探讨磁盘环境下大规模数据检索的挑战与解决方案,重点讲解B+树如何通过索引与数据分离、多阶平衡树结构及双向链表优化,实现高效磁盘I/O和范围查询,广泛应用于数据库等工业级系统。
|
1天前
|
自然语言处理 运维 负载均衡
10 | 索引拆分:大规模检索系统如何使用分布式技术加速检索?
在大规模检索系统中,分布式技术通过拆分倒排索引提升性能。基于文档的水平拆分将数据随机分布到多台服务器,实现并行检索与负载均衡;基于关键词的垂直拆分则按词典划分,减少请求复制但易引发热点问题。前者扩展性好、运维简单,后者适用于特定高性能场景。合理选择拆分策略是提升系统吞吐与响应速度的关键。
|
1天前
|
存储 自然语言处理 分布式计算
08 | 索引构建:搜索引擎如何为万亿级别网站生成索引?
针对超大规模数据,可通过分治与多路归并生成内存外倒排索引。先将文档分批在内存建索引,再写入有序临时文件,最后归并为全局索引。检索时结合内存哈希、B+树及分层加载技术,提升效率。