This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="01 | 核心原理能否画张图解释下RPC的通信流程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/48/0bf4282806173a0339aea119b9822f48.mp3"></audio>
你好我是何小锋。只要你做过几年开发那我相信RPC这个词你肯定是不陌生了。写专栏之前我还特意查了下RPC的百度指数发现这些年RPC的搜索趋势都是稳步上升的这也侧面说明了这项技术正在逐步渗透到我们的日常开发中。作为专栏的第一讲我想只围绕“RPC”这个词和你聊聊它的定义它要解决的问题以及工作原理。
在前些年我面试工程师的时候最喜欢问候选人一个问题“你能否给我解释下RPC的通信流程”。这问题其实并不难不过因为很多工程师平时都在用各种框架他们可能并未停下来思考过框架的原理所以问完这问题有的人就犹豫了吱唔了半天也没说出所以然来。
紧接着我会引导他说“你想想如果没有RPC框架那你要怎么调用另外一台服务器上的接口呢”。你看这问题可深可浅也特别考验候选人的基本功。如果你是候选人你会怎么回答呢今天我就来试着回答你这个问题。
## 什么是RPC
我知道你肯定不喜欢听概念我也是这样看书的时候一看到概念就直接略过。不过到后来我才发现“定义”是一件多么伟大的事情。当我们能够用一句话把一个东西给定义出来的时候侧面也说明你已经彻底理解这事了不仅知道它要解决什么问题还要知道它的边界。所以你可以先停下来想想什么是RPC。
RPC的全称是Remote Procedure Call即远程过程调用。简单解读字面上的意思远程肯定是指要跨机器而非本机所以需要用到网络编程才能实现但是不是只要通过网络通信访问到另一台机器的应用程序就可以称之为RPC调用了显然并不够。
我理解的RPC是帮助我们屏蔽网络编程细节实现调用远程方法就跟调用本地同一个项目中的方法一样的体验我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。
这就好比建在小河上的桥一样连接着河的两岸,如果没有小桥,我们需要通过划船、绕道等其他方式才能到达对面,但是有了小桥之后,我们就能像在路面上一样行走到达对面,并且跟在路面上行走的体验没有区别。所以**我认为RPC的作用就是体现在这样两个方面**
- 屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法;
- 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。
## RPC通信流程
理解了什么是RPC接下来我们讲下RPC框架的通信流程方便我们进一步理解RPC。
如前面所讲RPC能帮助我们的应用透明地完成远程调用发起调用请求的那一方叫做调用方被调用的一方叫做服务提供方。为了实现这个目标我们就需要在RPC框架里面对整个通信细节进行封装**那一个完整的RPC会涉及到哪些步骤呢**
我们已经知道RPC是一个远程调用那肯定就需要通过网络来传输数据并且RPC常用于业务系统之间的数据交互需要保证其可靠性所以RPC一般默认采用TCP来传输。我们常用的HTTP协议也是建立在TCP之上的。
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。
调用方持续地把请求参数序列化成二进制后经过TCP传输给了服务提供方。服务提供方从TCP通道里面收到二进制数据那如何知道一个请求的数据到哪里结束是一个什么类型的请求呢
在这里我们可以想想高速公路,它上面有很多出口,为了让司机清楚地知道从哪里出去,管理部门会在路上建立很多指示牌,并在指示牌上标明下一个出口是哪里、还有多远。那回到数据包识别这个场景,我们是不是也可以建立一些“指示牌”,并在上面标明数据包的类型和长度,这样就可以正确的解析数据了。确实可以,并且我们把数据格式的约定内容叫做“协议”。大多数的协议会分成两部分,分别是数据头和消息体。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。
根据协议格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。
服务提供方再根据反序列化出来的请求对象找到对应的实现类完成真正的方法调用然后把执行结果序列化后回写到对应的TCP通道里面。调用方获取到应答的数据包后再反序列化成应答对象这样调用方就完成了一次RPC调用。
**那上述几个流程就组成了一个完整的RPC吗**
在我看来还缺点东西。因为对于研发人员来说这样做要掌握太多的RPC底层细节需要手动写代码去构造请求、调用序列化并进行网络调用整个API非常不友好。
那我们有什么办法来简化API屏蔽掉RPC细节让使用方只需要关注业务接口像调用本地一样来调用远程呢
如果你了解Spring一定对其AOP技术很佩服其核心是采用动态代理的技术通过字节码增强对方法进行拦截增强以便于增加需要的额外处理逻辑。其实这个技术也可以应用到RPC场景来解决我们刚才面临的问题。
由服务提供者给出业务接口声明在调用方的程序里面RPC框架根据调用的服务接口提前生成动态代理实现类并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有的方法调用在提供的方法处理逻辑里面完成一整套的远程调用并把远程调用结果返回给调用方这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。
到这里一个简单版本的RPC框架就实现了。我把整个流程都画出来了供你参考
<img src="https://static001.geekbang.org/resource/image/ac/fa/acf53138659f4982bbef02acdd30f1fa.jpg" alt="">
## RPC在架构中的位置
围绕RPC我们讲了这么多那RPC在架构中究竟处于什么位置呢
如刚才所讲RPC是解决应用间通信的一种方式而无论是在一个大型的分布式应用系统还是中小型系统中应用架构最终都会从“单体”演进成“微服务化”整个应用系统会被拆分为多个不同功能的应用并将它们部署在不同的服务器中而应用之间会通过RPC进行通信可以说RPC对应的是整个分布式应用系统就像是“经络”一样的存在。
那么如果没有RPC我们现实中的开发过程是怎样的一个体验呢
所有的功能代码都会被我们堆砌在一个大项目中开发过程中你可能要改一行代码但改完后编译会花掉你2分钟编译完想运行起来验证下结果可能要5分钟是不是很酸爽更难受的是在人数比较多的团队里面多人协同开发的时候如果团队其他人把接口定义改了你连编译通过的机会都没有系统直接报错从而导致整个团队的开发效率都会非常低下。而且当我们准备要上线发版本的时候QA也很难评估这次的测试范围为了保险起见我们只能把所有的功能进行回归测试这样会导致我们上线新功能的整体周期都特别长。
无论你是研发还是架构师,我相信这种系统架构我们肯定都不能接受,那怎么才能解决这个问题呢?
我们首先都会想到可以采用“分而治之”的思想来进行拆分,但是拆分完的系统怎么保持跟未拆分前的调用方式一样呢?我们总不能因为架构升级,就把所有的代码都推倒重写一遍吧。
**RPC框架能够帮助我们解决系统拆分后的通信问题并且能让我们像调用本地一样去调用远程方法。**利用RPC我们不仅可以很方便地将应用架构从“单体”演进成“微服务化”而且还能解决实际开发过程中的效率低下、系统耦合等问题这样可以使得我们的系统架构整体清晰、健壮应用可运维度增强。
当然RPC不仅可以用来解决通信问题它还被用在了很多其他场景比如发MQ、分布式缓存、数据库等。下图是我之前开发的一个应用架构图
<img src="https://static001.geekbang.org/resource/image/50/be/506e902e06e91663334672c29bfbc2be.jpg" alt="">
在这个应用中我使用了MQ来处理异步流程、Redis缓存热点数据、MySQL持久化数据还有就是在系统中调用另外一个业务系统的接口对我的应用来说这些都是属于RPC调用而MQ、MySQL持久化的数据也会存在于一个分布式文件系统中他们之间的调用也是需要用RPC来完成数据交互的。
由此可见RPC确实是我们日常开发中经常接触的东西只是被包装成了各种框架导致我们很少意识到这就是RPC让RPC变成了我们最“熟悉的陌生人”。现在回过头想想我说RPC是整个应用系统的“经络”这不为过吧我们真的很有必要学好RPC不仅因为RPC是构建复杂系统的基石还是提升自身认知的利器。
## 总结
本讲我主要讲了下RPC的原理RPC就是提供一种透明调用机制让使用者不必显式地区分本地调用和远程调用。RPC虽然可以帮助开发者屏蔽远程调用跟本地调用的区别但毕竟涉及到远程网络通信所以这里还是有很多使用上的区别比如
- 调用过程中超时了怎么处理业务?
- 什么场景下最适合使用RPC
- 什么时候才需要考虑开启压缩?
无论你是一个初级开发者还是高级开发者RPC都应该是你日常开发过程中绕不开的一个话题所以作为软件开发者的我们真的很有必要详细地了解RPC实现细节。只有这样才能帮助我们更好地在日常工作中使用RPC。
## 课后思考
1. 你应用中有哪些地方用到了RPC
1. 你认为RPC使用过程中需要注意哪些问题
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View File

@@ -0,0 +1,77 @@
<audio id="audio" title="02 | 协议:怎么设计可扩展且向后兼容的协议?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/34/6b81cc4a6eff06d4c02f3ab654c8c034.mp3"></audio>
你好我是何小锋。上一讲我分享了RPC原理其核心是让我们像调用本地一样调用远程帮助我们的应用层屏蔽远程调用的复杂性使得我们可以更加方便地构建分布式系统。总结起来其实就一个关键字透明化。
接着上一讲的内容我们再来聊聊RPC协议。
一提到协议,你最先想到的可能是 TCP协议、UDP协议等等这些网络传输协议的实现在我看来有点晦涩难懂。虽然在RPC中我们也会用到这些协议但这些协议更多的是对我们上层应用是透明的我们RPC在使用过程中并不太需要关注他们的细节。那我今天要讲的RPC协议到底是什么呢
可能我举个例子你立马就明白了。HTTP协议是不是很熟悉本讲里面所说的HTTP默认都是1.X 这应该是我们日常工作中用得最频繁的协议了每天打开浏览器浏览的网页就是使用的HTTP协议。那HTTP协议跟RPC协议又有什么关系呢看起来他俩好像不搭边但他们有一个共性就是都属于应用层协议。
所以**我们今天要讲的RPC协议就是围绕应用层协议展开的。**我们可以先了解下HTTP协议我们先看看它的协议格式是什么样子的。回想一下我们在浏览器里面输入一个URL会发生什么抛开DNS解析暂且不谈浏览器收到命令后会封装一个请求并把请求发送到DNS解析出来的IP上通过抓包工具我们可以抓到请求的数据包如下图所示
<img src="https://static001.geekbang.org/resource/image/5c/99/5ca698cbdc61b8e8b090773406b3ab99.jpg" alt="">
## 协议的作用
看完HTTP协议之后你可能会有一个疑问我们为什么需要协议这个东西呢没有协议就不能通信吗
我们知道只有二进制才能在网络中传输所以RPC请求在发送到网络中之前他需要把方法调用的请求参数转成二进制转成二进制后写入本地Socket中然后被网卡发送到网络设备中。
但在传输过程中RPC并不会把请求参数的所有二进制数据整体一下子发送到对端机器上中间可能会拆分成好几个数据包也可能会合并其他请求的数据包合并的前提是同一个TCP连接上的数据至于怎么拆分合并这其中的细节会涉及到系统参数配置和TCP窗口大小。对于服务提供方应用来说他会从TCP通道里面收到很多的二进制数据那这时候怎么识别出哪些二进制是第一个请求的呢
这就好比让你读一篇没有标点符号的文章,你要怎么识别出每一句话到哪里结束呢?很简单啊,我们加上标点,完成断句就好了。
同理在RPC传输数据的时候为了能准确地“断句”我们也必须在应用发送请求的数据包里面加入“句号”这样才能帮我们的接收方应用从数据流里面分割出正确的数据。这个数据包里面的句号就是消息的边界用于标示请求数据的结束位置。举个具体例子调用方发送 AB、CD、EF 3 个消息如果没有边界的话接收端就可能收到ABCDEF或者ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。
所以呢,为了避免语义不一致的事情发生,我们就需要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所说的协议。
## 如何设计协议?
理解了协议的作用我们再来看看在RPC里面是怎么设计协议的。可能你会问“前面你不是说了HTTP协议跟RPC都属于应用层协议那有了现成的HTTP协议为啥不直接用还要为RPC设计私有协议呢
这还要从RPC的作用说起相对于HTTP的用处RPC更多的是负责应用间的通信所以性能要求相对更高。但HTTP协议的数据包大小相对请求数据本身要大很多又需要加入很多无用的内容比如换行符号、回车符等还有一个更重要的原因是HTTP协议属于无状态协议客户端无法对请求和响应进行关联每次请求都需要重新建立连接响应完成后再关闭连接。因此对于要求高性能的RPC来说HTTP协议基本很难满足需求所以RPC会选择设计更紧凑的私有协议。
**那怎么设计一个私有RPC协议呢 **
在设计协议前我们先梳理下要完成RPC通信的时候在协议里面需要放哪些内容。
首先要想到的就是我们前面说的消息边界了但RPC每次发请求发的大小都是不固定的所以我们的协议必须能让接收方正确地读出不定长的内容。我们可以先固定一个长度比如4个字节用来保存整个请求数据大小这样收到数据的时候我们先读取固定长度的位置里面的值值的大小就代表协议体的长度接着再根据值的大小来读取协议体的数据整个协议可以设计成这样
<img src="https://static001.geekbang.org/resource/image/de/67/debcb69ad381d9d86d13dcc7c72b0967.jpg" alt="" title="不定长协议">
但上面这种协议只实现了正确的断句效果在RPC里面还行不通。因为对于服务提供方来说他是不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。如果不能知道调用方用的序列化方式即使服务提供方还原出了正确的语义也并不能把二进制还原成对象那服务提供方收到这个数据后也就不能完成调用了。因此我们需要把序列化方式单独拿出来类似协议长度一样用固定的长度存放这些需要固定长度存放的参数我们可以统称为“协议头”这样整个协议就会拆分成两部分协议头和协议体。
在协议头里面我们除了会放协议长度、序列化方式还会放一些像协议标示、消息ID、消息类型这样的参数而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。这样一个完整的RPC协议大概就出来了协议头是由一堆固定的长度参数组成而协议体是根据请求接口和参数构造的长度属于可变的具体协议如下图所示
<img src="https://static001.geekbang.org/resource/image/ac/2b/ac5f5236d972608fdb24c6eefce7e82b.jpg" alt="" title="定长协议">
## 可扩展的协议
刚才讲的协议属于定长协议头那也就是说往后就不能再往协议头里加新参数了如果加参数就会导致线上兼容问题。举个具体例子假设你设计了一个88Bit的协议头其中协议长度占用32bit然后你为了加入新功能在协议头里面加了2bit并且放到协议头的最后。升级后的应用会用新的协议发出请求然而没有升级的应用收到的请求后还是按照88bit读取协议头新加的2个bit会当作协议体前2个bit数据读出来但原本的协议体最后2个bit会被丢弃了这样就会导致协议体的数据是错的。
可能你会想:“那我把参数加在不定长的协议体里面行不行?而且刚才你也说了,协议体里面会放一些扩展属性。”
没错,协议体里面是可以加新的参数,但这里有一个关键点,就是协议体里面的内容都是经过序列化出来的,也就是说你要获取到你参数的值,就必须把整个协议体里面的数据经过反序列化出来。但在某些场景下,这样做的代价有点高啊!
比如说服务提供方收到一个过期请求这个过期是说服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间既然已经过期就没有必要接着处理直接返回一个超时就好了。那要实现这个功能就要在协议里面传递这个配置的超时时间那如果之前协议里面没有加超时时间参数的话我们现在把这个超时时间加到协议体里面是不是就有点重了呢显然会加重CPU的消耗。
所以为了保证能平滑地升级改造前后的协议,我们有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分我们还是可以统称为“协议头”,具体协议如下:
<img src="https://static001.geekbang.org/resource/image/2a/72/2a202f980458baca9fc50c53275c6772.jpg" alt="" title="可扩展协议">
最后,我想说,**设计一个简单的RPC协议并不难难的就是怎么去设计一个可“升级”的协议。**不仅要让我们在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以我们协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展。上述这种设计方法来源于我多年的线上经验,可以说做好扩展性是至关重要的,期待这个协议模版能帮你避掉一些坑。
## 总结
我们人类区别于其他动物的一个很大原因,就是我们能够通过语言去沟通,用文字去沉淀文明,从而让我们能站在巨人的肩膀上成长,但为了保证我们记录的文字能够被其他人理解,我们必须通过符号去实现断句,否则就可能导致文字的意义被曲解,甚至闹出笑话。
在RPC里面协议的作用就类似于文字中的符号作为应用拆解请求消息的边界保证二进制数据经过网络传输后还能被正确地还原语义避免调用方跟被调用方之间的“鸡同鸭讲”。
但我们在设计协议的时候,也不能只单纯考虑满足目前功能,还应该从更高的层次出发。就好比我们设计系统架构一样,我们需要保证设计出来的系统能够能很好地扩展,支持新增功能。
## 课后思考
好了今天的内容就到这里最后留一道思考题。今天我们讨论过RPC不直接用HTTP协议的一个原因是无法实现请求跟响应关联每次请求都需要重新建立连接响应完成后再关闭连接所以我们要设计私有协议。那么在RPC里面我们是怎么实现请求跟响应关联的呢
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View File

@@ -0,0 +1,271 @@
<audio id="audio" title="03 | 序列化:对象怎么在网络中传输?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/1d/5084522618aabb4ea9b0b6df2918921d.mp3"></audio>
你好我是何小锋。上一讲我讲解了在RPC框架中如何设计可扩展的、向后兼容的协议其关键点就是利用好Header中的扩展字段以及Payload中的扩展字段通过扩展字段向后兼容。
那么承接上一讲的一个重点今天我会讲解下RPC框架中的序列化。要知道在不同的场景下合理地选择序列化方式对提升RPC框架整体的稳定性和性能是至关重要的。
## 为什么需要序列化?
首先,我们得知道什么是序列化与反序列化。
我们先回顾下[[第 01 讲]](https://time.geekbang.org/column/article/199650) 介绍过的RPC原理的内容在描述RPC通信流程的时候我说过
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输的,所以我们需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。 这时,服务提供方就可以正确地从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这个过程我们称之为“反序列化”。
这两个过程如下图所示:
<img src="https://static001.geekbang.org/resource/image/d2/04/d215d279ef8bfbe84286e81174b4e704.jpg" alt="" title="序列化与反序列化">
**总结来说,**序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程。
那么RPC框架为什么需要序列化呢还是请你回想下RPC的通信流程
<img src="https://static001.geekbang.org/resource/image/82/59/826a6da653c4093f3dc3f0a833915259.jpg" alt="" title="RPC通信流程图">
不妨借用个例子帮助你理解,比如发快递,我们要发一个需要自行组装的物件。发件人发之前,会把物件拆开装箱,这就好比序列化;这时候快递员来了,不能磕碰呀,那就要打包,这就好比将序列化后的数据进行编码,封装成一个固定格式的协议;过了两天,收件人收到包裹了,就会拆箱将物件拼接好,这就好比是协议解码和反序列化。
所以现在你清楚了吗因为网络传输的数据必须是二进制数据所以在RPC调用中对入参对象与返回值对象进行序列化与反序列化是一个必须的过程。
## 有哪些常用的序列化?
那这么看来,你会不会觉得这个过程很简单呢?实则不然,很复杂。我们可以先看看都有哪些常用的序列化,下面我来简单地介绍下几种常用的序列化方式。
### JDK原生序列化
如果你会使用Java语言开发那么你一定知道JDK原生的序列化下面是JDK序列化的一个例子
```
import java.io.*;
public class Student implements Serializable {
//学号
private int no;
//姓名
private String name;
public int getNo() {
return no;
}
public void setNo(int no) {
this.no = no;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return &quot;Student{&quot; +
&quot;no=&quot; + no +
&quot;, name='&quot; + name + '\'' +
'}';
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
String home = System.getProperty(&quot;user.home&quot;);
String basePath = home + &quot;/Desktop&quot;;
FileOutputStream fos = new FileOutputStream(basePath + &quot;student.dat&quot;);
Student student = new Student();
student.setNo(100);
student.setName(&quot;TEST_STUDENT&quot;);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(student);
oos.flush();
oos.close();
FileInputStream fis = new FileInputStream(basePath + &quot;student.dat&quot;);
ObjectInputStream ois = new ObjectInputStream(fis);
Student deStudent = (Student) ois.readObject();
ois.close();
System.out.println(deStudent);
}
}
```
我们可以看到JDK自带的序列化机制对使用者而言是非常简单的。序列化具体的实现是由ObjectOutputStream完成的而反序列化的具体实现是由ObjectInputStream完成的。
那么JDK的序列化过程是怎样完成的呢我们看下下面这张图
<img src="https://static001.geekbang.org/resource/image/7e/9f/7e2616937e3bc5323faf3ba4c09d739f.jpg" alt="" title="ObjectOutputStream序列化过程图">
序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。
- 头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容
- 对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据
- 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑
**实际上任何一种序列化框架,核心思想就是设计一种序列化协议**,将对象的类型、属性类型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个新的对象,来完成反序列化。
### JSON
JSON可能是我们最熟悉的一种序列化格式了JSON是典型的Key-Value方式没有数据类型是一种文本型序列化框架JSON的具体格式和特性网上相关的资料非常多这里就不再介绍了。
他在应用上还是很广泛的无论是前台Web用Ajax调用、用磁盘存储文本类型的数据还是基于HTTP协议的RPC框架通信都会选择JSON格式。
但用JSON进行序列化有这样两个问题你需要格外注意
- JSON进行序列化的额外空间开销比较大对于大数据量服务这意味着需要巨大的内存和磁盘开销
- JSON没有类型但像Java这种强类型语言需要通过反射统一解决所以性能不会太好。
所以如果RPC框架选用JSON序列化服务提供者与服务调用者之间传输的数据量要相对较小否则将严重影响性能。
### Hessian
Hessian是动态类型、二进制、紧凑的并且可跨语言移植的一种序列化框架。Hessian协议要比JDK、JSON更加紧凑性能上要比JDK、JSON序列化高效很多而且生成的字节数也更小。
使用代码示例如下:
```
Student student = new Student();
student.setNo(101);
student.setName(&quot;HESSIAN&quot;);
//把student对象转化为byte数组
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output output = new Hessian2Output(bos);
output.writeObject(student);
output.flushBuffer();
byte[] data = bos.toByteArray();
bos.close();
//把刚才序列化出来的byte数组转化为student对象
ByteArrayInputStream bis = new ByteArrayInputStream(data);
Hessian2Input input = new Hessian2Input(bis);
Student deStudent = (Student) input.readObject();
input.close();
System.out.println(deStudent);
```
相对于JDK、JSON由于Hessian更加高效生成的字节数更小有非常好的兼容性和稳定性所以Hessian更加适合作为RPC框架远程通信的序列化协议。
但Hessian本身也有问题官方版本对Java里面一些常见对象的类型不支持比如
- Linked系列LinkedHashMap、LinkedHashSet等但是可以通过扩展CollectionDeserializer类修复
- Locale类可以通过扩展ContextSerializerFactory类修复
- Byte/Short反序列化的时候变成Integer。
以上这些情况,你在实践时需要格外注意。
### Protobuf
Protobuf 是 Google 公司内部的混合语言数据标准是一种轻便、高效的结构化数据存储格式可以用于结构化数据序列化支持Java、Python、C++、Go等语言。Protobuf使用的时候需要定义IDLInterface description language然后使用不同语言的IDL编译器生成序列化工具类它的优点是
- 序列化后体积相比 JSON、Hessian小很多
- IDL能清晰地描述语义所以足以帮助并保证应用程序之间的类型不会丢失无需类似 XML 解析器;
- 序列化反序列化速度很快,不需要通过反射获取类型;
- 消息格式升级和兼容性不错,可以做到向后兼容。
使用代码示例如下:
```
/**
*
* // IDl 文件格式
* synax = &quot;proto3&quot;;
* option java_package = &quot;com.test&quot;;
* option java_outer_classname = &quot;StudentProtobuf&quot;;
*
* message StudentMsg {
* //序号
* int32 no = 1;
* //姓名
* string name = 2;
* }
*
*/
StudentProtobuf.StudentMsg.Builder builder = StudentProtobuf.StudentMsg.newBuilder();
builder.setNo(103);
builder.setName(&quot;protobuf&quot;);
//把student对象转化为byte数组
StudentProtobuf.StudentMsg msg = builder.build();
byte[] data = msg.toByteArray();
//把刚才序列化出来的byte数组转化为student对象
StudentProtobuf.StudentMsg deStudent = StudentProtobuf.StudentMsg.parseFrom(data);
System.out.println(deStudent);
```
Protobuf 非常高效但是对于具有反射和动态能力的语言来说这样用起来很费劲这一点就不如Hessian比如用Java的话这个预编译过程不是必须的可以考虑使用Protostuff。
Protostuff不需要依赖IDL文件可以直接对Java领域对象进行反/序列化操作在效率上跟Protobuf差不多生成的二进制格式和Protobuf是完全相同的可以说是一个Java版本的Protobuf序列化框架。但在使用过程中我遇到过一些不支持的情况也同步给你
- 不支持null
- ProtoStuff不支持单纯的Map、List集合对象需要包在对象里面。
## RPC框架中如何选择序列化
我刚刚简单地介绍了几种最常见的序列化协议,其实远不止这几种,还有 Message pack、kryo等。那么面对这么多的序列化协议在RPC框架中我们该如何选择呢
首先你可能想到的是性能和效率不错这的确是一个非常值得参考的因素。我刚才讲过序列化与反序列化过程是RPC调用的一个必须过程那么序列化与反序列化的性能和效率势必将直接关系到RPC框架整体的性能和效率。
那除了这点,你还想到了什么?
还有空间开销也就是序列化之后的二进制数据的体积大小。序列化后的字节数据体积越小网络传输的数据量就越小传输数据的速度也就越快由于RPC是远程调用那么网络传输的速度将直接关系到请求响应的耗时。
现在请你再想想,还有什么因素可以影响到我们的选择?
没错就是序列化协议的通用性和兼容性。在RPC的运营中序列化问题恐怕是我碰到的和解答过的最多的问题了经常有业务会向我反馈这个问题比如某个类型为集合类的入参服务调用者不能解析了服务提供方将入参类加一个属性之后服务调用方不能正常调用升级了RPC版本后发起调用时报序列化异常了…
在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为他是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。我们更加看重这种序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次我们才会去考虑性能、效率和空间开销。
还有一点我要特别强调。除了序列化协议的通用性和兼容性序列化协议的安全性也是非常重要的一个参考因素甚至应该放在第一位去考虑。以JDK原生序列化为例它就存在漏洞。如果序列化存在安全漏洞那么线上的服务就很可能被入侵。
<img src="https://static001.geekbang.org/resource/image/b4/a5/b42e44968c3fdcdfe2acf96377f5b2a5.jpg" alt="">
综合上面几个参考因素,现在我们再来总结一下这几个序列化协议。
我们首选的还是Hessian与Protobuf因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上都满足了我们的要求。其中Hessian在使用上更加方便在对象的兼容性上更好Protobuf则更加高效通用性上更有优势。
## RPC框架在使用时要注意哪些问题
了解了在RPC框架中如何选择序列化那么我们在使用过程中需要注意哪些序列化上的问题呢
我刚才讲过在RPC的运营中我遇到的最多的问题就是序列化问题了除了早期RPC框架本身出现的问题以外大多数问题都是使用方使用不正确导致的接下来我们就盘点下这些高频出现的人为问题。
**对象构造得过于复杂:**属性很多并且存在多层的嵌套比如A对象关联B对象B对象又聚合C对象C对象又关联聚合很多其他对象对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时对象越复杂就越浪费性能消耗CPU这会严重影响RPC框架整体的性能另外对象越复杂在序列化与反序列化的过程中出现问题的概率就越高。
**对象过于庞大:**我经常遇到业务过来咨询为啥他们的RPC请求经常超时排查后发现他们的入参对象非常得大比如为一个大List或者大Map序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU并且序列化一个如此大的对象是很耗费时间的这肯定会直接影响到请求的耗时。
**使用序列化框架不支持的类作为入参类:**比如Hessian框架他天然是不支持LinkHashMap、LinkedHashSet等而且大多数情况下最好不要使用第三方集合类如Guava中的集合类很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类应尽量选用原生的、最为常用的集合类如HashMap、ArrayList。
**对象有复杂的继承关系:**大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化当有继承关系时会不停地寻找父类遍历属性。就像问题1一样对象关系越复杂就越浪费性能同时又很容易出现序列化上的问题。
在RPC框架的使用过程中我们要尽量构建简单的对象作为入参和返回值对象避免上述问题。
## 总结
今天我们深入学习了什么是序列化并介绍了如JDK原生序列化、JSON、Hessian以及Protobuf等几种常见的序列化方式。
除了这些基础知识之外我们重点讲解了在RPC框架中如何去选择序列化协议我们有这样几个很重要的参考因素优先级从高到低依次是安全性、通用性和兼容性之后我们会再考虑序列化框架的性能、效率和空间开销。
这归根结底还是因为服务调用的稳定性与可靠性要比服务的性能与响应耗时更加重要。另外对于RPC调用来说整体调用上最为耗时、最消耗性能的操作大多都是服务提供者执行业务逻辑的操作这时序列化的开销对于服务整体的开销来说影响相对较小。
在使用RPC框架的过程中我们构造入参、返回值对象主要记住以下几点
1. 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚;
1. 入参对象与返回值对象体积不要太大,更不要传太大的集合;
1. 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类;
1. 对象不要有复杂的继承关系,最好不要有父子类的情况。
实际上虽然RPC框架可以让我们发起远程调用就像调用本地一样但在RPC框架的传输过程中入参与返回值的根本作用就是用来传递信息的为了提高RPC调用整体的性能和稳定性我们的入参与返回值对象要构造得尽量简单这很重要。
## 课后思考
RPC框架在序列化框架的选型上你认为还需要考虑哪些因素你还知道哪些优秀的序列化框架它们又是否适合在RPC调用中使用
欢迎留言和我分享你的答案和经验,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View File

@@ -0,0 +1,129 @@
<audio id="audio" title="04 | 网络通信RPC框架在网络通信上更倾向于哪种网络IO模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/31/273f5c57e767a5122a7e92074a410431.mp3"></audio>
你好我是何小锋。在上一讲我讲解了RPC框架中的序列化通过上一讲我们知道由于网络传输的数据都是二进制数据所以我们要传递对象就必须将对象进行序列化而RPC框架在序列化的选择上我们更关注序列化协议的安全性、通用性、兼容性其次才关注序列化协议的性能、效率、空间开销。承接上一讲这一讲我要专门讲解下RPC框架中的网络通信这也是我们在开篇词中就强调过的重要内容。
那么网络通信在RPC调用中起到什么作用呢
我在[[第 01 讲]](https://time.geekbang.org/column/article/199650) 中讲过RPC是解决进程间通信的一种方式。一次RPC调用本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络IO发送一条请求消息服务提供者接收并解析处理完相关的业务逻辑之后再发送一条响应消息给服务调用者服务调用者接收并解析响应消息处理完相关的响应逻辑一次RPC调用便结束了。可以说网络通信是整个RPC调用流程的基础。
## 常见的网络IO模型
那说到网络通信就不得不提一下网络IO模型。为什么要讲网络IO模型呢因为所谓的两台PC机之间的网络通信实际上就是两台PC机对网络IO的操作。
常见的网络IO模型分为四种同步阻塞IOBIO、同步非阻塞IONIO、IO多路复用和异步非阻塞IOAIO。在这四种IO模型中只有AIO为异步IO其他都是同步IO。
其中最常用的就是同步阻塞IO和IO多路复用这一点通过了解它们的机制你会get到。至于其他两种IO模型因为不常用则不作为本讲的重点有兴趣的话我们可以在留言区中讨论。
### 阻塞IOblocking IO
同步阻塞IO是最简单、最常见的IO模型在Linux中默认情况下所有的socket都是blocking的先看下操作流程。
首先应用进程发起IO系统调用后应用进程被阻塞转到内核空间处理。之后内核开始等待数据等待到数据之后再将内核中的数据拷贝到用户内存中整个IO处理完毕后返回进程。最后应用的进程解除阻塞状态运行业务逻辑。
这里我们可以看到系统内核处理IO操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中应用进程中IO操作的线程会一直都处于阻塞状态如果是基于Java多线程开发那么每一个IO操作都要占用线程直至IO操作结束。
这个流程就好比我们去餐厅吃饭,我们到达餐厅,向服务员点餐,之后要一直在餐厅等待后厨将菜做好,然后服务员会将菜端给我们,我们才能享用。
### IO多路复用IO multiplexing
多路复用IO是在高并发场景中使用最为广泛的一种IO模型如Java的NIO、Redis、Nginx的底层实现就是此类IO模型的应用经典的Reactor模式也是基于此类IO模型。
那么什么是IO多路复用呢通过字面上的理解多路就是指多个通道也就是多个网络连接的IO而复用就是指多个通道复用在一个复用器上。
多个网络连接的IO可以注册到一个复用器select当用户进程调用了select那么整个进程会被阻塞。同时内核会“监视”所有select负责的socket当任何一个socket中的数据准备好了select就会返回。这个时候用户进程再调用read操作将数据从内核中拷贝到用户进程。
这里我们可以看到当用户进程发起了select调用进程会被阻塞当发现该select负责的socket有准备好的数据时才返回之后才发起一次read整个流程要比阻塞IO要复杂似乎也更浪费性能。但它最大的优势在于用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket然后不断地调用select读取被激活的socket即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中必须通过多线程的方式才能达到这个目的。
同样好比我们去餐厅吃饭,这次我们是几个人一起去的,我们专门留了一个人在餐厅排号等位,其他人就去逛街了,等排号的朋友通知我们可以吃饭了,我们就直接去享用了。
### 为什么说阻塞IO和IO多路复用最为常用
了解完二者的机制我们就可以回到起初的问题了——我为什么说阻塞IO和IO多路复用最为常用。对比这四种网络IO模型阻塞IO、非阻塞IO、IO多路复用、异步IO。实际在网络IO的应用上需要的是系统内核的支持以及编程语言的支持。
在系统内核的支持上现在大多数系统内核都会支持阻塞IO、非阻塞IO和IO多路复用但像信号驱动IO、异步IO只有高版本的Linux系统内核才会支持。
在编程语言上无论C++还是Java在高性能的网络编程框架的编写上大多数都是基于Reactor模式其中最为典型的便是Java的Netty框架而Reactor模式是基于IO多路复用的。当然在非高并发场景下同步阻塞IO是最为常见的。
综合来讲在这四种常用的IO模型中应用最多的、系统内核与编程语言支持最为完善的便是阻塞IO和IO多路复用。这两种IO模型已经可以满足绝大多数网络IO的应用场景。
### RPC框架在网络通信上倾向选择哪种网络IO模型
讲完了这两种最常用的网络IO模型我们可以看看它们都适合什么样的场景。
IO多路复用更适合高并发的场景可以用较少的进程线程处理较多的socket的IO请求但使用难度比较高。当然高级的编程语言支持得还是比较好的比如Java语言有很多的开源框架对Java原生API做了封装如Netty框架使用非常简便而GO语言语言本身对IO多路复用的封装就已经很简洁了。
而阻塞IO与IO多路复用相比阻塞IO每处理一个socket的IO请求都会阻塞进程线程但使用难度较低。在并发量较低、业务逻辑只需要同步进行IO操作的场景下阻塞IO已经满足了需求并且不需要发起select调用开销上还要比IO多路复用低。
RPC调用在大多数的情况下是一个高并发调用的场景考虑到系统内核的支持、编程语言的支持以及IO模型本身的特点在RPC框架的实现中在网络通信的处理上我们会选择IO多路复用的方式。开发语言的网络通信框架的选型上我们最优的选择是基于Reactor模式实现的框架如Java语言首选的框架便是Netty框架Java还有很多其他NIO框架但目前Netty应用得最为广泛并且在Linux环境下也要开启epoll来提升系统性能Windows环境下是无法开启epoll的因为系统内核不支持
了解完以上内容,我们可以继续看这样一个关键问题——零拷贝。在我们应用的过程中,他是非常重要的。
## 什么是零拷贝?
刚才讲阻塞IO的时候我讲到系统内核处理IO操作分为两个阶段——等待数据和拷贝数据。等待数据就是系统内核在等待网卡接收到数据后把数据写到内核中而拷贝数据就是系统内核在获取到数据后将数据拷贝到用户进程的空间中。以下是具体流程
<img src="https://static001.geekbang.org/resource/image/cd/8a/cdf3358f751d2d71564ab58d4f78bc8a.jpg" alt="" title="网络IO读写流程">
应用进程的每一次写操作都会把数据写到用户空间的缓冲区中再由CPU将数据拷贝到系统内核的缓冲区中之后再由DMA将这份数据拷贝到网卡中最后由网卡发送出去。这里我们可以看到一次写操作数据要拷贝两次才能通过网卡发送出去而用户进程的读操作则是将整个流程反过来数据同样会拷贝两次才能让应用程序读取到数据。
应用进程的一次完整的读写操作都需要在用户空间与内核空间中来回拷贝并且每一次拷贝都需要CPU进行一次上下文切换由用户进程切换到系统内核或由系统内核切换到用户进程这样是不是很浪费CPU和性能呢那有没有什么方式可以减少进程间的数据拷贝提高数据传输的效率呢
这时我们就需要零拷贝Zero-copy技术。
所谓的零拷贝就是取消用户空间与内核空间之间的数据拷贝操作应用进程每一次的读写操作都可以通过一种方式让应用进程向用户空间写入或者读取数据就如同直接向内核空间写入或者读取数据一样再通过DMA将内核中的数据拷贝到网卡或将网卡中的数据copy到内核。
那怎么做到零拷贝?你想一下是不是用户空间与内核空间都将数据写到一个地方,就不需要拷贝了?此时你有没有想到虚拟内存?
<img src="https://static001.geekbang.org/resource/image/00/79/0017969e25ed01f650d7879ac0a2cc79.jpg" alt="" title="虚拟内存">
零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式mmap+write方式的核心原理就是通过虚拟内存来解决的。这两种实现方式都不难市面上可查阅的资料也很多在此就不详述了有问题可以在留言区中解决。
## Netty中的零拷贝
了解完零拷贝我们再看看Netty中的零拷贝。
我刚才讲到RPC框架在网络通信框架的选型上我们最优的选择是基于Reactor模式实现的框架如Java语言首选的便是Netty框架。那么Netty框架是否也有零拷贝机制呢Netty框架中的零拷贝和我之前讲的零拷贝又有什么不同呢
刚才我讲的零拷贝是操作系统层面上的零拷贝主要目标是避免用户空间与内核空间之间的数据拷贝操作可以提升CPU的利用率。
而Netty的零拷贝则不大一样他完全站在了用户空间上也就是JVM上它的零拷贝主要是偏向于数据操作的优化上。
**那么Netty这么做的意义是什么呢**
回想下[[第 02 讲]](https://time.geekbang.org/column/article/199651)在这一讲中我讲解了RPC框架如何去设计协议其中我讲到在传输过程中RPC并不会把请求参数的所有二进制数据整体一下子发送到对端机器上中间可能会拆分成好几个数据包也可能会合并其他请求的数据包所以消息都需要有边界。那么一端的机器收到消息之后就需要对数据包进行处理根据边界对数据包进行分割和合并最终获得一条完整的消息。
那收到消息后,对数据包的分割和合并,是在用户空间完成,还是在内核空间完成的呢?
当然是在用户空间因为对数据包的处理工作都是由应用程序来处理的那么这里有没有可能存在数据的拷贝操作可能会存在当然不是在用户空间与内核空间之间的拷贝是用户空间内部内存中的拷贝处理操作。Netty的零拷贝就是为了解决这个问题在用户空间对数据操作进行优化。
那么Netty是怎么对数据操作进行优化的呢
- Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf避免了各个 ByteBuf 之间的拷贝。
- ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf避免了内存的拷贝。
- 通过 wrap 操作,我们可以将 byte[] 数组、ByteBuf、ByteBuffer 等包装成一个 Netty ByteBuf 对象, 进而避免拷贝操作。
Netty框架中很多内部的ChannelHandler实现类都是通过CompositeByteBuf、slice、wrap操作来处理TCP传输中的拆包与粘包问题的。
那么Netty有没有解决用户空间与内核空间之间的数据拷贝问题的方法呢
Netty 的 ByteBuffer 可以采用 Direct Buffers使用堆外直接内存进行Socket的读写操作最终的效果与我刚才讲解的虚拟内存所实现的效果是一样的。
Netty 还提供 FileRegion 中包装 NIO 的 FileChannel.transferTo() 方法实现了零拷贝这与Linux 中的 sendfile 方式在原理上也是一样的。
## 总结
今天我们详细地介绍了阻塞IO与IO多路复用拓展了零拷贝相关的知识以及Netty框架中的零拷贝。
考虑到系统内核的支持、编程语言的支持以及IO模型本身的特点RPC框架在网络通信的处理上我们更倾向选择IO多路复用的方式。
零拷贝带来的好处就是避免没必要的CPU拷贝让CPU解脱出来去做其他的事同时也减少了CPU在用户空间与内核空间之间的上下文切换从而提升了网络通信效率与应用程序的整体性能。
而Netty的零拷贝与操作系统的零拷贝是有些区别的Netty的零拷贝偏向于用户空间中对数据操作的优化这对处理TCP传输中的拆包粘包问题有着重要的意义对应用程序处理请求数据与返回数据也有重要的意义。
在 RPC框架的开发与使用过程中我们要深入了解网络通信相关的原理知识尽量做到零拷贝如使用Netty框架我们要合理使用ByteBuf子类做到完全零拷贝提升RPC框架的整体性能。
## 课后思考
回想一下,你所接触的开源中间件框架有哪些框架在网络通信上做到了零拷贝?都是使用哪种方式实现的零拷贝?
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View File

@@ -0,0 +1,209 @@
<audio id="audio" title="05 | 动态代理面向接口编程屏蔽RPC处理流程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/65/3116e2aac6cda68241c512ea34069c65.mp3"></audio>
你好我是何小锋。上一讲我分享了网络通信其实要理解起来也很简单RPC 是用来解决两个应用之间的通信,而网络则是两台机器之间的“桥梁”,只有架好了桥梁,我们才能把请求数据从一端传输另外一端。其实关于网络通信,你只要记住一个关键字就行了——可靠的传输。
那么接着上一讲的内容,我们再来聊聊动态代理在 RPC 里面的应用。
如果我问你,你知道动态代理吗? 你可能会如数家珍般地告诉我动态代理的作用以及好处。那我现在接着问你,你在项目中用过动态代理吗?这时候可能有些人就会犹豫了。那我再换一个方式问你,你在项目中有实现过统一拦截的功能吗?比如授权认证、性能统计等等。你可能立马就会想到,我实现过呀,并且我知道可以用 Spring 的 AOP 功能来实现。
没错,进一步再想,在 Spring AOP 里面我们是怎么实现统一拦截的效果呢?并且是在我们不需要改动原有代码的前提下,还能实现非业务逻辑跟业务逻辑的解耦。这里的核心就是采用动态代理技术,通过对字节码进行增强,在方法调用的时候进行拦截,以便于在方法调用前后,增加我们需要的额外处理逻辑。
那话说回来,动态代理跟 RPC 又有什么关系呢?
## 远程调用的魔法
我说个具体的场景,你可能就明白了。
在项目中,当我们要使用 RPC 的时候,我们一般的做法是先找服务提供方要接口,通过 Maven 或者其他的工具把接口依赖到我们项目中。我们在编写业务逻辑的时候,如果要调用提供方的接口,我们就只需要通过依赖注入的方式把接口注入到项目中就行了,然后在代码里面直接调用接口的方法 。
我们都知道,接口里并不会包含真实的业务逻辑,业务逻辑都在服务提供方应用里,但我们通过调用接口方法,确实拿到了想要的结果,是不是感觉有点神奇呢?想一下,在 RPC 里面,我们是怎么完成这个魔术的。
**这里面用到的核心技术就是前面说的动态代理。**RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。
通过这种“偷梁换柱”的手法,就可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验,整体流程如下图所示:
<img src="https://static001.geekbang.org/resource/image/05/53/05cd18e7e33c5937c7c39bf8872c5753.jpg" alt="" title="调用流程">
## 实现原理
动态代理在 RPC 里面的作用,就像是个魔术。现在我不妨给你揭秘一下,我们一起看看这是怎么实现的。之后,学以致用自然就不难了。
我们以 Java 为例,看一个具体例子,代码如下所示:
```
/**
* 要代理的接口
*/
public interface Hello {
String say();
}
/**
* 真实调用对象
*/
public class RealHello {
public String invoke(){
return &quot;i'm proxy&quot;;
}
}
/**
* JDK代理类生成
*/
public class JDKProxy implements InvocationHandler {
private Object target;
JDKProxy(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] paramValues) {
return ((RealHello)target).invoke();
}
}
/**
* 测试例子
*/
public class TestProxy {
public static void main(String[] args){
// 构建代理器
JDKProxy proxy = new JDKProxy(new RealHello());
ClassLoader classLoader = ClassLoaderUtils.getCurrentClassLoader();
// 把生成的代理类保存到文件
System.setProperty(&quot;sun.misc.ProxyGenerator.saveGeneratedFiles&quot;,&quot;true&quot;);
// 生成代理类
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 版本梳理的。
<img src="https://static001.geekbang.org/resource/image/50/41/5042cf1b79e6b9233f2152e1e0aca741.jpg" alt="" title="代理类生成流程">
在生成字节码的那个地方,也就是 ProxyGenerator.generateProxyClass() 方法里面,通过代码我们可以看到,里面是用参数 saveGeneratedFiles 来控制是否把生成的字节码保存到本地磁盘。同时为了更直观地了解代理的本质,我们需要把参数 saveGeneratedFiles 设置成true但这个参数的值是由key为“sun.misc.ProxyGenerator.saveGeneratedFiles”的Property来控制的动态生成的类会保存在工程根目录下的 com/sun/proxy 目录里面。现在我们找到刚才生成的 $Proxy0.class通过反编译工具打开class文件你会看到这样的代码
```
package com.sun.proxy;
import com.proxy.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 m3;
private static Method m1;
private static Method m0;
private static Method m2;
public $Proxy0(InvocationHandler paramInvocationHandler) {
super(paramInvocationHandler);
}
public final String say() {
try {
return (String)this.h.invoke(this, m3, null);
} catch (Error|RuntimeException error) {
throw null;
} catch (Throwable throwable) {
throw new UndeclaredThrowableException(throwable);
}
}
public final boolean equals(Object paramObject) {
try {
return ((Boolean)this.h.invoke(this, m1, new Object[] { paramObject })).booleanValue();
} catch (Error|RuntimeException error) {
throw null;
} catch (Throwable throwable) {
throw new UndeclaredThrowableException(throwable);
}
}
public final int hashCode() {
try {
return ((Integer)this.h.invoke(this, m0, null)).intValue();
} catch (Error|RuntimeException error) {
throw null;
} catch (Throwable throwable) {
throw new UndeclaredThrowableException(throwable);
}
}
public final String toString() {
try {
return (String)this.h.invoke(this, m2, null);
} catch (Error|RuntimeException error) {
throw null;
} catch (Throwable throwable) {
throw new UndeclaredThrowableException(throwable);
}
}
static {
try {
m3 = Class.forName(&quot;com.proxy.Hello&quot;).getMethod(&quot;say&quot;, new Class[0]);
m1 = Class.forName(&quot;java.lang.Object&quot;).getMethod(&quot;equals&quot;, new Class[] { Class.forName(&quot;java.lang.Object&quot;) });
m0 = Class.forName(&quot;java.lang.Object&quot;).getMethod(&quot;hashCode&quot;, new Class[0]);
m2 = Class.forName(&quot;java.lang.Object&quot;).getMethod(&quot;toString&quot;, new Class[0]);
return;
} catch (NoSuchMethodException noSuchMethodException) {
throw new NoSuchMethodError(noSuchMethodException.getMessage());
} catch (ClassNotFoundException classNotFoundException) {
throw new NoClassDefFoundError(classNotFoundException.getMessage());
}
}
}
```
我们可以看到 $Proxy0 类里面有一个跟 Hello 一样签名的 say() 方法,其中 this.h 绑定的是刚才传入的 JDKProxy 对象,所以当我们调用 Hello.say() 的时候其实它是被转发到了JDKProxy.invoke()。到这儿,整个魔术过程就透明了。
## 实现方法
其实在 Java 领域除了JDK 默认的nvocationHandler能完成代理功能我们还有很多其他的第三方框架也可以比如像 Javassist、Byte Buddy 这样的框架。
单纯从代理功能上来看JDK 默认的代理功能是有一定的局限性的,它要求被代理的类只能是接口。原因是因为生成的代理类会继承 Proxy 类但Java 是不支持多重继承的。
这个限制在RPC应用场景里面还是挺要紧的因为对于服务调用方来说在使用RPC的时候本来就是面向接口来编程的这个我们刚才在前面已经讨论过了。使用JDK默认的代理功能最大的问题就是性能问题。它生成后的代理类是使用反射来完成方法调用的而这种方式相对直接用编码调用来说性能会降低但好在JDK8及以上版本对反射调用的性能有很大的提升所以还是可以期待一下的。
相对 JDK 自带的代理功能Javassist的定位是能够操纵底层字节码所以使用起来并不简单要生成动态代理类恐怕是有点复杂了。但好的方面是通过Javassist生成字节码不需要通过反射完成方法调用所以性能肯定是更胜一筹的。在使用中我们要注意一个问题通过Javassist生成一个代理类后此 CtClass 对象会被冻结起来,不允许再修改;否则,再次生成时会报错。
Byte Buddy 则属于后起之秀在很多优秀的项目中像Spring、Jackson都用到了Byte Buddy来完成底层代理。相比JavassistByte Buddy提供了更容易操作的API编写的代码可读性更高。更重要的是生成的代理类执行速度比Javassist更快。
虽然以上这三种框架使用的方式相差很大但核心原理却是差不多的区别就只是通过什么方式生成的代理类以及在生成的代理类里面是怎么完成的方法调用。同时呢也正是因为这些细小的差异才导致了不同的代理框架在性能方面的表现不同。因此我们在设计RPC框架的时候还是需要进行一些比较的具体你可以综合它们的优劣以及你的场景需求进行选择。
## 总结
今天我们介绍了动态代理在RPC里面的应用虽然它只是一种具体实现的技术但我觉得只有理解了方法调用是怎么被拦截的才能厘清在RPC里面我们是怎么做到面向接口编程帮助用户屏蔽RPC调用细节的最终呈现给用户一个像调用本地一样去调用远程的编程体验。
既然动态代理是一种具体的技术框架,那就会涉及到选型。我们可以从这样三个角度去考虑:
- 因为代理类是在运行中生成的,那么代理框架生成代理类的速度、生成代理类的字节码大小等等,都会影响到其性能——生成的字节码越小,运行所占资源就越小。
- 还有就是我们生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时生成的代理类的执行效率就需要很高效。
- 最后一个是从我们的使用角度出发的我们肯定希望选择一个使用起来很方便的代理类框架比如我们可以考虑API设计是否好理解、社区活跃度、还有就是依赖复杂度等等。
最后我想再强调一下。动态代理在RPC里面虽然看起来只是一个很小的技术点但就是这个创新使得用户可以不用关注细节了。其实我们在日常设计接口的时候也是一样的我们会想尽一切办法把细节对调用方屏蔽让调用方的接入尽可能的简单。这就好比让你去设计一个商品发布的接口你并不需要暴露给用户一些细节比如告诉他们商品数据是怎么存储的。
## 课后思考
请你设想一下如果没有动态代理帮我们完成方法调用拦截用户该怎么完成RPC调用
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View File

@@ -0,0 +1,253 @@
<audio id="audio" title="06 | RPC实战剖析gRPC源码动手实现一个完整的RPC" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/cc/a0661ac1c3e1930a02f4c43bcb321ccc.mp3"></audio>
你好,我是何小锋。上一讲我分享了动态代理,其作用总结起来就是一句话:“我们可以通过动态代理技术,屏蔽 RPC 调用的细节,从而让使用者能够面向接口编程。”
到今天为止,我们已经把 RPC 通信过程中要用到的所有基础知识都讲了一遍,但这些内容多属于理论。**这一讲我们就来实战一下,看看具体落实到代码上,我们应该怎么实现一个 RPC 框架?**
为了能让咱们快速达成共识,我选择剖析 gRPC 源码(源码地址:[https://github.com/grpc/grpc-java](https://github.com/grpc/grpc-java))。通过分析 gRPC 的通信过程,我们可以清楚地知道在 gRPC 里面这些知识点是怎么落地到具体代码上的。
gRPC 是由 Google 开发并且开源的一款高性能、跨语言的 RPC 框架,当前支持 C、Java 和 Go 等语言,当前 Java 版本最新 Release 版为 1.27.0。gRPC 有很多特点,比如跨语言,通信协议是基于标准的 HTTP/2 设计的,序列化支持 PBProtocol Buffer和 JSON整个调用示例如下图所示
<img src="https://static001.geekbang.org/resource/image/86/0d/8671942cd89feea3a2544d3530da450d.jpg" alt="" title="gRPC调用示例图">
如果你想快速地了解一个全新框架的工作原理,我个人认为最快的方式就是从使用示例开始,所以现在我们就以最简单的 HelloWord 为例开始了解。
在这个例子里面,我们会定义一个 say 方法,调用方通过 gRPC 调用服务提供方,然后服务提供方会返回一个字符串给调用方。
为了保证调用方和服务提供方能够正常通信,我们需要先约定一个通信过程中的契约,也就是我们在 Java 里面说的定义一个接口,这个接口里面只会包含一个 say 方法。在 gRPC 里面定义接口是通过写 Protocol Buffer 代码,从而把接口的定义信息通过 Protocol Buffer 语义表达出来。HelloWord 的 Protocol Buffer 代码如下所示:
```
syntax = &quot;proto3&quot;;
option java_multiple_files = true;
option java_package = &quot;io.grpc.hello&quot;;
option java_outer_classname = &quot;HelloProto&quot;;
option objc_class_prefix = &quot;HLW&quot;;
package hello;
service HelloService{
rpc Say(HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
```
有了这段代码,我们就可以为客户端和服务器端生成消息对象和 RPC 基础代码。我们可以利用 Protocol Buffer 的编译器 protoc再配合 gRPC Java 插件protoc-gen-grpc-java通过命令行 protoc3 加上 plugin 和 proto 目录地址参数,我们就可以生成消息对象和 gRPC 通信所需要的基础代码。如果你的项目是 Maven 工程的话,你还可以直接选择使用 Maven 插件来生成同样的代码。
## 发送原理
生成完基础代码以后,我们就可以基于生成的代码写下调用端代码,具体如下:
```
package io.grpc.hello;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;
import java.util.concurrent.TimeUnit;
public class HelloWorldClient {
private final ManagedChannel channel;
private final HelloServiceGrpc.HelloServiceBlockingStub blockingStub;
/**
* 构建Channel连接
**/
public HelloWorldClient(String host, int port) {
this(ManagedChannelBuilder.forAddress(host, port)
.usePlaintext()
.build());
}
/**
* 构建Stub用于发请求
**/
HelloWorldClient(ManagedChannel channel) {
this.channel = channel;
blockingStub = HelloServiceGrpc.newBlockingStub(channel);
}
/**
* 调用完手动关闭
**/
public void shutdown() throws InterruptedException {
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
}
/**
* 发送rpc请求
**/
public void say(String name) {
// 构建入参对象
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
// 发送请求
response = blockingStub.say(request);
} catch (StatusRuntimeException e) {
return;
}
System.out.println(response);
}
public static void main(String[] args) throws Exception {
HelloWorldClient client = new HelloWorldClient(&quot;127.0.0.1&quot;, 50051);
try {
client.say(&quot;world&quot;);
} finally {
client.shutdown();
}
}
}
```
**调用端代码大致分成三个步骤:**
- 首先用 host 和 port 生成 channel 连接;
- 然后用前面生成的 HelloService gRPC 创建 Stub 类;
- 最后我们可以用生成的这个 Stub 调用 say 方法发起真正的 RPC 调用,后续其它的 RPC 通信细节就对我们使用者透明了。
为了能看清楚里面具体发生了什么,我们需要进入到 ClientCalls.blockingUnaryCall 方法里面看下逻辑细节。但是为了避免太多的细节影响你理解整体流程,我在下面这张图中只画下了最重要的部分。
<img src="https://static001.geekbang.org/resource/image/57/5d/57c7d1c005c1d426ccee48a84e286b5d.jpg" alt="" title="整体流程图">
我们可以看到在调用端代码里面我们只需要一行第48行代码就可以发起一个 RPC 调用,而具体这个请求是怎么发送到服务提供者那端的呢?这对于我们 gRPC 使用者来说是完全透明的,我们只要关注是怎么创建出 stub 对象的就可以了。
比如入参是一个字符对象gRPC 是怎么把这个对象传输到服务提供方的呢?因为在[[第 03 讲]](https://time.geekbang.org/column/article/202779) 中我们说过,只有二进制才能在网络中传输,但是目前调用端代码的入参是一个字符对象,那在 gRPC 里面我们是怎么把对象转成二进制数据的呢?
回到上面流程图的第3步在 writePayload 之前ClientCallImpl 里面有一行代码就是 method.streamRequest(message),看方法签名我们大概就知道它是用来把对象转成一个 InputStream有了 InputStream 我们就很容易获得入参对象的二进制数据。这个方法返回值很有意思,就是为啥不直接返回我们想要的二进制数组,而是返回一个 InputStream 对象呢?你可以先停下来想下原因,我们会在最后继续讨论这个问题。
我们接着看 streamRequest 方法的拥有者 method 是个什么对象?我们可以看到 method 是 MethodDescriptor 对象关联的一个实例,而 MethodDescriptor 是用来存放要调用 RPC 服务的接口名、方法名、服务调用的方式以及请求和响应的序列化和反序列化实现类。
大白话说就是MethodDescriptor 是用来存储一些 RPC 调用过程中的元数据,而在 MethodDescriptor 里面 requestMarshaller 是在绑定请求的时候用来序列化方式对象的,所以当我们调用 method.streamRequest(message) 的时候,实际是调用 requestMarshaller.stream(requestMessage) 方法,而 requestMarshaller 里面会绑定一个 Parser这个 Parser 才真正地把对象转成了 InputStream 对象。
讲完序列化在 gRPC 里面的应用后,我们再来看下在 gRPC 里面是怎么完成请求数据“断句”的,就是我们在[[第 02 讲]](https://time.geekbang.org/column/article/199651) 中说的那个问题——二进制流经过网络传输后,怎么正确地还原请求前语义?
我们在 gRPC 文档中可以看到gRPC 的通信协议是基于标准的 HTTP/2 设计的,而 HTTP/2 相对于常用的 HTTP/1.X 来说它最大的特点就是多路复用、双向流该怎么理解这个特点呢这就好比我们生活中的单行道和双行道HTTP/1.X 就是单行道HTTP/2 就是双行道。
那既然在请求收到后需要进行请求“断句”,那肯定就需要在发送的时候把断句的符号加上,我们看下在 gRPC 里面是怎么加的?
因为 gRPC 是基于 HTTP/2 协议,而 HTTP/2 传输基本单位是 FrameFrame 格式是以固定 9 字节长度的 header后面加上不定长的 payload 组成,协议格式如下图所示:
<img src="https://static001.geekbang.org/resource/image/d5/75/d5554644aefe5aaab42718d75a47de75.jpg" alt="">
那在 gRPC 里面就变成怎么构造一个 HTTP/2 的 Frame 了。
现在回看我们上面那个流程图的第 4 步,在 write 到 Netty 里面之前,我们看到在 MessageFramer.writePayload 方法里面会间接调用 writeKnownLengthUncompressed 方法,该方法要做的两件事情就是构造 Frame Header 和 Frame Body然后再把构造的 Frame 发送到 NettyClientHandler最后将 Frame 写入到 HTTP/2 Stream 中,完成请求消息的发送。
## 接收原理
讲完 gRPC 的请求发送原理,我们再来看下服务提供方收到请求后会怎么处理?我们还是接着前面的那个例子,先看下服务提供方代码,具体如下:
```
static class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
@Override
public void say(HelloRequest req, StreamObserver&lt;HelloReply&gt; responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage(&quot;Hello &quot; + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
```
上面 HelloServiceImpl 类是按照 gRPC 使用方式实现了 HelloService 接口逻辑,但是对于调用者来说并不能把它调用过来,因为我们没有把这个接口对外暴露,在 gRPC 里面我们是采用 Build 模式对底层服务进行绑定,具体代码如下:
```
package io.grpc.hello;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;
import java.io.IOException;
public class HelloWorldServer {
private Server server;
/**
* 对外暴露服务
**/
private void start() throws IOException {
int port = 50051;
server = ServerBuilder.forPort(port)
.addService(new HelloServiceImpl())
.build()
.start();
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
HelloWorldServer.this.stop();
}
});
}
/**
* 关闭端口
**/
private void stop() {
if (server != null) {
server.shutdown();
}
}
/**
* 优雅关闭
**/
private void blockUntilShutdown() throws InterruptedException {
if (server != null) {
server.awaitTermination();
}
}
public static void main(String[] args) throws IOException, InterruptedException {
final HelloWorldServer server = new HelloWorldServer();
server.start();
server.blockUntilShutdown();
}
}
```
服务对外暴露的目的是让过来的请求在被还原成信息后,能找到对应接口的实现。在这之前,我们需要先保证能正常接收请求,通俗地讲就是要先开启一个 TCP 端口,让调用方可以建立连接,并把二进制数据发送到这个连接通道里面,这里依然只展示最重要的部分。
<img src="https://static001.geekbang.org/resource/image/b4/cd/b43a3fb6d6929bb862893aebd7cd40cd.jpg" alt="">
这四个步骤是用来开启一个 Netty Server并绑定编解码逻辑的如果你暂时看不懂没关系的我们可以先忽略细节。我们重点看下 NettyServerHandler 就行了,在这个 Handler 里面会绑定一个 FrameListenergRPC 会在这个 Listener 里面处理收到数据请求的 Header 和 Body并且也会处理 Ping、RST 命令等,具体流程如下图所示:
<img src="https://static001.geekbang.org/resource/image/98/f5/98d0ecec8e2c9b75159e86253c6c3cf5.jpg" alt="">
在收到 Header 或者 Body 二进制数据后NettyServerHandler 上绑定的FrameListener 会把这些二进制数据转到 MessageDeframer 里面,从而实现 gRPC 协议消息的解析 。
那你可能会问,这些 Header 和 Body 数据是怎么分离出来的呢?按照我们前面说的,调用方发过来的是一串二进制数据,这就是我们前面开启 Netty Server 的时候绑定 Default HTTP/2FrameReader 的作用,它能帮助我们按照 HTTP/2 协议的格式自动切分出 Header 和 Body 数据来,而对我们上层应用 gRPC 来说,它可以直接拿拆分后的数据来用。
## 总结
这是我们基础篇的最后一讲,我们采用剖析 gRPC 源码的方式来学习如何实现一个完整的 RPC。当然整个 gRPC 的代码量可比这多得多,但今天的主要目就是想让你把前面所学的序列化、协议等方面的知识落实到具体代码上,所以我们这儿只分析了 gRPC 收发请求两个过程。
实现了这两个过程,我们就可以完成一个点对点的 RPC 功能,但在实际使用的时候,我们的服务提供方通常都是以一个集群的方式对外提供服务的,所以在 gRPC 里面你还可以看到负载均衡、服务发现等功能。而且 gRPC 采用的是 HTTP/2 协议,我们还可以通过 Stream 方式来调用服务,以提升调用性能。
总的来说,其实我们可以简单地认为**gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC**,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。
## 课后思考
我们讲到,在 gRPC 调用的时候,我们有一个关键步骤就是把对象转成可传输的二进制,但是在 gRPC 里面,我们并没有直接转成二进制数组,而是返回一个 InputStream你知道这样做的好处是什么吗
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!