This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
<audio id="audio" title="29 | 流计算与消息通过Flink理解流计算的原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/41/c48b2a303aebccc614870b56c026ce41.mp3"></audio>
你好,我是李玥。
在上节课中,我简单地介绍了消息队列和流计算的相关性。在生产中,消息队列和流计算往往是相互配合,一起来使用的。而流计算也是后端程序员技术栈中非常重要的一项技术。在接下来的两节课中,我们一起通过两个例子来实际演练一下,如何使用消息队列配合流计算框架实现一些常用的流计算任务。
这节课我们一起来基于Flink实现一个流计算任务通过这个例子来感受一下流计算的好处同时我还会给你讲解流计算框架的实现原理。下一节课中我们会把本节课中的例子升级改造使用Kafka配合Flink来实现Exactly Once语义确保数据在计算过程中不重不丢。
无论你之前是否接触过像Storm、Flink或是Spark这些流计算框架都没有关系因为我们已经学习了消息队列的实现原理以及实现消息队列必备的像异步网络传输、序列化这些知识。在掌握了这些知识和底层的原理之后再来学习和理解流计算框架的实现原理你会发现事情就变得非常简单了。
为什么这么说,一个原因是,对于很多中间件或者说基础框架这类软件来说,它们用到很多底层的技术都是一样;另外一个原因是,流计算和消息队列处理的都实时的、流动的数据,很多处理流数据的方法也是一样的。
## 哪些问题适合用流计算解决?
首先,我们来说一下,哪些问题适合用流计算来解决?或者说,流计算它的应用场景是什么样的呢?
在这里,我用一句话来回答这个问题:**对实时产生的数据进行实时统计分析,这类场景都适合使用流计算来实现。 **
你在理解这句话的时候,需要特别注意的是,这里面有两个“实时”,一个是说,数据是“实时”产生的,另一个是说,统计分析这个过程是“实时”进行的,统计结果也是第一时间就计算出来了。对于这样的场景,你都可以考虑使用流计算框架。
因为流计算框架可以自动地帮我们实现实时的并行计算性能非常好并且内置了很多常用的统计分析的算子比如TimeWindow、GroupBy、Sum和Count所以非常适合用来做实时的统计和分析。举几个例子
- 每分钟按照IP统计Web请求次数
- 电商在大促时,实时统计当前下单量;
- 实时统计App中的埋点数据分析营销推广效果。
以上这些场景,以及和这些场景类似的场景,都是比较适合用流计算框架来实现的。特别是基于时间维度的统计分析,使用流计算框架来实现是非常方便的。
## 用代码定义Job并在Flink中执行
接下来我们用Flink来实现一个实时统计任务接收NGINX的access.log每5秒钟按照IP地址统计Web请求的次数。这个统计任务它一个非常典型的按照Key来进行分类汇总的统计任务并且汇总是按照一定周期来实时进行的我们日常工作中遇到的很多统计分析类的需求都可以套用这个例子的模式来实现所以我们就以它为例来做一个实现。
假设我们已经有一个实时发送access.log的日志服务它运行在本地的9999端口上只要有客户端连接上来他就会通过Socket给客户端发送实时的访问日志日志的内容只包含访问时间和IP地址每条数据的结尾用一个换行符(\n)作为分隔符。这个日志服务就是我们流计算任务的数据源。
我们用NetCat连接到这个服务上看一下数据格式
```
$nc localhost 9999
14:37:11 192.168.1.3
14:37:11 192.168.1.2
14:37:12 192.168.1.4
14:37:14 192.168.1.2
14:37:14 192.168.1.4
14:37:14 192.168.1.3
...
```
接下来我们用Scala语言和Flink来实现这个流计算任务。你可以先不用关心如何部署启动Flink如何设置开发环境这些问题一起来跟我看一下定义这个流计算任务的代码
```
object SocketWindowIpCount {
def main(args: Array[String]) : Unit = {
// 获取运行时环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
// 按照EventTime来统计
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
// 设置并行度
env.setParallelism(4)
// 定义输入从Socket端口中获取数据输入
val hostname: String = &quot;localhost&quot;
val port: Int = 9999
// Task 1
val input: DataStream[String] = env.socketTextStream(hostname, port, '\n')
// 数据转换将非结构化的以空格分隔的文本转成结构化数据IpAndCount
// Task 2
input
.map { line =&gt; line.split(&quot;\\s&quot;) }
.map { wordArray =&gt; IpAndCount(new SimpleDateFormat(&quot;HH:mm:ss&quot;).parse(wordArray(0)), wordArray(1), 1) }
// 计算每5秒钟按照ip对count求和
.assignAscendingTimestamps(_.date.getTime) // 告诉Flink时间从哪个字段中获取
.keyBy(&quot;ip&quot;) // 按照ip地址统计
// Task 3
.window(TumblingEventTimeWindows.of(Time.seconds(5))) // 每5秒钟统计一次
.sum(&quot;count&quot;) // 对count字段求和
// 输出:转换格式,打印到控制台上
.map { aggData =&gt; new SimpleDateFormat(&quot;HH:mm:ss&quot;).format(aggData.date) + &quot; &quot; + aggData.ip + &quot; &quot; + aggData.count }
.print()
env.execute(&quot;Socket Window IpCount&quot;)
}
/** 中间数据结构 */
case class IpAndCount(date: Date, ip: String, count: Long)
}
```
我来给你解读一下这段代码。
首先需要获取流计算的运行时环境也就是这个env对象对env做一些初始化的设置。然后我们再定义输入的数据源这里面就是我刚刚讲的运行在9999端口上的日志服务。
在代码中env.socketTextStream(hostname, port, \n)这个语句中的三个参数分别是主机名、端口号和分隔符返回值的数据类型是DataStream[String]代表一个数据流其中的每条数据都是String类型的。它告诉Flink我们的数据源是一个Socket服务。这样Flink在执行这个计算任务的时候就会去连接日志服务来接收数据。
定义完数据源之后需要做一些数据转换把字符串转成结构化的数据IpAndCount便于后续做计算。在定义计算的部分依次告诉Flink时间从date字段中获取按照IP地址进行汇总每5秒钟汇总一次汇总方式就是对count字段求和。
之后定义计算结果如何输出,在这个例子中,我们直接把结果打印到控制台上就好了。
这样就完成了一个流计算任务的定义。可以看到,定义一个计算任务的代码还是非常简单的,如果我们要自己写一个分布式的统计程序来实现一样的功能,代码量和复杂度肯定要远远超过上面这段代码。
总结下来无论是使用Flink、Spark还是其他的流计算框架定义一个流计算的任务基本上都可以分为定义输入、定义计算逻辑和定义输出三部分通俗地说也就是**数据从哪儿来,怎么计算,结果写到哪儿去**,这三件事儿。
我把这个例子的代码上传到了GitHub上你可以在[这里](https://github.com/liyue2008/IpCount)下载关于如何设置环境、编译并运行这个例子我在代码中的README中都给出了说明你可以下载查看。
执行计算任务打印出的计算结果是这样的:
```
1&gt; 18:40:10 192.168.1.2 23
4&gt; 18:40:10 192.168.1.4 16
4&gt; 18:40:15 192.168.1.4 27
3&gt; 18:40:15 192.168.1.3 23
1&gt; 18:40:15 192.168.1.2 25
4&gt; 18:40:15 192.168.1.1 21
1&gt; 18:40:20 192.168.1.2 21
3&gt; 18:40:20 192.168.1.3 31
4&gt; 18:40:20 192.168.1.1 25
4&gt; 18:40:20 192.168.1.4 26
```
对于流计算的初学者,特别不好理解的一点是,我们上面编写的这段代码,**它只是“用来定义计算任务的代码”,而不是“真正处理数据的代码”。**对于普通的应用程序源代码编译之后计算机就直接执行了这个比较好理解。而在Flink中当这个计算任务在Flink集群的计算节点中运行的时候真正处理数据的代码并不是我们上面写的那段代码而是Flink在解析了计算任务之后动态生成的代码。
这个有点儿类似于我们在查询MySQL的时候执行的SQL我们提交一个SQL查询后MySQL在执行查询遍历数据库中每条数据时并不是对每条数据执行一遍SQL真正执行的其实是MySQL自己的代码。SQL只是告诉MySQL我们要如何来查询数据同样我们编写的这段定义计算任务的代码只是告诉Flink我们要如何来处理数据而已。
## Job是如何在Flink集群中执行的
那我们的计算任务是如何在Flink中执行的呢在讲解这个问题之前我们先简单看一下Flink集群在运行时的架构。
下面这张图来自于[Flink的官方文档](https://github.com/liyue2008/IpCount)。
<img src="https://static001.geekbang.org/resource/image/91/78/91fd3d493f1b7e3e18224d9a8ba33678.png" alt="">
这张图稍微有点儿复杂我们先忽略细节看整体。Flink的集群和其他分布式系统都是类似的集群的大部分节点都是TaskManager节点每个节点就是一个Java进程负责执行计算任务。另外一种节点是JobManager节点它负责管理和协调所有的计算节点和计算任务同时客户端和Web控制台也是通过JobManager来提交和管理每个计算任务的。
我们编写好计算任务的代码后打包成JAR文件然后通过Flink的客户端提交到JobManager上。计算任务被Flink解析后会生成一个Dataflow Graph也叫JobGraph简称DAG这是一个有向无环图DAG比如我们的这个例子它生成的DAG是这样的
<img src="https://static001.geekbang.org/resource/image/4f/f6/4f8358c8f2ca8b2ed57dfa8bc7aa4cf6.png" alt="">
图中的每个节点是一个Task每个Task就是一个执行单元运行在某一个TaskManager的进程内。你可以想象一下就像电流流过电路图一样数据从Source Task流入进入这个DAG每流过一个Task就被这个Task做一些计算和变换然后数据继续流入下一个Task直到最后一个Sink Task流出DAG就自然完成了计算。
对于图中的3个Task每个Task对应执行了什么计算完全可以和我们上面定义计算任务的源代码对应上我也在源代码的注释中用"//Task n"的形式给出了标注。第一个Task执行的计算很简单就是连接日志服务接收日志数据然后将日志数据发往下一个Task。第二个Task执行了两个map变换把文本数据转换成了结构化的数据并添加Watermark水印。Watermark这个概念可以先不用管主要是用于触发按时间汇总的操作。第三个Task执行了剩余的计算任务按时间汇总日志并输出打印到控制台上。
这个DAG仍然是一个逻辑图它到底是怎么在Flink集群中执行的呢你注意到图中每个Task都标注了一个Parallelism并行度的数字吗这个并行度的意思就是这个Task可以被多少个线程并行执行。比如图中的第二个任务它的并行度是4就代表Task在Flink集群中运行的时候会有4个线程都在执行这个Task每个线程就是一个SubTask子任务。注意如果Flink集群的节点数够多这4个SubTask可能会运行在不同的TaskManager节点上。
建立了SubTask的概念之后我们再重新回过头来看一下这个图中的两个箭头。第一个箭头连接前两个Task这个箭头标注了REBALANCE重新分配因为第一个Task并行度是1而第二个Task并行度是4意味着从第一个Task流出的数据将被重新分配给第二个Task的4个线程也就是4个SubTask子任务这样就实现了并行处理。这和消息队列中每个主题分成多个分区进行并行收发的设计思想是一样的。
再来看连接第二、第三这两个Task的箭头这个箭头上标注的是HASH为什么呢可以看到第二个Task中最后一步业务逻辑是keyBy(“ip”)也就是按照IP这个字段做一个HASH分流。你可以想一下第三个Task它的并行度是4也就是有4个线程在并行执行汇总。如果要统计每个IP的日志条数那必须得把相同IP的数据发送到同一个SubTask子任务中去这样在每个SubTask子任务对于每一条数据只要在对应IP汇总记录上进行累加就可以了。
反之要是相同IP的数据被分到多个SubTask子任务这些SubTask又可能分布在多个物理节点上那就没办法统计了。所以第二个Task会把数据按照IP地址做一个HASH分流保证IP相同的数据都发送到第三个Task中相同的SubTask子任务中。这个HASH分流的设计是不是感觉很眼熟我们之前课程中讲到的严格顺序消息的实现方法通过HASH算法让key相同的数据总是发送到相同的分区上来保证严格顺序和Flink这里的设计就是一样的。
最后在第三个Task中4个SubTask并行进行数据汇总每个SubTask负责汇总一部分IP地址的数据。最终打印到控制台上的时候也是4个线程并行打印。你可以回过头去看一下输出的计算结果每一行数据前面的数字就是第三个Task中SubTask的编号。
到这里我们不仅实现并运行了一个流计算任务也理清了任务在Flink集群中运行的过程。
## 小结
流计算框架适合对实时产生的数据进行实时统计分析。我们通过一个“按照IP地址统计Web请求的次数”的例子学习了Flink实现流计算任务的原理。首先我们用一段代码定义了计算任务把计算任务代码编译成JAR包后通过Flink客户端提交到JobManager上。
这里需要注意的是我们编写的代码只是用来定义计算任务和在Flink节点上执行的真正做实时计算的代码是不一样的。真正执行计算的代码是Flink在解析计算任务后动态生成的。
Flink分析计算任务之后生成JobGraphJobGraph是一个有向无环图数据流过这个图中的节点在每个节点进行计算和变换最终流出有向无环图就完成了计算。JobGraph中的每个节点是一个TaskTask是可以并行执行的每个线程就是一个SubTask。SubTask被JobManager分配给某个TaskManager在TaskManager进程中的一个线程中执行。
通过分析Flink的实现原理我们可以看到流计算框架本身并没有什么神奇的技术之所以能够做到非常好的性能主要有两个原因。一个是它能自动拆分计算任务来实现并行计算这个和Hadoop中Map Reduce的原理是一样的。另外一个原因是流计算框架中都内置了很多常用的计算和统计分析的算子这些算子的实现都是经过很多大神级程序员反复优化过的不仅能方便我们开发性能上也比大多数程序员自行实现要快很多。
## 思考题
我们在启动Flink集群之前修改了Flink的一个配置槽数taskmanager.numberOfTaskSlots。请你课后看一下Flink的文档搞清楚这个槽数的含义。然后再想一下这个槽数和我们在计算任务中定义的并行度又是什么关系呢
欢迎在留言区写下你的思考,如果有任何问题,也欢迎与我交流。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="30 | 流计算与消息在流计算中使用Kafka链接计算任务" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a5/08/a591eb84e7b8b045a5d06e6b4e209c08.mp3"></audio>
你好,我是李玥。
上节课我们一起实现了一个流计算的例子,并通过这个例子学习了流计算的实现原理。我们知道,流计算框架本身是个分布式系统,一般由多个节点组成一个集群。我们的计算任务在计算集群中运行的时候,会被拆分成多个子任务,这些子任务也是分布在集群的多个计算节点上的。
大部分流计算平台都会采用存储计算分离的设计将计算任务的状态保存在HDFS等分布式存储系统中。每个子任务将状态分离出去之后就变成了无状态的节点如果某一个计算节点发生宕机使用集群中任意一个节点都可以替代故障节点。
但是,对流计算来说,这里面还有一个问题没解决,就是在集群中流动的数据并没有被持久化,所以它们就有可能由于节点故障而丢失,怎么解决这个问题呢?办法也比较简单粗暴,就是直接重启整个计算任务,并且从数据源头向前回溯一些数据。计算任务重启之后,会重新分配计算节点,顺便就完成了故障迁移。
回溯数据源,可以保证数据不丢失,这和消息队列中,通过重发未成功的消息来保证数据不丢的方法是类似的。所以,它们面临同样的问题:可能会出现重复的消息。消息队列可以通过在消费端做幂等来克服这个问题,但是对于流计算任务来说,这个问题就很棘手了。
对于接收计算结果的下游系统它可能会收到重复的计算结果这还不是最糟糕的。像一些统计类的计算任务就会有比较大的影响比如上节课中统计访问次数的例子本来这个IP地址在统计周期内被访问了5次产生了5条访问日志正确的结果应该是5次。如果日志被重复统计那结果就会多于5次重复的数据导致统计结果出现了错误。怎么解决这个问题呢
我们之前提到过Kafka支持Exactly Once语义它的这个特性就是为了解决这个问题而生的。这节课我们就来通过一个例子学习一下如何使用Kafka配合Flink解决数据重复的问题实现端到端的Exactly Once语义。
## Flink是如何保证Exactly Once语义的
我们所说的端到端Exactly Once这里面的“端到端”指的是数据从Kafka的A主题消费发送给Flink的计算集群进行计算计算结果再发给Kafka的B主题。在这整个过程中无论是Kafka集群的节点还是Flink集群的节点发生故障都不会影响计算结果每条消息只会被计算一次不能多也不能少。
在理解端到端Exactly Once的实现原理之前我们需要先了解一下Flink集群本身是如何保证Exactly Once语义的。为什么Flink也需要保证Exactly Once呢Flink集群本身也是一个分布式系统它首先需要保证数据在Flink集群内部只被计算一次只有在这个基础上才谈得到端到端的Exactly Once。
Flink通过CheckPoint机制来定期保存计算任务的快照这个快照中主要包含两个重要的数据
1. 整个计算任务的状态。这个状态主要是计算任务中,每个子任务在计算过程中需要保存的临时状态数据。比如,上节课例子中汇总了一半的数据。
1. 数据源的位置信息。这个信息记录了在数据源的这个流中已经计算了哪些数据。如果数据源是Kafka的主题这个位置信息就是Kafka主题中的消费位置。
有了CheckPoint当计算任务失败重启的时候可以从最近的一个CheckPoint恢复计算任务。具体的做法是每个子任务先从CheckPoint中读取并恢复自己的状态然后整个计算任务从CheckPoint中记录的数据源位置开始消费数据只要这个恢复位置和CheckPoint中每个子任务的状态是完全对应的或者说每个子任务的状态恰好是“刚刚处理完恢复位置之前的那条数据还没有开始处理恢复位置对应的这条数据”这个时刻保存的状态就可以做到严丝合缝地恢复计算任务每一条数据既不会丢失也不会重复。
因为每个子任务分布在不同的节点上并且数据是一直在子任务中流动的所以确保CheckPoint中记录的恢复位置和每个子任务的状态完全对应并不是一件容易的事儿Flink是怎么实现的呢
Flink通过在数据流中插入一个Barrier屏障来确保CheckPoint中的位置和状态完全对应。下面这张图来自[Flink官网的说明文档](https://ci.apache.org/projects/flink/flink-docs-stable/internals/stream_checkpointing.html)。
<img src="https://static001.geekbang.org/resource/image/0c/fa/0c301d798341dc53515611c31e9031fa.png" alt="">
你可以把Barrier理解为一条特殊的数据。Barrier由Flink生成并在数据进入计算集群时被插入到数据流中。这样无限的数据流就被很多的Barrier分隔成很多段。Barrier在流经每个计算节点的时候就会触发这个节点在CheckPoint中保存本节点的状态如果这个节点是数据源节点还会保存数据源的位置。
当一个Barrier流过所有计算节点流出计算集群后一个CheckPoint也就保存完成了。由于每个节点都是在Barrier流过的时候保存的状态这时的状态恰好就是Barrier所在位置也就是CheckPoint数据源位置对应的状态这样就完美解决了状态与恢复位置对应的问题。
Flink通过CheckPoint机制实现了集群内计算任务的Exactly Once语义但是仍然实现不了在输入和输出两端数据不丢不重。比如Flink在把一条计算结果发给Kafka并收到来自Kafka的“发送成功”响应之后才会继续处理下一条数据。如果这个时候重启计算任务Flink集群内的数据都可以完美地恢复到上一个CheckPoint但是已经发给Kafka的消息却没办法撤回还是会出现数据重复的问题。
所以我们需要配合Kafka的Exactly Once机制才能实现端到端的Exactly Once。
## Kafka如何配合Flink实现端到端Exactly Once
Kafka的Exactly Once语义是通过它的事务和生产幂等两个特性来共同实现的。其中Kafka事务的实现原理我们在《[25 | RocketMQ与Kafka中如何实现事务](https://time.geekbang.org/column/article/138724)》这节课中讲过。它可以保证一个事务内的所有消息,要么都成功投递,要么都不投递。
生产幂等这个特性可以保证在生产者给Kafka Broker发送消息这个过程中消息不会重复发送。这个实现原理和我们在《[05 | 如何确保消息不会丢失?](https://time.geekbang.org/column/article/111488)》这节课中介绍的“检测消息丢失”的方法是类似的都是通过连续递增的序号进行检测。Kafka的生产者给每个消息增加都附加一个连续递增的序号Broker端会检测这个序号的连续性如果序号重复了Broker会拒绝这个重复消息。
Kafka的这两个机制配合Flink就可以来实现端到端的Exactly Once了。简单地说就是每个Flink的CheckPoint对应一个Kafka事务。Flink在创建一个CheckPoint的时候同时开启一个Kafka的事务完成CheckPoint同时提交Kafka的事务。当计算任务重启的时候在Flink中计算任务会恢复到上一个CheckPoint这个CheckPoint正好对应Kafka上一个成功提交的事务。未完成的CheckPoint和未提交的事务中的消息都会被丢弃这样就实现了端到端的Exactly Once。
但是怎么才能保证“完成CheckPoint同时提交Kafka的事务”呢或者说如何来保证“完成CheckPoint”和“提交Kafka事务”这两个操作要么都成功要么都失败呢这不就是一个典型的分布式事务问题嘛
所以Flink基于两阶段提交这个常用的分布式事务算法实现了一分布式事务的控制器来解决这个问题。如果你对具体的实现原理感兴趣可以看一下Flink官网文档中的[这篇文章](https://flink.apache.org/features/2018/03/01/end-to-end-exactly-once-apache-flink.html)。
## Exactly Once版本的Web请求的统计
下面进入实战环节我们来把上节课的“统计Web请求的次数”的Flink Job改造一下让这个Job具备Exactly Once特性。这个实时统计任务接收NGINX的access.log每5秒钟按照IP地址统计Web请求的次数。假设我们已经有一个实时发送access.log的日志服务来发送日志日志的内容只包含访问时间和IP地址这个日志服务就是我们流计算任务的数据源。
改造之后我们需要把数据的来源替换成Kafka的ip_count_source主题计算结果也要保存到Kafka的主题ip_count_sink中。
整个系统的数据流向就变成下图这样:
<img src="https://static001.geekbang.org/resource/image/b6/6d/b62a67148c0600a1814f763a70b2056d.jpg" alt="">
日志服务将日志数据发送到Kafka的主题ip_count_source计算任务消费这个主题的数据作为数据源计算结果会被写入到Kafka的主题ip_count_sink中。
Flink提供了Kafka Connector模块可以作为数据源从Kafka中消费数据也可以作为Kafka的Producer将计算结果发送给Kafka并且这个Kafka Connector已经实现了Exactly Once语义我们在使用的时候只要做适当的配置就可以了。
这次我们用Java语言来实现这个任务改造后的计算任务代码如下
```
public class ExactlyOnceIpCount {
public static void main(String[] args) throws Exception {
// 设置输入和输出
FlinkKafkaConsumer011&lt;IpAndCount&gt; sourceConsumer = setupSource();
FlinkKafkaProducer011&lt;String&gt; sinkProducer = setupSink();
// 设置运行时环境
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); // 按照EventTime来统计
env.enableCheckpointing(5000); // 每5秒保存一次CheckPoint
// 设置CheckPoint
CheckpointConfig config = env.getCheckpointConfig();
config.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); // 设置CheckPoint模式为EXACTLY_ONCE
config.enableExternalizedCheckpoints(
CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION); // 取消任务时保留CheckPoint
config.setPreferCheckpointForRecovery(true); // 启动时从CheckPoint恢复任务
// 设置CheckPoint的StateBackend在这里CheckPoint保存在本地临时目录中。
// 只适合单节点做实验在生产环境应该使用分布式文件系统例如HDFS。
File tmpDirFile = new File(System.getProperty(&quot;java.io.tmpdir&quot;));
env.setStateBackend((StateBackend) new FsStateBackend(tmpDirFile.toURI().toURL().toString()));
// 设置故障恢复策略任务失败的时候自动每隔10秒重启一共尝试重启3次
env.setRestartStrategy(RestartStrategies.fixedDelayRestart(
3, // number of restart attempts
10000 // delay
));
// 定义输入从Kafka中获取数据
DataStream&lt;IpAndCount&gt; input = env
.addSource(sourceConsumer);
// 计算每5秒钟按照ip对count求和
DataStream&lt;IpAndCount&gt; output =
input
.keyBy(IpAndCount::getIp) // 按照ip地址统计
.window(TumblingEventTimeWindows.of(Time.seconds(5))) // 每5秒钟统计一次
.allowedLateness(Time.seconds(5))
.sum(&quot;count&quot;); // 对count字段求和
// 输出到kafka topic
output.map(IpAndCount::toString).addSink(sinkProducer);
// execute program
env.execute(&quot;Exactly-once IpCount&quot;);
}
}
```
这段代码和上节课中原始版本的代码整体架构是差不多的同样是定义数据源、定义计算逻辑和定义输入这三大步骤。下面主要来说不同之处这些不同的地方也就是如何配置Exactly Once特性的关键点。
首先我们需要开启并配置好CheckPoint。在这段代码中我们开启了CheckPoint设置每5秒钟创建一个CheckPoint。然后还需要定义保存CheckPoint的StateBackend也就是告诉Flink把CheckPoint保存在哪儿。在生产环境中CheckPoint应该保存到HDFS这样的分布式文件系统中。我们这个例子中为了方便运行调试直接把CheckPoint保存到本地的临时目录中。之后我们还需要将Job配置成自动重启这样当节点发生故障时Flink会自动重启Job并从最近一次CheckPoint开始恢复。
我们在定义输出创建FlinkKafkaProducer的时候需要指定Exactly Once语义这样Flink才会开启Kafka的事务代码如下
```
private static FlinkKafkaProducer011&lt;String&gt; setupSink() {
// 设置Kafka Producer属性
Properties producerProperties = new Properties();
producerProperties.put(&quot;bootstrap.servers&quot;, &quot;localhost:9092&quot;);
// 事务超时时间设置为1分钟
producerProperties.put(&quot;transaction.timeout.ms&quot;, &quot;60000&quot;);
// 创建 FlinkKafkaProducer指定语义为EXACTLY_ONCE
return new FlinkKafkaProducer011&lt;&gt;(
&quot;ip_count_sink&quot;,
new KeyedSerializationSchemaWrapper&lt;&gt;(new SimpleStringSchema()),
producerProperties,
FlinkKafkaProducer011.Semantic.EXACTLY_ONCE);
}
```
最后一点需要注意的在从Kafka主题ip_count_sink中消费计算结果的时候需要配置Consumer属性isolation.level=read_committed也就是只消费已提交事务的消息。因为默认情况下Kafka的Consumer是可以消费到未提交事务的消息的。
这个例子的完整代码我放到了GitHub上编译和运行这个例子的方法我也写在了项目的README中你可以点击[这里](https://github.com/liyue2008/kafka-flink-exactlyonce-example)查看。
## 小结
端到端Exactly Once语义可以保证在分布式系统中每条数据不多不少只被处理一次。在流计算中因为数据重复会导致计算结果错误所以Exactly Once在流计算场景中尤其重要。Kafka和Flink都提供了保证Exactly Once的特性配合使用可以实现端到端的Exactly Once语义。
在Flink中如果节点出现故障可以自动重启计算任务重新分配计算节点来保证系统的可用性。配合CheckPoint机制可以保证重启后任务的状态恢复到最后一次CheckPoint然后从CheckPoint中记录的恢复位置继续读取数据进行计算。Flink通过一个巧妙的Barrier使CheckPoint中恢复位置和各节点状态完全对应。
Kafka的Exactly Once语义是通过它的事务和生产幂等两个特性来共同实现的。在配合Flink的时候每个Flink的CheckPoint对应一个Kafka事务只要保证CheckPoint和Kafka事务同步提交就可以实现端到端的Exactly OnceFlink通过“二阶段提交”这个分布式事务的经典算法来保证CheckPoint和Kafka事务状态的一致性。
可以看到Flink配合Kafka来实现端到端的Exactly Once语义整个实现过程比较复杂但是这个复杂的大问题是由一个一个小问题组成的每个小问题的原理都是很简单的。比如Kafka如何实现的生产幂等Flink如何通过存储计算分离解决子任务状态恢复的很多这些小问题和我们课程中遇到的类似问题是差不多的那你就可以用到我们学习过的解决方法。
你需要重点掌握的是每一个小问题它面临的场景是什么样的以及如何解决问题的方法。而不要拘泥于Kafka或者Flink的某个参数怎么配这些细节问题。这些问题可以等到你在生产中真正需要使用的时候再去读文档“现学现卖”都来得及。
## 思考题
我们的课程中反复强调过在消息队列的消费端一定要“先执行消费业务逻辑再确认消费”这样才能保证不丢数据。我们这节课中并没有提到FlinkKafkaConsumer在从数据源主题ip_count_sink消费数据之后如何来确认消费的。如果消费位置管理不好一样会导致消息丢失或者重复课后请你查看一下相关的文档和源代码看一下FlinkKafkaConsumer是如何来确认消费的。欢迎在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,223 @@
<audio id="audio" title="31 | 动手实现一个简单的RPC框架原理和程序的结构" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/79/e236850409129aed8756da485c62db79.mp3"></audio>
你好,我是李玥。
接下来的四节课我们会一起实现一个RPC框架。你可能会问为什么不实现一个消息队列而要实现一个RPC框架呢原因很简单我们课程的目的是希望你能够学以致用举一反三而不只是照猫画虎。在之前的课程中我们一直在讲解消息队列的原理和实现消息队列的各种技术那我们在实践篇如果再实现一个消息队列不过是把之前课程中的内容重复实现一遍意义不大。
消息队列和RPC框架是我们最常用的两种通信方式虽然这两种中间系统的功能不一样但是**实现这两种中间件系统的过程中,有很多相似之处**,比如,它们都是分布式系统,都需要解决应用间通信的问题,都需要解决序列化的问题等等。
实现RPC框架用到的大部分底层技术是和消息队列一样的也都是我们在之前的课程中讲过的。所以我们花四节课的时间来实现一个RPC框架既可以检验你对进阶篇中学习到的底层技术掌握的是不是扎实又可以学到RPC框架的实现原理买一送一很超值。
接下来的四节课我们是这样安排的。本节课我们先来学习RPC框架的实现原理然后我们一起看一下如何来使用这个RPC框架顺便给出整个项目的总体结构。第二节课中一起来实现RPC框架的通信与序列化部分最后的两节课分别来实现客户端与服务端这两部分。
下面我们先来一起了解一下RPC框架的实现原理。
首先需要明确一下RPC框架的范围。我们这里所说的RPC框架是指类似于Dubbo、gRPC这种框架使用这些框架应用程序可以“在客户端直接调用服务端方法就像调用本地方法一样。”而一些基于REST的远程调用框架虽然同样可以实现远程调用但它对使用者并不透明无论是服务端还是客户端都需要和HTTP协议打交道解析和封装HTTP请求和响应。这类框架并不能算是“RPC框架”。
## RPC框架是怎么调用远程服务的
所有的RPC框架它们的总体结构和实现原理都是一样的。接下来我们以最常使用的Spring和Dubbo配合的微服务体系为例一起来看一下RPC框架到底是如何实现调用远程服务的。
一般来说,我们的客户端和服务端分别是这样的:
```
@Component
public class HelloClient {
@Reference // dubbo注解
private HelloService helloService;
public String hello() {
return helloService.hello(&quot;World&quot;);
}
}
@Service // dubbo注解
@Component
public class HelloServiceImpl implements HelloService {
@Override
public String hello(String name) {
return &quot;Hello &quot; + name;
}
}
```
在客户端,我们可以通过@Reference注解获得一个实现了HelloServicer这个接口的对象我们的业务代码只要调用这个对象的方法就可以获得结果。对于客户端代码来说调用就是helloService这个本地对象但实际上真正的服务是在远程的服务端进程中实现的。
再来看服务端在服务端我们的实现类HelloServiceImpl实现了HelloService这个接口。然后我们通过@Service这个注解(注意,这个@Service是Dubbo提供的注解不是Spring提供的同名注解在Dubbo框架中注册了这个实现类HelloServiceImpl。在服务端我们只是提供了接口HelloService的实现并没有任何远程调用的实现代码。
对于业务代码来说无论是客户端还是服务端除了增加了两个注解以外和实现一个进程内调用没有任何区别。Dubbo看起来就像把服务端进程中的实现类“映射”到了客户端进程中一样。接下来我们一起来看一下Dubbo这类RPC框架是如何来实现调用远程服务的。
注意Dubbo的实现原理或者说是RPC框架的实现原理是各大厂面试中最容易问到的问题之一所以接下来的这一段非常重要。
在客户端业务代码得到的HelloService这个接口的实例并不是我们在服务端提供的真正的实现类HelloServiceImpl的一个实例。它实际上是由RPC框架提供的一个代理类的实例。这个代理类有一个专属的名称叫“桩Stub”。
在不同的RPC框架中这个桩的生成方式并不一样有些是在编译阶段生成的有些是在运行时动态生成的这个和编程语言的语言特性是密切相关的所以在不同的编程语言中有不同的实现这部分很复杂可以先不用过多关注。我们只需要知道这个桩它做了哪些事儿就可以了。
我们知道HelloService的桩同样要实现HelloServer接口客户端在调用HelloService的hello方法时实际上调用的是桩的hello方法在这个桩的hello方法里面它会构造一个请求这个请求就是一段数据结构请求中包含两个重要的信息
1. 请求的服务名在我们这个例子中就是HelloService#hello(String)也就是说客户端调用的是HelloService的hello方法
1. 请求的所有参数在我们这个例子中就只有一个参数name 它的值是“World”。
然后,它会把这个请求发送给服务端,等待服务的响应。这个时候,请求到达了服务端,然后我们来看服务端是怎么处理这个请求的。
服务端的RPC框架收到这个请求之后先把请求中的服务名解析出来然后根据这个服务名找一下在服务端进程中有没有这个服务名对应的服务提供者。
在这个例子的服务端中,由于我们已经通过@Service注解向RPC框架注册过HelloService的实现类所以RPC框架在收到请求后可以通过请求中的服务名找到HelloService真正的实现类HelloServiceImpl。找到实现类之后RPC框架会调用这个实现类的hello方法使用的参数值就是客户端发送过来的参数值。服务端的RPC框架在获得返回结果之后再将结果封装成响应返回给客户端。
客户端RPC框架的桩收到服务端的响应之后从响应中解析出返回值返回给客户端的调用方。这样就完成了一次远程调用。我把这个调用过程画成一张图放在下面你可以对着这张图再消化一下上面的流程。
<img src="https://static001.geekbang.org/resource/image/94/ea/946841b09cab0b11ce349a5a1eeea0ea.jpg" alt="">
在上面的这个调用流程中我们忽略了一个问题那就是客户端是如何找到服务端地址的呢在RPC框架中**这部分的实现原理其实和消息队列的实现是完全一样的**都是通过一个NamingService来解决的。
在RPC框架中这个NamingService一般称为注册中心。服务端的业务代码在向RPC框架中注册服务之后RPC框架就会把这个服务的名称和地址发布到注册中心上。客户端的桩在调用服务端之前会向注册中心请求服务端的地址请求的参数就是服务名称也就是我们上面例子中的方法签名HelloService#hello,注册中心会返回提供这个服务的地址,然后客户端再去请求服务端。
有些RPC框架比如gRPC是可以支持跨语言调用的。它的服务提供方和服务调用方是可以用不同的编程语言来实现的。比如我们可以用Python编写客户端用Go语言来编写服务端这两种语言开发的服务端和客户端仍然可以正常通信。这种支持跨语言调用的RPC框架的实现原理和普通的单语言的RPC框架并没有什么本质的不同。
我们可以再回顾一下上面那张调用的流程图,如果需要实现跨语言的调用,也就是说,图中的客户端进程和服务端进程是由两种不同的编程语言开发的。其实,只要客户端发出去的请求能被服务端正确解析,同样,服务端返回的响应,客户端也能正确解析,其他的步骤完全不用做任何改变,不就可以实现跨语言调用了吗?
在客户端和服务端收发请求响应的工作都是RPC框架来实现的所以**只要RPC框架保证在不同的编程语言中使用相同的序列化协议就可以实现跨语言的通信。**另外为了在不同的语言中能描述相同的服务定义也就是我们上面例子中的HelloService接口跨语言的RPC框架还需要提供一套描述服务的语言称为IDLInterface description language。所有的服务都需要用IDL定义再由RPC框架转换为特定编程语言的接口或者抽象类。这样就可以实现跨语言调用了。
讲到这里RPC框架的基本实现原理就很清楚了可以看到实现一个简单的RPC框架并不是很难这里面用到的绝大部分技术包括高性能网络传输、序列化和反序列化、服务路由的发现方法等都是我们在学习消息队列实现原理过程中讲过的知识。
下面我就一起来实现一个“麻雀虽小但五脏俱全”的RPC框架。
## RPC框架的总体结构是什么样的
虽然我们这个RPC框架只是一个原型系统但它仍然有近50个源代码文件2000多行源代码。学习这样一个复杂的项目最好的方式还是先学习它的总体结构然后再深入到每一部分的实现细节中去所以我们一起先来看一下这个项目的总体结构。
我们采用Java语言来实现这个RPC框架。我们把RPC框架对外提供的所有服务定义在一个接口RpcAccessPoint中
```
/**
* RPC框架对外提供的服务接口
*/
public interface RpcAccessPoint extends Closeable{
/**
* 客户端获取远程服务的引用
* @param uri 远程服务地址
* @param serviceClass 服务的接口类的Class
* @param &lt;T&gt; 服务接口的类型
* @return 远程服务引用
*/
&lt;T&gt; T getRemoteService(URI uri, Class&lt;T&gt; serviceClass);
/**
* 服务端注册服务的实现实例
* @param service 实现实例
* @param serviceClass 服务的接口类的Class
* @param &lt;T&gt; 服务接口的类型
* @return 服务地址
*/
&lt;T&gt; URI addServiceProvider(T service, Class&lt;T&gt; serviceClass);
/**
* 服务端启动RPC框架监听接口开始提供远程服务。
* @return 服务实例,用于程序停止的时候安全关闭服务。
*/
Closeable startServer() throws Exception;
}
```
这个接口主要的方法就只有两个第一个方法getRemoteService供客户端来使用这个方法的作用和我们上面例子中Dubbo的@Reference注解是一样的客户端调用这个方法可以获得远程服务的实例。第二个方法addServiceProvider供服务端来使用这个方法的作用和Dubbo的@Service注解是一样的服务端通过调用这个方法来注册服务的实现。方法startServer和close在父接口Closeable中定义用于服务端启动和停止服务。
另外我们还需要定一个注册中心的接口NameService
```
/**
* 注册中心
*/
public interface NameService {
/**
* 注册服务
* @param serviceName 服务名称
* @param uri 服务地址
*/
void registerService(String serviceName, URI uri) throws IOException;
/**
* 查询服务地址
* @param serviceName 服务名称
* @return 服务地址
*/
URI lookupService(String serviceName) throws IOException;
}
```
这个注册中心只有两个方法分别是注册服务地址registerService和查询服务地址lookupService。
以上就是我们要实现的这个RPC框架的全部功能了。然后我们通过一个例子看一下这个RPC框架如何来使用。同样需要先定义一个服务接口
```
public interface HelloService {
String hello(String name);
}
```
接口定义和本节课开始的例子是一样的。然后我们分别看一下服务端和客户端是如何使用这个RPC框架的。
客户端:
```
URI uri = nameService.lookupService(serviceName);
HelloService helloService = rpcAccessPoint.getRemoteService(uri, HelloService.class);
String response = helloService.hello(name);
logger.info(&quot;收到响应: {}.&quot;, response);
```
客户端首先调用注册中心NameService的lookupService方法查询服务地址然后调用rpcAccessPoint的getRemoteService方法获得远程服务的本地实例也就是我们刚刚讲的“桩”helloService。最后调用helloService的hello方法获得返回值并打印出来。
然后来看服务端首先我们需要有一个HelloService的实现
```
public class HelloServiceImpl implements HelloService {
@Override
public String hello(String name) {
String ret = &quot;Hello, &quot; + name;
return ret;
}
}
```
然后我们将这个实现注册到RPC框架上并启动RPC服务
```
rpcAccessPoint.startServer();
URI uri = rpcAccessPoint.addServiceProvider(helloService, HelloService.class);
nameService.registerService(serviceName, uri);
```
首先启动RPC框架的服务然后调用rpcAccessPoint.addServiceProvider方法注册helloService服务然后我们再调用nameServer.registerService方法在注册中心注册服务的地址。
可以看到我们将要实现的这个RPC框架的使用方式总体上和上面使用Dubbo和Spring的例子是一样的唯一的一点区别是由于我们没有使用Spring和注解所以需要用代码的方式实现同样的功能。
我把这个RPC框架的实现代码以及上面如何使用这个RPC框架的例子放在了GitHub的[simple-rpc-framework](https://github.com/liyue2008/simple-rpc-framework)项目中。整个项目分为如下5个Module
<img src="https://static001.geekbang.org/resource/image/43/55/43172b2271987c93e37492843894a955.jpg" alt="">
其中RPC框架提供的服务RpcAccessPoint和注册中心服务NameService这两个接口的定义在Module rpc-api中。使用框架的例子HelloService接口定义在Module hello-service-api中例子中的客户端和服务端分别在client和server这两个Module中。
后面的三节课我们将一起来实现这个RPC框架也就是Module rpc-netty。
## 小结
从这节课开始我们要用四节课利用之前学习的、实现消息队列用到的知识来实现一个RPC框架。
我们在实现RPC框架之前需要先掌握RPC框架的实现原理。在RPC框架中最关键的就是理解“桩”的实现原理桩是RPC框架在客户端的服务代理它和远程服务具有相同的方法签名或者说是实现了相同的接口。客户端在调用RPC框架提供的服务时实际调用的就是“桩”提供的方法在桩的实现方法中它会发请求的服务名和参数到服务端服务端的RPC框架收到请求后解析出服务名和参数后调用在RPC框架中注册的“真正的服务提供者”然后将结果返回给客户端。
## 思考题
课后你需要从GitHub上把我们即将实现的RPC框架的源代码下载到本地先分别运行一下例子中的服务端和客户端对整个项目有一个感性的认识。然后再分别看一下rpc-api、hello-service-api、server和client这四个Module的源代码理清楚RPC框架的功能以及如何使用这个RPC框架为后续三节课的学习做好准备。欢迎在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,265 @@
<audio id="audio" title="32 | 动手实现一个简单的RPC框架通信与序列化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a6/b7/a6c308578d7ff50a685e254c492fb6b7.mp3"></audio>
你好,我是李玥。
继续上节课的内容这节课我们一起来实现这个RPC框架的通信和序列化部分。如何实现高性能的异步通信、如何来将结构化的数据序列化成字节流用于网络传输或者存储到文件中这两部分内容我在进阶篇中都有在专门的课程分别讲解过。
网络传输和序列化这两部分的功能相对来说是非常通用并且独立的在设计的时候只要能做到比较好的抽象这两部的实现它的通用性是非常强的。不仅可以用于我们这个例子中的RPC框架中同样可以直接拿去用于实现消息队列或者其他需要互相通信的分布式系统中。
我们在实现这两部分的时候,会尽量以开发一个高性能的生产级系统这样的质量要求来设计和实现,但是为了避免代码过于繁杂影响你理解主干流程,我也会做适当的简化,简化的部分我会尽量给出提示。
## 如何设计一个通用的高性能序列化实现?
我们先来实现序列化和反序列化部分,因为后面讲到的部分会用到序列化和反序列化。
首先我们需要设计一个可扩展的,通用的序列化接口,为了方便使用,我们直接使用静态类的方式来定义这个接口(严格来说这并不是一个接口)。
```
public class SerializeSupport {
public static &lt;E&gt; E parse(byte [] buffer) {
// ...
}
public static &lt;E&gt; byte [] serialize(E entry) {
// ...
}
}
```
上面的parse方法用于反序列化serialize方法用于序列化。如果你对Java语言不是特别的熟悉可能会看不懂`&lt;E&gt;`是什么意思这是Java语言泛型机制你可以先忽略它。看一下如何来使用这个类就明白了
```
// 序列化
MyClass myClassObject = new MyClass();
byte [] bytes = SerializeSupport.serialize(myClassObject);
// 反序列化
MyClass myClassObject1 = SerializeSupport.parse(bytes);
```
我在讲解序列化和反序列化的时候说过可以使用通用的序列化实现也可以自己来定义专用的序列化实现。专用的序列化性能最好但缺点是实现起来比较复杂你要为每一种类型的数据专门编写序列化和反序列化方法。一般的RPC框架采用的都是通用的序列化实现比如gRPC采用的是Protobuf序列化实现Dubbo支持hession2等好几种序列化实现。
那为什么这些RPC框架不像消息队列一样采用性能更好的专用的序列化实现呢这个原因很简单消息队列它需要序列化数据的类型是固定的只是它自己的内部通信的一些命令。但RPC框架它需要序列化的数据是用户调用远程方法的参数这些参数可能是各种数据类型所以必须使用通用的序列化实现确保各种类型的数据都能被正确的序列化和反序列化。我们这里还是采用专用的序列化实现主要的目的是带你一起来实践一下如何来实现序列化和反序列化。
我们给所有序列化的实现类定义一个Serializer接口所有的序列化实现类都实现这个接口就可以了
```
public interface Serializer&lt;T&gt; {
/**
* 计算对象序列化后的长度,主要用于申请存放序列化数据的字节数组
* @param entry 待序列化的对象
* @return 对象序列化后的长度
*/
int size(T entry);
/**
* 序列化对象。将给定的对象序列化成字节数组
* @param entry 待序列化的对象
* @param bytes 存放序列化数据的字节数组
* @param offset 数组的偏移量,从这个位置开始写入序列化数据
* @param length 对象序列化后的长度,也就是{@link Serializer#size(java.lang.Object)}方法的返回值。
*/
void serialize(T entry, byte[] bytes, int offset, int length);
/**
* 反序列化对象
* @param bytes 存放序列化数据的字节数组
* @param offset 数组的偏移量,从这个位置开始写入序列化数据
* @param length 对象序列化后的长度
* @return 反序列化之后生成的对象
*/
T parse(byte[] bytes, int offset, int length);
/**
* 用一个字节标识对象类型,每种类型的数据应该具有不同的类型值
*/
byte type();
/**
* 返回序列化对象类型的Class对象。
*/
Class&lt;T&gt; getSerializeClass();
}
```
这个接口中除了serialize和parse这两个序列化和反序列化两个方法以外还定义了下面这几个方法size方法计算序列化之后的数据长度用于事先来申请存放序列化数据的字节数组type方法定义每种序列化实现的类型这个类型值也会写入到序列化之后的数据中主要的作用是在反序列化的时候能够识别是什么数据类型的以便找到对应的反序列化实现类getSerializeClass这个方法返回这个序列化实现类对应的对象类型目的是在执行序列化的时候通过被序列化的对象类型找到对应序列化实现类。
利用这个Serializer接口我们就可以来实现SerializeSupport这个支持任何对象类型序列化的通用静态类了。首先我们定义两个Map这两个Map中存放着所有实现Serializer接口的序列化实现类。
```
private static Map&lt;Class&lt;?&gt;/*序列化对象类型*/, Serializer&lt;?&gt;/*序列化实现*/&gt; serializerMap = new HashMap&lt;&gt;();
private static Map&lt;Byte/*序列化实现类型*/, Class&lt;?&gt;/*序列化对象类型*/&gt; typeMap = new HashMap&lt;&gt;();
```
serializerMap中的key是序列化实现类对应的序列化对象的类型它的用途是在序列化的时候通过被序列化的对象类型找到对应的序列化实现类。typeMap的作用和serializerMap是类似的它的key是序列化实现类的类型用于在反序列化的时候从序列化的数据中读出对象类型然后找到对应的序列化实现类。
理解了这两个Map的作用实现序列化和反序列化这两个方法就很容易了。这两个方法的实现思路是一样的都是通过一个类型在这两个Map中进行查找查找的结果就是对应的序列化实现类的实例也就是Serializer接口的实现然后调用对应的序列化或者反序列化方法就可以了。具体的实现在SerializeSupport中你可以自行查看。我刚刚讲的这几个类型听起来可能会感觉有些晕但其实并不难你对着代码来自己看一遍就很容易理解了。
所有的Serializer的实现类是怎么加载到SerializeSupport的那两个Map中的呢这里面利用了Java的一个SPI类加载机制我会在后面的课程中专门来讲。
到这里我们就封装好了一个通用的序列化的接口对于使用序列化的模块来说它只要依赖SerializeSupport这个静态类调用它的序列化和反序列化方法就可以了不需要依赖任何序列化实现类。对于序列化实现的提供者来说也只需要依赖并实现Serializer这个接口就可以了。比如我们的HelloService例子中的参数是一个String类型的数据我们需要实现一个支持String类型的序列化实现
```
public class StringSerializer implements Serializer&lt;String&gt; {
@Override
public int size(String entry) {
return entry.getBytes(StandardCharsets.UTF_8).length;
}
@Override
public void serialize(String entry, byte[] bytes, int offset, int length) {
byte [] strBytes = entry.getBytes(StandardCharsets.UTF_8);
System.arraycopy(strBytes, 0, bytes, offset, strBytes.length);
}
@Override
public String parse(byte[] bytes, int offset, int length) {
return new String(bytes, offset, length, StandardCharsets.UTF_8);
}
@Override
public byte type() {
return Types.TYPE_STRING;
}
@Override
public Class&lt;String&gt; getSerializeClass() {
return String.class;
}
}
```
这里面有个初学者容易犯的错误在把String和byte数组做转换的时候一定要指定编码方式确保序列化和反序列化的时候都使用一致的编码我们这里面统一使用UTF8编码。否则如果遇到执行序列化和反序列化的两台服务器默认编码不一样就会出现乱码。我们在开发过程用遇到的很多中文乱码问题绝大部分都是这个原因。
在我们这个例子中还有一个更复杂的序列化实现MetadataSerializer用于将注册中心的数据持久化到文件中你也可以参考一下。
到这里序列化的部分就实现完成了。我们这个序列化的实现对外提供服务的就只有一个SerializeSupport静态类并且可以通过扩展支持序列化任何类型的数据这样一个通用的实现不仅可以用在我们这个RPC框架的例子中你完全可以把这部分直接拿过去用在你的业务代码中。
## 使用Netty来实现异步网络通信
接下来我们来说网络通信部分的实现。
同样的思路我们把通信的部分也封装成接口。在我们这个RPC框架中对于通信模块的需求是这样的只需要客户端给服务端发送请求然后服务返回响应就可以了。所以我们的通信接口只需要提供一个发送请求方法就可以了
```
public interface Transport {
/**
* 发送请求命令
* @param request 请求命令
* @return 返回值是一个FutureFuture
*/
CompletableFuture&lt;Command&gt; send(Command request);
}
```
这个send方法参数request就是需要发送的请求数据返回值是一个CompletableFuture对象通过这个CompletableFuture对象可以获得响应结果。这里面使用一个CompletableFuture作为返回值使用起来就非常灵活我们可以直接调用它的get方法来获取响应数据这就相当于同步调用也可以使用以then开头的一系列异步方法指定当响应返回的时候需要执行的操作就等同于异步调用。等于这样一个方法既可以同步调用也可以异步调用。
在这个接口中请求和响应数据都抽象成了一个Command类我们来看一下这个类是如何定义的
```
public class Command {
protected Header header;
private byte [] payload;
//...
}
public class Header {
private int requestId;
private int version;
private int type;
// ...
}
public class ResponseHeader extends Header {
private int code;
private String error;
// ...
}
```
Command类包含一个命令头Header和一个payload字节数组。payload就是命令中要传输的数据这里我们要求这个数据已经是被序列化之后生成的字节数组。Header中包含三个属性requestId用于唯一标识一个请求命令在我们使用双工方式异步收发数据的时候这个requestId可以用于请求和响应的配对儿。我们在加餐那节课实现两个大爷对话的例子中使用的是同样的设计。
version这个属性用于标识这条命令的版本号。type用于标识这条命令的类型这个类型主要的目的是为了能让接收命令一方来识别收到的是什么命令以便路由到对应的处理类中去。
另外在返回的响应Header中我们还需要包含一个code字段和一个error字段code字段使用一个数字表示响应状态0代表成功其他值分别代表各种错误这个设计和HTTP协议中的StatueCode是一样的。
在设计通信协议时,让协议具备持续的升级能力,并且保持向下兼容是非常重要的。因为所有的软件,唯一不变的就是变化,由于需求一直变化,你不可能保证传输协议永远不变,一旦传输协议发生变化,为了确保使用这个传输协议的这些程序还能正常工作,或者是向下兼容,协议中必须提供一个版本号,标识收到的这条数据使用的是哪个版本的协议。
发送方在发送命令的时候需要带上这个命令的版本号,接收方在收到命令之后必须先检查命令的版本号,如果接收方可以支持这个版本的命令就正常处理,否则就拒绝接收这个命令,返回响应告知对方:我不认识这个命令。这样才是一个完备的,可持续的升级的通信协议。
需要注意的是,这个版本号是命令的版本号,或者说是传输协议的版本号,它不等同于程序的版本号。我们这个例子中,并没有检查命令版本号,你在生产系统中需要自己补充这部分逻辑。
然后我们继续来看Transport这个接口的实现NettyTransport类。这个send方法的实现本质上就是一个异步方法在把请求数据发出去之后就返回了并不会阻塞当前这个线程去等待响应返回来。来看一下它的实现
```
@Override
public CompletableFuture&lt;Command&gt; send(Command request) {
// 构建返回值
CompletableFuture&lt;Command&gt; completableFuture = new CompletableFuture&lt;&gt;();
try {
// 将在途请求放到inFlightRequests中
inFlightRequests.put(new ResponseFuture(request.getHeader().getRequestId(), completableFuture));
// 发送命令
channel.writeAndFlush(request).addListener((ChannelFutureListener) channelFuture -&gt; {
// 处理发送失败的情况
if (!channelFuture.isSuccess()) {
completableFuture.completeExceptionally(channelFuture.cause());
channel.close();
}
});
} catch (Throwable t) {
// 处理发送异常
inFlightRequests.remove(request.getHeader().getRequestId());
completableFuture.completeExceptionally(t);
}
return completableFuture;
}
```
这段代码实际上就干了两件事儿第一件事儿是把请求中的requestId和返回的completableFuture一起构建了一个ResponseFuture对象然后把这个对象放到了inFlightRequests这个变量中。inFlightRequests中存放了所有在途的请求也就是已经发出了请求但还没有收到响应的这些responseFuture对象。
第二件事儿就是调用netty发送数据的方法把这个request命令发给对方。这里面需要注意的一点是已经发出去的请求有可能会因为网络连接断开或者对方进程崩溃等各种异常情况永远都收不到响应。那为了确保这些孤儿ResponseFuture不会在内存中越积越多我们必须要捕获所有的异常情况结束对应的ResponseFuture。所以我们在上面代码中两个地方都做了异常处理分别应对发送失败和发送异常两种情况。
即使是我们对所有能捕获的异常都做了处理也不能保证所有ResponseFuture都能正常或者异常结束比如说编写对端程序的程序员写的代码有问题收到了请求就是没给我们返回响应为了应对这种情况还必须有一个兜底超时的机制来保证所有情况下ResponseFuture都能结束无论什么情况只要超过了超时时间还没有收到响应我们就认为这个ResponseFuture失败了结束并删除它。这部分代码在InFlightRequests这个类中。
这里面还有一个很重要的最佳实践分享给你。我们知道如果是同步发送请求客户端需要等待服务端返回响应服务端处理这个请求需要花多长时间客户端就要等多长时间。这实际上是一个天然的背压机制Back pressure服务端处理速度会天然地限制客户端请求的速度。
但是在异步请求中,客户端异步发送请求并不会等待服务端,缺少了这个天然的背压机制,如果服务端的处理速度跟不上客户端的请求速度,客户端的发送速度也不会因此慢下来,就会出现在途的请求越来越多,这些请求堆积在服务端的内存中,内存放不下就会一直请求失败。服务端处理不过来的时候,客户端还一直不停地发请求显然是没有意义的。为了避免这种情况,我们需要增加一个背压机制,在服务端处理不过来的时候限制一下客户端的请求速度。
这个背压机制的实现也在InFlightRequests类中在这里面我们定义了一个信号量
```
private final Semaphore semaphore = new Semaphore(10);
```
这个信号量有10个许可我们每次往inFlightRequest中加入一个ResponseFuture的时候需要先从信号量中获得一个许可如果这时候没有许可了就会阻塞当前这个线程也就是发送请求的这个线程直到有人归还了许可才能继续发送请求。我们每结束一个在途请求就归还一个许可这样就可以保证在途请求的数量最多不超过10个请求积压在服务端正在处理或者待处理的请求也不会超过10个。这样就实现了一个简单有效的背压机制。
我们在ResponseInvocation这个类中异步接收所有服务端返回的响应处理逻辑比较简单就是根据响应头中的requestId去在途请求inFlightRequest中查找对应的ResponseFuture设置返回值并结束这个ResponseFuture就可以了。
使用Netty来收发数据这部分代码我都放在了com.github.liyue2008.rpc.transport.netty这个包中你可以自行查看。
## 小结
这节课我们一起实现了序列化和异步网络通信这两部分的代码,用到的都是在进阶篇中讲过的知识。我们在设计序列化和网络传输这两部分实现的时候,都预先定义了对外提供服务的接口。使用服务的使用方只依赖这个接口,而不依赖这个接口的任何实现。
这样做的好处是让接口的使用者和接口的调用者充分解耦使得我们可以安全地替换接口的实现。把接口定义的尽量通用让接口定义与接口的使用方无关这个接口的实现就很容易被复用比如我们这个例子中网络传输和序列化这两部分代码不仅可以用在这个RPC框架中同样可以不做任何修改就用在其他的系统中。
在设计协议的时候,我们给每个命令都设计了一个固定的头,这样设计的好处是,我们在解析命令的时候可以先把头解析出来,就可以对命令进行版本检查、路由分发等通用的预处理工作,而不必把整个命令都解析出来。那为了应对变化,使协议具有持续升级的能力,命令中需要携带一个协议版本号,我们需要在收到命令后检查这个版本号,确保接收方可以支持这个版本的协议。
在实现异步网络传输的时候,一定要配套实现一个背压的机制,避免客户端请求速度过快,导致大量的请求失败。
## 思考题
课后给你留一个思考题来做一个序列化的替换实现。我们这个例子中使用了自己实现的专有的序列化实现这些实现类都放在了com.github.liyue2008.rpc.serialize.impl这个包中你需要换一种序列化的实现方式来替换掉我们这个序列化实现。具体实现可以使用JSON、Protobuf或者任何一种序列化方式。
你可以删除或者改动com.github.liyue2008.rpc.serialize.impl这个包中的所有代码但是不要修改其他代码。要求替换后我们的这个RPC框架仍然可以正常运行。欢迎在留言区分享你的代码。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="33 | 动手实现一个简单的RPC框架客户端" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/19/f1e4398eb600301bb39287ac134be519.mp3"></audio>
你好,我是李玥。
上节课我们已经一起实现了这个RPC框架中的两个基础组件序列化和网络传输部分这节课我们继续来实现这个RPC框架的客户端部分。
在《[31 | 动手实现一个简单的RPC框架原理和程序的结构](https://time.geekbang.org/column/article/144320)》这节课中我们提到过在RPC框架中最关键的就是理解“桩”的实现原理桩是RPC框架在客户端的服务代理它和远程服务具有相同的方法签名或者说是实现了相同的接口客户端在调用RPC框架提供的服务时实际调用的就是“桩”提供的方法在桩的实现方法中它会发请求到服务端获取调用结果并返回给调用方。
**在RPC框架的客户端中最关键的部分也就是如何来生成和实现这个桩。**
## 如何来动态地生成桩?
RPC框架中的这种桩的设计它其实采用了一种设计模式“代理模式”。代理模式给某一个对象提供一个代理对象并由代理对象控制对原对象的引用被代理的那个对象称为委托对象。
在RPC框架中代理对象都是由RPC框架的客户端来提供的也就是我们一直说的“桩”委托对象就是在服务端真正实现业务逻辑的服务类的实例。
<img src="https://static001.geekbang.org/resource/image/6c/48/6ca3f88f1a6c06513d5adfe976efcc48.jpg" alt="">
我们最常用Spring框架它的核心IOC依赖注入和AOP面向切面机制就是这种代理模式的一个实现。我们在日常开发的过程中可以利用这种代理模式在调用流程中动态地注入一些非侵入式业务逻辑。
这里的“非侵入”指的是在现有的调用链中增加一些业务逻辑而不用去修改调用链上下游的代码。比如说我们要监控一个方法A的请求耗时普通的方式就是在方法的开始和返回这两个地方各加一条记录时间的语句这种方法就需要修改这个方法的代码这是一种“侵入式”的方式。
我们还可以给这个方法所在的类创建一个代理类在这个代理类的A方法中先记录开始时间然后调用委托类的A方法再记录结束时间。把这个代理类加入到调用链中就可以实现“非侵入式”记录耗时了。同样的方式我们还可以用在权限验证、风险控制、调用链跟踪等等这些场景中。
下面我们来看下在我们这个RPC框架的客户端中怎么来实现的这个代理类也就是“桩”。首先我们先定一个StubFactory接口这个接口就只有一个方法
```
public interface StubFactory {
&lt;T&gt; T createStub(Transport transport, Class&lt;T&gt; serviceClass);
}
```
这个桩工厂接口只定义了一个方法createStub它的功能就是创建一个桩的实例这个桩实现的接口可以是任意类型的也就是上面代码中的泛型T。这个方法有两个参数第一个参数是一个Transport对象这个Transport我们在上节课介绍过它是用来给服务端发请求的时候使用的。第二个参数是一个Class对象它用来告诉桩工厂我需要你给我创建的这个桩应该是什么类型的。createStub的返回值就是由工厂创建出来的桩。
如何来实现这个工厂方法创建桩呢这个桩它是一个由RPC框架生成的类这个类它要实现给定的接口里面的逻辑就是把方法名和参数封装成请求发送给服务端然后再把服务端返回的调用结果返回给调用方。这里我们已经解决了网络传输和序列化的问题剩下一个核心问题就是如何来生成这个类了。
我们知道普通的类它是由我们编写的源代码通过编译器编译之后生成的。那RPC框架怎么才能根据要实现的接口来生成一个类呢在这一块儿不同的RPC框架的实现是不一样的比如gRPC它是在编译IDL的时候就把桩生成好了这个时候编译出来桩它是目标语言的源代码文件。比如说目标语言是Java编译完成后它们会生成一些Java的源代码文件其中以Grpc.java结尾的文件就是生成的桩的源代码。这些生成的源代码文件再经过Java编译器编译以后就成了桩。
而Dubbo是在运行时动态生成的桩这个实现就更加复杂了并且它利用了很多Java语言底层的特性。但是它的原理并不复杂Java源代码编译完成之后生成的是一些class文件JVM在运行的时候读取这些Class文件来创建对应类的实例。
这个Class文件虽然非常复杂但本质上它里面记录的内容就是我们编写的源代码中的内容包括类的定义方法定义和业务逻辑等等并且它也是有固定的格式的。如果说我们按照这个格式来生成一个class文件只要这个文件的格式是符合Java规范的JVM就可以识别并加载它。这样就不需要经过源代码、编译这些过程直接动态来创建一个桩。
由于动态生成class文件这部分逻辑和Java语言的特性是紧密关联的考虑有些同学并不熟悉Java语言所以在这个RPC的例子中我们采用一种更通用的方式来动态生成桩。我们采用的方式是先生成桩的源代码然后动态地编译这个生成的源代码然后再加载到JVM中。
为了让这部分代码不会过于复杂便于你快速理解我们限定服务接口只能有一个方法并且这个方法只能有一个参数参数和返回值的类型都是String类型。你在学会这部分动态生成桩的原理之后很容易重构这部分代码来解除这个限定无非是多遍历几次方法和参数而已。
我之前讲过我们需要动态生成的这个桩它每个方法的逻辑都是一样的都是把类名、方法名和方法的参数封装成请求然后发给服务端收到服务端响应之后再把结果作为返回值返回给调用方。所以我们定义一个AbstractStub的抽象类在这个类中实现大部分通用的逻辑让所有动态生成的桩都继承这个抽象类这样动态生成桩的代码会更少一些。
下面我们来实现客户端最关键的这部分代码实现这个StubFactory接口动态生成桩。
```
public class DynamicStubFactory implements StubFactory{
private final static String STUB_SOURCE_TEMPLATE =
&quot;package com.github.liyue2008.rpc.client.stubs;\n&quot; +
&quot;import com.github.liyue2008.rpc.serialize.SerializeSupport;\n&quot; +
&quot;\n&quot; +
&quot;public class %s extends AbstractStub implements %s {\n&quot; +
&quot; @Override\n&quot; +
&quot; public String %s(String arg) {\n&quot; +
&quot; return SerializeSupport.parse(\n&quot; +
&quot; invokeRemote(\n&quot; +
&quot; new RpcRequest(\n&quot; +
&quot; \&quot;%s\&quot;,\n&quot; +
&quot; \&quot;%s\&quot;,\n&quot; +
&quot; SerializeSupport.serialize(arg)\n&quot; +
&quot; )\n&quot; +
&quot; )\n&quot; +
&quot; );\n&quot; +
&quot; }\n&quot; +
&quot;}&quot;;
@Override
@SuppressWarnings(&quot;unchecked&quot;)
public &lt;T&gt; T createStub(Transport transport, Class&lt;T&gt; serviceClass) {
try {
// 填充模板
String stubSimpleName = serviceClass.getSimpleName() + &quot;Stub&quot;;
String classFullName = serviceClass.getName();
String stubFullName = &quot;com.github.liyue2008.rpc.client.stubs.&quot; + stubSimpleName;
String methodName = serviceClass.getMethods()[0].getName();
String source = String.format(STUB_SOURCE_TEMPLATE, stubSimpleName, classFullName, methodName, classFullName, methodName);
// 编译源代码
JavaStringCompiler compiler = new JavaStringCompiler();
Map&lt;String, byte[]&gt; results = compiler.compile(stubSimpleName + &quot;.java&quot;, source);
// 加载编译好的类
Class&lt;?&gt; clazz = compiler.loadClass(stubFullName, results);
// 把Transport赋值给桩
ServiceStub stubInstance = (ServiceStub) clazz.newInstance();
stubInstance.setTransport(transport);
// 返回这个桩
return (T) stubInstance;
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
}
```
一起来看一下这段代码静态变量STUB_SOURCE_TEMPLATE是桩的源代码的模板我们需要做的就是填充模板中变量生成桩的源码然后动态的编译、加载这个桩就可以了。
先来看这个模板它唯一的这个方法中就只有一行代码把接口的类名、方法名和序列化后的参数封装成一个RpcRequest对象调用父类AbstractStub中的invokeRemote方法发送给服务端。invokeRemote方法的返回值就是序列化的调用结果我们在模板中把这个结果反序列化之后直接作为返回值返回给调用方就可以了。
再来看下面的createStrub方法从serviceClass这个参数中可以取到服务接口定义的所有信息包括接口名、它有哪些方法、每个方法的参数和返回值类型等等。通过这些信息我们就可以来填充模板生成桩的源代码。
桩的类名就定义为:“接口名 + Stub”为了避免类名冲突我们把这些桩都统一放到固定的包com.github.liyue2008.rpc.client.stubs下面。填充好模板生成的源代码存放在source变量中然后经过动态编译、动态加载之后我们就可以拿到这个桩的类clazz利用反射创建一个桩的实例stubInstance。把用于网络传输的对象transport赋值给桩这样桩才能与服务端进行通信。到这里我们就实现了动态创建一个桩。
## 使用依赖倒置原则解耦调用者和实现
在这个RPC框架的例子中很多地方我们都采用了同样一种解耦的方法通过定义一个接口来解耦调用方和实现。在设计上这种方法称为“依赖倒置原则Dependence Inversion Principle它的核心思想是调用方不应依赖于具体实现而是为实现定义一个接口让调用方和实现都依赖于这个接口。这种方法也称为“面向接口编程”。它的好处我们之前已经反复说过了可以解耦调用方和具体的实现不仅实现是可替换的实现连同定义实现的接口也是可以复用的。
比如我们上面定义的StubFactory它是一个接口它的实现类是DynamicStubFactory调用方是NettyRpcAccessPoint调用方NettyAccessPoint并不依赖实现类DynamicStubFactory就可以调用DynamicStubFactory的createStub方法。
要解耦调用方和实现类,还需要解决一个问题:谁来创建实现类的实例?一般来说,都是谁使用谁创建,但这里面我们为了解耦调用方和实现类,调用方就不能来直接创建实现类,因为这样就无法解耦了。那能不能用一个第三方来创建这个实现类呢?也是不行的,即使用一个第三方类来创建实现,那依赖关系就变成了:调用方依赖第三方类,第三方类依赖实现类,调用方还是间接依赖实现类,还是没有解耦。
这个问题怎么来解决没错使用Spring的依赖注入是可以解决的。这里再给你介绍一种Java语言内置的更轻量级的解决方案SPIService Provider Interface。在SPI中每个接口在目录META-INF/services/下都有一个配置文件文件名就是以这个接口的类名文件的内容就是它的实现类的类名。还是以StubFactory接口为例我们看一下它的配置文件
```
$cat rpc-netty/src/main/resources/META-INF/services/com.github.liyue2008.rpc.client.StubFactory
com.github.liyue2008.rpc.client.DynamicStubFactory
```
只要把这个配置文件、接口和实现类都放到CLASSPATH中就可以通过SPI的方式来进行加载了。加载的参数就是这个接口的class对象返回值就是这个接口的所有实现类的实例这样就在“不依赖实现类”的前提下获得了一个实现类的实例。具体的实现代码在ServiceSupport这个类中。
## 小结
这节课我们一起实现了这个RPC框架的客户端在客户端中最核心的部分就是桩也就是远程服务的代理类。在桩中每个方法的逻辑都是一样的就是把接口名、方法名和请求的参数封装成一个请求发给服务端由服务端调用真正的业务类获取结果并返回给客户端的桩桩再把结果返回给调用方。
客户端实现的难点就是如何来动态地生成桩。像gRPC这类多语言的RPC框架都是在编译IDL的过程中生成桩的源代码再和业务代码使用目标语言的编译器一起编译的。而像Dubbo这类没有编译过程的RPC框架都是在运行时利用一些语言动态特性动态创建的桩。
RPC框架的这种“桩”的设计其实是一种动态代理设计模式。这种设计模式可以在不修改源码甚至不需要源码的情况下在调用链中注入一些业务逻辑。这是一种非常有用的高级技巧可以用在权限验证、风险控制、调用链跟踪等等很多场景中希望你能掌握它的实现原理。
最后我们介绍的依赖倒置原则,可以非常有效地降低系统各部分之间的耦合度,并且不会过度增加系统的复杂度,建议你在设计软件的时候广泛的采用。其实你想一下,现在这么流行的微服务思想,其实就是依赖倒置原则的实践。只是在微服务中,它更极端地把调用方和实现分离成了不同的软件项目,实现了完全的解耦。
## 思考题
今天的课后作业还是需要动手来写代码。熟悉Java语言的同学请你扩展一下我们现在这个RPC框架客户端解除“服务接口只能有一个方法并且这个方法只能有一个参数参数和返回值的类型都是String类型”这个限制让我们的这个RPC框架真正能支持任意接口。
不熟悉Java语言的同学你可以用你擅长的语言把我们这节课讲解的RPC客户端实现出来要求采用和我们这个例子一样的序列化方式这样你实现的客户端是可以和我们例子中的服务端正常进行通信实现跨语言调用的。欢迎你在评论区留言分享你的代码。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,180 @@
<audio id="audio" title="34 | 动手实现一个简单的RPC框架服务端" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/32/c5e8030aa04e3ae3db66015dba8dd932.mp3"></audio>
你好,我是李玥。
上节课我们一起学习了如何来构建这个RPC框架中最关键的部分也就是在客户端如何根据用户注册的服务接口来动态生成桩的方法。在这里除了和语言特性相关的一些动态编译小技巧之外你更应该掌握的是其中动态代理这种设计思想它的使用场景以及实现方法。
这节课我们一起来实现这个框架的最后一部分服务端。对于我们这个RPC框架来说服务端可以分为两个部分注册中心和RPC服务。其中注册中心的作用是帮助客户端来寻址找到对应RPC服务的物理地址RPC服务用于接收客户端桩的请求调用业务服务的方法并返回结果。
## 注册中心是如何实现的?
我们先来看看注册中心是如何实现的。一般来说一个完整的注册中心也是分为客户端和服务端两部分的客户端给调用方提供API并实现与服务端的通信服务端提供真正的业务功能记录每个RPC服务发来的注册信息并保存到它的元数据中。当有客户端来查询服务地址的时候它会从元数据中获取服务地址返回给客户端。
由于注册中心并不是这个RPC框架的重点内容所以在这里我们只实现了一个单机版的注册中心它只有客户端没有服务端所有的客户端依靠读写同一个元数据文件来实现元数据共享。所以我们这个注册中心只能支持单机运行并不支持跨服务器调用。
但是我们在这里同样采用的是“面向接口编程”的设计模式这样你可以在不改动一行代码的情况下就可以通过增加一个SPI插件的方式提供一个可以跨服务器调用的真正的注册中心实现比如说一个基于HTTP协议实现的注册中心。我们再来复习一下这种面向接口编程的设计是如何在注册中心中来应用的。
首先我们在RPC服务的接入点接口RpcAccessPoint中增加一个获取注册中心实例的方法
```
public interface RpcAccessPoint extends Closeable{
/**
* 获取注册中心的引用
* @param nameServiceUri 注册中心URI
* @return 注册中心引用
*/
NameService getNameService(URI nameServiceUri);
// ...
}
```
这个方法的参数就是注册中心的URI也就是它的地址返回值就是访问这个注册中心的实例。然后我们再给NameService接口增加两个方法
```
public interface NameService {
/**
* 所有支持的协议
*/
Collection&lt;String&gt; supportedSchemes();
/**
* 连接注册中心
* @param nameServiceUri 注册中心地址
*/
void connect(URI nameServiceUri);
// ...
}
```
其中supportedSchemes方法返回可以支持的所有协议比如我们在这个例子中的实现它的协议是“file”。connect方法就是给定注册中心服务端的URI去建立与注册中心服务端的连接。
下面我们来看获取注册中心的方法getNameService的实现它的实现也很简单就是通过SPI机制加载所有的NameService的实现类然后根据给定的URI中的协议去匹配支持这个协议的实现类然后返回这个实现的引用就可以了。由于这部分实现是通用并且不会改变的我们直接把实现代码放在RpcAccessPoint这个接口中。
这样我们就实现了一个可扩展的注册中心接口系统可以根据URI中的协议动态地来选择不同的注册中心实现。增加一种注册中心的实现也不需要修改任何代码只要按照SPI的规范把协议的实现加入到运行时CLASSPATH中就可以了。这里设置CLASSPATH的目的在于告诉Java执行环境在哪些目录下可以找到你所要执行的Java程序所需要的类或者包。
我们这个例子中注册中心的实现类是LocalFileNameService它的实现比较简单就是去读写一个本地文件实现注册服务registerService方法时把服务提供者保存到本地文件中实现查找服务lookupService时就是去本地文件中读出所有的服务提供者找到对应的服务提供者然后返回。
这里面有一点需要注意的是由于这个本地文件它是一个共享资源它会被RPC框架所有的客户端和服务端并发读写。所以这时你要怎么做呢**必须要加锁!**
由于我们这个文件可能被多个进程读写所以这里不能使用我们之前讲过的编程语言提供的那些锁原因是这些锁只能在进程内起作用它锁不住其他进程。我们这里面必须使用由操作系统提供的文件锁。这个锁的使用和其他的锁并没有什么区别同样是在访问共享文件之前先获取锁访问共享资源结束后必须释放锁。具体的代码你可以去查看LocalFileNameService这个实现类。
## RPC服务是怎么实现的
接下来我们再来看看RPC服务是怎么实现的。RPC服务也就是RPC框架的服务端。我们在之前讲解这个RPC框架的实现原理时讲到过RPC框架的服务端主要需要实现下面这两个功能
1. 服务端的业务代码把服务的实现类注册到RPC框架中;
1. 接收客户端桩发出的请求,调用服务的实现类并返回结果。
把服务的实现类注册到RPC框架中这个逻辑的实现很简单我们只要使用一个合适的数据结构记录下所有注册的实例就可以了后面在处理客户端请求的时候会用到这个数据结构来查找服务实例。
然后我们来看RPC框架的服务端如何来处理客户端发送的RPC请求。首先来看服务端中使用Netty接收所有请求数据的处理类RequestInvocation的channelRead0方法。
```
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, Command request) throws Exception {
RequestHandler handler = requestHandlerRegistry.get(request.getHeader().getType());
if(null != handler) {
Command response = handler.handle(request);
if(null != response) {
channelHandlerContext.writeAndFlush(response).addListener((ChannelFutureListener) channelFuture -&gt; {
if (!channelFuture.isSuccess()) {
logger.warn(&quot;Write response failed!&quot;, channelFuture.cause());
channelHandlerContext.channel().close();
}
});
} else {
logger.warn(&quot;Response is null!&quot;);
}
} else {
throw new Exception(String.format(&quot;No handler for request with type: %d!&quot;, request.getHeader().getType()));
}
}
```
这段代码的处理逻辑就是根据请求命令的Hdader中的请求类型type去requestHandlerRegistry中查找对应的请求处理器RequestHandler然后调用请求处理器去处理请求最后把结果发送给客户端。
这种通过“请求中的类型”把请求分发到对应的处理类或者处理方法的设计我们在RocketMQ和Kafka的源代码中都见到过在服务端处理请求的场景中这是一个很常用的方法。我们这里使用的也是同样的设计不同的是我们使用了一个命令注册机制让这个路由分发的过程省略了大量的if-else或者是switch代码。这样做的好处是可以很方便地扩展命令处理器而不用修改路由分发的方法并且代码看起来更加优雅。这个命令注册机制的实现类是RequestHandlerRegistry你可以自行去查看。
因为我们这个RPC框架中只需要处理一种类型的请求RPC请求所以我们只实现了一个命令处理器RpcRequestHandler。这部分代码是这个RPC框架服务端最核心的部分你需要重点掌握。另外为了便于你理解在这里我只保留了核心业务逻辑你在充分理解这部分核心业务逻辑之后可以再去查看项目中完整的源代码补全错误处理部分。
我们先来看它处理客户端请求也就是这个handle方法的实现。
```
@Override
public Command handle(Command requestCommand) {
Header header = requestCommand.getHeader();
// 从payload中反序列化RpcRequest
RpcRequest rpcRequest = SerializeSupport.parse(requestCommand.getPayload());
// 查找所有已注册的服务提供方寻找rpcRequest中需要的服务
Object serviceProvider = serviceProviders.get(rpcRequest.getInterfaceName());
// 找到服务提供者利用Java反射机制调用服务的对应方法
String arg = SerializeSupport.parse(rpcRequest.getSerializedArguments());
Method method = serviceProvider.getClass().getMethod(rpcRequest.getMethodName(), String.class);
String result = (String ) method.invoke(serviceProvider, arg);
// 把结果封装成响应命令并返回
return new Command(new ResponseHeader(type(), header.getVersion(), header.getRequestId()), SerializeSupport.serialize(result));
// ...
}
```
1. 把requestCommand的payload属性反序列化成为RpcRequest
1. 根据rpcRequest中的服务名去成员变量serviceProviders中查找已注册服务实现类的实例
1. 找到服务提供者之后利用Java反射机制调用服务的对应方法
1. 把结果封装成响应命令并返回在RequestInvocation中它会把这个响应命令发送给客户端。
再来看成员变量serviceProviders它的定义是Map&lt;String/**service name**/, Object/**service provider**/&gt; serviceProviders。它实际上就是一个MapKey就是服务名Value就是服务提供方也就是服务实现类的实例。这个Map的数据从哪儿来的呢我们来看一下RpcRequestHandler这个类的定义
```
@Singleton
public class RpcRequestHandler implements RequestHandler, ServiceProviderRegistry {
@Override
public synchronized &lt;T&gt; void addServiceProvider(Class&lt;? extends T&gt; serviceClass, T serviceProvider) {
serviceProviders.put(serviceClass.getCanonicalName(), serviceProvider);
logger.info(&quot;Add service: {}, provider: {}.&quot;,
serviceClass.getCanonicalName(),
serviceProvider.getClass().getCanonicalName());
}
// ...
}
```
可以看到这个类不仅实现了处理客户端请求的RequestHandler接口同时还实现了注册RPC服务ServiceProviderRegistry接口也就是说RPC框架服务端需要实现的两个功能——注册RPC服务和处理客户端RPC请求都是在这一个类RpcRequestHandler中实现的所以说这个类是这个RPC框架服务端最核心的部分。成员变量serviceProviders这个Map中的数据也就是在addServiceProvider这个方法的实现中添加进去的。
还有一点需要注意的是我们RpcRequestHandler上增加了一个注解@Singleton限定这个类它是一个单例模式这样确保在进程中任何一个地方无论通过ServiceSupport获取RequestHandler或者ServiceProviderRegistry这两个接口的实现类拿到的都是RpcRequestHandler这个类的唯一的一个实例。这个@Singleton的注解和获取单例的实现在ServiceSupport中你可以自行查看代码。顺便说一句在Spring中也提供了单例Bean的支持它的实现原理也是类似的。
## 小结
以上就是实现这个RPC框架服务端的全部核心内容照例我们来做一个总结。
首先我们一起来实现了一个注册中心注册中心的接口设计采用了依赖倒置的设计原则也就是“面向接口编程”的设计并且还提供了一个“根据URI协议自动加载对应实现类”的机制使得我们可以通过扩展不同的协议增加不同的注册中心实现。
这种“通过请求参数中的类型来动态加载对应实现”的设计在我们这个RPC框架中不止这一处用到在“处理客户端命令并路由到对应的处理类”这部分代码中使用的也是这样一种设计。
在RPC框架的服务端处理客户端请求的业务逻辑中我们分两层做了两次请求分发
1. 在RequestInvocation类中根据请求命令中的请求类型(command.getHeader().getType())分发到对应的请求处理器RequestHandler中
1. RpcRequestHandler类中根据RPC请求中的服务名把RPC请求分发到对应的服务实现类的实例中去。
这两次分发采用的设计是差不多的但你需要注意的是这并不是一种过度设计。原因是我们这两次分发分别是在不同的业务抽象分层中第一次分发是在服务端的网络传输层抽象中它是网络传输的一部分而第二次分发是RPC框架服务端的业务层是RPC框架服务端的一部分。良好的分层设计目的也是让系统各部分更加的“松耦合高内聚”。
## 思考题
这节课的课后作业我们来继续写代码。需要你实现一个JDBC协议的注册中心并加入到我们的RPC框架中。加入后我们的注册中心就可以使用一个支持JDBC协议的数据库比如MySQL作为注册中心的服务端实现跨服务器的服务注册和查询。要求
1. 调用RpcAccessPoint.getNameService()方法获取注册中心实例时传入的参数就是JDBC的URL比如“jdbc:mysql://127.0.0.1/mydb”;
1. 不能修改RPC框架的源代码;
1. 实现必须具有通用性可以支持任意一种JDBC数据库。
欢迎你在评论区留言,分享你的代码。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,97 @@
<audio id="audio" title="35 | 答疑解惑(三):主流消息队列都是如何存储消息的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/93/b581121f85f078bd087300fde4795893.mp3"></audio>
你好,我是李玥。
在我们一起做了两个实践案例以后相信你或多或少都会有一些收获。在学习和练习这两个实践案例中我希望你收获的不仅仅是流计算和RPC框架的设计实现原理还能学会并掌握在实现这些代码过程中我们用到的很多设计模式和编码技巧以及代码背后无处不在的“松耦合”、“拥抱变化”这些设计思想。最重要的是把这些学到的东西能最终用在你编写的代码中才是真正的收获。
照例,在每一模块的最后一节课,我们安排热点问题答疑,解答同学们关注比较多的一些问题。
## 1. 主流消息队列都是如何存储消息的?
我在之前的课程中提到过,现代的消息队列它本质上是一个分布式的存储系统。那决定一个存储系统的性能好坏,最主要的因素是什么?就是它的存储结构。
很多大厂在面试的时候,特别喜欢问各种二叉树、红黑树和哈希表这些你感觉平时都用不到的知识,原因是什么?其实,无论是我们开发的应用程序,还是一些开源的数据库系统,在数据量达到一个量级之上的时候,决定你系统整体性能的往往就是,你用什么样的数据结构来存储这些数据。而大部分数据库,它最基础的存储结构不是树就是哈希表。
即使你不去开发一个数据库,在设计一个超大规模的数据存储的时候,你也需要掌握各种数据库的存储结构,才能选择一个适合你的业务数据的数据库产品。所以,掌握这些最基础的数据结构相关的知识,是很有必要的,不仅仅是为了应付面试。
在所有的存储系统中消息队列的存储可能是最简单的。每个主题包含若干个分区每个分区其实就是一个WALWrite Ahead Log写入的时候只能尾部追加不允许修改。读取的时候根据一个索引序号进行查询然后连续顺序往下读。
接下来我们看看,几种主流的消息队列都是如何设计它们的存储结构的。
先来看KafkaKafka的存储以Partition为单位每个Partition包含一组消息文件Segment file和一组索引文件Index并且消息文件和索引文件一一对应具有相同的文件名但文件扩展名不一样文件名就是这个文件中第一条消息的索引序号。
每个索引中保存索引序号也就是这条消息是这个分区中的第几条消息和对应的消息在消息文件中的绝对位置。在索引的设计上Kafka采用的是稀疏索引为了节省存储空间它不会为每一条消息都创建索引而是每隔几条消息创建一条索引。
写入消息的时候非常简单,就是在消息文件尾部连续追加写入,一个文件写满了再写下一个文件。查找消息时,首先根据文件名找到所在的索引文件,然后用二分法遍历索引文件内的索引,在里面找到离目标消息最近的索引,再去消息文件中,找到这条最近的索引指向的消息位置,从这个位置开始顺序遍历消息文件,找到目标消息。
可以看到,寻址过程还是需要一定时间的。一旦找到消息位置后,就可以批量顺序读取,不必每条消息都要进行一次寻址。
然后我们再来看一下RocketMQRocketMQ的存储以Broker为单位。它的存储也是分为消息文件和索引文件但是在RocketMQ中每个Broker只有一组消息文件它把在这个Broker上的所有主题的消息都存在这一组消息文件中。索引文件和Kafka一样是按照主题和队列分别建立的每个队列对应一组索引文件这组索引文件在RocketMQ中称为ConsumerQueue。RocketMQ中的索引是定长稠密索引它为每一条消息都建立索引每个索引的长度注意不是消息长度是固定的20个字节。
写入消息的时候Broker上所有主题、所有队列的消息按照自然顺序追加写入到同一个消息文件中一个文件写满了再写下一个文件。查找消息的时候可以直接根据队列的消息序号计算出索引的全局位置索引序号x索引固定长度20然后直接读取这条索引再根据索引中记录的消息的全局位置找到消息。可以看到这里两次寻址都是绝对位置寻址比Kafka的查找是要快的。
<img src="https://static001.geekbang.org/resource/image/34/60/343e3423618fc5968405e798b7928660.png" alt="">
对比这两种存储结构,你可以看到它们有很多共通的地方,都是采用消息文件+索引文件的存储方式,索引文件的名字都是第一条消息的索引序号,索引中记录了消息的位置等等。
在消息文件的存储粒度上Kafka以分区为单位粒度更细优点是更加灵活很容易进行数据迁移和扩容。RocketMQ以Broker为单位较粗的粒度牺牲了灵活性带来的好处是在写入的时候同时写入的文件更少有更好的批量不同主题和分区的数据可以组成一批一起写入更多的顺序写入尤其是在Broker上有很多主题和分区的情况下有更好的写入性能。
索引设计上RocketMQ和Kafka分别采用了稠密和稀疏索引稠密索引需要更多的存储空间但查找性能更好稀疏索引能节省一些存储空间代价是牺牲了查找性能。
可以看到两种消息队列在存储设计上有不同的选择。大多数场景下这两种存储设计的差异其实并不明显都可以满足需求。但是在某些极限场景下依然会体现出它们设计的差异。比如在一个Broker上有上千个活动主题的情况下RocketMQ的写入性能就会体现出优势。再比如如果我们的消息都是几个、十几个字节的小消息但是消息的数量很多这时候Kafka的稀疏索引设计就能节省非常多的存储空间。
## 2. 流计算与批计算的区别是什么?
有些同学在《[29 | 流计算与消息通过Flink理解流计算的原理](https://time.geekbang.org/column/article/143215)》的课后留言提问,对于“按照固定的时间窗口定时汇总”的场景,流计算和批计算是不是就是一样的呢?对于这个问题,我们通过一个例子来分析一下就明白了。
比如,你要在一个学校门口开个网吧,到底能不能赚钱需要事先进行调研,看看学生的流量够不够撑起你这个网吧。然后,你就蹲在学校门口数人头,每过来一个学生你就数一下,数一下一天中每个小时会有多少个学生经过,这是流计算。你还可以放个摄像头,让它自动把路过的每个人都拍下来,然后晚上回家再慢慢数这些照片,这就是批计算。简单地说,流计算就是实时统计计算,批计算则是事后统计计算,这两种方式都可以统计出每小时的人流量。
那这两种方式哪种更好呢?还是那句话,**看具体的使用场景和需求**。流计算的优势就是实时统计每到整点的时候上一个小时的人流量就已经数出来了。在T+0的时刻就能第一时间得到统计结果批计算相对就要慢一些它最早在T+0时刻才开始进行统计什么时候出结果取决于统计的耗时。
但是,流计算也有它的一些不足,比如说,你在数人头的时候突然来了个美女,你多看了几眼,漏数了一些人怎么办?没办法,明天再来重新数吧。也就是说,对于流计算的故障恢复还是一个比较难解决的问题。
另外,你数了一整天人头,回去做分析的时候才发现,去网吧的大多数都是男生,所以你需要统计的是在校男生,而不是所有人的数量。这时候,如果你保存了这一天所有人的照片,那你重新数一遍照片就可以了,否则,你只能明天上街再数一次人头。这个时候批计算的优势就体现出来了,因为你有原始数据,当需求发生变化的时候,你可以随时改变算法重新计算。
总结下来,大部分的统计分析类任务,使用流计算和批计算都可以实现。流计算具有更好的实时性,而批计算可靠性更好,并且更容易应对需求变化。所以,大部分针对海量数据的统计分析,只要是对实时性要求没有那么高的场景,大多采用的还是批计算的方式。
## 3. RPC框架的JDBC注册中心
上节课《[34 | 动手实现一个简单的RPC框架服务端](https://time.geekbang.org/column/article/148482)》的课后思考题要求你基于JDBC协议实现一个注册中心这样就可以支持跨服务器来访问注册中心。这个作业应该是我们这个系列课程中比较难的一个作业了我在这里也给出一个实现供你参考。
这个参考实现的代码同样在放在GitHub上你可以在[这里查看或者下载](https://github.com/liyue2008/simple-rpc-framework/tree/jdbc-nameservice)它和之前的RPC框架是同一个项目的不同分支分支名称是jdbc-nameservice。同样我把如何设置环境编译代码启动数据库运行这个RPC框架示例的方法都写在了README中你可以参照运行。
相比于原版的RPC框架我们增加了一个单独的Modulejdbc-nameservice也就是JDBC版的注册中心的实现。这个实现中只有一个类JdbcNameService和LocalFileNameService一样他们都实现了NameService接口。在JdbcNameService这个注册中心实现中它提供JDBC协议的支持注册中心的元数据都存放在数据库中。
我们这个思考题其中的一个要求就是能兼容所有支持JDBC协议的数据库。虽然JDBC的协议是通用的但是每种数据库支持SQL的语法都不一样所以我们这里把SQL语句作为一种资源文件从源代码中独立出来这样确保源代码能兼容所有的JDBC数据库。不同类型的数据的SQL语句可以和数据库的JDBC驱动一样在运行时来提供就可以了。
这个数据库中我们只需要一张表就够了这里面我们的表名是rpc_name_service表结构如下:
<img src="https://static001.geekbang.org/resource/image/a5/4c/a520c21a5ee1f1a12c13bb15eb9da34c.jpg" alt="">
为了能自动根据数据库类型去加载对应的sql我们规定sql文件的名称为[SQL名] [数据库类型].sql。比如我们使用的HSQLDB自动建表的SQL文件它的文件名就是ddl.hsqldb.sql。<br>
JdbcNameService这个类的实现就比较简单了在connect方法中去连接数据库如果rpc_name_service不存在就创建这个表。在registerService中往数据库中插入或者更新一条数据在lookupService中去数据库查询对应服务名的URI。
在使用的时候还需要在CLASSPATH中包含下面几个文件
1. add-service.[数据库类型].sql
1. lookup-service.[数据库类型].sql
1. ddl.[数据库类型].sql
1. 数据库的JDBC驱动JAR文件。
在我们这个实现中已经包含了HSQLDB这种数据库的SQL文件和驱动你也可以尝试提供MySQL的SQL文件和驱动就可以使用MySQL作为注册中心的数据库了。
## 4. 完成作业的最佳姿势
我们案例篇的几个编码的作业都是基于课程中讲解的代码进行一些修改和扩展很多同学在留言区分享了代码。为了便于你修改和分享代码建议你使用GitHub的Fork功能用法也很简单在示例项目的GitHub页面的右上角有一个Fork按钮点击之后会在你自己的GitHub账号下面创建一份这个项目的副本你可以在这个副本上进行修改和扩展来完成你的作业最后直接分享这个副本的项目就可以了。
## 总结
以上就是我们这次热点问题答疑的全部内容了,同时我们这个系列课程的最后一篇:案例篇到这里也就结束了。
这个案例篇模块不同于前两个模块,之前主要是讲解一些消息队列相关的实现原理、知识和方法技巧等等,案例篇的重点还是来通过实际的案例,来复习和练习前两篇中涉及到的一些知识。我们案例篇中每节课的作业,大多也都是需要你来写一些代码。
希望你在学习案例篇的时候,不要只是听和看,更重要的就是动手来写代码,通过练习把学到的东西真正的消化掉。也欢迎你在评论区留言,分享你的代码。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。