微服务系列--深入理解RPC底层原理与设计实践(上)

本文涉及的产品
云原生网关 MSE Higress,422元/月
注册配置 MSE Nacos/ZooKeeper,118元/月
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
简介: 微服务系列--深入理解RPC底层原理与设计实践(上)

在微服务系统当中,各个服务之间进行远程调用的时候需要考虑各种各样的场景,例如以下几种异常情况:


  • 超时调用
  • 失败重试
  • 服务下线通知
  • 服务上线通知
  • 服务分组
  • 请求队列


等等…

国内也有一些有先见之明的技术专家们对于这些技术有了较早的认知,因此很早便开始了关于远程服务调用中间件的开发。慢慢地,一些国内大厂自研的RPC调用框架开始变做了一款产品向市面上去进行推广。


今年年初的时候,我花了大概一个半月的业余时间自己打磨了一套RPC框架,通过实践尝试后发现,要想真正地落地一款给公司内部使用的RPC框架难度真的超乎想象。本文不会过多地去介绍市面上某一款中间件的底层源代码是如何执行和编写的,更多是通过结合一些中间件底层设计的原理来阐述 我自己是如何设计一款RPC框架的。


准备工作



为了写一款可用的RPC框架,我大概准备了这些技术工作:


  • 阅读了Dubbo内部的大量源码设计。
  • 了解关于RPC框架设计的难点和痛点。(这里主要感谢nx的东哥,上了他的课程之后很多技术点都有了新的理解和感悟)
  • 不断地实践和测试。
  • 自己编写的中间件如何能够优雅地接入Spring容器。


RPC的整体设计思想



起初在设计RPC远程调用框架的时候,主要的设计思路是采用了经典的生产者-消费者思想。客户端发送请求,服务端接收之后匹配本地已有的服务方法进行处理执行。


但是在实际到落地过程中却发现,其中的技术复杂性远远超出预期~~

最终结果如下图所示:


整个项目的包结构整理


网络异常,图片无法展示
|


客户端调用:


网络异常,图片无法展示
|


服务端使用:


网络异常,图片无法展示
|


ps:这里面的每个api和设计思路大部分都是模仿了Dubbo框架内部的源代码设计以及部分自己的改编。


本地代理的设计



为了能够保证远程方法的调用使用起来和本地方法调用一样简单,通常可以使用代理模式去实现。场景的代理模式有好2大类:静态代理和动态代理,静态代理需要通过硬编码的方式实现,不现实,这里直接不合适。


动态代理主要有以下两种:


  • JDK代理
  • CGLIB代理


Java给出了动态代理,动态代理具有如下特点:


1.Proxy对象不需要implements接口;


2.Proxy对象的生成利用JDK的Api,在JVM内存中动态的构建Proxy对象。需要使用java.lang.reflect.Proxy类的newProxyInstance接口


public static <T> T getProxy(final Class interfaceClass, ReferenceConfig referenceConfig) {
        return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class[]{interfaceClass}, new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                //每次执行目标方法的时候都会回调到这个invoke方法处
                return null;
            }
        });
    }
复制代码


JDK动态代理要求target对象是一个接口的实现对象,假如target对象只是一个单独的对象,并没有实现任何接口,这时候就会用到Cglib代理(Code Generation Library),即通过构建一个子类对象,从而实现对target对象的代理,因此目标对象不能是final类(报错),且目标对象的方法不能是final或static(不执行代理功能)。


//给目标对象创建一个代理对象
    public Object getProxyInstance() {
        //工具类
        Enhancer en = new Enhancer();
        //设置父类
        en.setSuperclass(target.getClass());
        //设置回调函数
        en.setCallback(this);
        //创建子类代理对象
        return en.create();
    }
    public Object intercept(Object object, Method method, Object[] arg2, MethodProxy proxy) throws Throwable {
        System.out.println("before");
        Object obj = method.invoke(target);
        System.out.println("after");
        return obj;
    }
复制代码


我最终选择了JDK作为基本的动态代理实现方案,一开始的技术选型并没有选择更加完美的方案,而是采用了最为简单熟悉的技术。


如果读者感兴趣的话,可以阅读我之前介绍aop原理的文章,内部有详细讲解cglib底层原理的细节。点击这跳转


远程调用的数据传输



本地代理设计好了之后,需要考虑如何将数据发送给到服务端的问题了。底层采用的是netty框架,为了避免粘包和拆包的问题,我尝试使用了ObjectEncoder和ObjectDecoder两个netty内置的组件。


关于netty内部出现粘包,拆包现象的解决手段,可以细看这篇文章:

www.cnblogs.com/rickiyang/p…


协议体的内部需要设计哪些字段?


大概整理了一下代码,基本结构如下所示:


public class IettyProtocol implements Serializable {
    private static final long serialVersionUID = -7523782352702351753L;
    /**
     * 魔数
     */
    protected long MAGIC = 0;
    /**
     * 客户端的请求id
     */
    private String requestId;
    /**
     * netty专属
     */
    private ChannelHandlerContext channelHandlerContext;
    /**
     * 0请求 1响应
     * @see CommonConstants.ReqOrRespTypeEnum
     */
    protected byte reqOrResp = 0;
    /**
     * 0需要从服务端返回数据 1不需要从服务端响应数据
     */
    protected final byte way = 0;
    /**
     * 0是心跳时间,1不是心跳事件
     */
    private byte event = 0;
    /**
     * 序列化类型
     */
    private String serializationType;
    /**
     * 状态
     */
    private short status;
    /**
     * 返回的数据类型格式
     */
    private Type type;
    /**
     * 消息体 请求方发送的函数类型,参数信息都存在这里, 接收方响应的信息也都存在这里
     */
    private byte[] body;
}
复制代码


稍微解释几个字段:


requestId 客户端的请求id(用于请求响应做必配使用,下文中会介绍到)


reqOrResp 协议数据包的类型(标示该数据包是请求类型还是响应类型)


type 是指调用该方法的返回数据格式类型 (例如int,String,返回类型在做数据的序列化转换的时候会非常有用)


body 这里面是核心重点,主要的调用服务名称,参数,方法等详细信息都会先转换为字节数组,然后再通过网络将其发送出去。


如何将不同格式的数据转换为字节数组


数字类型

将数字类型转换为二进制,在之前的一篇文章中我有写过详细的底层实现机制,核心是通过将数字的二进制数右移8位,然后存入byte数组当中。核心代码为:


/**
     * 字节转成数字 int 大小是4个字节
     *
     * @param bytes
     * @return
     */
    public static int byteToInt(byte[] bytes) {
        if (bytes.length != 4) {
            return 0;
        }
        return (bytes[0]) & 0xff | (bytes[1] << 8) & 0xff00 | (bytes[2] << 16) & 0xff0000 | (bytes[3] << 24) & 0xff000000;
    }
    /**
     * 数字转成字节 int 大小是4个字节
     *
     * @param n
     * @return
     */
    public static byte[] intToByte(int n) {
        byte[] buf = new byte[4];
        for (int i = 0; i < buf.length; i++) {
            buf[i] = (byte) (n >> (8 * i));
        }
        return buf;
    }
复制代码


字符串类型


将字符串转换为对应的字符数组,然后每个数组的char类型使用asc码映射为数字,接下来又是回归到数字转换的思路上了。


集合,复杂对象类型


这些类型可以尝试先通过json转换为字符串,然后再将字符串转换为char数组,再转换为数字数组类型,后还是要回归到数字转换的思路上。


数据接收与响应设计



早期在做RPC通讯设计的时候,采用的是简单的生产者消费者模型。下边给出早期自己在进行实现过程中所思考的一些点:


同步发送数据


网络异常,图片无法展示
|


这类同步发送设计案例来看,consumer端发送数据之后,consumer会一直处于等待状态,只有等到数据抵达到provider端并且处理完毕之后,consumer端才会继续进行下去。


这样设计的弊端很明显:


consumer和provider的吞吐量都不高,而且一旦某个接口出现了超时还会影响其他接口的调用堵塞。


consumer端异步发送,provider端异步接收处理


这里需要引入两个新的概念,io线程和业务线程。整体设计如下图所示:


网络异常,图片无法展示
|


客户端发送数据的时候,不再是处于等待的状态,它会只需要将数据放入到一个本地的请求队列中即可。客户端的io线程会不断地尝试从队列中取出数据,然后进行网络发送。


服务端也会专门有一个io线程负责接收这类数据,接着将数据放入到服务端的一个队列缓冲中,然后再交给服务端的业务线程池去慢慢消费掉服务端的缓冲队列内部的数据。

服务端的核心设计如下:


网络异常,图片无法展示
|


provider端的数据处理完毕之后该如何正确返回?


为了解决这个问题,我尝试阅读了一下Dubbo的底层源代码,然后借鉴了其中的设计思路进行了一波实现。


客户端如何接收响应


其核心的本质是客户端在发送请求到时候会生成一个唯一的requestId,然后客户端在发送数据之后,会有一个Map集合(key是requestId,value是接口响应值)管理接口响应到数据,客户端的调用线程在执行了写入数据到发送队列之后需要不断监听Map集合中对应requestId的value是否有值,如果超过指定时间都没有数据,那么就抛出超时异常,如果收到了响应数据则正常返回即可。


服务端返回响应


服务端的本地代码正常处理完数据之后要将数据写入一个Map集合中,服务端的io线程会不断轮训这份Map集合(key是客户端发送过来的requestId,value是本地代码处理完之后写入的数据),如果发现对应的requestId有写好的返回数据,就会将其发送给客户端。


整体设计大概如下图所示:


网络异常,图片无法展示
|

目录
相关文章
|
6月前
|
Dubbo Java 应用服务中间件
微服务学习 | Springboot整合Dubbo+Nacos实现RPC调用
微服务学习 | Springboot整合Dubbo+Nacos实现RPC调用
|
4天前
|
存储 Dubbo Java
分布式 RPC 底层原理详解,看这篇就够了!
本文详解分布式RPC的底层原理与系统设计,大厂面试高频,建议收藏。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
分布式 RPC 底层原理详解,看这篇就够了!
|
5月前
|
存储 缓存 Linux
【实战指南】嵌入式RPC框架设计实践:六大核心类构建高效RPC框架
在先前的文章基础上,本文讨论如何通过分层封装提升一个针对嵌入式Linux的RPC框架的易用性。设计包括自动服务注册、高性能通信、泛型序列化和简洁API。框架分为6个关键类:BindingHub、SharedRingBuffer、Parcel、Binder、IBinder和BindInterface。BindingHub负责服务注册,SharedRingBuffer实现高效数据传输,Parcel处理序列化,而Binder和IBinder分别用于服务端和客户端交互。BindInterface提供简单的初始化接口,简化应用集成。测试案例展示了客户端和服务端的交互,验证了RPC功能的有效性。
401 9
|
5月前
|
网络协议 网络架构
RPC原理解析
RPC原理解析
89 0
|
6月前
|
Java fastjson 数据安全/隐私保护
【Dubbo3技术专题】「云原生微服务开发实战」 一同探索和分析研究RPC服务的底层原理和实现
【Dubbo3技术专题】「云原生微服务开发实战」 一同探索和分析研究RPC服务的底层原理和实现
153 0
|
6月前
|
消息中间件 缓存 API
|
6月前
|
存储 负载均衡 Java
【Spring底层原理高级进阶】微服务 Spring Cloud 的注册发现机制:Eureka 的架构设计、服务注册与发现的实现原理,深入掌握 Ribbon 和 Feign 的用法 ️
【Spring底层原理高级进阶】微服务 Spring Cloud 的注册发现机制:Eureka 的架构设计、服务注册与发现的实现原理,深入掌握 Ribbon 和 Feign 的用法 ️
|
6月前
|
消息中间件 Dubbo Java
Simple RPC - 01 框架原理及总体架构初探
Simple RPC - 01 框架原理及总体架构初探
82 0
|
6月前
|
设计模式 负载均衡 网络协议
【分布式技术专题】「分布式技术架构」实践见真知,手把手教你如何实现一个属于自己的RPC框架(架构技术引导篇)
【分布式技术专题】「分布式技术架构」实践见真知,手把手教你如何实现一个属于自己的RPC框架(架构技术引导篇)
265 0
|
16天前
|
自然语言处理 负载均衡 API
gRPC 一种现代、开源、高性能的远程过程调用 (RPC) 可以在任何地方运行的框架
gRPC 是一种现代开源高性能远程过程调用(RPC)框架,支持多种编程语言,可在任何环境中运行。它通过高效的连接方式,支持负载平衡、跟踪、健康检查和身份验证,适用于微服务架构、移动设备和浏览器客户端连接后端服务等场景。gRPC 使用 Protocol Buffers 作为接口定义语言,支持四种服务方法:一元 RPC、服务器流式处理、客户端流式处理和双向流式处理。