一、问题思考
面对大数据系统中几乎每天都会遇到的概念,序列化协议、PRC协议的区别与联系,你真的可以分清楚吗?
- 常见的PRC协议有哪些?常见的序列化协议有哪些?
- 序列化协议、PRC协议有什么关系?相等or包含?thrift是序列化协议还是rpc协议?
如果你的反应是这个表情,那么就一起来温故知新吧~
免责声明:本文并不在于理解源码或者技术细节,而在于统一某一方面的认知。 内容简介:
- PRC
- 为什么PRC
- RPC是什么
- 一个经典的PRC
- 简化后的PRC的核心的组成
- RPC调用过程
- 手撕PRC代码
- 序列化与反序列化
二、RPC框架
2.1为什么有RPC
- 解决分布式系统中,服务之间的调用问题(已经有40历史了)。
- 远程调用时,要能够像本地调用一样方便,让调用者感知不到远程调用的逻辑(调用无感)。
https://zhuanlan.zhihu.com/p/36528189
2.2 PRC是什么?
RPC(Remote Procedure Call):远程过程调用,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的思想。 https://developer.51cto.com/art/201906/597963.htm RPC是一个完整的远程调用方案,它包括了:接口规范+序列化反序列化规范+通信协议等。
RPC 是一种技术思想而非一种规范或协议,常见 RPC 技术和框架有:
- 应用级的服务框架:阿里的 Dubbo/Dubbox、Google gRPC、Spring Boot/Spring Cloud。
- 远程通信协议:RMI、Socket、SOAP(HTTP XML)、REST(HTTP JSON)。
- 通信框架:MINA 和 Netty。
2.3一个完整的PRC框架
在一个典型 RPC 的使用场景中,包含了服务发现(注册中心,例如zk)、负载、容错、网络传输、序列化、监控等组件,其中“RPC 协议”就指明了程序如何进行网络传输和序列化。
如下是 Dubbo 的设计架构图,分层清晰,功能复杂:
2.4 PRC的核心功能组成(简化模型)
RPC 的核心功能是指实现一个 RPC 最重要的功能模块,就是上图中的”RPC 协议”部分
一个 RPC 的核心功能主要有 5 个部分组成,分别是:客户端、客户端 Stub、网络传输模块、服务端 Stub、服务端等。
一个完整的 RPC 架构里面包含了5个核心的组件,分别是 Client,Client Stub,Server 以及 Server Stub,Network Service,这个Stub 可以理解为存根。
- 客户端(Client),服务的调用方。
- 客户端存根(Client Stub),存放服务端的地址消息,再将客户端的请求参数打包成网络消息,然后通过网络远程发送给服务方。
- 服务端(Server),服务提供方。
- 服务端存根(Server Stub),接收客户端发送过来的消息,将消息解包,并调用本地的方法。
- Network Service:底层传输,可以是 TCP 或 HTTP。
其中序列化与反序列化就存在于 Client Stub 和 Server Stub 中,并利用 IDL 生成的代码,另外最底层通过 Sockets 进行传输。
2.5常见的RPC框架有那些
Dubbo(主流) 阿里巴巴公司开源。 使用Hessian的序列化协议,传输则是TCP协议,使用了高性能的NIO框架Netty。协议和序列化框架都可插拔是及其鲜明的特色。远程接口是基于Java Interface。
RMI (淘汰) RMI就好比它是本地工作,采用tcp/ip协议,客户端直接调用服务端上的一些方法。优点是强类型,编译期可检查错误,缺点是只能基于JAVA语言,客户机与服务器紧耦合。 Hessian Hessian是一个轻量级的 remoting-on-http 工具,使用简单的方法提供了 RMI 的功能。 相比 WebService,Hessian 更简单、快捷。采用的是二进制 RPC协议,因为采用的是二进制协议,所以它很适合于发送二进制数据。 Motan:微博内部使用的 RPC 框架,于 2016 年对外开源,仅支持 Java 语言。 Tars:腾讯内部使用的 RPC 框架,于 2017 年对外开源,仅支持 C++ 语言。 Spring Cloud:国外 Pivotal 公司 2014 年对外开源的 RPC 框架,仅支持 Java 语言 gRPC(主流) 它的原理是通过 IDL(Interface Definition Language)文件定义服务接口的参数和返回值类型,然后通过代码生成程序生成服务端和客户端的具体实现代码,这样在 gRPC 里,客户端应用可以像调用本地对象一样调用另一台服务器上对应的方法。
Thrift(主流)RPC框架一般都有注册中心,有丰富的监控管理;发布、下线接口、动态扩展等,对调用方来说是无感知、统一化的操作 Thrift 是一种轻量级的跨语言 RPC 通信方案,支持多达 25 种编程语言。它有一个代码生成器来对它所定义的IDL定义文件自动生成服务代码框架。用户只要在其之前进行二次开发就行 https://blog.csdn.net/baidu_22254181/article/details/82814489
如果想了解更多,可以参考:Java 中几种常用的 RPC 框架介绍
(远程过程调用,或者远程方法调用),涉及到序列化 avro序列化方式 如Google的Protocol Buffers Facebook的Thrift 阿里巴巴公司开源的Dubbo,使用Hessian的序列化协议,传输则是TCP协议,使用了高性能的NIO框架Netty
2.6 PRC调用过程
https://www.zhihu.com/question/25536695 一个基本的PRC框架(想象自己实现一个PRC框架)需要解决三个问题: ①寻址问题: 服务寻址可以使用 Call ID 映射。在本地调用中,函数体是直接通过函数指针来指定的,但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。 在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <–> Call ID} 的对应表。 当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。
②序列化和反序列化 不同与本地调用,可以将参数压栈,然后让函数自己去栈中读取就行了; 远程调用时候,客户端和服务端在不同的进程,不能通过内存传递参数,只能通过客户端的参数转换字节流传递给服务端;然后服务端再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。 ③网络传输 远程调用往往用在网络上,客户端和服务端是通过网络连接的。 网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。 它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty也属于这层的东西。 TCP 的连接是最常见的,简要分析基于 TCP 的连接:通常 TCP 连接可以是按需连接(需要调用的时候就先建立连接,调用结束后就立马断掉),也可以是长连接(客户端和服务器建立起连接之后保持长期持有,不管此时有无数据包的发送,可以配合心跳检测机制定期检测建立的连接是否存活有效),多个远程过程调用共享同一个连接。
服务调用端(本地机器)
服务提供端(远程机器):
举例: int Multiply(int l, int r) { int y = l * r; return y; }
int lvalue = 10; int rvalue = 20; int l_times_r = Multiply(lvalue, rvalue);
解释: // Client端 // int l_times_r = Call(ServerAddr, Multiply, lvalue, rvalue)
- 将这个调用映射为Call ID。这里假设用最简单的字符串当Call ID的方法
- 将Call ID,lvalue和rvalue序列化。可以直接将它们的值以二进制形式打包
- 把2中得到的数据包发送给ServerAddr,这需要使用网络传输层
- 等待服务器返回结果
- 如果服务器调用成功,那么就将结果反序列化,并赋给l_times_r
// Server端
- 在本地维护一个Call ID到函数指针的映射call_id_map,可以用std::map<std::string, std::function<>>
- 等待请求
- 得到一个请求后,将其数据包反序列化,得到Call ID
- 通过在call_id_map中查找,得到相应的函数指针
- 将lvalue和rvalue反序列化后,在本地调用Multiply函数,得到结果
- 将结果序列化后通过网络返回给Client
其中:
- Call ID映射可以直接使用函数字符串,也可以使用整数ID。映射表一般就是一个哈希表。
- 序列化反序列化可以自己写,也可以使用Protobuf或者FlatBuffers之类的。
- 网络传输库可以自己写socket,或者用asio,ZeroMQ,Netty之类。
有了http,为什么还要RPC,有啥区别?
本质上两种都是一种远程调方案; 1.HTTP+Restful(可以说是基于Restful的PRC) (1)优势 它可读性好,且可以得到防火墙的支持、跨语言的支持。而且,在近几年的报告中,Restful大有超过RPC的趋势。 (2)其缺点: 首先是有用信息占比少,毕竟HTTP工作在第七层,包含了大量的HTTP头等信息。 其次是效率低,还是因为第七层的缘故,必须按照HTTP协议进行层层封装。 还有,其可读性似乎没有必要,因为我们可以引入网关增加可读性。 此外,使用HTTP协议调用远程方法比较复杂,要封装各种参数名和参数值。 2.狭义上的RPC (1)优点 多是采用基于TCP/UDP实现,在协议无用信息少,使用OSI第四层的通信协议,高效的序列化/反序列方式(不同于Json)等,更加适合服务之间大数据量的频繁通信场景; (2)缺点: 为了高效率而牺牲了可读性和易用性;
两种方案没有好坏之分,只是适用于不同的场景罢了!
2.7 代码实现PRC
如何实现一个简单的RPC 看一个视频:https://www.zhihu.com/zvideo/1230233678649233408 自己实现一个PRC的代码实现:扒一扒RPC 里面设计到动态代理的知识:https://www.jianshu.com/p/269afd0a52e6 看看thrift示例:https://www.cnblogs.com/fingerboy/p/6424248.html
①定义rpc调用的主要逻辑 包含序列化与反序列化、socket通信等; export()这个部分相当于是一个server-stub,部署在服务端,通过java反射的方式,负责读取远程客户端发过来的信息(反序列化方法名,参数类型,参数对象); refer()部署在客户端,相当于client-stub,负责接受上层服务层的远程方法请求,将请求的化方法名,参数类型,参数对象, 打包封装之后,经过序列化、压缩等,通过网络传输到服务端; 客户端调用的动态代理过程: Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class<?>[] {interfaceClass}, new InvocationHandler()
所谓动态代理是指:在程序运行期间根据需要动态创建代理类及其实例来完成具体的功能。JDK动态代理-超详细源码分析 主要是返回服务实现类的代理对象,我们在分析JDK动态代理的时候知道,当我们调用代理对象的方法时,invoke方法会被执行。在invoke方法中, Socket socket = new Socket(host, port); 创建Socket与服务器取得连接。然后将方法名,方法类型,方法参数序列化发给服务器端,这部分功能相当于Client-stub。然后获得服务器端发送过来的结果。 这样RPC的功能就实现了。 具体来说,通过可以调用传入interfaceClass.getClassLoader(),传入接口的不同实现类的类加载器,可以直接调用不同的HelloService接口实现类(此处为HelloServiceImp类),从而实现支持Client发起不同的服务的远程过程调用;例如,此时支持一个新的扩展SayServiceImp类,该类实现了HelloService的接口,则可以在client调用的时候,发起不同类的远程调用;
package rpc.demo;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.ServerSocket;
import java.net.Socket;
public class RpcFramework {
public static void export(final Object service, int port) throws Exception {
if (service == null)
throw new IllegalArgumentException("service instance == null");
if (port <= 0 || port > 65535)
throw new IllegalArgumentException("Invalid port " + port);
System.out.println("Export service " + service.getClass().getName() + " on port " + port);
ServerSocket server = new ServerSocket(port);
for(;;) {
try {
final Socket socket = server.accept();
new Thread(new Runnable() {
@Override
public void run() {
try {
try {
ObjectInputStream input = new ObjectInputStream(socket.getInputStream());
try {
String methodName = input.readUTF();
Class<?>[] parameterTypes = (Class<?>[])input.readObject();
Object[] arguments = (Object[])input.readObject();
ObjectOutputStream output = new ObjectOutputStream(socket.getOutputStream());
try {
Method method = service.getClass().getMethod(methodName, parameterTypes);
Object result = method.invoke(service, arguments);
output.writeObject(result);
} catch (Throwable t) {
output.writeObject(t);
} finally {
output.close();
}
} finally {
input.close();
}
} finally {
socket.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
} catch (Exception e) {
e.printStackTrace();
}
}
}
@SuppressWarnings("unchecked")
public static <T> T refer(final Class<T> interfaceClass, final String host, final int port) throws Exception {
if (interfaceClass == null)
throw new IllegalArgumentException("Interface class == null");
if (! interfaceClass.isInterface())
throw new IllegalArgumentException("The " + interfaceClass.getName() + " must be interface class!");
if (host == null || host.length() == 0)
throw new IllegalArgumentException("Host == null!");
if (port <= 0 || port > 65535)
throw new IllegalArgumentException("Invalid port " + port);
System.out.println("Get remote service " + interfaceClass.getName() + " from server " + host + ":" + port);
return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class<?>[] {interfaceClass}, new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[] arguments) throws Throwable {
Socket socket = new Socket(host, port);
try {
ObjectOutputStream output = new ObjectOutputStream(socket.getOutputStream());
try {
output.writeUTF(method.getName());
output.writeObject(method.getParameterTypes());
output.writeObject(arguments);
ObjectInputStream input = new ObjectInputStream(socket.getInputStream());
try {
Object result = input.readObject();
if (result instanceof Throwable) {
throw (Throwable) result;
}
return result;
} finally {
input.close();
}
} finally {
output.close();
}
} finally {
socket.close();
}
}
});
}
}
②定义rpc方法接口和实现
package rpc.demo;
public interface HelloService {
String hello(String name);
}
③prc的接口实现 例如如下,实现类重写了hello的方法
package rpc.demo;
public class HelloServiceImpl implements HelloService{
@Override
public String hello(String name) {
return "Hello " + name;
}
}
④启动rpc的后端server服务 此时会启动一个监控特定端口1234的服务,等待client的rpc的数据请求;
package rpc.demo;
public class RpcServer {
public static void main(String[] args) throws Exception {
HelloService service = new HelloServiceImpl();
RpcFramework.export(service, 1234);
}
}
⑤启动rpc的后端client请求服务 此时rpc不同的会向远程主机127.0.0.1的1234端口发送远程过程调用的请求
package rpc.demo;
public class RpcClient {
public static void main(String[] args) throws Exception {
HelloService service = RpcFramework.refer(HelloService.class, "127.0.0.1", 1234);
for (int i = 0; i < Integer.MAX_VALUE; i ++) {
String hello = service.hello("World" + i);
System.out.println(hello);
Thread.sleep(1000);
}
}
}
三.序列化与反序列化
讲了PRC为什么要讲序列化? 通过对PRC的介绍,我们知道序列化是PRC远程调用中不可缺少的一环,对于同一个PRC框架(例如dubbo等),都可以采用不同的序列化方式。那么常用的序列化方式有哪些呢?例如thrift到底是一种rpc框架,还是一种序列化方式?
什么是序列化
将内存对象转化为字节流的过程。相对的是反序列化,即将字节流转化为内存对象的过程。 常见的序列化格式 XML JSON Hessian Protobuf thrift
序列化和反序列的组件和流程
参考链接:https://tech.meituan.com/2015/02/26/serialization-vs-deserialization.html
典型的序列化和反序列化过程往往需要如下组件:
- IDL(Interface description language)文件:参与通讯的各方需要对通讯的内容需要做相关的约定(Specifications)。为了建立一个与语言和平台无关的约定,这个约定需要采用与具体开发语言、平台无关的语言来进行描述。这种语言被称为接口描述语言(IDL),采用IDL撰写的协议约定称之为IDL文件。
- IDL Compiler:IDL文件中约定的内容为了在各语言和平台可见,需要有一个编译器,将IDL文件转换成各语言对应的动态库。
- Stub/Skeleton Lib:负责序列化和反序列化的工作代码。Stub是一段部署在分布式系统客户端的代码,一方面接收应用层的参数,并对其序列化后通过底层协议栈发送到服务端,另一方面接收服务端序列化后的结果数据,反序列化后交给客户端应用层;Skeleton部署在服务端,其功能与Stub相反,从传输层接收序列化参数,反序列化后交给服务端应用层,并将应用层的执行结果序列化后最终传送给客户端Stub。
- Client/Server:指的是应用层程序代码,他们面对的是IDL所生存的特定语言的class或struct。
- 底层协议栈和互联网:序列化之后的数据通过底层的传输层、网络层、链路层以及物理层协议转换成数字信号在互联网中传递。
几种常见的序列化和反序列化协议
XML&SOAP 是什么:XML是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点。 通用性原因:自我描述与递归, XML本质上是一种描述语言,并且具有自我描述(Self-describing)的属性,所以XML自身就被用于XML序列化的IDL。; SOAP是一种采用XML进行序列化和反序列化的协议,它的IDL是WSDL. 而WSDL的描述文件是XSD,而XSD自身是一种XML文件。 这里产生了一种有趣的在数学上称之为“递归”的问题,这种现象往往发生在一些具有自我属性(Self-description)的事物上。 优点:
- 基于HTTP的传输协议使得其在穿越防火墙时具有良好安全特性
- XML所具有的人眼可读(Human-readable)特性使得其具有出众的可调试性;
- 预演和平台通用性
缺点: - 由于XML的额外空间开销大,序列化之后的数据量剧增,对于数据量巨大序列持久化应用常景,这意味着巨大的内存和磁盘开销,不太适合XML;
- 内存和磁盘开销大,导致对于对性能要求在ms级别的服务,不推荐使用
JSON 是什么:JSON起源于弱类型语言Javascript, 它的产生来自于一种称之为”Associative array”的概念;而Attribute-value”的方式可以用来描述对象。实际上在Javascript和PHP等弱类型语言中,类的描述方式就是Associative array。 优点: - 保持了XML的人眼可读(Human-readable)的优点
- 序列化之后体积更小;(XML所产生序列化之后文件的大小接近JSON的两倍)
- 与XML相比,其协议比较简单,解析速度比较快
- 松散的Associative array使得其具有良好的可扩展性和兼容性
Thrift 是什么: Thrift是Facebook开源提供的一个高性能,轻量级RPC服务框架,其产生正是为了满足当前大数据量、分布式、跨语言、跨平台数据通讯的需求。Thrift并不仅仅是序列化协议,而是一个RPC框架。 优点: - 高性能、跨语言、跨平台;
- 相对于JSON和XML而言,Thrift在空间开销和解析性能上有了比较大的提升
缺点: - 由于Thrift的序列化被嵌入到Thrift框架里面,Thrift框架本身并没有透出序列化和反序列化接口,这导致其很难和其他传输层协议共同使用(例如HTTP)
- 其Server是基于自身的Socket服务,所以在跨防火墙访问时,安全是一个顾虑,所以在公司间进行通讯时需要谨慎
- 另外Thrift序列化之后的数据是Binary数组,不具有可读性,调试代码时相对困难。
- 由于Thrift的序列化和框架紧耦合,无法支持向持久层直接读写数据,所以不适合做数据持久化序列化协议
Protobuf 1、标准的IDL和IDL编译器,这使得其对工程师非常友好。 2、序列化数据非常简洁,紧凑,与XML相比,其序列化之后的数据量约为1/3到1/10。 3、解析速度非常快,比对应的XML快约20-100倍。 4、提供了非常友好的动态库,使用非常简介,反序列化只需要一行代码。 优点: - Protobuf是一个纯粹的展示层协议,可以和各种传输层协议一起使用(比thrift仅使用tpc丰富)
- Protobuf的文档也非常完善
缺点: - 仅仅支持Java、C++、Python三种语言(所支持的语言相对较少)
- Protobuf支持的数据类型相对较少,不支持常量类型
- 展现层协议(Presentation Layer),目前并没有一个专门支持Protobuf的RPC框架
Avro Avro的产生解决了JSON的冗长和没有IDL的问题,Avro提供两种序列化格式:JSON格式或者Binary格式。Binary格式在空间开销和解析性能方面可以和Protobuf媲美,JSON格式方便测试阶段的调试。 优点: - Avro支持JSON格式的IDL和类似于Thrift和Protobuf的IDL
- Avro解析性能高并且序列化之后的数据非常简洁,比较适合于高性能的序列化服务。
总 结
常见的序列化框架有 Thrift、Protobuf、Avro,而由于 Thrift、Avro 可以生成 RPC 实现,所以当提到如 Thrift 服务这种说法时一般指的是 Thrift 实现的 RPC 服务端。而 Protobuf 没有 RPC 实现,所以指的就是序列化与反序列化操作,一般会结合 gRPC 来进行 RPC 实现。 Protobuf是一个纯粹的展示层协议,可以和各种传输层协议一起使用;Protobuf的文档也非常完善。 但是由于Protobuf产生于Google,所以目前其仅仅支持Java、C++、Python三种语言。另外Protobuf支持的数据类型相对较少,不支持常量类型。由于其设计的理念是纯粹的展现层协议(Presentation Layer),目前并没有一个专门支持Protobuf的RPC框架。 选型建议: 有人做过的各种序列化的性能对比 解析性能
序列化空间开销
1、对于公司间的系统调用,如果性能要求在100ms以上的服务,基于XML的SOAP协议是一个值得考虑的方案。 2、基于Web browser的Ajax,以及Mobile app与服务端之间的通讯,JSON协议是首选。对于性能要求不太高,或者以动态类型语言为主,或者传输数据载荷很小的的运用场景,JSON也是非常不错的选择。 3、对于调试环境比较恶劣的场景,采用JSON或XML能够极大的提高调试效率,降低系统开发成本。 4、当对性能和简洁性有极高要求的场景,Protobuf,Thrift,Avro之间具有一定的竞争关系。 5、对于T级别的数据的持久化应用场景,Protobuf和Avro是首要选择。如果持久化后的数据存储在Hadoop子项目里,Avro会是更好的选择。 6、由于Avro的设计理念偏向于动态类型语言,对于动态语言为主的应用场景,Avro是更好的选择。 7、对于持久层非Hadoop项目,以静态类型语言为主的应用场景,Protobuf会更符合静态类型语言工程师的开发习惯。 8、如果需要提供一个完整的RPC解决方案,Thrift是一个好的选择。 9、如果序列化之后需要支持不同的传输层协议,或者需要跨防火墙访问的高性能场景,Protobuf可以优先考虑。
参考文章:https://tech.meituan.com/2015/02/26/serialization-vs-deserialization.html
组内讨论
- http2.0和普通的RPC框架之前有啥区别?速度差距大吗?
- netty框架的有事到底是什么,为什么部署socket方式?
- Hessian的序列化什么?
- thrift的rpc对比当前的demo的rpc有啥区别?
|