在微服务系统当中,各个服务之间进行远程调用的时候需要考虑各种各样的场景,例如以下几种异常情况:
- 超时调用
- 失败重试
- 服务下线通知
- 服务上线通知
- 服务分组
- 请求队列
等等…
国内也有一些有先见之明的技术专家们对于这些技术有了较早的认知,因此很早便开始了关于远程服务调用中间件的开发。慢慢地,一些国内大厂自研的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内部出现粘包,拆包现象的解决手段,可以细看这篇文章:
协议体的内部需要设计哪些字段?
大概整理了一下代码,基本结构如下所示:
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有写好的返回数据,就会将其发送给客户端。
整体设计大概如下图所示: