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,177 @@
<audio id="audio" title="第32讲 | RPC协议综述远在天边近在眼前" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/2d/97718e118ab62bfaa6283e753ee9392d.mp3"></audio>
前面我们讲了容器网络如何实现跨主机互通,以及微服务之间的相互调用。
<img src="https://static001.geekbang.org/resource/image/06/65/06ba300a78aef37b9d190aba61c37865.jpg" alt="">
网络是打通了那服务之间的互相调用该怎么实现呢你可能说咱不是学过Socket吗。服务之间分调用方和被调用方我们就建立一个TCP或者UDP的连接不就可以通信了
<img src="https://static001.geekbang.org/resource/image/77/92/77d5eeb659d5347874bda5e8f711f692.jpg" alt="">
你仔细想一下,这事儿没这么简单。我们就拿最简单的场景,客户端调用一个加法函数,将两个整数加起来,返回它们的和。
如果放在本地调用,那是简单的不能再简单了,只要稍微学过一种编程语言,三下五除二就搞定了。但是一旦变成了远程调用,门槛一下子就上去了。
首先你要会Socket编程至少先要把咱们这门网络协议课学一下然后再看N本砖头厚的Socket程序设计的书学会咱们学过的几种Socket程序设计的模型。这就使得本来大学毕业就能干的一项工作变成了一件五年工作经验都不一定干好的工作而且搞定了Socket程序设计才是万里长征的第一步。后面还有很多问题呢
## 如何解决这五个问题?
### 问题一:如何规定远程调用的语法?
客户端如何告诉服务端我是一个加法而另一个是乘法。我是用字符串“add”传给你还是传给你一个整数比如1表示加法2表示乘法服务端该如何告诉客户端我的这个加法目前只能加整数不能加小数不能加字符串而另一个加法“add1”它能实现小数和整数的混合加法。那返回值是什么正确的时候返回什么错误的时候又返回什么
### 问题二:如果传递参数?
我是先传两个整数后传一个操作符“add”还是先传操作符再传两个整数是不是像咱们数据结构里一样如果都是UDP想要实现一个逆波兰表达式放在一个报文里面还好如果是TCP是一个流在这个流里面如何将两次调用进行分界什么时候是头什么时候是尾把这次的参数和上次的参数混了起来TCP一端发送出去的数据另外一端不一定能一下子全部读取出来。所以怎么才算读完呢
### 问题三:如何表示数据?
在这个简单的例子中传递的就是一个固定长度的int值这种情况还好如果是变长的类型是一个结构体甚至是一个类应该怎么办呢如果是int不同的平台上长度也不同该怎么办呢
在网络上传输超过一个Byte的类型还有大端Big Endian和小端Little Endian的问题。
假设我们要在32位四个Byte的一个空间存放整数1很显然只要一个Byte放1其他三个Byte放0就可以了。那问题是最后一个Byte放1呢还是第一个Byte放1呢或者说1作为最低位应该是放在32位的最后一个位置呢还是放在第一个位置呢
最低位放在最后一个位置叫作Little Endian最低位放在第一个位置叫作Big Endian。TCP/IP协议栈是按照Big Endian来设计的而X86机器多按照Little Endian来设计的因而发出去的时候需要做一个转换。
### 问题四:如何知道一个服务端都实现了哪些远程调用?从哪个端口可以访问这个远程调用?
假设服务端实现了多个远程调用,每个可能实现在不同的进程中,监听的端口也不一样,而且由于服务端都是自己实现的,不可能使用一个大家都公认的端口,而且有可能多个进程部署在一台机器上,大家需要抢占端口,为了防止冲突,往往使用随机端口,那客户端如何找到这些监听的端口呢?
### 问题五:发生了错误、重传、丢包、性能等问题怎么办?
本地调用没有这个问题但是一旦到网络上这些问题都需要处理因为网络是不可靠的虽然在同一个连接中我们还可通过TCP协议保证丢包、重传的问题但是如果服务器崩溃了又重启当前连接断开了TCP就保证不了了需要应用自己进行重新调用重新传输会不会同样的操作做两遍远程调用性能会不会受影响呢
## 协议约定问题
看到这么多问题,你是不是想起了我[第一节](https://time.geekbang.org/column/article/7581)讲过的这张图。
<img src="https://static001.geekbang.org/resource/image/98/ab/984b421d4e13d42e2b0500d0427d94ab.jpg" alt="">
本地调用函数里有很多问题,比如词法分析、语法分析、语义分析等等,这些编译器本来都能帮你做了。但是在远程调用中,这些问题你都需要重新操心。
很多公司的解决方法是弄一个核心通信组里面都是Socket编程的大牛实现一个统一的库让其他业务组的人来调用业务的人不需要知道中间传输的细节。通信双方的语法、语义、格式、端口、错误处理等都需要调用方和被调用方开会协商双方达成一致。一旦有一方改变要及时通知对方否则通信就会有问题。
可是不是每一个公司都有这种大牛团队,往往只有大公司才配得起,那有没有已经实现好的框架可以使用呢?
当然有。一个大牛Bruce Jay Nelson写了一篇论文[Implementing Remote Procedure Calls](http://www.cs.cmu.edu/~dga/15-712/F07/papers/birrell842.pdf)定义了RPC的调用标准。后面所有RPC框架都是按照这个标准模式来的。
<img src="https://static001.geekbang.org/resource/image/85/25/8534c52daf3682cd1cfe5a3375ec9525.jpg" alt="">
当客户端的应用想发起一个远程调用时它实际是通过本地调用本地调用方的Stub。它负责将调用的接口、方法和参数通过约定的协议规范进行编码并通过本地的RPCRuntime进行传输将调用网络包发送到服务器。
服务器端的RPCRuntime收到请求后交给提供方Stub进行解码然后调用服务端的方法服务端执行方法返回结果提供方Stub将返回结果编码后发送给客户端客户端的RPCRuntime收到结果发给调用方Stub解码得到结果返回给客户端。
这里面分了三个层次对于用户层和服务端都像是本地调用一样专注于业务逻辑的处理就可以了。对于Stub层处理双方约定好的语法、语义、封装、解封装。对于RPCRuntime主要处理高性能的传输以及网络的错误和异常。
最早的RPC的一种实现方式称为Sun RPC或ONC RPC。Sun公司是第一个提供商业化RPC库和 RPC编译器的公司。这个RPC框架是在NFS协议中使用的。
NFSNetwork File System就是网络文件系统。要使NFS成功运行要启动两个服务端一个是mountd用来挂载文件路径一个是nfsd用来读写文件。NFS可以在本地mount一个远程的目录到本地的一个目录从而本地的用户在这个目录里面写入、读出任何文件的时候其实操作的是远程另一台机器上的文件。
操作远程和远程调用的思路是一样的就像操作本地一样。所以NFS协议就是基于RPC实现的。当然无论是什么RPC底层都是Socket编程。
<img src="https://static001.geekbang.org/resource/image/2a/eb/2a0fd84c2d3dced623511e2a5226d0eb.jpg" alt="">
XDRExternal Data Representation外部数据表示法是一个标准的数据压缩格式可以表示基本的数据类型也可以表示结构体。
这里是几种基本的数据类型。
<img src="https://static001.geekbang.org/resource/image/4a/af/4a649954fea1cee22fcfa8bdb34c03af.jpg" alt="">
在RPC的调用过程中所有的数据类型都要封装成类似的格式。而且RPC的调用和结果返回也有严格的格式。
<li>
XID唯一标识一对请求和回复。请求为0回复为1。
</li>
<li>
RPC有版本号两端要匹配RPC协议的版本号。如果不匹配就会返回Deny原因就是RPC_MISMATCH。
</li>
<li>
程序有编号。如果服务端找不到这个程序就会返回PROG_UNAVAIL。
</li>
<li>
程序有版本号。如果程序的版本号不匹配就会返回PROG_MISMATCH。
</li>
<li>
一个程序可以有多个方法方法也有编号如果找不到方法就会返回PROC_UNAVAIL。
</li>
<li>
调用需要认证鉴权如果不通过则Deny。
</li>
<li>
最后是参数列表如果参数无法解析则返回GABAGE_ARGS。
</li>
<img src="https://static001.geekbang.org/resource/image/c7/65/c724675527afdbd43964bdf24684fa65.jpg" alt="">
为了可以成功调用RPC在客户端和服务端实现RPC的时候首先要定义一个双方都认可的程序、版本、方法、参数等。
<img src="https://static001.geekbang.org/resource/image/5c/58/5c3ebb31ac4415d7895247bf8758fa58.jpg" alt="">
如果还是上面的加法则双方约定为一个协议定义文件同理如果是NFS、mount和读写也会有类似的定义。
有了协议定义文件ONC RPC会提供一个工具根据这个文件生成客户端和服务器端的Stub程序。
<img src="https://static001.geekbang.org/resource/image/27/b9/27dc1ccd0481408055c87e0e5d8b02b9.jpg" alt="">
最下层的是XDR文件用于编码和解码参数。这个文件是客户端和服务端共享的因为只有双方一致才能成功通信。
在客户端会调用clnt_create创建一个连接然后调用add_1这是一个Stub函数感觉是在调用本地一样。其实是这个函数发起了一个RPC调用通过调用clnt_call来调用ONC RPC的类库来真正发送请求。调用的过程非常复杂一会儿我详细说这个。
当然服务端也有一个Stub程序监听客户端的请求当调用到达的时候判断如果是add则调用真正的服务端逻辑也即将两个数加起来。
服务端将结果返回服务端的Stub这个Stub程序发送结果给客户端客户端的Stub程序正在等待结果当结果到达客户端Stub就将结果返回给客户端的应用程序从而完成整个调用过程。
有了这个RPC的框架前面五个问题中的前三个“如何规定远程调用的语法”“如何传递参数”以及“如何表示数据”基本解决了这三个问题我们统称为**协议约定问题**。
## 传输问题
但是错误、重传、丢包、性能等问题还没有解决,这些问题我们统称为**传输问题**。这个就不用Stub操心了而是由ONC RPC的类库来实现。这是大牛们实现的我们只要调用就可以了。
<img src="https://static001.geekbang.org/resource/image/33/e4/33e1afe4a79e81096e09b850424930e4.jpg" alt="">
在这个类库中为了解决传输问题对于每一个客户端都会创建一个传输管理层而每一次RPC调用都会是一个任务在传输管理层你可以看到熟悉的队列机制、拥塞窗口机制等。
由于在网络传输的时候经常需要等待因而同步的方式往往效率比较低因而也就有Socket的异步模型。为了能够异步处理对于远程调用的处理往往是通过状态机来实现的。只有当满足某个状态的时候才进行下一步如果不满足状态不是在那里等而是将资源留出来用来处理其他的RPC调用。
<img src="https://static001.geekbang.org/resource/image/02/f5/0258775aac1126735504c9a6399745f5.jpg" alt="">
从这个图可以看出,这个状态转换图还是很复杂的。
首先进入起始状态查看RPC的传输层队列中有没有空闲的位置可以处理新的RPC任务。如果没有说明太忙了或直接结束或重试。如果申请成功就可以分配内存获取服务的端口号然后连接服务器。
连接的过程要有一段时间因而要等待连接的结果会有连接失败或直接结束或重试。如果连接成功则开始发送RPC请求然后等待获取RPC结果这个过程也需要一定的时间如果发送出错可以重新发送如果连接断了可以重新连接如果超时可以重新传输如果获取到结果就可以解码正常结束。
这里处理了连接失败、重试、发送失败、超时、重试等场景。不是大牛真写不出来因而实现一个RPC的框架其实很有难度。
## 服务发现问题
传输问题解决了我们还遗留一个问题就是问题四“如何找到RPC服务端的那个随机端口”。这个问题我们称为服务发现问题。在ONC RPC中服务发现是通过portmapper实现的。
<img src="https://static001.geekbang.org/resource/image/2a/7c/2aff190d1f878749d2a5bd73228ca37c.jpg" alt="">
portmapper会启动在一个众所周知的端口上RPC程序由于是用户自己写的会监听在一个随机端口上但是RPC程序启动的时候会向portmapper注册。客户端要访问RPC服务端这个程序的时候首先查询portmapper获取RPC服务端程序的随机端口然后向这个随机端口建立连接开始RPC调用。从图中可以看出mount命令的RPC调用就是这样实现的。
## 小结
好了,这一节就到这里,我们来总结一下。
<li>
远程调用看起来用Socket编程就可以了其实是很复杂的要解决协议约定问题、传输问题和服务发现问题。
</li>
<li>
大牛Bruce Jay Nelson的论文、早期ONC RPC框架以及NFS的实现给出了解决这三大问题的示范性实现也即协议约定要公用协议描述文件并通过这个文件生成Stub程序RPC的传输一般需要一个状态机需要另外一个进程专门做服务发现。
</li>
最后,给你留两个思考题。
<li>
在这篇文章中mount的过程是通过系统调用最终调用到RPC层。一旦mount完毕之后客户端就像写入本地文件一样写入NFS了这个过程是如何触发RPC层的呢
</li>
<li>
ONC RPC是早期的RPC框架你觉得它有哪些问题呢
</li>
我们的专栏更新到第32讲不知你掌握得如何每节课后我留的思考题你都有没有认真思考并在留言区写下答案呢我会从**已发布的文章中选出一批认真留言的同学**,赠送**学习奖励礼券**和我整理的**独家网络协议知识图谱**。
欢迎你留言和我讨论。趣谈网络协议,我们下期见!

View File

@@ -0,0 +1,212 @@
<audio id="audio" title="第33讲 | 基于XML的SOAP协议不要说NBA请说美国职业篮球联赛" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/9a/7e3f7b2cbb61438d484ab627a7dcf39a.mp3"></audio>
上一节我们讲了RPC的经典模型和设计要点并用最早期的ONC RPC为例子详述了具体的实现。
## ONC RPC存在哪些问题
ONC RPC将客户端要发送的参数以及服务端要发送的回复都压缩为一个二进制串这样固然能够解决双方的协议约定问题但是存在一定的不方便。
首先,**需要双方的压缩格式完全一致**,一点都不能差。一旦有少许的差错,多一位,少一位或者错一位,都可能造成无法解压缩。当然,我们可以用传输层的可靠性以及加入校验值等方式,来减少传输过程中的差错。
其次,**协议修改不灵活**。如果不是传输过程中造成的差错,而是客户端因为业务逻辑的改变,添加或者删除了字段,或者服务端添加或者删除了字段,而双方没有及时通知,或者线上系统没有及时升级,就会造成解压缩不成功。
因而当业务发生改变需要多传输一些参数或者少传输一些参数的时候都需要及时通知对方并且根据约定好的协议文件重新生成双方的Stub程序。自然这样灵活性比较差。
如果仅仅是沟通的问题也还好解决,其实更难弄的还有**版本的问题**。比如在服务端提供一个服务参数的格式是版本一的已经有50个客户端在线上调用了。现在有一个客户端有个需求要加一个字段怎么办呢这可是一个大工程所有的客户端都要适配这个需要重新写程序加上这个字段但是传输值是0不需要这个字段的客户端很“冤”本来没我啥事儿为啥让我也忙活
最后,**ONC RPC的设计明显是面向函数的而非面向对象**。而当前面向对象的业务逻辑设计与实现方式已经成为主流。
这一切的根源就在于压缩。这就像平时我们爱用缩略语。如果是篮球爱好者你直接说NBA他马上就知道什么意思但是如果你给一个大妈说NBA她可能就不知所云。
所以这种RPC框架只能用于客户端和服务端全由一拨人开发的场景或者至少客户端和服务端的开发人员要密切沟通相互合作有大量的共同语言才能按照既定的协议顺畅地进行工作。
## XML与SOAP
但是一般情况下我们做一个服务都是要提供给陌生人用的你和客户不会经常沟通也没有什么共同语言。就像你给别人介绍NBA你要说美国职业篮球赛这样不管他是干啥的都能听得懂。
放到我们的场景中,对应的就是用**文本类**的方式进行传输。无论哪个客户端获得这个文本,都能够知道它的意义。
一种常见的文本类格式是XML。我们这里举个例子来看。
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;geek:purchaseOrder xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot; xmlns:geek=&quot;http://www.example.com/geek&quot;&gt;
&lt;order&gt;
&lt;date&gt;2018-07-01&lt;/date&gt;
&lt;className&gt;趣谈网络协议&lt;/className&gt;
&lt;Author&gt;刘超&lt;/Author&gt;
&lt;price&gt;68&lt;/price&gt;
&lt;/order&gt;
&lt;/geek:purchaseOrder&gt;
```
我这里不准备详细讲述XML的语法规则但是你相信我看完下面的内容即便你没有学过XML也能一看就懂这段XML描述的是什么不像全面的二进制你看到的都是010101不知所云。
有了这个,刚才我们说的那几个问题就都不是问题了。
首先,**格式没必要完全一致**。比如如果我们把price和author换个位置并不影响客户端和服务端解析这个文本也根本不会误会说这个作者的名字叫68。
如果有的客户端想增加一个字段,例如添加一个推荐人字段,只需要在上面的文件中加一行:
```
&lt;recommended&gt; Gary &lt;/recommended&gt;
```
对于不需要这个字段的客户端,只要不解析这一行就是了。只要用简单的处理,就不会出现错误。
另外,这种表述方式显然是描述一个订单对象的,是一种面向对象的、更加接近用户场景的表示方式。
既然XML这么好接下来我们来看看怎么把它用在RPC中。
### 传输协议问题
我们先解决第一个,传输协议的问题。
基于XML的最著名的通信协议就是**SOAP**了,全称**简单对象访问协议**Simple Object Access Protocol。它使用XML编写简单的请求和回复消息并用HTTP协议进行传输。
SOAP将请求和回复放在一个信封里面就像传递一个邮件一样。信封里面的信分**抬头**和**正文**。
```
POST /purchaseOrder HTTP/1.1
Host: www.geektime.com
Content-Type: application/soap+xml; charset=utf-8
Content-Length: nnn
```
```
&lt;?xml version=&quot;1.0&quot;?&gt;
&lt;soap:Envelope xmlns:soap=&quot;http://www.w3.org/2001/12/soap-envelope&quot;
soap:encodingStyle=&quot;http://www.w3.org/2001/12/soap-encoding&quot;&gt;
&lt;soap:Header&gt;
&lt;m:Trans xmlns:m=&quot;http://www.w3schools.com/transaction/&quot;
soap:mustUnderstand=&quot;1&quot;&gt;1234
&lt;/m:Trans&gt;
&lt;/soap:Header&gt;
&lt;soap:Body xmlns:m=&quot;http://www.geektime.com/perchaseOrder&quot;&gt;
&lt;m:purchaseOrder&quot;&gt;
&lt;order&gt;
&lt;date&gt;2018-07-01&lt;/date&gt;
&lt;className&gt;趣谈网络协议&lt;/className&gt;
&lt;Author&gt;刘超&lt;/Author&gt;
&lt;price&gt;68&lt;/price&gt;
&lt;/order&gt;
&lt;/m:purchaseOrder&gt;
&lt;/soap:Body&gt;
&lt;/soap:Envelope&gt;
```
HTTP协议我们学过这个请求使用POST方法发送一个格式为 application/soap + xml 的XML正文给 [www.geektime.com](http://www.geektime.com)从而下一个单这个订单封装在SOAP的信封里面并且表明这是一笔交易transaction而且订单的详情都已经写明了。
### 协议约定问题
接下来我们解决第二个问题,就是双方的协议约定是什么样的?
因为服务开发出来是给陌生人用的就像上面下单的那个XML文件对于客户端来说它如何知道应该拼装成上面的格式呢这就需要对于服务进行描述因为调用的人不认识你所以没办法找到你问你的服务应该如何调用。
当然你可以写文档,然后放在官方网站上,但是你的文档不一定更新得那么及时,而且你也写的文档也不一定那么严谨,所以常常会有调试不成功的情况。因而,我们需要一种相对比较严谨的**Web服务描述语言****WSDL**Web Service Description Languages。它也是一个XML文件。
在这个文件中要定义一个类型order与上面的XML对应起来。
```
&lt;wsdl:types&gt;
&lt;xsd:schema targetNamespace=&quot;http://www.example.org/geektime&quot;&gt;
&lt;xsd:complexType name=&quot;order&quot;&gt;
&lt;xsd:element name=&quot;date&quot; type=&quot;xsd:string&quot;&gt;&lt;/xsd:element&gt;
&lt;xsd:element name=&quot;className&quot; type=&quot;xsd:string&quot;&gt;&lt;/xsd:element&gt;
&lt;xsd:element name=&quot;Author&quot; type=&quot;xsd:string&quot;&gt;&lt;/xsd:element&gt;
&lt;xsd:element name=&quot;price&quot; type=&quot;xsd:int&quot;&gt;&lt;/xsd:element&gt;
&lt;/xsd:complexType&gt;
&lt;/xsd:schema&gt;
&lt;/wsdl:types&gt;
```
接下来需要定义一个message的结构。
```
&lt;wsdl:message name=&quot;purchase&quot;&gt;
&lt;wsdl:part name=&quot;purchaseOrder&quot; element=&quot;tns:order&quot;&gt;&lt;/wsdl:part&gt;
&lt;/wsdl:message&gt;
```
接下来,应该暴露一个端口。
```
&lt;wsdl:portType name=&quot;PurchaseOrderService&quot;&gt;
&lt;wsdl:operation name=&quot;purchase&quot;&gt;
&lt;wsdl:input message=&quot;tns:purchase&quot;&gt;&lt;/wsdl:input&gt;
&lt;wsdl:output message=&quot;......&quot;&gt;&lt;/wsdl:output&gt;
&lt;/wsdl:operation&gt;
&lt;/wsdl:portType&gt;
```
然后我们来编写一个binding将上面定义的信息绑定到SOAP请求的body里面。
```
&lt;wsdl:binding name=&quot;purchaseOrderServiceSOAP&quot; type=&quot;tns:PurchaseOrderService&quot;&gt;
&lt;soap:binding style=&quot;rpc&quot;
transport=&quot;http://schemas.xmlsoap.org/soap/http&quot; /&gt;
&lt;wsdl:operation name=&quot;purchase&quot;&gt;
&lt;wsdl:input&gt;
&lt;soap:body use=&quot;literal&quot; /&gt;
&lt;/wsdl:input&gt;
&lt;wsdl:output&gt;
&lt;soap:body use=&quot;literal&quot; /&gt;
&lt;/wsdl:output&gt;
&lt;/wsdl:operation&gt;
&lt;/wsdl:binding&gt;
```
最后我们需要编写service。
```
&lt;wsdl:service name=&quot;PurchaseOrderServiceImplService&quot;&gt;
&lt;wsdl:port binding=&quot;tns:purchaseOrderServiceSOAP&quot; name=&quot;PurchaseOrderServiceImplPort&quot;&gt;
&lt;soap:address location=&quot;http://www.geektime.com:8080/purchaseOrder&quot; /&gt;
&lt;/wsdl:port&gt;
&lt;/wsdl:service&gt;
```
WSDL还是有些复杂的不过好在有工具可以生成。
对于某个服务,哪怕是一个陌生人,都可以通过在服务地址后面加上“?wsdl”来获取到这个文件但是这个文件还是比较复杂比较难以看懂。不过好在也有工具可以根据WSDL生成客户端Stub让客户端通过Stub进行远程调用就跟调用本地的方法一样。
### 服务发现问题
最后解决第三个问题,服务发现问题。
这里有一个**UDDI**Universal Description, Discovery, and Integration也即**统一描述、发现和集成协议**。它其实是一个注册中心服务提供方可以将上面的WSDL描述文件发布到这个注册中心注册完毕后服务使用方可以查找到服务的描述封装为本地的客户端进行调用。
## 小结
好了,这一节就到这里了,我们来总结一下。
<li>
原来的二进制RPC有很多缺点格式要求严格修改过于复杂不面向对象于是产生了基于文本的调用方式——基于XML的SOAP。
</li>
<li>
SOAP有三大要素协议约定用WSDL、传输协议用HTTP、服务发现用UDDL。
</li>
最后,给你留两个思考题:
<li>
对于HTTP协议来讲有多种方法但是SOAP只用了POST这样会有什么问题吗
</li>
<li>
基于文本的RPC虽然解决了二进制的问题但是SOAP还是有点复杂还有一种更便捷的接口规则你知道是什么吗
</li>
我们的专栏更新到第33讲不知你掌握得如何每节课后我留的思考题你都有没有认真思考并在留言区写下答案呢我会从**已发布的文章中选出一批认真留言的同学**,赠送学习奖励礼券和我整理的独家网络协议知识图谱。
欢迎你留言和我讨论。趣谈网络协议,我们下期见!

View File

@@ -0,0 +1,142 @@
<audio id="audio" title="第34讲 | 基于JSON的RESTful接口协议我不关心过程请给我结果" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7a/cb/7a7e5b3b70e60c69b8a533659260dacb.mp3"></audio>
上一节我们讲了基于XML的SOAP协议SOAP的S是啥意思来着是Simple但是好像一点儿都不简单啊
你会发现对于SOAP来讲无论XML中调用的是什么函数多是通过HTTP的POST方法发送的。但是咱们原来学HTTP的时候我们知道HTTP除了POST还有PUT、DELETE、GET等方法这些也可以代表一个个动作而且基本满足增、删、查、改的需求比如增是POST删是DELETE查是GET改是PUT。
## 传输协议问题
对于SOAP来讲比如我创建一个订单用POST在XML里面写明动作是CreateOrder删除一个订单还是用POST在XML里面写明了动作是DeleteOrder。其实创建订单完全可以使用POST动作然后在XML里面放一个订单<!-- [[[read_end]]] -->的信息就可以了而删除用DELETE动作然后在XML里面放一个订单的ID就可以了。
于是上面的那个SOAP就变成下面这个简单的模样。
```
POST /purchaseOrder HTTP/1.1
Host: www.geektime.com
Content-Type: application/xml; charset=utf-8
Content-Length: nnn
&lt;?xml version=&quot;1.0&quot;?&gt;
&lt;order&gt;
&lt;date&gt;2018-07-01&lt;/date&gt;
&lt;className&gt;趣谈网络协议&lt;/className&gt;
&lt;Author&gt;刘超&lt;/Author&gt;
&lt;price&gt;68&lt;/price&gt;
&lt;/order&gt;
```
而且XML的格式也可以改成另外一种简单的文本化的对象表示格式JSON。
```
POST /purchaseOrder HTTP/1.1
Host: www.geektime.com
Content-Type: application/json; charset=utf-8
Content-Length: nnn
{
&quot;order&quot;: {
&quot;date&quot;: &quot;2018-07-01&quot;,
&quot;className&quot;: &quot;趣谈网络协议&quot;,
&quot;Author&quot;: &quot;刘超&quot;,
&quot;price&quot;: &quot;68&quot;
}
}
```
经常写Web应用的应该已经发现这就是RESTful格式的API的样子。
## 协议约定问题
然而RESTful可不仅仅是指API而是一种架构风格全称Representational State Transfer表述性状态转移来自一篇重要的论文《架构风格与基于网络的软件架构设计》Architectural Styles and the Design of Network-based Software Architectures
这篇文章从深层次更加抽象地论证了一个互联网应用应该有的设计要点而这些设计要点成为后来我们能看到的所有高并发应用设计都必须要考虑的问题再加上REST API比较简单直接所以后来几乎成为互联网应用的标准接口。
因此和SOAP不一样REST不是一种严格规定的标准它其实是一种设计风格。如果按这种风格进行设计RESTful接口和SOAP接口都能做到只不过后面的架构是REST倡导的而SOAP相对比较关注前面的接口。
而且由于能够通过WSDL生成客户端的Stub因而SOAP常常被用于类似传统的RPC方式也即调用远端和调用本地是一样的。
然而本地调用和远程跨网络调用毕竟不一样,这里的不一样还不仅仅是因为有网络而导致的客户端和服务端的分离,从而带来的网络性能问题。更重要的问题是,客户端和服务端谁来维护状态。所谓的状态就是对某个数据当前处理到什么程度了。
这里举几个例子,例如,我浏览到哪个目录了,我看到第几页了,我要买个东西,需要扣减一下库存,这些都是状态。本地调用其实没有人纠结这个问题,因为数据都在本地,谁处理都一样,而且一边处理了,另一边马上就能看到。
当有了RPC之后我们本来期望对上层透明就像上一节说的“远在天边尽在眼前”。于是使用RPC的时候对于状态的问题也没有太多的考虑。
就像NFS一样客户端会告诉服务端我要进入哪个目录服务端必须要为某个客户端维护一个状态就是当前这个客户端浏览到哪个目录了。例如客户端输入cd hello服务端要在某个地方记住上次浏览到/root/liuchao了因而客户的这次输入应该给它显示/root/liuchao/hello下面的文件列表。而如果有另一个客户端同样输入cd hello服务端也在某个地方记住上次浏览到/var/lib因而要给客户显示的是/var/lib/hello。
不光NFS如果浏览翻页我们经常要实现函数next()在一个列表中取下一页但是这就需要服务端记住客户端A上次浏览到2030页了那它调用next()应该显示3040页而客户端B上次浏览到100110页了调用next()应该显示110120页。
上面的例子都是在RPC场景下由服务端来维护状态很多SOAP接口设计的时候也常常按这种模式。这种模式原来没有问题是因为客户端和服务端之间的比例没有失衡。因为一般不会同时有太多的客户端同时连上来所以NFS还能把每个客户端的状态都记住。
公司内部使用的ERP系统如果使用SOAP的方式实现并且服务端为每个登录的用户维护浏览到报表那一页的状态由于一个公司内部的人也不会太多把ERP放在一个强大的物理机上也能记得过来。
但是互联网场景下,客户端和服务端就彻底失衡了。你可以想象“双十一”,多少人同时来购物,作为服务端,它能记得过来吗?当然不可能,只好多个服务端同时提供服务,大家分担一下。但是这就存在一个问题,服务端怎么把自己记住的客户端状态告诉另一个服务端呢?或者说,你让我给你分担工作,你也要把工作的前因后果给我说清楚啊!
那服务端索性就要想了,既然这么多客户端,那大家就分分工吧。服务端就只记录资源的状态,例如文件的状态,报表的状态,库存的状态,而客户端自己维护自己的状态。比如,你访问到哪个目录了啊,报表的哪一页了啊,等等。
这样对于API也有影响也就是说当客户端维护了自己的状态就不能这样调用服务端了。例如客户端说我想访问当前目录下的hello路径。服务端说我怎么知道你的当前路径。所以客户端要先看看自己当前路径是/root/liuchao然后告诉服务端说我想访问/root/liuchao/hello路径。
再比如客户端说我想访问下一页服务端说我怎么知道你当前访问到哪一页了。所以客户端要先看看自己访问到了100110页然后告诉服务器说我想访问110120页。
这就是服务端的无状态化。这样服务端就可以横向扩展了,一百个人一起服务,不用交接,每个人都能处理。
所谓的无状态其实是服务端维护资源的状态客户端维护会话的状态。对于服务端来讲只有资源的状态改变了客户端才调用POST、PUT、DELETE方法来找我如果资源的状态没变只是客户端的状态变了就不用告诉我了对于我来说都是统一的GET。
虽然这只改进了GET但是已经带来了很大的进步。因为对于互联网应用大多数是读多写少的。而且只要服务端的资源状态不变就给了我们缓存的可能。例如可以将状态缓存到接入层甚至缓存到CDN的边缘节点这都是资源状态不变的好处。
按照这种思路对于API的设计就慢慢变成了以资源为核心而非以过程为核心。也就是说客户端只要告诉服务端你想让资源状态最终变成什么样就可以了而不用告诉我过程不用告诉我动作。
还是文件目录的例子。客户端应该访问哪个绝对路径,而非一个动作,我就要进入某个路径。再如,库存的调用,应该查看当前的库存数目,然后减去购买的数量,得到结果的库存数。这个时候应该设置为目标库存数(但是当前库存数要匹配),而非告知减去多少库存。
这种API的设计需要实现幂等因为网络不稳定就会经常出错因而需要重试但是一旦重试就会存在幂等的问题也就是同一个调用多次调用的结果应该一样不能一次支付调用因为调用三次变成了支付三次。不能进入cd a做了三次就变成了cd a/a/a。也不能扣减库存调用了三次就扣减三次库存。
当然按照这种设计模式无论RESTful API还是SOAP API都可以将架构实现成无状态的面向资源的、幂等的、横向扩展的、可缓存的。
但是SOAP的XML正文中是可以放任何动作的。例如XML里面可以写&lt; ADD &gt;&lt; MINUS &gt;等。这就方便使用SOAP的人将大量的动作放在API里面。
RESTful没这么复杂也没给客户提供这么多的可能性正文里的JSON基本描述的就是资源的状态没办法描述动作而且能够出发的动作只有CRUD也即POST、GET、PUT、DELETE也就是对于状态的改变。
所以从接口角度就让你死了这条心。当然也有很多技巧的方法在使用RESTful API的情况下依然提供基于动作的有状态请求这属于反模式了。
## 服务发现问题
对于RESTful API来讲我们已经解决了传输协议的问题——基于HTTP协议约定问题——基于JSON最后要解决的是服务发现问题。
有个著名的基于RESTful API的跨系统调用框架叫Spring Cloud。在Spring Cloud中有一个组件叫 Eureka。传说阿基米德在洗澡时发现浮力原理高兴得来不及穿上裤子跑到街上大喊“Eureka我找到了”所以Eureka是用来实现注册中心的负责维护注册的服务列表。
服务分服务提供方它向Eureka做服务注册、续约和下线等操作注册的主要数据包括服务名、机器IP、端口号、域名等等。
另外一方是服务消费方向Eureka获取服务提供方的注册信息。为了实现负载均衡和容错服务提供方可以注册多个。
当消费方要调用服务的时候会从注册中心读出多个服务来那怎么调用呢当然是RESTful方式了。
Spring Cloud提供一个RestTemplate工具用于将请求对象转换为JSON并发起Rest调用RestTemplate的调用也是分POST、PUT、GET、 DELETE的当结果返回的时候根据返回的JSON解析成对象。
通过这样封装,调用起来也很方便。
## 小结
好了,这一节就到这里了,我们来总结一下。
<li>
SOAP过于复杂而且设计是面向动作的因而往往因为架构问题导致并发量上不去。
</li>
<li>
RESTful不仅仅是一个API而且是一种架构模式主要面向资源提供无状态服务有利于横向扩展应对高并发。
</li>
最后,给你留两个思考题:
<li>
在讨论RESTful模型的时候举了一个库存的例子但是这种方法有很大问题那你知道为什么要这样设计吗
</li>
<li>
基于文本的RPC虽然解决了二进制的问题但是它本身也有问题你能举出一些例子吗
</li>
我们的专栏更新到第34讲不知你掌握得如何每节课后我留的思考题你都有没有认真思考并在留言区写下答案呢我会从**已发布的文章中选出一批认真留言的同学**,赠送学习奖励礼券和我整理的独家网络协议知识图谱。
欢迎你留言和我讨论。趣谈网络协议,我们下期见!

View File

@@ -0,0 +1,207 @@
<audio id="audio" title="第35讲 | 二进制类RPC协议还是叫NBA吧总说全称多费劲" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/46/ea/46bc0b8723c669416304ffe550db3fea.mp3"></audio>
前面我们讲了两个常用文本类的RPC协议对于陌生人之间的沟通用NBA、CBA这样的缩略语会使得协议约定非常不方便。
在讲CDN和DNS的时候我们讲过接入层的设计对于静态资源或者动态资源静态化的部分都可以做缓存。但是对于下单、支付等交易场景还是需要调用API。
对于微服务的架构API需要一个API网关统一的管理。API网关有多种实现方式用Nginx或者OpenResty结合Lua脚本是常用的方式。在上一节讲过的Spring Cloud体系中有个组件Zuul也是干这个的。
## 数据中心内部是如何相互调用的?
API网关用来管理API但是API的实现一般在一个叫作**Controller层**的地方。这一层对外提供API。由于是让陌生人访问的我们能看到目前业界主流的基本都是RESTful的API是面向大规模互联网应用的。
<img src="https://static001.geekbang.org/resource/image/f0/b8/f08ef51889add2c26c57c9edd3db93b8.jpg" alt="">
在Controller之内就是咱们互联网应用的业务逻辑实现。上节讲RESTful的时候说过业务逻辑的实现最好是无状态的从而可以横向扩展但是资源的状态还需要服务端去维护。资源的状态不应该维护在业务逻辑层而是在最底层的持久化层一般会使用分布式数据库和ElasticSearch。
这些服务端的状态例如订单、库存、商品等都是重中之重都需要持久化到硬盘上数据不能丢但是由于硬盘读写性能差因而持久化层往往吞吐量不能达到互联网应用要求的吞吐量因而前面要有一层缓存层使用Redis或者memcached将请求拦截一道不能让所有的请求都进入数据库“中军大营”。
缓存和持久化层之上一般是**基础服务层**,这里面提供一些原子化的接口。例如,对于用户、商品、订单、库存的增删查改,将缓存和数据库对再上层的业务逻辑屏蔽一道。有了这一层,上层业务逻辑看到的都是接口,而不会调用数据库和缓存。因而对于缓存层的扩容,数据库的分库分表,所有的改变,都截止到这一层,这样有利于将来对于缓存和数据库的运维。
再往上就是**组合层**。因为基础服务层只是提供简单的接口,实现简单的业务逻辑,而复杂的业务逻辑,比如下单,要扣优惠券,扣减库存等,就要在组合服务层实现。
这样Controller层、组合服务层、基础服务层就会相互调用这个调用是在数据中心内部的量也会比较大还是使用RPC的机制实现的。
由于服务比较多,需要一个单独的注册中心来做服务发现。服务提供方会将自己提供哪些服务注册到注册中心中去,同时服务消费方订阅这个服务,从而可以对这个服务进行调用。
调用的时候有一个问题这里的RPC调用应该用二进制还是文本类其实文本的最大问题是占用字节数目比较多。比如数字123其实本来二进制8位就够了但是如果变成文本就成了字符串123。如果是UTF-8编码的话就是三个字节如果是UTF-16就是六个字节。同样的信息要多费好多的空间传输起来也更加占带宽时延也高。
因而对于数据中心内部的相互调用,很多公司选型的时候,还是希望采用更加省空间和带宽的二进制的方案。
这里一个著名的例子就是Dubbo服务化框架二进制的RPC方式。
<img src="https://static001.geekbang.org/resource/image/c6/c2/c622af64f47e264453088e79c3e631c2.jpg" alt="">
Dubbo会在客户端的本地启动一个Proxy其实就是客户端的Stub对于远程的调用都通过这个Stub进行封装。
接下来Dubbo会从注册中心获取服务端的列表根据路由规则和负载均衡规则在多个服务端中选择一个最合适的服务端进行调用。
调用服务端的时候首先要进行编码和序列化形成Dubbo头和序列化的方法和参数。将编码好的数据交给网络客户端进行发送网络服务端收到消息后进行解码。然后将任务分发给某个线程进行处理在线程中会调用服务端的代码逻辑然后返回结果。
这个过程和经典的RPC模式何其相似啊
## 如何解决协议约定问题?
接下来我们还是来看RPC的三大问题其中注册发现问题已经通过注册中心解决了。下面我们就来看协议约定问题。
Dubbo中默认的RPC协议是Hessian2。为了保证传输的效率Hessian2将远程调用序列化为二进制进行传输并且可以进行一定的压缩。这个时候你可能会疑惑同为二进制的序列化协议Hessian2和前面的二进制的RPC有什么区别呢这不绕了一圈又回来了吗
Hessian2是解决了一些问题的。例如原来要定义一个协议文件然后通过这个文件生成客户端和服务端的Stub才能进行相互调用这样使得修改就会不方便。Hessian2不需要定义这个协议文件而是自描述的。什么是自描述呢
所谓自描述就是关于调用哪个函数参数是什么另一方不需要拿到某个协议文件、拿到二进制靠它本身根据Hessian2的规则就能解析出来。
原来有协议文件的场景有点儿像两个人事先约定好0表示方法add然后后面会传两个数。服务端把两个数加起来这样一方发送012另一方知道是将1和2加起来但是不知道协议文件的当它收到012的时候完全不知道代表什么意思。
而自描述的场景就像两个人说的每句话都带前因后果。例如传递的是“函数add第一个参数1第二个参数2”。这样无论谁拿到这个表述都知道是什么意思。但是只不过都是以二进制的形式编码的。这其实相当于综合了XML和二进制共同优势的一个协议。
Hessian2是如何做到这一点的呢这就需要去看Hessian2的序列化的[语法描述文件](http://hessian.caucho.com/doc/hessian-serialization.html)。
<img src="https://static001.geekbang.org/resource/image/61/66/618bad147f6933f61ef56cf73d671166.jpg" alt="">
看起来很复杂,编译原理里面是有这样的语法规则的。
我们从Top看起下一层是value直到形成一棵树。这里面的有个思想为了防止歧义每一个类型的起始数字都设置成为独一无二的。这样解析的时候看到这个数字就知道后面跟的是什么了。
这里还是以加法为例子“add(2,3)”被序列化之后是什么样的呢?
```
H x02 x00 # Hessian 2.0
C # RPC call
x03 add # method &quot;add&quot;
x92 # two arguments
x92 # 2 - argument 1
x93 # 3 - argument 2
```
<li>
H开头表示使用的协议是HessionH的二进制是0x48。
</li>
<li>
C开头表示这是一个RPC调用。
</li>
<li>
0x03表示方法名是三个字符。
</li>
<li>
0x92表示有两个参数。其实这里存的应该是2之所以加上0x90就是为了防止歧义表示这里一定是一个int。
</li>
<li>
第一个参数是2编码为0x92第二个参数是3编码为0x93。
</li>
这个就叫作**自描述**。
另外Hessian2是面向对象的可以传输一个对象。
```
class Car {
String color;
String model;
}
out.writeObject(new Car(&quot;red&quot;, &quot;corvette&quot;));
out.writeObject(new Car(&quot;green&quot;, &quot;civic&quot;));
---
C # object definition (#0)
x0b example.Car # type is example.Car
x92 # two fields
x05 color # color field name
x05 model # model field name
O # object def (long form)
x90 # object definition #0
x03 red # color field value
x08 corvette # model field value
x60 # object def #0 (short form)
x05 green # color field value
x05 civic # model field value
```
首先定义这个类。对于类型的定义也传过去因而也是自描述的。类名为example.Car字符长11位因而前面长度为0x0b。有两个成员变量一个是color一个是model字符长5位因而前面长度0x05,。
然后传输的对象引用这个类。由于类定义在位置0因而对象会指向这个位置0编码为0x90。后面red和corvette是两个成员变量的值字符长分别为3和8。
接着又传输一个属于相同类的对象。这时候就不保存对于类的引用了只保存一个0x60表示同上就可以了。
可以看出Hessian2真的是能压缩尽量压缩多一个Byte都不传。
## 如何解决RPC传输问题
接下来我们再来看Dubbo的RPC传输问题。前面我们也说了基于Socket实现一个高性能的服务端是很复杂的一件事情在Dubbo里面使用了Netty的网络传输框架。
Netty是一个非阻塞的基于事件的网络传输框架在服务端启动的时候会监听一个端口并注册以下的事件。
<li>
**连接事件**当收到客户端的连接事件时会调用void connected(Channel channel) 方法。
</li>
<li>
当**可写事件**触发时会调用void sent(Channel channel, Object message),服务端向客户端返回响应数据。
</li>
<li>
当**可读事件**触发时会调用void received(Channel channel, Object message) ,服务端在收到客户端的请求数据。
</li>
<li>
当**发生异常**时会调用void caught(Channel channel, Throwable exception)。
</li>
当事件触发之后,服务端在这些函数中的逻辑,可以选择直接在这个函数里面进行操作,还是将请求分发到线程池去处理。一般异步的数据读写都需要另外的线程池参与,在线程池中会调用真正的服务端业务代码逻辑,返回结果。
Hessian2是Dubbo默认的RPC序列化方式当然还有其他选择。例如Dubbox从Spark那里借鉴Kryo实现高性能的序列化。
到这里我们说了数据中心里面的相互调用。为了高性能大家都愿意用二进制但是为什么后期Spring Cloud又兴起了呢这是因为并发量越来越大已经到了微服务的阶段。同原来的SOA不同微服务粒度更细模块之间的关系更加复杂。
在上面的架构中如果使用二进制的方式进行序列化虽然不用协议文件来生成Stub但是对于接口的定义以及传的对象DTO还是需要共享JAR。因为只有客户端和服务端都有这个JAR才能成功地序列化和反序列化。
但当关系复杂的时候JAR的依赖也变得异常复杂难以维护而且如果在DTO里加一个字段双方的JAR没有匹配好也会导致序列化不成功而且还有可能循环依赖。这个时候一般有两种选择。
第一种,建立严格的项目管理流程。
<li>
不允许循环调用,不允许跨层调用,只准上层调用下层,不允许下层调用上层。
</li>
<li>
接口要保持兼容性,不兼容的接口新添加而非改原来的,当接口通过监控,发现不用的时候,再下掉。
</li>
<li>
升级的时候,先升级服务提供端,再升级服务消费端。
</li>
第二种改用RESTful的方式。
<li>
使用Spring Cloud消费端和提供端不用共享JAR各声明各的只要能变成JSON就行而且JSON也是比较灵活的。
</li>
<li>
使用RESTful的方式性能会降低所以需要通过横向扩展来抵消单机的性能损耗。
</li>
这个时候,就看架构师的选择喽!
## 小结
好了,这节就到这里了,我们来总结一下。
<li>
RESTful API对于接入层和Controller层之外的调用已基本形成事实标准但是随着内部服务之间的调用越来越多性能也越来越重要于是Dubbo的RPC框架有了用武之地。
</li>
<li>
Dubbo通过注册中心解决服务发现问题通过Hessian2序列化解决协议约定的问题通过Netty解决网络传输的问题。
</li>
<li>
在更加复杂的微服务场景下Spring Cloud的RESTful方式在内部调用也会被考虑主要是JAR包的依赖和管理问题。
</li>
最后,给你留两个思考题。
<li>
对于微服务模式下的RPC框架的选择Dubbo和SpringCloud各有优缺点你能做个详细的对比吗
</li>
<li>
到目前为止我们讲过的RPC还没有跨语言调用的场景你知道如果跨语言应该怎么办吗
</li>
我们的专栏更新到第35讲不知你掌握得如何每节课后我留的思考题你都有没有认真思考并在留言区写下答案呢我会从**已发布的文章中选出一批认真留言的同学**,赠送学习奖励礼券和我整理的独家网络协议知识图谱。
欢迎你留言和我讨论。趣谈网络协议,我们下期见!

View File

@@ -0,0 +1,214 @@
<audio id="audio" title="第36讲 | 跨语言类RPC协议交流之前双方先来个专业术语表" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fc/ce/fc48ac4a5474ab681c2d9e50118924ce.mp3"></audio>
到目前为止咱们讲了四种RPC分别是ONC RPC、基于XML的SOAP、基于JSON的RESTful和Hessian2。
通过学习,我们知道,二进制的传输性能好,文本类的传输性能差一些;二进制的难以跨语言,文本类的可以跨语言;要写协议文件的严谨一些,不写协议文件的灵活一些。虽然都有服务发现机制,有的可以进行服务治理,有的则没有。
我们也看到了RPC从最初的客户端服务器模式最终演进到微服务。对于RPC框架的要求越来越多了具体有哪些要求呢
<li>
首先,传输性能很重要。因为服务之间的调用如此频繁了,还是二进制的越快越好。
</li>
<li>
其次,跨语言很重要。因为服务多了,什么语言写成的都有,而且不同的场景适宜用不同的语言,不能一个语言走到底。
</li>
<li>
最好既严谨又灵活,添加个字段不用重新编译和发布程序。
</li>
<li>
最好既有服务发现也有服务治理就像Dubbo和Spring Cloud一样。
</li>
## Protocol Buffers
这是要多快好省地建设社会主义啊。理想还是要有的嘛这里我就来介绍一个向“理想”迈进的GRPC。
GRPC首先满足二进制和跨语言这两条二进制说明压缩效率高跨语言说明更灵活。但是又是二进制又是跨语言这就相当于两个人沟通你不但说方言还说缩略语人家怎么听懂呢所以最好双方弄一个协议约定文件里面规定好双方沟通的专业术语这样沟通就顺畅多了。
对于GRPC来讲二进制序列化协议是Protocol Buffers。首先需要定义一个协议文件.proto。
我们还看买极客时间专栏的这个例子。
```
syntax = “proto3”;
package com.geektime.grpc
option java_package = “com.geektime.grpc”;
message Order {
required string date = 1;
required string classname = 2;
required string author = 3;
required int price = 4;
}
message OrderResponse {
required string message = 1;
}
service PurchaseOrder {
rpc Purchase (Order) returns (OrderResponse) {}
}
```
在这个协议文件中我们首先指定使用proto3的语法然后我们使用Protocol Buffers的语法定义两个消息的类型一个是发出去的参数一个是返回的结果。里面的每一个字段例如date、classname、author、price都有唯一的一个数字标识这样在压缩的时候就不用传输字段名称了只传输这个数字标识就行了能节省很多空间。
最后定义一个Service里面会有一个RPC调用的声明。
无论使用什么语言都有相应的工具生成客户端和服务端的Stub程序这样客户端就可以像调用本地一样调用远程的服务了。
## 协议约定问题
Protocol Buffers是一款压缩效率极高的序列化协议有很多设计精巧的序列化方法。
对于int类型32位的一般都需要4个Byte进行存储。在Protocol Buffers中使用的是变长整数的形式。对于每一个Byte的8位最高位都有特殊的含义。
如果该位为 1表示这个数字没完后续的Byte也属于这个数字如果该位为 0则这个数字到此结束。其他的7个Bit才是用来表示数字的内容。因此小于128的数字都可以用一个Byte表示大于128的数字比如130会用两个字节来表示。
对于每一个字段使用的是TLVTagLengthValue的存储办法。
其中Tag = (field_num &lt;&lt; 3) | wire_type。field_num就是在proto文件中给每个字段指定唯一的数字标识而wire_type用于标识后面的数据类型。
<img src="https://static001.geekbang.org/resource/image/a6/10/a66aa9ca6c6575f4b335881ae786ba10.jpg" alt="">
例如对于string author = 3在这里field_num为3string的wire_type为2于是 (field_num &lt;&lt; 3) | wire_type = (11000) | 10 = 11010 = 26接下来是Length最后是Value为“liuchao”如果使用UTF-8编码长度为7个字符因而Length为7。
可见在序列化效率方面Protocol Buffers简直做到了极致。
在灵活性方面,这种基于协议文件的二进制压缩协议往往存在更新不方便的问题。例如,客户端和服务器因为需求的改变需要添加或者删除字段。
这一点上Protocol Buffers考虑了兼容性。在上面的协议文件中每一个字段都有修饰符。比如
<li>
required这个值不能为空一定要有这么一个字段出现
</li>
<li>
optional可选字段可以设置也可以不设置如果不设置则使用默认值
</li>
<li>
repeated可以重复0到多次。
</li>
如果我们想修改协议文件对于赋给某个标签的数字例如string author=3这个就不要改变了改变了就不认了也不要添加或者删除required字段因为解析的时候发现没有这个字段就会报错。对于optional和repeated字段可以删除也可以添加。这就给了客户端和服务端升级的可能性。
例如我们在协议里面新增一个string recommended字段表示这个课程是谁推荐的就将这个字段设置为optional。我们可以先升级服务端当客户端发过来消息的时候是没有这个值的将它设置为一个默认值。我们也可以先升级客户端当客户端发过来消息的时候是有这个值的那它将被服务端忽略。
至此,我们解决了协议约定的问题。
## 网络传输问题
接下来,我们来看网络传输的问题。
如果是Java技术栈GRPC的客户端和服务器之间通过Netty Channel作为数据通道每个请求都被封装成HTTP 2.0的Stream。
Netty是一个高效的基于异步IO的网络传输框架这个上一节我们已经介绍过了。HTTP 2.0在[第14讲](https://time.geekbang.org/column/article/9410)我们也介绍过。HTTP 2.0协议将一个TCP的连接切分成多个流每个流都有自己的ID而且流是有优先级的。流可以是客户端发往服务端也可以是服务端发往客户端。它其实只是一个虚拟的通道。
HTTP 2.0还将所有的传输信息分割为更小的消息和帧,并对它们采用二进制格式编码。
通过这两种机制HTTP 2.0的客户端可以将多个请求分到不同的流中,然后将请求内容拆成帧,进行二进制传输。这些帧可以打散乱序发送, 然后根据每个帧首部的流标识符重新组装,并且可以根据优先级,决定优先处理哪个流的数据。
<img src="https://static001.geekbang.org/resource/image/03/dd/03d4a216c024a9e761ed43c6787bf7dd.jpg" alt="">
由于基于HTTP 2.0GRPC和其他的RPC不同可以定义四种服务方法。
第一种,也是最常用的方式是**单向RPC**,即客户端发送一个请求给服务端,从服务端获取一个应答,就像一次普通的函数调用。
```
rpc SayHello(HelloRequest) returns (HelloResponse){}
```
第二种方式是**服务端流式RPC**,即服务端返回的不是一个结果,而是一批。客户端发送一个请求给服务端,可获取一个数据流用来读取一系列消息。客户端从返回的数据流里一直读取,直到没有更多消息为止。
```
rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse){}
```
第三种方式为**客户端流式RPC**,也即客户端的请求不是一个,而是一批。客户端用提供的一个数据流写入并发送一系列消息给服务端。一旦客户端完成消息写入,就等待服务端读取这些消息并返回应答。
```
rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse) {}
```
第四种方式为**双向流式 RPC**,即两边都可以分别通过一个读写数据流来发送一系列消息。这两个数据流操作是相互独立的,所以客户端和服务端能按其希望的任意顺序读写,服务端可以在写应答前等待所有的客户端消息,或者它可以先读一个消息再写一个消息,或者读写相结合的其他方式。每个数据流里消息的顺序会被保持。
```
rpc BidiHello(stream HelloRequest) returns (stream HelloResponse){}
```
如果基于HTTP 2.0,客户端和服务器之间的交互方式要丰富得多,不仅可以单方向远程调用,还可以实现当服务端状态改变的时候,主动通知客户端。
至此,传输问题得到了解决。
## 服务发现与治理问题
最后是服务发现与服务治理的问题。
GRPC本身没有提供服务发现的机制需要借助其他的组件发现要访问的服务端在多个服务端之间进行容错和负载均衡。
其实负载均衡本身比较简单LVS、HAProxy、Nginx都可以做关键问题是如何发现服务端并根据服务端的变化动态修改负载均衡器的配置。
在这里我们介绍一种对于GRPC支持比较好的负载均衡器Envoy。其实Envoy不仅仅是负载均衡器它还是一个高性能的C++写的Proxy转发器可以配置非常灵活的转发规则。
这些规则可以是静态的放在配置文件中的在启动的时候加载。要想重新加载一般需要重新启动但是Envoy支持热加载和热重启这在一定程度上缓解了这个问题。
当然最好的方式是将规则设置为动态的放在统一的地方维护。这个统一的地方在Envoy眼中被称为服务发现Discovery Service过一段时间去这里拿一下配置就修改了转发策略。
无论是静态的,还是动态的,在配置里面往往会配置四个东西。
第一个是listener。Envoy既然是Proxy专门做转发就得监听一个端口接入请求然后才能够根据策略转发这个监听的端口就称为listener。
第二个是endpoint是目标的IP地址和端口。这个是Proxy最终将请求转发到的地方。
第三个是cluster。一个cluster是具有完全相同行为的多个endpoint也即如果有三个服务端在运行就会有三个IP和端口但是部署的是完全相同的三个服务它们组成一个cluster从cluster到endpoint的过程称为负载均衡可以轮询。
第四个是route。有时候多个cluster具有类似的功能但是是不同的版本号可以通过route规则选择将请求路由到某一个版本号也即某一个cluster。
如果是静态的则将后端的服务端的IP地址拿到然后放在配置文件里面就可以了。
如果是动态的就需要配置一个服务发现中心这个服务发现中心要实现Envoy的APIEnvoy可以主动去服务发现中心拉取转发策略。
<img src="https://static001.geekbang.org/resource/image/ef/ce/ef916f46dc293ac2d5739b496f0b27ce.jpg" alt="">
看来Envoy进程和服务发现中心之间要经常相互通信互相推送数据所以Envoy在控制面和服务发现中心沟通的时候就可以使用GRPC也就天然具备在用户面支撑GRPC的能力。
Envoy如果复杂的配置都能干什么事呢
一种常见的规则是**配置路由策略**。例如后端的服务有两个版本可以通过配置Envoy的route来设置两个版本之间也即两个cluster之间的route规则一个占99%的流量一个占1%的流量。
另一种常见的规则就是**负载均衡策略**。对于一个cluster下的多个endpoint可以配置负载均衡机制和健康检查机制当服务端新增了一个或者挂了一个都能够及时配置Envoy进行负载均衡。
<img src="https://static001.geekbang.org/resource/image/50/3c/50443d6848f890e475e71be11489d33c.jpg" alt="">
所有这些节点的变化都会上传到注册中心,所有这些策略都可以通过注册中心进行下发,所以,更严格的意义上讲,注册中心可以称为**注册治理中心**。
Envoy这么牛是不是能够将服务之间的相互调用全部由它代理如果这样服务也不用像Dubbo或者Spring Cloud一样自己感知到注册中心自己注册自己治理对应用干预比较大。
如果我们的应用能够意识不到服务治理的存在就可以直接进行GRPC的调用。
这就是未来服务治理的趋势**Serivce Mesh**也即应用之间的相互调用全部由Envoy进行代理服务之间的治理也被Envoy进行代理完全将服务治理抽象出来到平台层解决。
<img src="https://static001.geekbang.org/resource/image/15/02/15e254a8e92e031b20feb6ebdcc32402.jpg" alt="">
至此RPC框架中有治理功能的Dubbo、Spring Cloud、Service Mesh就聚齐了。
## 小结
好了,这一节就到这里了,我们来总结一下。
<li>
GRPC是一种二进制性能好跨语言还灵活同时可以进行服务治理的多快好省的RPC框架唯一不足就是还是要写协议文件。
</li>
<li>
GRPC序列化使用Protocol Buffers网络传输使用HTTP 2.0服务治理可以使用基于Envoy的Service Mesh。
</li>
最后,给你留一个思考题吧。
在讲述Service Mesh的时候我们说了希望Envoy能够在服务不感知的情况下将服务之间的调用全部代理了你知道怎么做到这一点吗
我们《趣谈网络协议》专栏已经接近尾声了。你还记得专栏开始,我们讲过的那个“双十一”下单的故事吗?
下节开始,我会将这个过程涉及的网络协议细节,全部串联起来,给你还原一个完整的网络协议使用场景。信息量会很大,做好准备哦,我们下期见!