RPC
RPC(Remote Procedure Call)
本文参考:
JavaGuide星球RPC文档
1. 什么是RPC?RPC原理是什么?
什么是RPC?
RPC(Remote Procedure Call) 即远程过程调用,通过名字我们就能看出 RPC 关注的是远程调用而非本地调用。
为什么要 RPC ? 因为,两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收。但是,如果我们自己手动网络编程来实现这个调用过程的话工作量是非常大的,因为,我们需要考虑底层传输方式(TCP 还是 UDP)、序列化方式等等方面。
RPC 能帮助我们做什么呢? 简单来说,通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。并且!我们不需要了解底层网络编程的具体细节。举个例子:两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。
一言蔽之:RPC 的出现就是为了让你调用远程方法像调用本地方法一样简单。
RPC原理是什么?
可以将整个 RPC 的 核心功能看作是下面 5 个部分实现的:
- 客户端(服务消费端) :调用远程方法的一端。
- 客户端 Stub(桩) : 这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。
- 网络传输 : 网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最基本的 Socket 或者性能以及封装更加优秀的 Netty(推荐)。
- 服务端 Stub(桩) :这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去指定对应的方法然后返回结果给客户端的类。
- 服务端(服务提供端) :提供远程方法的一端。
- 服务消费端(client)以本地调用的方式调用远程服务;
- 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):
RpcRequest
; - 客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端;
- 服务端 Stub(桩)收到消息将消息反序列化为 Java 对象:
RpcRequest
; - 服务端 Stub(桩)根据
RpcRequest
中的类、方法、方法参数等信息调用本地的方法; - 服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:
RpcResponse
(序列化)发送至消费方; - 客户端 Stub(client stub)接收到消息并将消息反序列化为 Java 对象:
RpcResponse
,这样也就得到了最终结果。over!
2. 有了HTTP协议,为什么还要有RPC?
从TCP聊起
假设我们需要在 A 电脑的进程发一段数据到 B 电脑的进程,我们一般会在代码里使用 socket 进行编程。这时候,我们可选项一般也就TCP 和 UDP 二选一。TCP 可靠,UDP 不可靠。
类似下面这样。
1 |
|
其中SOCK_STREAM
,是指使用字节流传输数据,说白了就是TCP 协议。
在定义了 socket 之后,我们就可以愉快的对这个 socket 进行操作,比如用bind()
绑定 IP 端口,用connect()
发起建连。
在连接建立之后,我们就可以使用send()
发送数据,recv()
接收数据。光这样一个纯裸的 TCP 连接,就可以做到收发数据了,那是不是就够了?不行,这么用会有问题。
使用纯裸TCP会有什么问题
TCP 是有三个特点,面向连接、可靠、基于字节流。字节流可以理解为一个双向的通道里流淌的二进制数据,也就是 01 串 。纯裸 TCP 收发的这些 01 串之间是 没有任何边界 的,你根本不知道到哪个地方才算一条完整消息。正因为这个没有任何边界的特点,所以当我们选择使用 TCP 发送 “夏洛”和”特烦恼” 的时候,接收端收到的就是 “夏洛特烦恼” ,这时候接收端没发区分你是想要表达 “夏洛”+”特烦恼” 还是 “夏洛特”+”烦恼” ,这就是所谓的粘包问题。
因此,纯裸TCP是不能直接拿来用的,你需要在这个基础上加入一些 自定义的规则 ,用于区分 消息边界 。于是我们会把每条要发送的数据都包装一下,比如加入 消息头 ,消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的 消息体 。
而这里头提到的 消息头 ,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的 **协议。**每个使用 TCP 的项目都可能会定义一套类似这样的协议解析标准,他们可能 有区别,但原理都类似。
于是基于 TCP,就衍生了非常多的协议,比如 HTTP 和 RPC。
HTTP和RPC
RPC其实是一直调用方式
TCP 是传输层的协议 ,而基于 TCP 造出来的 HTTP 和各类 RPC 协议,它们都只是定义了不同消息格式的 应用层协议 而已。
HTTP(Hyper Text Transfer Protocol)协议又叫做 超文本传输协议 。我们用的比较多,平时上网在浏览器上敲个网址就能访问网页,这里用到的就是 HTTP 协议。
而 RPC(Remote Procedure Call)又叫做 远程过程调用,它本身并不是一个具体的协议,而是一种 调用方式 。举个例子,我们平时调用一个 本地方法 就像下面这样。
1 |
|
如果现在这不是个本地方法,而是个远端服务器暴露出来的一个方法remoteFunc
,如果我们还能像调用本地方法那样去调用它,这样就可以屏蔽掉一些网络细节,用起来更方便,岂不美哉?
1 |
|
基于这个思路,大佬们造出了非常多款式的 RPC 协议,比如比较有名的gRPC
,thrift
。
值得注意的是,虽然大部分 RPC 协议底层使用 TCP,但实际上 它们不一定非得使用 TCP,改用 UDP 或者 HTTP,其实也可以做到类似的功能。
那既然有 RPC 了,为什么还要有 HTTP 呢?
其实,TCP 是 70 年 代出来的协议,而 HTTP 是 90 年代 才开始流行的。而直接使用裸 TCP 会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有 80 年代 出来的RPC
。
所以我们该问的不是 既然有 HTTP 协议为什么要有 RPC ,而是 为什么有 RPC 还要有 HTTP 协议?
现在电脑上装的各种联网软件,比如 xx 管家,xx 卫士,它们都作为客户端(Client) 需要跟服务端(Server) 建立连接收发消息,此时都会用到应用层协议,在这种 Client/Server (C/S) 架构下,它们可以使用自家造的 RPC 协议,因为它只管连自己公司的服务器就 ok 了。
但有个软件不同,浏览器(Browser) ,不管是 Chrome 还是 IE,它们不仅要能访问自家公司的服务器(Server) ,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP 就是那个时代用于统一 Browser/Server (B/S) 的协议。
也就是说在多年以前,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。 很多软件同时支持多端,比如某度云盘,既要支持网页版,还要支持手机端和 PC 端,如果通信协议都用 HTTP 的话,那服务器只用同一套就够了。而 RPC 就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。
那这么说的话,都用 HTTP 得了,还用什么 RPC?
HTTP和RPC有什么区别
服务发现
首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 IP 地址和端口 。这个找到服务对应的 IP 端口的过程,其实就是 服务发现。
在 HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口。
而 RPC 的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS。
可以看出服务发现这一块,两者是有些区别,但不太能分高低。
底层连接形式
以主流的 HTTP1.1 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。
而 RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。
由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池,比如 Go 就是这么干的。
传输的内容
基于 TCP 传输的消息,说到底,无非都是 消息头 Header 和消息体 Body。
Header 是用于标记一些特殊信息,其中最重要的是 消息体长度。
Body 则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 JSON,Protocol Buffers (Protobuf) 。
这个将结构体转为二进制数组的过程就叫 序列化 ,反过来将二进制数组复原成结构体的过程叫 反序列化。
对于主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计 初是用于做网页文本展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 JSON 来 序列化 结构体数据。
可以看到这里面的内容非常多的冗余,显得非常啰嗦。最明显的,像 Header 里的那些信息,其实如果我们约定好头部的第几位是 Content-Type
,就不需要每次都真的把 Content-Type
这个字段都传过来,类似的情况其实在 Body 的 JSON 结构里也特别明显。
而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。
当然上面说的 HTTP,其实 特指的是现在主流使用的 HTTP1.1,HTTP2
在前者的基础上做了很多改进,所以 性能可能比很多 RPC 协议还要好,甚至连gRPC
底层都直接用的HTTP2
。
为什么既然有了HTTP2,还要有RPC
这个是由于 HTTP2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。
总结
- 纯裸 TCP 是能收发数据,但它是个无边界的数据流,上层需要定义消息格式用于定义 消息边界 。于是就有了各种协议,HTTP 和各类 RPC 协议就是在 TCP 之上定义的应用层协议。
- RPC 本质上不算是协议,而是一种调用方式,而像 gRPC 和 Thrift 这样的具体实现,才是协议,它们是实现了 RPC 调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 RPC 有很多种实现方式,不一定非得基于 TCP 协议。
- 从发展历史来说,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。 很多软件同时支持多端,所以对外一般用 HTTP 协议,而内部集群的微服务之间则采用 RPC 协议进行通讯。
- RPC 其实比 HTTP 出现的要早,且比目前主流的 HTTP1.1 性能要更好,所以大部分公司内部都还在使用 RPC。
- HTTP2.0 在 HTTP1.1 的基础上做了优化,性能可能比很多 RPC 协议都要好,但由于是这几年才出来的,所以也不太可能取代掉 RPC。
3.如何自己实现一个RPC框架
一般情况下, RPC 框架不仅要提供服务发现功能,还要提供负载均衡、容错等功能,这样的 RPC 框架才算真正合格的。
从上图我们可以看出:服务提供端 Server 向注册中心注册服务,服务消费者 Client 通过注册中心拿到服务相关信息,然后再通过网络请求服务提供端 Server。
注册中心
注册中心负责服务地址的注册与查找,相当于目录服务。 服务端启动的时候将服务名称及其对应的地址(ip+port)注册到注册中心,服务消费端根据服务名称找到对应的服务地址。有了服务地址之后,服务消费端就可以通过网络请求服务端了。结合Dubbo到架构图:
上述节点简单说明:
- Provider: 暴露服务的服务提供方
- Consumer: 调用远程服务的服务消费方
- Registry: 服务注册与发现的注册中心
- Monitor: 统计服务的调用次数和调用时间的监控中心
- Container: 服务运行容器
调用关系说明:
- 服务容器负责启动,加载,运行服务提供者。
- 服务提供者在启动时,向注册中心注册自己提供的服务。
- 服务消费者在启动时,向注册中心订阅自己所需的服务。
- 注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
- 服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
- 服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。
网络传输
既然我们要调用远程的方法,就要发送网络请求来传递目标类和方法的信息以及方法的参数等数据到服务提供端。
网络传输具体实现你可以使用 Socket ( Java 中最原始、最基础的网络通信方式。但是,Socket 是阻塞 IO、性能低并且功能单一)。
序列化与反序列化
要在网络传输数据就要涉及到序列化。为什么需要序列化和反序列化呢?
因为网络传输的数据必须是二进制的。因此,我们的 Java 对象没办法直接在网络中传输。为了能够让 Java 对象在网络中传输我们需要将其序列化为二进制的数据。我们最终需要的还是目标 Java 对象,因此我们还要将二进制的数据“解析”为目标 Java 对象,也就是对二进制数据再进行一次反序列化。
JDK 自带的序列化,只需实现 java.io.Serializable
接口即可,不过这种方式不推荐,因为不支持跨语言调用并且性能比较差。现在比较常用序列化的有 hessian、kryo、protostuff ……
动态代理
我们知道代理模式就是: 我们给某一个对象提供一个代理对象,并由代理对象来代替真实对象做一些事情。你可以把代理对象理解为一个幕后的工具人。 举个例子:我们真实对象调用方法的时候,我们可以通过代理对象去做一些事情比如安全校验、日志打印等等。但是,这个过程是完全对真实对象屏蔽的。
讲完了代理模式,再来说动态代理在 RPC 框架中的作用。
- 前面第一节的时候,我们就已经提到 :RPC 的主要目的就是让我们调用远程方法像调用本地方法一样简单,我们不需要关心远程方法调用的细节比如网络传输。
怎样才能屏蔽远程方法调用的底层细节呢?
- 答案就是动态代理。简单来说,当你调用远程方法的时候,实际会通过代理对象来传输网络请求,不然的话,怎么可能直接就调用到远程方法。
- 因为消费端没有服务的实现类,无法获得一个实例进行方法调用,所以只能通过动态代理获得一个代理对象。然后通过代理对象替我们调用方法。
负载均衡
我们的系统中的某个服务的访问量特别大,我们将这个服务部署在了多台服务器上,当客户端发起请求的时候,多台服务器都可以处理这个请求。那么,如何正确选择处理该请求的服务器就很关键。假如,你就要一台服务器来处理该服务的请求,那该服务部署在多台服务器的意义就不复存在了。负载均衡就是为了避免单个服务器响应同一请求,容易造成服务器宕机、崩溃等问题,我们从负载均衡的这四个字就能明显感受到它的意义。
传输协议
我们还需要设计一个私有的 RPC 协议,这个协议是客户端(服务消费方)和服务端(服务提供方)交流的基础。
简单来说:**通过设计协议,我们定义需要传输哪些类型的数据, 并且还会规定每一种类型的数据应该占多少字节。这样我们在接收到二进制数据之后,就可以正确的解析出我们需要的数据。**这有一点像密文传输的感觉。
通常一些标准的 RPC 协议包含下面这些内容:
- 魔数 : 通常是 4 个字节。这个魔数主要是为了筛选来到服务端的数据包,有了这个魔数之后,服务端首先取出前面四个字节进行比对,能够在第一时间识别出这个数据包并非是遵循自定义协议的,也就是无效数据包,为了安全考虑可以直接关闭连接以节省资源。
- 序列化器编号 :标识序列化的方式,比如是使用 Java 自带的序列化,还是 json,kryo 等序列化方式。
- 消息体长度 : 运行时计算出来。
4. 序列化介绍以及序列化协议选择
什么是序列化和反序列化
- 序列化: 将数据结构或对象转换成二进制字节流的过程
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
序列化协议对应于TCP/IP 4层模型中的哪一层?
如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这不就对应的是序列化和反序列化么?
因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。
常见的序列化协议有哪些?
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
JDK自带的序列化方式
JDK 自带的序列化,只需实现 java.io.Serializable
接口即可。
1 |
|
serialVersionUID有什么作用?
- 序列化号
serialVersionUID
属于版本控制的作用。反序列化时,会检查serialVersionUID
是否和当前类的serialVersionUID
一致。如果serialVersionUID
不一致则会抛出InvalidClassException
异常。强烈推荐每个序列化类都手动指定其serialVersionUID
,如果不手动指定,那么编译器会动态生成默认的serialVersionUID
。
serialVersionUID 不是被 static 变量修饰了吗?为什么还会被“序列化”?
static
修饰的变量是静态变量,位于方法区,本身是不会被序列化的。但是,serialVersionUID
的序列化做了特殊处理,在序列化时,会将serialVersionUID
序列化到二进制字节流中;在反序列化时,也会解析它并做一致性判断。A serializable class can declare its own serialVersionUID explicitly by declaring a field named
"serialVersionUID"
that must bestatic
,final
, and of typelong
;如果想显式指定
serialVersionUID
,则需要在类中使用static
和final
关键字来修饰一个long
类型的变量,变量名字必须为"serialVersionUID"
。也就是说,
serialVersionUID
只是用来被 JVM 识别,实际并没有被序列化。
如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,可以使用 transient
关键字修饰。
transient
关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient
修饰的变量值不会被持久化和恢复。
关于 transient
还有几点注意:
transient
只能修饰变量,不能修饰类和方法。transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
。static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化。
为什么不推荐使用 JDK 自带的序列化?
- 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
- 性能差 :相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
- 存在安全问题 :序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
Kryo
Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。
1 |
|
5. Socket网络通信
什么是Socket(套接字)
Socket 是一个抽象概念,应用程序可以通过它发送或接收数据。在使用 Socket 进行网络通信的时候,通过 Socket 就可以让我们的数据在网络中传输。操作套接字的时候,和我们读写文件很像。套接字是 IP 地址与端口的组合,套接字 Socket=(IP 地址:端口号)。
要通过互联网进行通信,至少需要一对套接字:
- 运行于服务器端的 Server Socket。
- 运行于客户机端的 Client Socket
在 Java 开发中使用 Socket 时会常用到两个类,都在 java.net
包中:
Socket
: 一般用于客户端ServerSocket
:用于服务端
Socket网络通信过程
Socket 网络通信过程简单来说分为下面 4 步:
- 建立服务端并且监听客户端请求
- 客户端请求,服务端和客户端建立连接
- 两端之间可以传递数据
- 关闭资源
对应到服务端和客户端的话,是下面这样的。
服务器端:
- 创建
ServerSocket
对象并且绑定地址(ip)和端口号(port):server.bind(new InetSocketAddress(host, port))
- 通过
accept()
方法监听客户端请求 - 连接建立后,通过输入流读取客户端发送的请求信息
- 通过输出流向客户端发送响应信息
- 关闭相关资源
客户端:
- 创建
Socket
对象并且连接指定的服务器的地址(ip)和端口号(port):socket.connect(inetSocketAddress)
- 连接建立后,通过输出流向服务器端发送请求信息
- 通过输入流获取服务器响应的信息
- 关闭相关资源
Socket网络通信实战
服务端
1 |
|
ServerSocket
的 accept()
方法是阻塞方法,也就是说 ServerSocket
在调用 accept()
等待客户端的连接请求时会阻塞,直到收到客户端发送的连接请求才会继续往下执行代码。
很明显,上面演示的代码片段有一个很严重的问题:**只能同时处理一个客户端的连接,如果需要管理多个客户端的话,就需要为我们请求的客户端单独创建一个线程。**对应的 Java 代码可能是下面这样的:
1 |
|
但是,这样会导致一个很严重的问题:资源浪费。
我们知道线程是很宝贵的资源,如果我们为每一次连接都用一个线程处理的话,就会导致线程越来越多,最后达到了极限之后,就无法再创建线程处理请求了。处理的不好的话,甚至可能直接就宕机掉了。很多人就会问了:那有没有改进的方法呢?
当然有! 比较简单并且实际的改进方法就是使用线程池。线程池还可以让线程的创建和回收成本相对较低,并且我们可以指定线程池的可创建线程的最大数量,这样就不会导致线程创建过多,机器资源被不合理消耗。但是,即使你再怎么优化和改变。也改变不了它的底层仍然是同步阻塞的 BIO 模型的事实,因此无法从根本上解决问题。
1 |
|
bio的话,线程池最多防止资源耗费过多,因为你读完数据,下一轮还是会阻塞到read方法,并不会结束这个线程的任务,所以线程还不回线程池。
客户端
1 |
|
首先运行服务端,然后再运行客户端,控制台输出如下:
服务端:
1 |
|
客户端:
1 |
|