mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-20 08:03:43 +08:00
del
This commit is contained in:
184
极客时间专栏/geek/消息队列高手课/案例篇/29 | 流计算与消息(一):通过Flink理解流计算的原理.md
Normal file
184
极客时间专栏/geek/消息队列高手课/案例篇/29 | 流计算与消息(一):通过Flink理解流计算的原理.md
Normal 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 = "localhost"
|
||||
val port: Int = 9999
|
||||
// Task 1
|
||||
val input: DataStream[String] = env.socketTextStream(hostname, port, '\n')
|
||||
|
||||
// 数据转换:将非结构化的以空格分隔的文本转成结构化数据IpAndCount
|
||||
// Task 2
|
||||
input
|
||||
.map { line => line.split("\\s") }
|
||||
.map { wordArray => IpAndCount(new SimpleDateFormat("HH:mm:ss").parse(wordArray(0)), wordArray(1), 1) }
|
||||
|
||||
// 计算:每5秒钟按照ip对count求和
|
||||
|
||||
.assignAscendingTimestamps(_.date.getTime) // 告诉Flink时间从哪个字段中获取
|
||||
|
||||
|
||||
.keyBy("ip") // 按照ip地址统计
|
||||
// Task 3
|
||||
.window(TumblingEventTimeWindows.of(Time.seconds(5))) // 每5秒钟统计一次
|
||||
.sum("count") // 对count字段求和
|
||||
|
||||
// 输出:转换格式,打印到控制台上
|
||||
|
||||
.map { aggData => new SimpleDateFormat("HH:mm:ss").format(aggData.date) + " " + aggData.ip + " " + aggData.count }
|
||||
.print()
|
||||
|
||||
env.execute("Socket Window IpCount")
|
||||
}
|
||||
|
||||
/** 中间数据结构 */
|
||||
|
||||
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> 18:40:10 192.168.1.2 23
|
||||
4> 18:40:10 192.168.1.4 16
|
||||
4> 18:40:15 192.168.1.4 27
|
||||
3> 18:40:15 192.168.1.3 23
|
||||
1> 18:40:15 192.168.1.2 25
|
||||
4> 18:40:15 192.168.1.1 21
|
||||
1> 18:40:20 192.168.1.2 21
|
||||
3> 18:40:20 192.168.1.3 31
|
||||
4> 18:40:20 192.168.1.1 25
|
||||
4> 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分析计算任务之后生成JobGraph,JobGraph是一个有向无环图,数据流过这个图中的节点,在每个节点进行计算和变换,最终流出有向无环图就完成了计算。JobGraph中的每个节点是一个Task,Task是可以并行执行的,每个线程就是一个SubTask。SubTask被JobManager分配给某个TaskManager,在TaskManager进程中的一个线程中执行。
|
||||
|
||||
通过分析Flink的实现原理,我们可以看到,流计算框架本身并没有什么神奇的技术,之所以能够做到非常好的性能,主要有两个原因。一个是,它能自动拆分计算任务来实现并行计算,这个和Hadoop中Map Reduce的原理是一样的。另外一个原因是,流计算框架中,都内置了很多常用的计算和统计分析的算子,这些算子的实现都是经过很多大神级程序员反复优化过的,不仅能方便我们开发,性能上也比大多数程序员自行实现要快很多。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们在启动Flink集群之前,修改了Flink的一个配置:槽数taskmanager.numberOfTaskSlots。请你课后看一下Flink的文档,搞清楚这个槽数的含义。然后再想一下,这个槽数和我们在计算任务中定义的并行度又是什么关系呢?
|
||||
|
||||
欢迎在留言区写下你的思考,如果有任何问题,也欢迎与我交流。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
||||
169
极客时间专栏/geek/消息队列高手课/案例篇/30 | 流计算与消息(二):在流计算中使用Kafka链接计算任务.md
Normal file
169
极客时间专栏/geek/消息队列高手课/案例篇/30 | 流计算与消息(二):在流计算中使用Kafka链接计算任务.md
Normal 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<IpAndCount> sourceConsumer = setupSource();
|
||||
FlinkKafkaProducer011<String> 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("java.io.tmpdir"));
|
||||
env.setStateBackend((StateBackend) new FsStateBackend(tmpDirFile.toURI().toURL().toString()));
|
||||
// 设置故障恢复策略:任务失败的时候自动每隔10秒重启,一共尝试重启3次
|
||||
env.setRestartStrategy(RestartStrategies.fixedDelayRestart(
|
||||
3, // number of restart attempts
|
||||
10000 // delay
|
||||
));
|
||||
|
||||
// 定义输入:从Kafka中获取数据
|
||||
DataStream<IpAndCount> input = env
|
||||
.addSource(sourceConsumer);
|
||||
|
||||
// 计算:每5秒钟按照ip对count求和
|
||||
DataStream<IpAndCount> output =
|
||||
input
|
||||
.keyBy(IpAndCount::getIp) // 按照ip地址统计
|
||||
.window(TumblingEventTimeWindows.of(Time.seconds(5))) // 每5秒钟统计一次
|
||||
.allowedLateness(Time.seconds(5))
|
||||
.sum("count"); // 对count字段求和
|
||||
|
||||
// 输出到kafka topic
|
||||
output.map(IpAndCount::toString).addSink(sinkProducer);
|
||||
|
||||
// execute program
|
||||
env.execute("Exactly-once IpCount");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码和上节课中原始版本的代码整体架构是差不多的,同样是:定义数据源、定义计算逻辑和定义输入这三大步骤。下面主要来说不同之处,这些不同的地方也就是如何配置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<String> setupSink() {
|
||||
// 设置Kafka Producer属性
|
||||
Properties producerProperties = new Properties();
|
||||
producerProperties.put("bootstrap.servers", "localhost:9092");
|
||||
// 事务超时时间设置为1分钟
|
||||
producerProperties.put("transaction.timeout.ms", "60000");
|
||||
|
||||
// 创建 FlinkKafkaProducer,指定语义为EXACTLY_ONCE
|
||||
return new FlinkKafkaProducer011<>(
|
||||
"ip_count_sink",
|
||||
new KeyedSerializationSchemaWrapper<>(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 Once,Flink通过“二阶段提交”这个分布式事务的经典算法来保证CheckPoint和Kafka事务状态的一致性。
|
||||
|
||||
可以看到,Flink配合Kafka来实现端到端的Exactly Once语义,整个实现过程比较复杂,但是,这个复杂的大问题是由一个一个小问题组成的,每个小问题的原理都是很简单的。比如:Kafka如何实现的生产幂等?Flink如何通过存储计算分离解决子任务状态恢复的?很多这些小问题和我们课程中遇到的类似问题是差不多的,那你就可以用到我们学习过的解决方法。
|
||||
|
||||
你需要重点掌握的是,每一个小问题它面临的场景是什么样的,以及如何解决问题的方法。而不要拘泥于,Kafka或者Flink的某个参数怎么配这些细节问题。这些问题可以等到你在生产中真正需要使用的时候,再去读文档,“现学现卖”都来得及。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们的课程中反复强调过,在消息队列的消费端,一定要“先执行消费业务逻辑,再确认消费”,这样才能保证不丢数据。我们这节课中,并没有提到FlinkKafkaConsumer在从数据源主题ip_count_sink消费数据之后,如何来确认消费的。如果消费位置管理不好,一样会导致消息丢失或者重复,课后请你查看一下相关的文档和源代码,看一下FlinkKafkaConsumer是如何来确认消费的。欢迎在留言区与我分享讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
||||
223
极客时间专栏/geek/消息队列高手课/案例篇/31 | 动手实现一个简单的RPC框架(一):原理和程序的结构.md
Normal file
223
极客时间专栏/geek/消息队列高手课/案例篇/31 | 动手实现一个简单的RPC框架(一):原理和程序的结构.md
Normal 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("World");
|
||||
}
|
||||
}
|
||||
|
||||
@Service // dubbo注解
|
||||
@Component
|
||||
public class HelloServiceImpl implements HelloService {
|
||||
|
||||
@Override
|
||||
public String hello(String name) {
|
||||
return "Hello " + 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框架还需要提供一套描述服务的语言,称为IDL(Interface 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 <T> 服务接口的类型
|
||||
* @return 远程服务引用
|
||||
*/
|
||||
<T> T getRemoteService(URI uri, Class<T> serviceClass);
|
||||
|
||||
/**
|
||||
* 服务端注册服务的实现实例
|
||||
* @param service 实现实例
|
||||
* @param serviceClass 服务的接口类的Class
|
||||
* @param <T> 服务接口的类型
|
||||
* @return 服务地址
|
||||
*/
|
||||
<T> URI addServiceProvider(T service, Class<T> 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("收到响应: {}.", response);
|
||||
|
||||
```
|
||||
|
||||
客户端首先调用注册中心NameService的lookupService方法,查询服务地址,然后调用rpcAccessPoint的getRemoteService方法,获得远程服务的本地实例,也就是我们刚刚讲的“桩”helloService。最后,调用helloService的hello方法,获得返回值并打印出来。
|
||||
|
||||
然后来看服务端,首先我们需要有一个HelloService的实现:
|
||||
|
||||
```
|
||||
public class HelloServiceImpl implements HelloService {
|
||||
@Override
|
||||
public String hello(String name) {
|
||||
String ret = "Hello, " + 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框架,为后续三节课的学习做好准备。欢迎在留言区与我分享讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
||||
265
极客时间专栏/geek/消息队列高手课/案例篇/32 | 动手实现一个简单的RPC框架(二):通信与序列化.md
Normal file
265
极客时间专栏/geek/消息队列高手课/案例篇/32 | 动手实现一个简单的RPC框架(二):通信与序列化.md
Normal 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 <E> E parse(byte [] buffer) {
|
||||
// ...
|
||||
}
|
||||
public static <E> byte [] serialize(E entry) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面的parse方法用于反序列化,serialize方法用于序列化。如果你对Java语言不是特别的熟悉,可能会看不懂`<E>`是什么意思,这是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<T> {
|
||||
/**
|
||||
* 计算对象序列化后的长度,主要用于申请存放序列化数据的字节数组
|
||||
* @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<T> getSerializeClass();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个接口中,除了serialize和parse这两个序列化和反序列化两个方法以外,还定义了下面这几个方法:size方法计算序列化之后的数据长度,用于事先来申请存放序列化数据的字节数组;type方法定义每种序列化实现的类型,这个类型值也会写入到序列化之后的数据中,主要的作用是在反序列化的时候,能够识别是什么数据类型的,以便找到对应的反序列化实现类;getSerializeClass这个方法返回这个序列化实现类对应的对象类型,目的是,在执行序列化的时候,通过被序列化的对象类型找到对应序列化实现类。
|
||||
|
||||
利用这个Serializer接口,我们就可以来实现SerializeSupport这个支持任何对象类型序列化的通用静态类了。首先我们定义两个Map,这两个Map中存放着所有实现Serializer接口的序列化实现类。
|
||||
|
||||
```
|
||||
private static Map<Class<?>/*序列化对象类型*/, Serializer<?>/*序列化实现*/> serializerMap = new HashMap<>();
|
||||
private static Map<Byte/*序列化实现类型*/, Class<?>/*序列化对象类型*/> typeMap = new HashMap<>();
|
||||
|
||||
```
|
||||
|
||||
serializerMap中的key是序列化实现类对应的序列化对象的类型,它的用途是在序列化的时候,通过被序列化的对象类型,找到对应的序列化实现类。typeMap的作用和serializerMap是类似的,它的key是序列化实现类的类型,用于在反序列化的时候,从序列化的数据中读出对象类型,然后找到对应的序列化实现类。
|
||||
|
||||
理解了这两个Map的作用,实现序列化和反序列化这两个方法就很容易了。这两个方法的实现思路是一样的,都是通过一个类型在这两个Map中进行查找,查找的结果就是对应的序列化实现类的实例,也就是Serializer接口的实现,然后调用对应的序列化或者反序列化方法就可以了。具体的实现在SerializeSupport中,你可以自行查看。我刚刚讲的这几个类型,听起来可能会感觉有些晕,但其实并不难,你对着代码来自己看一遍,就很容易理解了。
|
||||
|
||||
所有的Serializer的实现类是怎么加载到SerializeSupport的那两个Map中的呢?这里面利用了Java的一个SPI类加载机制,我会在后面的课程中专门来讲。
|
||||
|
||||
到这里,我们就封装好了一个通用的序列化的接口,对于使用序列化的模块来说,它只要依赖SerializeSupport这个静态类,调用它的序列化和反序列化方法就可以了,不需要依赖任何序列化实现类。对于序列化实现的提供者来说,也只需要依赖并实现Serializer这个接口就可以了。比如,我们的HelloService例子中的参数是一个String类型的数据,我们需要实现一个支持String类型的序列化实现:
|
||||
|
||||
```
|
||||
public class StringSerializer implements Serializer<String> {
|
||||
@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<String> getSerializeClass() {
|
||||
return String.class;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面有个初学者容易犯的错误,在把String和byte数组做转换的时候,一定要指定编码方式,确保序列化和反序列化的时候都使用一致的编码,我们这里面统一使用UTF8编码。否则,如果遇到执行序列化和反序列化的两台服务器默认编码不一样,就会出现乱码。我们在开发过程用遇到的很多中文乱码问题,绝大部分都是这个原因。
|
||||
|
||||
在我们这个例子中,还有一个更复杂的序列化实现MetadataSerializer,用于将注册中心的数据持久化到文件中,你也可以参考一下。
|
||||
|
||||
到这里序列化的部分就实现完成了。我们这个序列化的实现,对外提供服务的就只有一个SerializeSupport静态类,并且可以通过扩展支持序列化任何类型的数据,这样一个通用的实现,不仅可以用在我们这个RPC框架的例子中,你完全可以把这部分直接拿过去用在你的业务代码中。
|
||||
|
||||
## 使用Netty来实现异步网络通信
|
||||
|
||||
接下来我们来说网络通信部分的实现。
|
||||
|
||||
同样的思路,我们把通信的部分也封装成接口。在我们这个RPC框架中,对于通信模块的需求是这样的:只需要客户端给服务端发送请求,然后服务返回响应就可以了。所以,我们的通信接口只需要提供一个发送请求方法就可以了:
|
||||
|
||||
```
|
||||
public interface Transport {
|
||||
/**
|
||||
* 发送请求命令
|
||||
* @param request 请求命令
|
||||
* @return 返回值是一个Future,Future
|
||||
*/
|
||||
CompletableFuture<Command> 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<Command> send(Command request) {
|
||||
// 构建返回值
|
||||
CompletableFuture<Command> completableFuture = new CompletableFuture<>();
|
||||
try {
|
||||
// 将在途请求放到inFlightRequests中
|
||||
inFlightRequests.put(new ResponseFuture(request.getHeader().getRequestId(), completableFuture));
|
||||
// 发送命令
|
||||
channel.writeAndFlush(request).addListener((ChannelFutureListener) channelFuture -> {
|
||||
// 处理发送失败的情况
|
||||
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框架仍然可以正常运行。欢迎在留言区分享你的代码。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
||||
146
极客时间专栏/geek/消息队列高手课/案例篇/33 | 动手实现一个简单的RPC框架(三):客户端.md
Normal file
146
极客时间专栏/geek/消息队列高手课/案例篇/33 | 动手实现一个简单的RPC框架(三):客户端.md
Normal 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 {
|
||||
<T> T createStub(Transport transport, Class<T> 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 =
|
||||
"package com.github.liyue2008.rpc.client.stubs;\n" +
|
||||
"import com.github.liyue2008.rpc.serialize.SerializeSupport;\n" +
|
||||
"\n" +
|
||||
"public class %s extends AbstractStub implements %s {\n" +
|
||||
" @Override\n" +
|
||||
" public String %s(String arg) {\n" +
|
||||
" return SerializeSupport.parse(\n" +
|
||||
" invokeRemote(\n" +
|
||||
" new RpcRequest(\n" +
|
||||
" \"%s\",\n" +
|
||||
" \"%s\",\n" +
|
||||
" SerializeSupport.serialize(arg)\n" +
|
||||
" )\n" +
|
||||
" )\n" +
|
||||
" );\n" +
|
||||
" }\n" +
|
||||
"}";
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T createStub(Transport transport, Class<T> serviceClass) {
|
||||
try {
|
||||
// 填充模板
|
||||
String stubSimpleName = serviceClass.getSimpleName() + "Stub";
|
||||
String classFullName = serviceClass.getName();
|
||||
String stubFullName = "com.github.liyue2008.rpc.client.stubs." + stubSimpleName;
|
||||
String methodName = serviceClass.getMethods()[0].getName();
|
||||
|
||||
String source = String.format(STUB_SOURCE_TEMPLATE, stubSimpleName, classFullName, methodName, classFullName, methodName);
|
||||
// 编译源代码
|
||||
JavaStringCompiler compiler = new JavaStringCompiler();
|
||||
Map<String, byte[]> results = compiler.compile(stubSimpleName + ".java", source);
|
||||
// 加载编译好的类
|
||||
Class<?> 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语言内置的,更轻量级的解决方案:SPI(Service 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客户端实现出来,要求采用和我们这个例子一样的序列化方式,这样,你实现的客户端是可以和我们例子中的服务端正常进行通信,实现跨语言调用的。欢迎你在评论区留言,分享你的代码。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
||||
180
极客时间专栏/geek/消息队列高手课/案例篇/34 | 动手实现一个简单的RPC框架(四):服务端.md
Normal file
180
极客时间专栏/geek/消息队列高手课/案例篇/34 | 动手实现一个简单的RPC框架(四):服务端.md
Normal 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<String> 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 -> {
|
||||
if (!channelFuture.isSuccess()) {
|
||||
logger.warn("Write response failed!", channelFuture.cause());
|
||||
channelHandlerContext.channel().close();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.warn("Response is null!");
|
||||
}
|
||||
} else {
|
||||
throw new Exception(String.format("No handler for request with type: %d!", 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<String/**service name**/, Object/**service provider**/> serviceProviders。它实际上就是一个Map,Key就是服务名,Value就是服务提供方,也就是服务实现类的实例。这个Map的数据从哪儿来的呢?我们来看一下RpcRequestHandler这个类的定义:
|
||||
|
||||
```
|
||||
@Singleton
|
||||
public class RpcRequestHandler implements RequestHandler, ServiceProviderRegistry {
|
||||
@Override
|
||||
public synchronized <T> void addServiceProvider(Class<? extends T> serviceClass, T serviceProvider) {
|
||||
serviceProviders.put(serviceClass.getCanonicalName(), serviceProvider);
|
||||
logger.info("Add service: {}, provider: {}.",
|
||||
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数据库。
|
||||
|
||||
欢迎你在评论区留言,分享你的代码。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
||||
97
极客时间专栏/geek/消息队列高手课/案例篇/35 | 答疑解惑(三):主流消息队列都是如何存储消息的?.md
Normal file
97
极客时间专栏/geek/消息队列高手课/案例篇/35 | 答疑解惑(三):主流消息队列都是如何存储消息的?.md
Normal 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. 主流消息队列都是如何存储消息的?
|
||||
|
||||
我在之前的课程中提到过,现代的消息队列它本质上是一个分布式的存储系统。那决定一个存储系统的性能好坏,最主要的因素是什么?就是它的存储结构。
|
||||
|
||||
很多大厂在面试的时候,特别喜欢问各种二叉树、红黑树和哈希表这些你感觉平时都用不到的知识,原因是什么?其实,无论是我们开发的应用程序,还是一些开源的数据库系统,在数据量达到一个量级之上的时候,决定你系统整体性能的往往就是,你用什么样的数据结构来存储这些数据。而大部分数据库,它最基础的存储结构不是树就是哈希表。
|
||||
|
||||
即使你不去开发一个数据库,在设计一个超大规模的数据存储的时候,你也需要掌握各种数据库的存储结构,才能选择一个适合你的业务数据的数据库产品。所以,掌握这些最基础的数据结构相关的知识,是很有必要的,不仅仅是为了应付面试。
|
||||
|
||||
在所有的存储系统中,消息队列的存储可能是最简单的。每个主题包含若干个分区,每个分区其实就是一个WAL(Write Ahead Log),写入的时候只能尾部追加,不允许修改。读取的时候,根据一个索引序号进行查询,然后连续顺序往下读。
|
||||
|
||||
接下来我们看看,几种主流的消息队列都是如何设计它们的存储结构的。
|
||||
|
||||
先来看Kafka,Kafka的存储以Partition为单位,每个Partition包含一组消息文件(Segment file)和一组索引文件(Index),并且消息文件和索引文件一一对应,具有相同的文件名(但文件扩展名不一样),文件名就是这个文件中第一条消息的索引序号。
|
||||
|
||||
每个索引中保存索引序号(也就是这条消息是这个分区中的第几条消息)和对应的消息在消息文件中的绝对位置。在索引的设计上,Kafka采用的是稀疏索引,为了节省存储空间,它不会为每一条消息都创建索引,而是每隔几条消息创建一条索引。
|
||||
|
||||
写入消息的时候非常简单,就是在消息文件尾部连续追加写入,一个文件写满了再写下一个文件。查找消息时,首先根据文件名找到所在的索引文件,然后用二分法遍历索引文件内的索引,在里面找到离目标消息最近的索引,再去消息文件中,找到这条最近的索引指向的消息位置,从这个位置开始顺序遍历消息文件,找到目标消息。
|
||||
|
||||
可以看到,寻址过程还是需要一定时间的。一旦找到消息位置后,就可以批量顺序读取,不必每条消息都要进行一次寻址。
|
||||
|
||||
然后我们再来看一下RocketMQ,RocketMQ的存储以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框架,我们增加了一个单独的Module:jdbc-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账号下面创建一份这个项目的副本,你可以在这个副本上进行修改和扩展来完成你的作业,最后直接分享这个副本的项目就可以了。
|
||||
|
||||
## 总结
|
||||
|
||||
以上就是我们这次热点问题答疑的全部内容了,同时我们这个系列课程的最后一篇:案例篇到这里也就结束了。
|
||||
|
||||
这个案例篇模块不同于前两个模块,之前主要是讲解一些消息队列相关的实现原理、知识和方法技巧等等,案例篇的重点还是来通过实际的案例,来复习和练习前两篇中涉及到的一些知识。我们案例篇中每节课的作业,大多也都是需要你来写一些代码。
|
||||
|
||||
希望你在学习案例篇的时候,不要只是听和看,更重要的就是动手来写代码,通过练习把学到的东西真正的消化掉。也欢迎你在评论区留言,分享你的代码。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user