Netflix Hystrix 通过类似滑动窗口的数据结构来统计调用的指标数据。Hystrix 1.5 将滑动窗口设计成了数据流(reactive stream, RxJava 中的 Observable
)的形式。通过消费数据流的形式利用滑动窗口,并对数据流进行变换后进行后续的操作,可以让开发者更加灵活地去使用。由于 Hystrix 里大量使用了 RxJava,再加上滑动窗口本质就是不断变换的数据流,滑动窗口中每个桶的数据都来自于源源不断的事件,因此滑动窗口非常适合用观察者模式和响应式编程思想的 RxJava 实现。使用 RxJava 实现有一大好处:可以通过 RxJava 的一系列操作符来实现滑动窗口,从而可以依赖 RxJava 的线程模型来保证数据写入和聚合的线程安全,将这一系列的机制交给 RxJava。所有的操作都是在 RxJava 的后台线程上进行的,RxJava 会保证操作的有序性和线程安全(参见 The Observable Contract)。
这里我们就以 Hystrix 熔断器依赖的记录调用情况统计的 HealthCountsStream
为例来看一下 Hystrix 1.5 是如何利用 RxJava 将滑动窗口抽象并实现成 reactive stream 的,以及如何去消费对应的数据流。
滑动窗口的实现都位于 com.netflix.hystrix.metric.consumer
包下,这里只挑 BucketedRollingCounterStream
这条线的实现来分析。首先先看一下类的继承结构:
最顶层的 BucketedCounterStream
抽象类提供了基本的桶计数器实现,按配置的时间间隔将所有事件聚合成桶;BucketedRollingCounterStream
抽象类在其基础上实现滑动窗口,并聚合成指标数据;而最底下一层的类则是各种具体的实现,比如 HealthCountsStream
最终会聚合成健康检查数据(HystrixCommandMetrics.HealthCounts
,统计调用成功和失败的计数),供 HystrixCircuitBreaker
使用。
BucketedCounterStream
抽象类提供了基本的桶计数器实现。用户在使用 Hystrix 的时候一般都要配两个值:timeInMilliseconds
和 numBuckets
,前者代表滑动窗口的长度(时间间隔),后者代表滑动窗口中桶的个数,那么每个桶对应的窗口长度就是 bucketSizeInMs = timeInMilliseconds / numBuckets
(记为一个单元窗口周期)。BucketedCounterStream
每隔一个单元窗口周期(bucketSizeInMs
)就把这段时间内的所有调用事件聚合到一个桶内。我们来看一下它的实现,首先来看一下它的泛型定义:
|
|
BucketedCounterStream
的泛型里接受三个类型参数,其中第一个 Event
类型代表 Hystrix 中的调用事件,如命令开始执行、命令执行完成等。这种事件驱动的设计也非常符合 RxJava 的思想,每个调用者都向订阅者发布事件,订阅者将事件聚合成调用指标;第二个 Bucket
类型代表桶的类型,第三个 Output
类型代表数据聚合的最终输出类型。
BucketedCounterStream
核心代码在构造函数里(为了可读性起见,将所有可以用 lambda expression 的地方都转换成了 lambda expression,下同):
|
|
其中 bucketedStream
即为本次得到的数据流(类型为 RxJava 中的 Observable
,即观察者模式中的 Publisher,会源源不断地产生事件/数据),里面最核心的逻辑就是如何将一个一个的事件按一段时间聚合成一个桶。我们可以看到 bucketedStream
是经事件源 inputEventStream
变换而成的,事件源的类型为 HystrixEventStream<Event>
,它代表事件流接口:
|
|
其中 observe
方法返回这个事件流对应的发布者 Observable
,订阅者可以对事件进行变换并消费。
Hystrix 中执行函数以命令模式封装成了一个一个命令(Command
),每个命令执行时都会触发某个事件,其中命令执行完成事件(HystrixCommandCompletion
)是 Hystrix 中最核心的事件,它可以代表某个命令执行成功、超时、异常等等的各种的状态,与服务调用的熔断息息相关。熔断器的计数依赖于 HystrixCommandCompletion
事件,因此这里我们只关注这个事件对应的事件流,其余类型的事件流原理类似。
那么这个事件流中的事件是从哪里发布的呢?我们来看一下相关的具体实现 - HystrixCommandCompletionStream
(仅核心代码):
|
|
从代码里我们可以看到 write
方法里通过向某个 Subject
发布事件来实现了发布的逻辑,那么 Subject
又是什么呢?简单来说,Subject
就像是一个桥梁,既可以作为发布者 Observable
,又可以作为订阅者 Observer
。它可以作为发布者和订阅者之间的一个“代理”,提供额外的功能(如流量控制、缓存等)。这里的 writeOnlySubject
是经过 SerializedSubject
封装的 PublishSubject
。PublishSubject
可以看做 hot observable。为了保证调用的顺序(根据 The Observable Contract,每个事件的产生需要满足顺序上的偏序关系,即使是在不同线程产生),需要用 SerializedSubject
封装一层来保证事件真正地串行地产生。这里还有一个问题,就是不同的发布者调用 write
方法发布事件时,线程上下文可能都不同,那么如何保证其线程安全呢?Hystrix 1.5 通过使用 ThreadLocal 来保证每个线程都有一份 Subject
的实例,确保事件发布的线程安全。相关代码位于 HystrixThreadEventStream
内(已略去其它事件的代码):
|
|
这里 Hystrix 通过 ThreadLocal 为每个不同的线程都创建了不同的 HystrixThreadEventStream
,里面的 Subject
都是 write-only, thread-safe 的。Hystrix 在这里额外加了一层 writeOnlyCommandCompletionSubject
,提供额外的流量控制机制(onBackpressureBuffer
),消费者太慢时这里会积压。其中会调用 HystrixCommandCompletionStream
的 write
方法产生对应的事件。
executionDone
方法最后会经 HystrixCommandMetrics
类的 markCommandDone
方法进行调用。HystrixCommandMetrics
是 Hystrix 中另一个重要的类,从中可以获取各种指标数据的流的实例。最后 Hystrix 会在对应命令执行完毕后,调用 markCommandDone
进行数据记录,并发布对应的事件。相关代码位于 AbstractCommand
类内:
|
|
AbstractCommand
类是 Hystrix 命令模式执行模型的实现,整合了资源隔离、熔断器等各种高可用机制,是整个 Hystrix 的核心。
上面我们探究了事件流的发布原理,以及如何保证写的线程安全。那么事件流写入到 writeOnlySubject
以后,如何被订阅者消费呢?如何保证多个订阅者都可以对事件流进行消费,并且序列一致呢?我们回到之前的 observe
方法,observe
方法返回的是一个 readOnlyStream
:
|
|
readOnlyStream
是 writeOnlySubject
的只读版本,它是通过 share
操作符产生的:
|
|
Hystrix 通过 RxJava 的 share
操作符产生一种特殊的 Observable
:当有一个订阅者去消费事件流时它就开始产生事件,可以有多个订阅者去订阅,同一时刻收到的事件是一致的;直到最后一个订阅者取消订阅以后,事件流才停止产生事件。其底层实现非常有意思:
|
|
在执行 publish
的时候,Observable
会被变换成为一个 ConnectableObservable
。这种 ConnectableObservable
只会在进行连接操作(connect
)以后才会产生数据(连接后行为类似于 hot observable)。而 share
操作底层的 refCount
操作符就帮我们做了这样的操作:refCount
底层维护着一个引用计数器,代表绑定的订阅者数目。当第一个订阅者去消费事件流的时候,引用计数大于 0,refCount
底层会自动进行 connect
,从而触发事件流产生事件;当最后一个订阅者取消订阅以后,引用计数归零,refCount
底层就会自动进行 disconnect
,事件流停止产生事件。也就是说,这样的一个可以被多个订阅者共享的事件流,底层是基于引用计数法来管理事件的产生的,和智能指针的思想类似。
上面我们研究完了事件流是如何产生的,接下来就回归到事件流聚合为桶的逻辑:
|
|
其中的核心是 window
操作符,它可以按单元窗口长度来将某个时间段内的调用事件聚集起来,此时数据流里每个对象都是一个集合:Observable<Event>
,所以需要将其聚集成桶类型以将其扁平化。Hystrix 通过 RxJava 的 reduce
操作符进行“归纳”操作,将一串事件归纳成一个桶:
|
|
其中我们需要提供桶的初值(即空桶),并要提供聚合函数来进行聚合,类型为 Bucket -> Event -> Bucket
(代表对于每个 Event
,都将其聚合到 Bucket
中,并返回聚合后的 Bucket
)。不同的实现对应的 Bucket
和规约函数不同,比如熔断器依赖的 HealthCountsStream
就以 long[]
来作为每个桶。
注:此处的
window(timespan, unit)
操作符属于计算型操作符,默认会在Schedulers.computation()
调度器下执行(CPU 密集型),其底层本质是线程数为 CPU 核数的线程池。RxJava 会确保其线程安全。
BucketedRollingCounterStream
按照滑动窗口的大小对每个单元窗口产生的桶进行聚合,这也是 Hystrix 1.5 中滑动窗口的抽象实现。其核心实现仍然位于构造函数内:
|
|
构造函数后两个参数参数分别代表两个函数:将事件流聚合成桶的函数(appendRawEventToBucket
) 以及 将桶聚合成输出对象的函数(reduceBucket
)。
我们看到 BucketedRollingCounterStream
实现了 observe
方法,返回了一个 Observable<Output>
类型的发布者 sourceStream
,供订阅者去消费。这里的 sourceStream
应该就是滑动窗口的终极形态了,那么它是如何变换得到的呢?这里面的核心还是 window
和 flatMap
算子。这里的 window
算子和之前的版本不同,它可以将数据流中的一定数量的数据聚集成一个集合,它的第二个参数 skip=1
的意思就是按照步长为 1 在数据流中滑动,不断聚集对象,这即为滑动窗口的真正实现。到这里每个窗口都已经形成了,下一步就是要对窗口进行聚合了。注意这里聚合操作没有用 reduce
,而是用了 scan
+ skip(numBuckets)
的组合:
|
|
这里每个集合的大小都是 numBuckets
,看起来用 reduce
和 scan
+ skip(numBuckets)
没有什么区别,但是注意当数据流终结时,最后面的窗口大小都不满 numBuckets
,这时候就需要把这些不完整的窗口给过滤掉来确保数据不缺失。这个地方也是开发的时候容易忽略的地方,很值得思考。
聚合完毕以后,基本的滑动窗口数据就OK了,为了支持多订阅者,还要进行 share
;并且利用 onBackpressureDrop
操作符实现流量控制,此处当消费者跟不上的时候就直接丢掉数据,不进行积压。
前面滑动窗口的抽象实现都已经分析完了,现在我们就来看一下其中的一个具体实现 - HealthCountsStream
,它提供实时的健康检查数据(HystrixCommandMetrics.HealthCounts
,统计调用成功和失败的计数)。
之前我们提到 BucketedRollingCounterStream
里面有三个类型参数和两个重要函数参数。HealthCountsStream
对应的三个类型参数分别为:
Event
: HystrixCommandCompletion
,代表命令执行完成。可以从中获取执行结果,并从中提取所有产生的事件(HystrixEventType
)Bucket
: 桶的类型为 long[]
,里面统计了各种事件的个数。其中 index 为事件类型枚举对应的索引(ordinal
),值为对应事件的个数Output
: HystrixCommandMetrics.HealthCounts
,里面统计了总的执行次数、失败次数以及失败百分比,供熔断器使用滑动窗口里用于将事件聚合成桶的函数实现:
|
|
滑动窗口里用于将每个窗口聚合成最终的统计数据的的函数实现:
|
|
Hystrix 熔断器里会实时地去消费每个窗口产生的健康统计数据,并根据指标来决定熔断器的状态:
|
|
Hystrix 1.5 使用 RxJava 1.x 来实现滑动窗口,将滑动窗口抽象成响应式数据流的形式,既适合 Hystrix 事件驱动的特点,又易于实现和使用。滑动窗口的实现的要点就是每个桶的聚合以及滑动窗口的形成,Hystrix 巧妙地运用了 RxJava 中的 window
操作符来将单位窗口时间内的事件,以及将一个窗口大小内的桶聚集到一起,并通过 reduce
等折叠操作将事件集合聚集为桶,将滑动窗口内的桶聚集成指标数据,非常巧妙。同时,Hystrix 利用 ThreadLocalshare
操作符可以确保多个订阅者从某个共享的 Observable 中观察的序列一致。
最后用一张图来总结 Hystrix Metrics 事件驱动的流程:
Continuation 也是一个老生常谈的东西了,我们来回顾一下。首先我们看一下 TSPL4 中定义的表达式求值需要做的事:
During the evaluation of a Scheme expression, the implementation must keep track of two things: (1) what to evaluate and (2) what to do with the value.
Continuation 即为其中的(2),即表达式被求值以后,接下来要对表达式做的计算。R5RS 中 continuation 的定义为:
The continuation represents an entire (default) future for the computation.
比如 (+ (* 2 3) (+ 1 7))
表达式中,(* 2 3)
的 continuation 为:保存 (* 2 3)
计算出的值 6
,然后计算 (+ 1 7)
的值,最后将两表达式的值相加,结束;(+ 1 7)
的 continuation 为:保存 (+ 1 7)
的值 8
,将其与前面计算出的 6
相加,结束。
Scheme 中的 continuation 是 first-class 的,也就是说它可以被当做参数进行传递和返回;并且 Scheme 中可以将 continuation 视为一个 procedure,也就是说可以调用 continuation 执行后续的运算。
每个表达式在求值的时候,都会有一个对应的 current continuation,它在等着当前表达式求值完毕然后把值传递给它。那么如何捕捉 current continuation 呢?这就要用到 Scheme 中强大的 call/cc
了。call/cc
的全名是 call-with-current-continuation
,它可以捕捉当前环境下的 current continuation 并利用它做各种各样的事情,如改变控制流,实现非本地退出(non-local exit)、协程(coroutine)、多任务(multi-tasking)等,非常方便。注意这里的 continuation 将当前 context 一并打包保存起来了,而不只是保存程序运行的位置。下面我们来举几个例子说明一下 call/cc
的用法。
我们先来看个最简单的例子 —— 用它来捕捉 current continuation 并作为 procedure 调用。call/cc
接受一个函数,该函数接受一个参数,此参数即为 current continuation。以之前 (+ (* 2 3) (+ 1 7))
表达式中 (* 2 3)
的 continuation 为例:
|
|
我们将 (* 2 3)
的 current continuation (用(+ ? (+ 1 7))
表示) 绑定给 cc
变量。现在 cc
就对应了一个 continuation ,它相当于过程 (define (cc x) (+ (x) (+ 1 7)))
,等待一个值然后进行后续的运算:
|
|
这个例子很好理解,我们下面引入 call/cc
的本质 —— 控制流变换。在 Scheme 中,假设 call/cc
捕捉到的 current continuation 为 cc
(位于 lambda
中),如果 cc
作为过程 直接或间接地被调用(即给它传值),call/cc
会立即返回,返回值即为传入 cc
的值。即一旦 current continuation 被调用,控制流会跳到 call/cc
处。因此,利用 call/cc
,我们可以摆脱顺序执行的限制,在程序中跳来跳去,非常灵活。下面我们举几个 non-local exit 的例子来说明。
Scheme 中没有 break
和 return
关键字,因此在循环中如果想 break
并提前返回的话就得借助 call/cc
。比如下面的例子寻找传入的 list
中是否包含 5
:
|
|
测试:
|
|
check-lst
过程会遍历列表中的元素,每次都会将 current continuation 传给 do-with
过程并进行调用,一旦do-with
遇到 5
,我们就将结果传给 current continuation (即 return
),此时控制流会马上跳回 check-lst
过程中的 call/cc
处,这时候就已经终止遍历了(跳出了循环)。call/cc
的返回值为 'find-five
,所以最后会在控制台上打印出 'find-five
。
我们再来看一个经典的 generator 的例子,它非常像 Python 和 ES 6 中的 yield
,每次调用的时候都会返回 list 中的一个元素:
|
|
调用:
|
|
注意到这个例子里有两个 call/cc
,大家刚看到的时候可能会有点晕,其实这两个 call/cc
各司其职,互不干扰。第一个 call/cc
负责保存遍历的状态(从此处恢复),而 generator
中的 call/cc
才是真正生成值的地方(非本地退出)。其中一个需要注意的地方就是 control-state
,它在第一次调用的时候还是个 procedure,在第一次调用的过程中它就被重新绑定成一个 continuation
,之后再调用 generator
生成器的时候,控制流就可以跳到之前遍历的位置继续执行下面的过程,从而达到生成器的效果。
continuation 环境嵌套。后面有时间专开一篇分析~
|
|
这里简单提一下 call/cc
与类型系统和数理逻辑的联系。call/cc
的类型是 ((P → Q) → P) → P
,通过 Curry-Howard 同构,它可以对应到经典逻辑中的 Peirce’s law:
$$((P \to Q) \to P) \to P$$
Peirce’s law 代表排中律 $P \land \lnot P$,这条逻辑无法在 λ演算所对应的直觉逻辑中表示(直觉逻辑中双重否定不成立),因此 call/cc
无法用 λ表达式定义。通常我们用扩展后的 $\lambda \mu \ calculus$ 来定义 call/cc
,$\lambda \mu \ calculus$ 经 Curry-Howard 同构可以得到经典逻辑。
Yak 是一个 JVM 平台上的针对大数据场景(分布式计算框架)设计、优化的 Garbage Collector。
在传统的基于分代模型的垃圾回收算法中,小对象首先被划分到 Young Generation(新生代);如果对象经历过一定阈值的 GC 还会存活后,它就会被晋升至 Old Generation(老年代);大对象可以直接进入老年代。
对于分布式计算框架而言,这样的模型有一定的弊端:没有考虑分布式计算情景下对象的生命周期。分布式计算中控制过程(Control Path)与数据处理过程(Data Path)界限明显,如果全都用统一的 GC 模型的话,会导致频繁请求 GC 数据,扫描全堆,最后实际回收的很少,导致 Full GC (STW)。因此,像 Apache Spark 在 1.5 版本以后已经放弃使用 JVM 的 GC 进行内存管理,而是直接利用 unsafe 包进行内存管理。这样十分麻烦还容易出错。
Yak 就是为了解决这样的问题而诞生的。既然分布式计算过程中控制过程与数据处理过程界限明显,Yak 针对这两种过程中的数据划分了两种不同的空间(space):
对于控制过程(比如任务的调度、日志记录等),其内存布局与传统的一致,GC 还是采用分代模型,分 YoungGen/OldGen/Metaspace。控制过程产生的对象小、生命周期短暂,符合分代假设。
而对于数据处理过程,其中的对象通常都是很大的、在计算周期中一直需要访问的,因此 Yak 提出了 Epoch Region,数据对象的生命周期依赖于每个 epoch。每个 epoch 的 start 与 end 需要用户来设置(但是很简单)。
Epoch hypothesis: many data-path objects have the same life span and can be reclaimed together at the end of an epoch.
时域抽象(Epoch Region): 抽象成 semilattice(半格),用于描述 nested epoches 之间的偏序关系。见论文 Figure 5。(Order Theory在这里非常有用)
如何正确地回收某个特定的 Region
如果有的对象生命周期超出此 epoch,如何将其迁移至“安全地带”?
对于标记的过程,可以以 cross-region/space references 为根节点来遍历对象图并且标记其中的 escaping objects(传递闭包)
对于决定其 destination 的过程,需要计算出对象 O 的引用 region 的上确界(via semilattice)。
如果对应的 region 具有继承关系,则应选择最上面的(上确界)。如果是不同线程执行的,那么对应的上确界则为 CS。
]]>TODO: 待详细总结
传统的分布式计算框架(如MapReduce)在执行计算任务时,中间结果通常会存于磁盘中,这样带来的IO消耗是非常大的,尤其是对于各种机器学习算法,它们需要复用上次计算的结果进行迭代,如果每次结果都存到磁盘上再从磁盘读取,耗时会很大。因此Spark这篇论文提出了一种新的分布式数据抽象 —— RDD。
Resilient Distributed Dataset(RDD)是Apache Spark中数据的核心抽象,它是一种只读的、分区的数据记录集合。
RDD的特点:
那么如何操作、处理数据呢?Spark提供了一组函数式编程风格的API,可以很方便地对RDD进行操作、变换,就像操作集合一样。比如:
|
|
并且开发者可以根据需要自己编写相应的RDD以及RDD之间的操作,非常方便。可以这么理解,RDD就相当于抽象的数据表示,而operation就相当于一套DSL用于对RDD进行变换或者求值。
Spark中的RDD主要包含五部分信息:
partitions()
: partition集合dependencies()
: 当前RDD的dependency集合iterator(split, context)
: 对每个partition进行计算或读取操作的函数partitioner()
: 分区方式,如HashPartitioner
和RangePartitioner
preferredLocations(split)
: 访问某个partition最快的节点所有的RDD都继承抽象类RDD
。几种常见的操作:
sc#textFile
: 生成HadoopRDD
,代表可以从HDFS中读取数据的RDDsc#parallelize
: 生成ParallelCollectionRDD
,代表从Scala集合中生成的RDDmap
, flatMap
, filter
: 生成MapPartitionsRDD
,其partition与parent RDD一致,同时会对parent RDD中iterator
函数返回的数据进行对应的操作(lazy)union
: 生成UnionRDD
或PartitionerAwareUnionRDD
reduceByKey
, groupByKey
: 生成ShuffledRDD
,需要进行shuffle操作cogroup
, join
: 生成CoGroupedRDD
Spark里面对RDD的操作分为两种:transformation 和 action。
这些transformation和action在FP中应该是很常见的,如map
, flatMap
, filter
, reduce
, count
, sum
。
对单个数据操作的transformation函数都在RDD
抽象类内,而对tuple操作的transformation都在PairRDDFunctions
包装类中。RDD
可以通过implicit函数在符合类型要求的时候自动转换为PairRDDFunctions
类,从而可以进行reduceByKey
之类的操作。对应的implicit函数:
|
|
上面我们提到,RDD只会在需要的时候计算结果,调用那些transformation方法以后,对应的transformation信息只是被简单地存储起来,直到调用某个action才会真正地去执行计算。Spark中RDD之间是有联系的,RDD之间会形成依赖关系,也就是形成lineage graph(依赖图)。Dependency大致分两种:narrow dependency和wide dependency。
NarrowDependency
): Parent RDD中的每个partition最多被child RDD中的一个partition使用,即一对一的关系。比如map
, flatMap
, filter
等transformation都是narrow dependencyShuffleDependency
):Parent RDD中的每个partition会被child RDD中的多个partition使用,即一对多的关系。比如join
生成的RDD一般是wide dependency(不同的partitioner)论文中的图例很直观地表示了RDD间的依赖关系:
这样划分dependency的原因:
Spark中的shuffle操作与MapReduce中类似,在计算wide dependency对应的RDD的时候(即ShuffleMapStage)会触发。
首先来回顾一下为什么要进行shuffle操作。以reduceByKey
操作为例,Spark要按照key把这些具有相同key的tuple聚集到一块然后进行计算操作。然而这些tuple可能在不同的partition中,甚至在不同的集群节点中,要想计算必须先把它们聚集起来。因此,Spark用一组map task来将每个分区写入到临时文件中,然后下一个stage端(reduce task)会根据编号获取临时文件,然后将partition中的tuple按照key聚集起来并且进行相应的操作。这里面还包括着排序操作(可能在map side也可能在reduce side进行)。
Shuffle是Spark的主要性能瓶颈之一(涉及磁盘IO,数据序列化和网络IO),其优化一直是个难题。
SortShuffleWriter#write
ShuffleRDD#compute
Checkpoint的目的是保存那些计算耗时较长的RDD数据(long lineage chains),执行Checkpoint的时候会新提交一个Job,因此最好先persist
后checkpoint
。
cache
和persist
用于缓存一些经常使用的RDD结果(但是不能太大)。
persist
方法的主要作用是改变StorageLevel
以在compute
的时候通过BlockManager
进行相应的持久化操作cache
方法相当于设置存储级别为MEMORY_ONLY
简单来说,Spark会将提交的计算划分为不同的stages,形成一个有向无环图(DAG)。Spark的调度器会按照DAG的次序依次进行计算每个stage,最终得到计算结果。执行计算的几个重要的类或接口如下:
DAGScheduler
ActiveJob
Stage
Task
TaskScheduler
SchedulerBackend
这里面最为重要的就是 DAGScheduler 了,它会将逻辑执行计划(即RDD lineage)转化为物理执行计划(stage/task)。之前我们提到过,当开发者对某个RDD执行action的时候,Spark才会执行真正的计算过程。当开发者执行action的时候,SparkContext
会将当前的逻辑执行计划传给DAGScheduler
,DAGScheduler
会根据给定的逻辑执行计划生成一个Job(对应ActiveJob
类)并提交。每执行一个acton都会生成一个ActiveJob
。
提交Job的过程中,DAGScheduler
会进行stage的划分。Spark里是按照shuffle
操作来划分stage的,也就是说stage之间都是wide dependency,每个stage之内的dependency都是narrow dependency。这样划分的好处是尽可能地把多个narrow dependency的RDD放到同一个stage之内以便于进行pipeline计算,而wide dependency中child RDD必须等待所有的parent RDD计算完成并且shuffle
以后才能接着计算,因此这样划分stage是最合适的。
划分好的stages会形成一个DAG,DAGScheduler
会根据DAG中的顺序先提交parent stages(如果存在的话),再提交当前stage,以此类推,最先提交的是没有parent stage的stage。从执行角度来讲,一个stage的parent stages执行完以后,该stage才可以被执行。最后一个stage是产生最终结果的stage,对应ResultStage
,而其余的stage都是ShuffleMapStage
。下面是论文中stage划分的一个图例,非常直观:
提交stage的时候,Spark会根据stage的类型生成一组对应类型的Task
(ResultTask
或ShuffleMapTask
),然后将这些Task
包装成TaskSet
提交到TaskScheduler
中。一个Task
对应某个RDD中的某一个partition,即一个Task
只负责某个partition的计算:
|
|
TaskScheduler
会向执行任务的后端(SchedulerBackend
,可以是Local, Mesos, Hadoop YARN或者其它集群管理组件)发送ReviveOffers
消息,对应的执行后端接收到消息以后会将Task
封装成TaskRunner
(Runnable
接口的实例),然后提交到底层的Executor
中,并行执行计算任务。
Executor
中的线程池定义如下:
|
|
|
|
可以看到底层执行task的线程池实际上是JUC中的CachedThreadPool
,按需创建新线程,同时会复用线程池中已经建好的线程。
最后用一幅图总结一下Job, Stage和Task的关系(图来自 Mastering Apache Spark 2.0):
整个Spark Context执行task的步骤图:
Spark中RDD的存储方式有两种:in memory和on disk,默认是in memory的。进行分布式计算的时候通常会读入大量的数据,并且通常还需要重用这些数据,如果简单地把内存管理交给GC的话,很容易导致回收失败从而cause full GC,影响性能。
Spark 1.5开始不再通过GC管理内存。Spark 1.5实现了一个内存管理器用于手动管理内存(Project Tungsten),底层通过Unsafe
类来直接分配和回收内存。
另外,分布式计算系统的GC方面还可以参考OSDI 2016的一篇论文: Yak: A High-Performance Big-Data-Friendly Garbage Collector。
下面在Spark中跑一个PageRank来观察一下生成的Stage DAG。PageRank的公式比较简单:
$$PageRank (p_i) = \frac{1-d}{N} + d \sum_{p_j \in M(p_i)} \frac{PageRank (p_j)}{L(p_j)} $$
这里我们选择damping factor=0.85,初始的rank值为1.0;PageRank算法可以用马尔科夫矩阵进行优化,但是这里迭代次数较小,可以直接进行迭代计算。对应代码:
|
|
对应的Stage DAG:
其中Stage 3中的RDD dependencies如下:
Vert.x 的线程模型设计的非常巧妙。总的来说,Vert.x 中主要有两种线程:Event Loop 线程 和 Worker 线程。其中,Event Loop 线程结合了 Netty 的 EventLoop
,用于处理事件。每一个 EventLoop
都与唯一的线程相绑定,这个线程就叫 Event Loop 线程。Event Loop 线程不能被阻塞,否则事件将无法被处理。
Worker 线程用于执行阻塞任务,这样既可以执行阻塞任务而又不阻塞 Event Loop 线程。
如果像 Node.js 一样只有单个 Event Loop 的话就不能充分利用多核 CPU 的性能了。为了充分利用多核 CPU 的性能,Vert.x 中提供了一组 Event Loop 线程。每个 Event Loop 线程都可以处理事件。为了保证线程安全,防止资源争用,Vert.x 保证了某一个 Handler
总是被同一个 Event Loop 线程执行,这样不仅可以保证线程安全,而且还可以在底层对锁进行优化提升性能。所以,只要开发者遵循 Vert.x 的线程模型,开发者就不需要再担心线程安全的问题,这是非常方便的。
本篇文章将底层的角度来解析 Vert.x 的线程模型。对应的 Vert.x 版本为 3.3.3。
首先回顾一下 Event Loop 线程,它会不断地轮询获取事件,并将获取到的事件分发到对应的事件处理器中进行处理:
Vert.x 线程模型中最重要的一点就是:永远不要阻塞 Event Loop 线程。因为一旦处理事件的线程被阻塞了,事件就会一直积压着不能被处理,整个应用也就不能正常工作了。
Vert.x 中内置一种用于检测 Event Loop 是否阻塞的线程:vertx-blocked-thread-checker
。一旦 Event Loop 处理某个事件的时间超过一定阈值(默认为 2000 ms)就会警告,如果阻塞的时间过长就会抛出异常。Block Checker 的实现原理比较简单,底层借助了 JUC 的 TimerTask
,定时计算每个 Event Loop 线程的处理事件消耗的时间,如果超时就进行相应的警告。
Vert.x 中的 Event Loop 线程及 Worker 线程都用 VertxThread
类表示,并通过 VertxThreadFactory
线程工厂来创建。VertxThreadFactory
创建 Vert.x 线程的过程非常简单:
|
|
除了创建 VertxThread
线程之外,VertxThreadFactory
还会将此线程注册至 Block Checker 线程中以监视线程的阻塞情况,并且将此线程添加至内部的 weakMap
中。这个 weakMap
作用只有一个,就是在注销对应的 Verticle 的时候可以将每个 VertxThread
中的 Context
实例清除(unset)。为了保证资源不被一直占用,这里使用了 WeakHashMap
来存储每一个 VertxThread
。当里面的 VertxThread
的引用不被其他实例持有的时候,它就会被标记为可清除的对象,等待 GC。
至于 VertxThread
,它其实就是在普通线程的基础上存储了额外的数据(如对应的 Vert.x Context,最大执行时长,当前执行时间,是否为 Worker 线程等),这里就不多讲了。
Vert.x 底层中一个重要的概念就是 Context
,每个 Context
都会绑定着一个 Event Loop 线程(而一个 Event Loop 线程可以对应多个 Context
)。我们可以把 Context
看作是控制一系列的 Handler
的执行作用域及顺序的上下文对象。
每当 Vert.x 底层将事件分发至 Handler
的时候,Vert.x 都会给此 Handler
绑定一个 Context
用于处理任务:
VertxThread
),那么 Vert.x 就会复用此线程上绑定的 Context
;如果没有对应的 Context
就创建新的Context
Vert.x 中存在三种 Context
,与之前的线程种类相对应:
EventLoopContext
WorkerContext
MultiThreadedWorkerContext
每个 Event Loop Context 都会对应着唯一的一个 EventLoop
,即一个 Event Loop Context 只会在同一个 Event Loop 线程上执行任务。在创建 Context
的时候,Vert.x 会自动根据轮询策略选择对应的 EventLoop
:
|
|
在 Netty 中,EventLoopGroup
代表一组 EventLoop
,而从中获取 EventLoop
的方法则是 next
方法。EventLoopGroup
中 EventLoop
的数量由 CPU 核数所确定。Vert.x 这里使用了 Netty NIO 对应的 NioEventLoop
:
|
|
对应的轮询算法:
|
|
可以看到,正常情况下 Netty 会用轮询策略选择 EventLoop
。特别地,如果 EventLoop
的个数是 2 的倍数的话,选择的会快一些:
|
|
我们可以在 Embedded 模式下测试一下 Event Loop 线程的分配:
|
|
运行结果(不同机器运行顺序、Event Loop线程数可能不同):
|
|
可以看到尽管每个 Context
对应唯一的 Event Loop 线程,而每个 Event Loop 线程却可能对应多个 Context
。
Event Loop Context 会在对应的 EventLoop
中执行 Handler
进行事件的处理(IO 事件,非阻塞)。Vert.x 会保证同一个 Handler
会一直在同一个 Event Loop 线程中执行,这样可以简化线程模型,让开发者在写 Handler
的时候不需要考虑并发的问题,非常方便。
我们来看一下 Handler
是如何在 EventLoop
上执行的。EventLoopContext
中实现了 executeAsync
方法用于包装 Handler
中事件处理的逻辑并将其提交至对应的 EventLoop
中进行执行:
|
|
这里 Vert.x 使用了 wrapTask
方法将 Handler
封装成了一个 Runnable
用于向 EventLoop
中提交。代码比较直观,大致就是检查当前线程是否为 Vert.x 线程,然后记录事件处理开始的时间,给当前的 Vert.x 线程设置 Context
,并且调用 Handler
里面的事件处理方法。具体请参考源码,这里就不贴出来了。
那么把封装好的 task 提交到 EventLoop
以后,EventLoop
是怎么处理的呢?这就需要更多的 Netty 相关的知识了。根据Netty 的模型,Event Loop 线程需要处理 IO 事件,普通事件(即我们的 Handler
)以及定时事件(比如 Vert.x 的 setTimer
)。Vert.x 会提供一个 NETTY_IO_RATIO
给Netty代表 EventLoop
处理 IO 事件时间占用的百分比(默认为 50,即 IO事件时间占用:非IO事件时间占用 = 1:1)。当 EventLoop
启动的时候,它会不断轮询 IO 事件及其它事件并进行处理:
|
|
这里面 Netty 会调用 processSelectedKeys
方法进行 IO 事件的处理,并且会计算出处理 IO 时间所用的事件然后计算出给非 IO 事件处理分配的时间,然后调用 runAllTasks
方法执行所有的非 IO 任务(这里面就有我们的各个 Handler
)。
runAllTasks
会按顺序从内部的任务队列中取出任务(Runnable
)然后进行安全执行。而我们刚才调用的 NioEventLoop
的 execute
方法其实就是将包装好的 Handler
置入 NioEventLoop
内部的任务队列中等待执行。
顾名思义,Worker Context 用于跑阻塞任务。与 Event Loop Context 相似,每一个 Handler
都只会跑在固定的 Worker 线程下。
Vert.x 还提供一种 Multi-threaded worker context 可以在多个 Worker 线程下并发执行任务,这样就会出现并发问题,需要开发者自行解决并发问题。因此一般情况下我们用不到 Multi-threaded worker context。
我们再来讨论一下 Verticle
中的 Context
。在部署 Verticle
的时候,Vert.x 会根据配置来创建 Context
并绑定到 Verticle 上,此后此 Verticle 上所有绑定的 Handler
都会在此 Context
上执行。相关实现位于 doDeploy
方法,这里摘取核心部分:
|
|
通过这样一种方式,Vert.x保证了Verticle
的线程安全 —— 即某个Verticle
上的所有Handler
都会在同一个Vert.x线程上执行,这样也保证了Verticle
内部成员的安全(没有race condition问题)。比如下面Verticle中处理IO及事件的处理都一直是在同一个Vert.x线程下执行的,每次打印出的线程名称应该是一样的:
|
|
之前我们已经提到过,Event Loop 线程池的类型为 Netty 中的NioEventLoopGroup
,里面的线程通过 Vert.x 自己的线程工厂VertxThreadFactory
进行创建:
|
|
其中 Event Loop 线程的数目可以在配置中指定。
在之前讲 executeBlocking
底层实现的文章中我们已经提到过 Worker 线程池,它其实就是一种 Fixed Thread Pool:
|
|
Worker线程同样由VertxThreadFactory
构造,类型为VertxThread
,用于执行阻塞任务。我们同样可以在配置中指定其数目。
|
|
Internal Blocking Pool可能设计用于内部使用,在executeBlocking(Action<T> action, Handler<AsyncResult<T>> resultHandler)
这个版本的方法中就使用了它。
大家可能会发现VertxImpl
类中还有一个acceptorEventLoopGroup
。顾名思义,它是Netty中的Acceptor线程池,负责处理客户端的连接请求:
|
|
由于系统只有一个服务端端口需要监听,因此这里只需要一个线程。
Vert.x中的HttpServer
就利用了acceptorEventLoopGroup
处理客户端的连接请求,具体的实现后边会另起一篇介绍。
大家都知道,Vert.x中的executeBlocking
方法用于执行阻塞任务,并且有两种模式:有序执行和无序执行。下面我们来看两段代码:
|
|
我们思考一下,每段代码每次执行的时候使用的线程相同么?正常情况下大家都知道executeBlocking
底层使用了Worker线程池,因此貌似两种情况没什么区别,都是轮询Worker线程池,每次可能用不同的Worker线程。但是我们测一下:
第一段代码:
|
|
第二段代码:
|
|
额。。。两段代码每次执行的线程居然有差异?第二次为什么每次都用相同的Worker线程?其实,大家可能忽略了一点:executeBlocking
方法默认顺序执行提交的阻塞任务。今天我们就来探究一下executeBlocking
内部的实现。
我们来回顾一下Vert.x底层的Worker线程池,它在创建VertxImpl
实例的时候进行初始化:
|
|
可以看到底层的Worker线程池本质上是一种FixedThreadPool
,里面的线程由VertxThreadFactory
控制生成,对应的线程类型为VertxThread
。Vert.x内部用WorkerPool
类对线程池以及线程池相关的Metrics类进行了封装。
有了Worker线程池的基础,我们来看一下Vertx
实例中的executeBlocking
方法,它的过程很简单:获取当前的Vert.x Context(没有就创建),然后委托调用Context
里的executeBlocking
方法:
|
|
在此方法中可以看到,ordered
标志位默认为true
,即默认按提交的次序执行阻塞任务。
我们再来看一下ContextImpl
类中的executeBlocking
方法:
|
|
它调用了另一个具体版本的executeBlocking
方法,其中第四个参数即为要执行阻塞任务的线程池。如果要有序执行(ordered
为true),底层就使用context
实例里的workerExec
线程池;如果无序执行,就调用workerPool
的executor
方法获取另一种线程池。
看到这里,我们大致已经想到了,有序执行和无序执行两种模式使用不同的线程池,因此底层实现肯定有差异。我们来看一下前面提到的两个线程池,它们都是ContextImpl
类的成员变量:
|
|
在通过Vertx
实例创建Context
的时候,这几个变量会被初始化,其来源就是之前我们看过的VertxImpl
实例中的Worker线程池。看一下ContextImpl
类的构造函数就一目了然了:
|
|
嗯。。。有序执行对应的线程池通过workerPool
的createOrderedExecutor
方法获得,而无序执行对应的线程池通过workerPool
的executor
方法获得。因此,WorkerPool
类是一个关键点,我们稍后就看一下其实现。
注意Vert.x规定,blockingCodeHandler
中的逻辑(即阻塞任务)在Worker线程内执行,而resultHandler
内的逻辑(结果处理)需要在Vert.x Conext中执行,因此前面需要预先设置当前使用的Worker线程的Context
为this
以便后面调用runOnContext
方法执行结果处理逻辑。
下面就来看一下有序执行和无序执行这两种线程池的具体区别。
我们看一下WorkerPool
类的源码中获取无序执行线程池的逻辑:
|
|
可以看到executor
方法直接返回了内部的pool
线程池,而pool
线程池其实就是VertxImpl
中的workerExec
线程池:
|
|
OK!如果大家熟悉并发的话,大家应该对无序执行对应的线程池 —— Worker线程池的行为非常清楚了。它属于一种FixedThreadPool
,底层通过阻塞队列LinkedBlockingQueue
实现。底层通过轮询算法获取Worker线程执行任务。
下面是时候看有序执行对应的逻辑了:
|
|
可以看到有序执行对应的线程池是通过OrderedExecutorFactory
创建的。其实,OrderedExecutorFactory
类会生成真正的有序执行线程池OrderedExecutor
,它其实是对Worker线程池pool
的一个简单包装,仅仅添加了有序执行相关的逻辑,最后还是委托Worker线程池进行任务处理。
那么OrderedExecutor
是如何实现顺序执行的呢?OrderedExecutor
内部维护着一个任务队列。每当调用executeBlocking
方法执行阻塞过程的时候,Vert.x会将阻塞过程包装成Runnable
然后置入OrderedExecutor
中的任务队列中;同时如果OrderedExecutor
没有开始执行任务,就委托内部的Worker线程池执行任务:
|
|
从代码中可以看出,最后委托Worker线程池执行的线程其实是又包装了一层的runner
线程。runner
的逻辑不难想:不断地从任务队列中取出队首的Runnable
然后调用其run
方法执行(相当于执行了此任务,只不过在runner对应的线程中);如果没有任务了就结束本线程。
这里就出现了一种情况:大批量提交阻塞任务的时候,线程池的状态running
一直为true
,此时所有的任务都积压到任务队列中,而执行所有任务的线程只有一个 —— runner
对应的线程。这种情况其实很好想,因为要保证有序执行,就只能让它一个接一个地在同个线程中执行。如果在不同线程中依次执行则不好调度,如果直接并行执行则不能保证有序性。
所以,根据OrderedExecutor
线程池的内部实现,只要提交任务的间隔时间小于任务执行的时间,底层其实就仅执行了一次runner
,也就是说所有提交的阻塞任务都只在一个线程下跑(running标志位控制)。
这样就可以很好地解释我们一开始提出的问题了。当sleep(200), setPeriodic(1000)
的时候,提交任务的间隔时间大于任务执行的时间,这样每次的runner
就可以在下一个任务提交之前执行完,因此每次所用的线程会不同(轮询策略);而sleep(2000), setPeriodic(1000)
的时候,提交任务的间隔时间小于任务执行的时间,底层最后都归结到一个runner
中执行了,因此所有过程都是在同一个Worker线程执行的(很好想,保证有序就要串行执行)。
当然,如果不想有序执行,可以用void executeBlocking(Handler<Future<T>> blockingCodeHandler, boolean ordered, Handler<AsyncResult<T>> asyncResultHandler)
这个版本的executeBlocking
方法,并将ordered
标志位设为false
。根据上面的源码,底层会直接使用Worker线程池而不是OrderedExecutor
线程池,这样就不会有上面OrderedExecutor
的情况了。
传统的 RPC 想必大家都不陌生,但是传统的 RPC 有个缺陷:传统的 RPC 都是阻塞型的,当调用者远程调用服务时需要阻塞着等待调用结果,这与 Vert.x 的异步开发模式相违背;而且,传统的 RPC 未对容错而设计。
因此,Vert.x 提供了 Service Proxy 用于进行异步 RPC,其底层依托 Clustered Event Bus 进行通信。我们只需要按照规范编写我们的服务接口(一般称为 Event Bus 服务),并加上 @ProxyGen
注解,Vert.x 就会自动为我们生成相应的代理类在底层处理 RPC。有了 Service Proxy,我们只需给异步方法提供一个回调函数 Handler<AsyncResult<T>>
,在调用结果发送过来的时候会自动调用绑定的回调函数进行相关的处理,这样就与 Vert.x 的异步开发模式相符了。由于 AsyncResult
本身就是为容错而设计的(两个状态),因此这里的 RPC 也具有了容错性。
假设有一个 Event Bus 服务接口:
|
|
这里定义了一个异步方法 process
,其异步调用返回的结果是 AsyncResult<JsonObject>
类型的。由于异步 RPC 底层通过 Clustered Event Bus 进行通信,我们需要给器指定一个通信地址 SERVICE_ADDRESS
。@Fluent
注解代表此方法返回自身,便于进行组合。我们同时还提供了两个辅助方法:createService
方法用于创建服务实例,而 createProxy
方法则通过 ProxyHelper
辅助类创建服务代理实例。
假设服务提供端A注册了一个 SomeService
类型的服务代理,服务调用端B需要通过异步RPC调用服务的 process
方法,此时调用端 B 可以利用 ProxyHelper
获取服务实例并进行服务调用。B 中获取的服务其实是一个 服务代理类,而真正的服务实例在 A 处。何为服务代理?服务代理可以帮助我们向服务提供端发送调用请求,并且响应调用结果。那么如何发送调用请求呢?相信大家能想到,是调用端B将调用参数和方法名称等必要信息包装成集群消息(ClusteredMessage
),然后通过 send
方法将请求通过 Clustered Event Bus 发送至服务提供端A处(需要提供此服务的通信地址)。A 在注册服务的时候会创建一个 MessageConsumer
监听此服务的地址来响应调用请求。当接收到调用请求的时候,A 会在本地调用方法,并将结果回复至调用端。所以异步RPC本质上其实是一个基于 代理模式 的 Request/Response 消息模式。
用时序图来描述一下上述过程:
以之前的 SomeService
接口为例,我们可以在集群中的一个节点上注册服务实例:
|
|
然后在另一个节点上获取此服务实例的代理,并进行服务调用。调用的时候看起来就像在本地调用(LPC)一样,其实是进行了 RPC 通信:
|
|
其实,这里获取到的 proxyService
实例的真正类型是 Vert.x 自动生成的服务代理类 SomeServiceVertxEBProxy
类,里面封装了通过 Event Bus 进行通信的逻辑。我们首先来讲一下 Service Proxy 生成代理类的命名规范。
Vert.x Service Proxy 在生成代理类时遵循一定的规范。假设有一 Event Bus 服务接口SomeService
,Vert.x 会自动为其生成代理类以及代理处理器:
VertxEBProxy
。比如SomeService
接口对应的代理类名称为SomeServiceVertxEBProxy
VertxProxyHandler
。比如SomeService
接口对应的代理处理器名称为SomeServiceVertxProxyHandler
ProxyHandler
抽象类ProxyHelper
辅助类中注册服务以及创建代理都是遵循了这个规范。
我们通过ProxyHelper
辅助类中的registerService
方法来向Event Bus上注册Event Bus服务,来看其具体实现:
|
|
首先根据约定生成对应的代理Handler
的名称,然后通过类加载器加载对应的Handler
类,再通过反射来创建代理Handler
的实例,最后调用handler
的registerHandler
方法注册服务地址。
registerHandler
方法的实现在Vert.x生成的各个代理处理器中。以之前的SomeService
为例,我们来看一下其对应的代理处理器SomeServiceVertxProxyHandler
实现。首先是注册并订阅地址的registerHandler
方法:
|
|
registerHandler
方法的实现非常简单,就是通过consumer
方法在address
地址上绑定了SomeServiceVertxProxyHandler
自身。那么SomeServiceVertxProxyHandler
是如何处理来自服务调用端的服务调用请求,并将调用结果返回到请求端呢?在回答这个问题之前,我们先来看看代理端(调用端)是如何发送服务调用请求的,这就要看对应的服务代理类的实现了。
我们来看一下服务调用端是如何发出服务调用请求的消息的。之前已经介绍过,服务调用端是通过Event Bus的send
方法发送调用请求的,并且会提供一个replyHandler
来等待方法调用的结果。调用的方法名称会存放在消息中名为action
的header中。以之前SomeService
的代理类SomeServiceVertxEBProxy
中process
方法的请求为例:
|
|
可以看到代理类把此方法传入的参数都放到一个JsonObject
中了,并将要调用的方法名称存放在消息中名为action
的header中。代理方法通过send
方法将包装好的消息发送至之前注册的服务地址处,并且绑定replyHandler
等待调用结果,然后使用我们传入到process
方法中的resultHandler
对结果进行处理。是不是很简单呢?
调用请求发出之后,我们的服务提供端就会收到调用请求消息,然后执行SomeServiceVertxProxyHandler
中的处理逻辑:
|
|
handle
方法首先从消息header中获取方法名称,如果获取不到则调用失败;接着handle
方法会调用accessed
方法记录最后调用服务的时间戳,这是为了实现超时的逻辑,后面我们会讲。接着handle
方法会根据方法名称分派对应的逻辑,在“真正”的服务实例上调用方法。注意异步RPC的过程本质是 Request/Response 模式,因此这里的异步结果处理函数resultHandler
应该将调用结果发送回调用端。此resultHandler
是通过createHandler
方法生成的,逻辑很清晰:
|
|
这样,一旦在服务提供端的调用过程完成时,调用结果就会被发送回调用端。这样调用端就可以调用结果执行真正的处理逻辑了。
Vert.x 自动生成的代理处理器内都封装了一个简单的超时处理逻辑,它是通过定时器定时检查最后的调用时间实现的。逻辑比较简单,直接放上相关逻辑:
|
|
一旦超时,就自动调用close
方法终止定时器,注销响应服务调用请求的consumer并关闭代理。
大家可能会很好奇,这些服务代理类是怎么生成出来的?其实,这都是 Vert.x Codegen 的功劳。Vert.x Codegen 的本质是一个 注解处理器(APT),它可以扫描源码中是否包含要处理的注解,检查规范后根据响应的模板生成对应的代码,这就是注解处理器的作用(注解处理器于 JDK 1.6 引入)。为了让 Codegen 正确地生成代码,我们需要配置编译参数来确保注解处理器能够正常的工作,具体的可以参考 Vert.x Codegen 的文档 (之前里面缺了 Gradle 相关的实例,我给补上了)。
Vert.x Codegen 使用 MVEL2 作为生成代码的模板,扩展名为 *.templ
,比如代理类和代理处理器的模板就位于 vert-x3/vertx-service-proxy 中,配置文件类似于这样:
|
|
具体的代码生成逻辑还要涉及 APT 及 MVEL2 的知识,这里就不展开讲了,有兴趣的朋友可以研究研究 Vert.x Codegen 的源码。
Vert.x 提供的这种 Async RPC 有着许多优点:
@VertxGen
注解并在编译期依赖中加上对应语言的依赖(如vertx-lang-ruby
),Vert.x Codegen 就会自动处理注解并生成对应语言的服务代理(通过调用 Java 版本的服务代理实现)。这样 Async RPC 可以真正地做到不限 language。当然 Vert.x 要求我们的服务接口必须是 基于回调的,这样写起来可能会不优雅。还好 @VertxGen
注解支持生成Rx版本的服务类,因此只要加上 vertx-rx-java
依赖,Codegen 就能生成对应的Rx风格的服务类(异步方法返回 Observable
),这样我们就能以更 reactive 的风格来构建应用了,岂不美哉?
当然,为了考虑多语言支持的兼容性,Vert.x 在传递消息的时候依然使用了传统的 JSON,这样传输效率可能不如 Protobuf 高,但是不一定成为瓶颈(看业务情况;真正的瓶颈一般还是在 DB 上)。
]]>RPC 模块将网络通信的过程封装成了方法调用的过程。从使用者的角度来看,在调用端进行RPC调用,就像进行本地函数调用一样;而在背后,RPC 模块会将先调用端的函数名称、参数等调用信息序列化,其中序列化的方式有很多种,比如 Java 原生序列化、JSON、Protobuf 等。接着 RPC 模块会将序列化后的消息通过某种协议(如 TCP, AMQP 等)发送到被调用端,被调用端在收到消息以后会对其解码,还原成调用信息,然后在本地进行方法调用,然后把调用结果发送回调用端,这样一次 RPC 调用过程就完成了。在这个过程中,我们要考虑到一些问题:
我们一点一点来思考。第一点是设计成什么样的调用模型。常见的几种模型:
Client
就提供了一个 Call
方法用于任意RPC调用,调用者需要传入方法名称、参数以及返回值指针(异步模式下传入 callback handler)我更倾向于选择服务代理这种模型,因为服务代理这种模型在进行 RPC 调用的时候非常方便,但是需要 RPC 模块生成服务代理类(静态或动态生成),实现起来可能会麻烦些;当然 Go 的 rpc 包封装的也比较好,调用也比较方便,考虑到 Go 的类型系统,这已经不错了。。。
RPC 调用耗时会包含通信耗时和本地调用耗时。当网络状况不好的时候,RPC 调用可能会很长时间才能得到结果。对传统的同步 RPC 模式来说,这期间会阻塞调用者的调用线程。当需要进行大量 RPC 调用的时候,这种阻塞就伤不起了。这时候,异步 RPC 模式就派上用场了。我们可以对传统 RPC 模式稍加改造,把服务接口设计成异步模式的,即每个方法都要绑定一个回调函数,或利用 Future-Promise 模型返回一个 Future
,或者直接上 reactive 模式(未来的趋势)。设计成异步、响应式模式以后,整个架构的灵活性就能得到很大的提升。
第二点是调用信息的序列化/反序列化以及传输。序列化主要分为文本(如 JSON, XML 等)和二进制(如 Thrift, Protocol 等)两种,不同的序列化策略性能不同,因此我们应该尽量选择性能高,同时便于开发的序列化策略。在大型项目中我们常用 Protobuf,性能比较好,支持多语言,但是需要单独定义 .proto
文件(可以利用 protostuff);有的时候我们会选择 JSON,尽管效率不是很高但是方便,比如 Vert.x Service Proxy 就选择了 JSON 格式(底层依赖 Event Bus)。另一点就是传输协议的选择。通常情况下我们会选择 TCP 协议(以及各种基于 TCP 的应用层协议,如 HTTP/2)进行通信,当然用基于 AMQP 协议的消息队列也可以,两者都比较可靠。
这里还需提一点:如何高效地并发处理 request / response,这依赖于通信模块的实现。拿 Java 来说,基于 Netty NIO 或者 Java NIO / AIO 的 I/O 多路复用都可以很好地并发处理请求;而像 Go RPC 则是来一个 request 就创建一个 Goroutine 并在其中处理请求(Goroutine 作为轻量级用户态线程,创建性能消耗小)。
最后一点也是最重要的一点:实现容错,这也是分布式系统设计要考虑的一个核心。想象一下一次 RPC 调用过程中可能产生的各种 failure:
一种简单的应对方式是不断地超时重传,即 at least once 模式。调用端设置一个超时定时器,若一定时间内没有收到response就继续发送调用请求,直到收到response或请求次数达到阈值。这种模式会发送重复请求,因此只适用于幂等性的操作,即执行多次结果相同的操作,比如读取操作。当然服务提供端也可以实现对应的逻辑来检查重复的请求。
更符合我们期望的容错方案是 at most once 模式。at most once 模式要求服务提供端检查重复请求,如果检查到当前请求是重复请求则返回之前的调用结果。服务提供端需要缓存之前的调用结果。这里面有几点需要考虑:
如果自己实现的话:
|
|
由此可见,虽然 RPC 模块看似比较简单,但是设计的时候要考虑的问题还是非常多的。尤其是在保证性能的基础上又要保证可靠性,还要保证开发者的易用性,这就需要细致地思考了。
这里我来简单总结一下用过的常见的几个 RPC 模块的使用及实现思路。
Golang 的 rpc
包使用了 Go 自己的 gob 协议作为序列化协议(通过encoding/gob
模块内的Encoder
/Decoder
进行编码和解码),而传输协议可以直接使用TCP(Dial
方法)或者使用HTTP(DialHTTP
)方法。开发者需要在服务端定义struct并且实现各种方法,然后将struct注册到服务端。需要进行RPC调用的时候,我们就可以在调用端通过Call
方法(同步)或者Go
方法(异步)进行调用。同步模式下调用结果即为reply
指针所指的对象,而异步模式则会在调用结果准备就绪后通知绑定的channel并执行处理。
在rpc包的实现中(net/rpc/server.go
),每个注册的服务类都被封装成了一个service
结构体,而其中的每个方法则被封装成了一个methodType
结构体:
|
|
每个服务端都被封装成了一个Server
结构体,其中的serviceMap
存储着各个服务类的元数据:
|
|
RPC Server 处理调用请求的默认路径是 /_goRPC_
。当请求到达时,Go就会调用Server
结构体实现的ServeHTTP
方法,经ServeConn
方法传入gob codec预处理以后最终在ServeCodec
方法内处理请求并进行调用:
|
|
如果成功读取请求数据,那么接下来 RPC Server 就会新建一个 Goroutine 用来在本地执行方法,并向调用端返回 response:
|
|
在执行调用的过程中应该注意并发问题,防止资源争用,修改数据时需要对数据加锁;至于方法的执行就是利用了Go的反射机制。调用完以后,RPC Server 接着调用sendResponse
方法发送response,其中写入response的时候同样需要加锁,防止资源争用。
gRPC 是 Google 开源的一个通用的 RPC 框架,支持 C, Java 和 Go 等语言。既然是 Google 出品,序列化协议必然用 protobuf 啦(毕竟高效),传输协议使用 HTTP/2,更为通用,性能也还可以。开发时需要在 .proto
文件里定义数据类型以及服务接口,然后配上 protoc 的 gRPC 插件就能够自动生成各个语言的服务接口和代理类。
Vert.x Service Proxy 是 Vert.x 的一个异步 RPC 组件,支持通过各种 JVM 语言(Java, Scala, JS, JRuby, Groovy等)进行 RPC 调用。使用 Vert.x Service Proxy 时我们只需要按照异步开发模式编写服务接口,加上相应的注解,Vert.x Service Proxy 就会自动生成相应的服务代理类和服务调用处理类。Vert.x Service Proxy 底层借助 Event Bus 进行通信,调用时将调用消息包装成 JSON 数据然后通过 Event Bus 传输到服务端,得到结果后再返回给调用端。Vert.x 的一大特性就是异步、响应式编程,因此 Vert.x Service Proxy 的 RPC 模型为异步 RPC,用起来非常方便。几个异步过程可以通过各种组合子串成一串,妥妥的 reactive programming 的风格~
更多的关于 Vert.x Service Proxy 的实现原理的内容可以看这一篇:Vert.x 技术内幕 | 异步 RPC 实现原理。
PS: 我经常吐槽 Vert.x Service Proxy 这个名字,因为光看名字很多人不知道它可以用来实现 RPC,导致了很多人以为 Vert.x 不能做 RPC,应该改名叫 Vert.x Async RPC 比较合适。。。当然它还有很大的改进空间,主要是被 Vert.x Event Bus 的性能和可靠性给拖累了。。。
]]>我们先来简单地介绍一下集群模式下Event Bus的基本原理。
我们可以通过集群模式下的Event Bus在不同的服务器之间进行通信,其本质为TCP通信。Vert.x集群模式需要一个集群管理器(默认为HazelcastClusterManager
)来管理集群的状态,存储元数据。当我们在某个节点A给集群模式的Event Bus绑定一个对应地址address
的consumer
的时候,Event Bus会将此节点的ServerID
(包含host
和port
信息)存储至集群管理器的共享Map中,key
为绑定的地址address
,value为绑定了此地址address
的所有结点的ServerID
集合(可以看作是具有负载均衡功能的Set
)。集群中的所有节点都可以从集群管理器中获取Map记录。并且绑定consumer的同时节点A会建立一个NetServer
接收数据。这样,我们再通过另一个结点B向此地址address
发送消息的时候,B就会从集群管理器中取出此地址对应的ServerID
集合,并根据是点对点发送还是发布,根据相应的策略创建NetClient
执行消息分发逻辑。这样,对应的NetServer
收到数据后会对其进行解码然后在本地进行消息的处理。
集群模式下我们还需要注意几个问题:
当某个节点挂掉的时候,其连接将会不可用,集群管理器就会将此节点的信息从集群中移除,并且传播到所有的节点删除对应缓存的信息,这样发消息的时候就不会发送到挂掉的无效节点处。至于高可用性,Vert.x提供了高可用管理器HAManager
用于实现高可用性,在发生故障时能够快速failover,详情可见官方文档。
好了,下面我们就来分析一下Clustered Event Bus的源码。集群模式下Event Bus的类型为ClusteredEventBus
,它继承了单机模式的EventBusImpl
类。其初始化过程与Local模式大同小异,因此这里就直接分析发送和接受消息相关的逻辑了。
我们还是先来看consumer
方法的逻辑。前面的调用逻辑都和Local模式下相同,可以参考之前的文章。不同之处在添加记录的地方。Cluster模式下Event Bus需要将当前机器的位置存储至Map中并且传播至集群内的所有节点,因此ClusteredEventBus
重写了四个参数版本的addRegistration
方法(之前在EventBusImpl
类中这个版本的方法用处不大,这里用处就大了):
|
|
如果要绑定MessageConsumer
对应的地址在本地中没有注册过,并且不是Event Bus自动生成的reply consumer,并且允许在集群范围内传播的话,Event Bus就会将当前机器的位置添加到集群内的记录subs
中。subs
的类型为AsyncMultiMap
:
|
|
ClusteredEventBus启动时会对其进行初始化:
|
|
从名字就可以看出来,AsyncMultiMap
允许一键多值,并且其变动可以在集群范围内传播。由于AsyncMultiMap
是集群范围内的,因此对其操作都是异步的。在这里我们可以简单地把它看作是一个Map<String, ChoosableIterable<ServerID>>
类型的键值对,其中ChoosableIterable
与之前见到过的Handlers
类似,属于可以通过轮询算法获取某一元素的集合。subs
的key为绑定的地址,value为绑定此地址的机器位置的集合。机器的位置用ServerID
表示,里面包含了该机器的host
和port
。这样,每当我们向某个地址绑定一个MessageConsumer
的时候,绑定consumer的ServerID
就会被记录到集群中并与地址相对应,其它机器在向此地址发送(或发布)消息的时候,Event Bus就可以从集群中获取在此地址上绑定了consumer的所有ServerID
,再根据相应的策略选出合适的ServerID
建立TCP通信将数据发送至对应机器中,对应机器收到消息后解码并在本地对其进行处理。
这里面还需要注意一点:我们可以在EventBusOptions
中指定ServerID
的port
和host
,若不指定则port
将随机分配(NetServer
的特性)。
剩下的过程也就大同小异了。至于unregister
方法,无非就是将底层的removeRegistration
方法重写,从subs
中删除对应的ServerID
并传播至其它节点:
|
|
集群模式下的消息与本地模式下的消息不同。集群模式下的消息实体类型为ClusteredMessage
,它继承了MessageImpl
消息实体类,并且根据远程传输的特性实现了一种Wire Protocol用于远程传输消息,并负责消息的编码和解码。具体的实现就不展开说了,如果有兴趣的话可以阅读ClusteredMessage
类中相关方法的实现。
我们上篇文章提到过,Event Bus底层通过createMessage
方法创建消息。因此ClusteredEventBus
里就对此方法进行了重写,当然改动就是把MessageImpl
替换成了ClusteredMessage
:
|
|
接下来就是消息的发送逻辑了。ClusteredEventBus
重写了sendOrPub
方法,此方法存在于SendContextImpl
类中的next
方法:
|
|
我们来看一下ClusteredEventBus
是如何进行集群内消息的分发的:
|
|
首先Event Bus需要从传入的sendContext
中获取要发送至的地址。接着Event Bus需要从集群管理器中获取在此地址上绑定consumer的所有ServerID
,这个过程是异步的,并且需要在Vert.x Context中执行。如果获取记录成功,我们会得到一个可通过轮询算法获取ServerID
的集合(类型为ChoosableIterable<ServerID>
)。如果集合为空,则代表集群内其它节点没有在此地址绑定consumer(或者由于一致性问题没有同步),Event Bus就将消息通过deliverMessageLocally
方法在本地进行相应的分发。deliverMessageLocally
方法的逻辑之前我们已经详细讲过了,这里就不再细说了;如果集合不为空,Event Bus就调用sendToSubs
方法进行下一步操作:
|
|
这里就到了分send
和publish
的时候了。如果发送消息的模式为点对点模式(send
),Event Bus会从给的的集合中通过轮询算法获取一个ServerID
。然后Event Bus会检查获取到的ServerID
是否与本机ServerID
相同,如果相同则代表在一个机子上,直接记录metrics信息并且调用deliverMessageLocally
方法往本地发送消息即可;如果不相同,Event Bus就会调用sendRemote
方法执行真正的远程消息发送逻辑。发布订阅模式的逻辑与其大同小异,只不过需要遍历一下ChoosableIterable<ServerID>
集合,然后依次执行之前讲过的逻辑。注意如果要在本地发布消息只需要发一次。
真正的远程消息发送逻辑在sendRemote
方法中:
|
|
一开始我们就提到过,节点之间通过Event Bus进行通信的本质是TCP,因此这里需要创建一个NetClient
作为TCP服务端,连接到之前获取的ServerID
对应的节点然后将消息通过TCP协议发送至接收端。这里Vert.x用一个封装类ConnectionHolder
对NetClient
进行了一些封装。
ClusteredEventBus
中维持着一个connections
哈希表对用于保存ServerID
对应的连接ConnectionHolder
。在sendRemote
方法中,Event Bus首先会从connections
中获取ServerID
对应的连接。如果获取不到就创建连接并将其添加至connections
记录中并调用对应ConnectionHolder
的connect
方法建立连接;最后调用writeMessage
方法将消息编码后通过TCP发送至对应的接收端。
那么ConnectionHolder
是如何实现的呢?我们来看一下其构造函数:
|
|
可以看到ConnectionHolder
初始化的时候会创建一个NetClient
作为TCP请求端,而请求的对象就是接收端的NetServer
(后边会讲),客户端配置已经在EventBusOptions
中事先配置好了。我们来看看connect
方法是如何建立连接的:
|
|
可以看到这里很简单地调用了NetClient#connect
方法建立TCP连接,如果建立连接成功的话会得到一个NetSocket
对象。Event Bus接着将其传至connected
方法中进行处理:
|
|
首先Event Bus通过exceptionHandler
和closeHandler
方法给连接对应的NetSocket
绑定异常回调和连接关闭回调,触发的时候都调用close
方法关闭连接;为了保证不丢失连接,消息发送方每隔一段时间就需要对消息接收方发送一次心跳包(PING
),如果消息接收方在一定时间内没有回复,那么就认为连接丢失,调用close
方法关闭连接。心跳检测的逻辑在schedulePing
方法中,比较清晰,这里就不详细说了。大家会发现ConnectionHolder
里也有个消息队列(缓冲区)pending
,并且这里会将队列中的消息依次通过TCP发送至接收端。为什么需要这样呢?其实,这要从创建TCP客户端说起。创建TCP客户端这个过程应该是异步的,需要消耗一定时间,而ConnectionHolder
中封装的connect
方法却是同步式的。前面我们刚刚看过,通过connect
方法建立连接以后会接着调用writeMessage
方法发送消息,而这时候客户端连接可能还没建立,因此需要这么个缓冲区先存着,等着连接建立了再一块发送出去(存疑:为什么不将connect
方法直接设计成异步的?)。
至于发送消息的writeMessage
方法,其逻辑一目了然:
|
|
如果连接已建立,Event Bus就会调用对应ClusteredMessage
的encodeToWire
方法将其转化为字节流Buffer
,然后记录metrics信息,最后通过socket
的write
方法将消息写入到Socket中,这样消息就从发送端通过TCP发送到了接收端。如果连接未建立,就如之前讲的那样,先把消息存到消息队列中,等连接建立了再一块发出去。
这样,Clustered Event Bus下消息的发送逻辑就理清楚了。下面我们看一下接收端是如何接收消息并在本地进行消息的处理的。
一开始我们提到过,每个节点的Clustered Event Bus在启动时都会创建一个NetServer
作为接收消息的TCP服务端。TCP Server的port
和host
可以在EventBusOptions
中指定,如果不指定的话默认随机分配port
,然后Event Bus会根据NetServer
的配置来生成当前节点的ServerID
。
创建TCP Server的逻辑在start
方法中,与接受消息有关的逻辑就是这一句:
|
|
我们知道,NetServer
的connectHandler
方法用于绑定对服务端Socket的处理函数,而这里绑定的处理函数是由getServerHandler
方法生成的:
|
|
逻辑非常清晰。这里Event Bus使用了RecordParser
来获取发送过来的对应长度的Buffer
,并将其绑定在NetServer
的Socket上。真正的解析Buffer
并处理的逻辑在内部的handler
中。之前ClusteredMessage
中的Wire Protocol规定Buffer
的首部第一个int
值为要发送Buffer
的长度(逻辑见ClusteredMessage#encodeToWire
方法),因此这里首先获取长度,然后给parser
设定正确的fixed size,这样parser
就可以截取正确长度的Buffer
流了。下面Event Bus会创建一个空的ClusteredMessage
,然后调用其readFromWire
方法从Buffer
中重建消息。当然这里还要记录消息已经读取的metrics信息。接着检测收到的消息实体类型是否为心跳检测包(PING
),如果是的话就发送回ACK消息(PONG
);如果不是心跳包,则代表是正常的消息,Event Bus就调用我们熟悉的deliverMessageLocally
函数在本地进行分发处理,接下来的过程就和Local模式一样了。
本文假定读者有一定的并发编程基础以及Vert.x使用基础,并且对Vert.x的线程模型以及back-pressure有所了解。
一般情况下,我们通过Vertx
的eventBus
方法来创建或获取一个EventBus
实例:
|
|
eventBus
方法定义于Vertx
接口中,我们来看一下其位于VertxImpl
类中的实现:
|
|
可以看到此方法返回VertxImpl
实例中的eventBus
成员,同时需要注意并发可见性问题。那么eventBus
成员是何时初始化的呢?答案在VertxImpl
类的构造函数中。这里截取相关逻辑:
|
|
可以看到VertxImpl
内部是通过createAndStartEventBus
方法来初始化eventBus
的。我们来看一下其逻辑:
|
|
可以看到此方法通过eventBus = new EventBusImpl(this)
将eventBus
进行了初始化(Local模式为EventBusImpl
),并且调用eventBus
的start
方法对其进行一些额外的初始化工作。我们来看一下EventBusImpl
类的start
方法:
|
|
首先初始化过程需要防止race condition,因此方法为synchronized
的。该方法仅仅将EventBusImpl
类中的一个started
标志位设为true
来代表Event Bus已启动。注意started
标志位为volatile
的,这样可以保证其可见性,确保其它线程通过checkStarted
方法读到的started
结果总是最新的。设置完started
标志位后,Vert.x会接着调用传入的completionHandler
处理函数,也就是上面我们在createAndStartEventBus
方法中看到的 —— 调用metrics
成员的eventBusInitialized
方法以便Metrics类可以在Event Bus初始化完毕后使用它(不过默认情况下此方法的逻辑为空)。
可以看到初始化过程还是比较简单的,我们接下来先来看看订阅消息 —— consumer
方法的逻辑。
我们来看一下consumer
方法的逻辑,其原型位于EventBus
接口中:
|
|
其实现位于EventBusImpl
类中:
|
|
首先要确保传入的handler
不为空,然后Vert.x会调用只接受一个address
参数的consumer
方法获取对应的MessageConsumer
,最后给获取到的MessageConsumer
绑定上传入的handler
。我们首先来看一下另一个consumer
方法的实现:
|
|
首先Vert.x会检查Event Bus是否已经启动,并且确保传入的地址不为空。然后Vert.x会传入一大堆参数创建一个新的HandlerRegistration
类型的实例,并返回。可以推测HandlerRegistration
是MessageConsumer
接口的具体实现,它一定非常重要。所以我们来看一看HandlerRegistration
类是个啥玩意。首先看一下HandlerRegistration
的类体系结构:
可以看到HandlerRegistration
类同时继承了MessageConsumer<T>
以及Handler<Message<T>>
接口,从其类名可以看出它相当于一个”Handler注册记录”,是非常重要的一个类。它有一堆的成员变量,构造函数对vertx
, metrics
, eventBus
, address
(发送地址), repliedAddress
(回复地址), localOnly
(是否在集群内传播), asyncResultHandler
等几个成员变量进行初始化,并且检查超时时间timeout
,如果设定了超时时间那么设定并保存超时计时器(仅用于reply handler中),如果计时器时间到,代表回复超时。因为有一些函数还没介绍,超时的逻辑我们后边再讲。
Note: 由于
MessageConsumer
接口继承了ReadStream
接口,因此它支持back-pressure,其实现就在HandlerRegistration
类中。我们将稍后解析back-pressure的实现。
现在回到consumer
方法中来。创建了MessageConsumer
实例后,我们接着调用它的handler
方法绑定上对应的消息处理函数。handler
方法的实现位于HandlerRegistration
类中:
|
|
首先,handler
方法将此HandlerRegistration
中的handler
成员设置为传入的消息处理函数。HandlerRegistration
类中有一个registered
标志位代表是否已绑定消息处理函数。handler
方法会检查传入的handler
是否为空且是否已绑定消息处理函数。如果不为空且未绑定,Vert.x就会将registered
标志位设为true
并且调用eventBus
的addRegistration
方法将此consumer注册至Event Bus上;如果handler
为空且已绑定消息处理函数,我们就调用unregister
方法注销当前的consumer。我们稍后会分析unregister
方法的实现。
前面提到过注册consumer的逻辑位于Event Bus的addRegistration
方法中,因此我们来分析一下它的实现:
|
|
addRegistration
方法接受四个参数:发送地址address
、传入的consumer registration
、代表是否为reply handler的标志位replyHandler
以及代表是否在集群范围内传播的标志位localOnly
。首先确保传入的HandlerRegistration
不为空。然后Vert.x会调用addLocalRegistration
方法将此consumer注册至Event Bus上:
|
|
首先该方法要确保地址address
不为空,接着它会获取当前线程下对应的Vert.x Context,如果获取不到则表明当前不在Verticle
中(即Embedded),需要调用vertx.getOrCreateContext()
来获取Context
;然后将获取到的Context
赋值给registration
内部的handlerContext
(代表消息处理对应的Vert.x Context)。
下面就要将给定的registration
注册至Event Bus上了。这里Vert.x用一个HandlerHolder
类来包装registration
和context
。接着Vert.x会从存储消息处理Handler
的哈希表handlerMap
中获取给定地址对应的Handlers
,哈希表的类型为ConcurrentMap<String, Handlers>
,key为地址,value为对应的HandlerHolder
集合。注意这里的Handlers
类代表一些Handler
的集合,它内部维护着一个列表list
用于存储每个HandlerHolder
。Handlers
类中只有一个choose
函数,此函数根据轮询算法从HandlerHolder
集合中选定一个HandlerHolder
,这即是Event Bus发送消息时实现load-balancing的方法:
|
|
获取到对应的handlers
以后,Vert.x首先需要检查其是否为空,如果为空代表此地址还没有注册消息处理Handler
,Vert.x就会创建一个Handlers
并且将其置入handlerMap
中,将newAddress
标志位设为true
代表这是一个新注册的地址,然后将其赋值给handlers
。接着我们向handlers
中的HandlerHolder
列表list
中添加刚刚创建的HandlerHolder
实例,这样就将registration
注册至Event Bus中了。
前面判断当前线程是否在Vert.x Context的标志位hasContext
还有一个用途:如果当前线程在Vert.x Context下(比如在Verticle中),Vert.x会通过addCloseHook
方法给当前的context
添加一个钩子函数用于注销当前绑定的registration
。当对应的Verticle
被undeploy的时候,此Verticle绑定的所有消息处理Handler
都会被unregister。Hook的类型为HandlerEntry<T>
,它继承了Closeable
接口,对应的逻辑在close
函数中实现:
|
|
可以看到close
函数会将绑定的registration
从Event Bus的handlerMap
中移除并执行completionHandler
中的逻辑,completionHandler
可由用户指定。
那么在哪里调用这些绑定的hook呢?答案是在DeploymentManager
类中的doUndeploy
方法中,通过context
的runCloseHooks
方法执行绑定的hook函数。相关代码如下(只截取相关逻辑):
|
|
再回到addRegistration
方法中。刚才addLocalRegistration
方法的返回值newAddress
代表对应的地址是否为新注册的。接着我们调用另一个版本的addRegistration
方法,传入了一大堆参数:
|
|
好吧,传入的前几个参数没用到。。。最后一个参数completionHandler
传入的是registration::setResult
方法引用,也就是说这个方法调用了对应registration
的setResult
方法。其实现位于HandlerRegistration
类中:
|
|
首先先设置registration
内部的result
成员(正常情况下为Future.succeededFuture()
)。接着Vert.x会判断registration
是否绑定了completionHandler
(与之前的completionHandler
不同,这里的completionHandler
是MessageConsumer
注册成功时调用的Handler
),若绑定则记录Metrics信息(handlerRegistered
)并在Vert.x Context内调用completionHandler
的逻辑;若未绑定completionHandler
则仅记录Metrics信息。
到此为止,consumer
方法的逻辑就分析完了。在分析send
和publish
方法的逻辑之前,我们先来看一下如何注销绑定的MessageConsumer
。
我们通过调用MessageConsumer
的unregister
方法实现注销操作。Vert.x提供了两个版本的unregister
方法:
|
|
其中第二个版本的unregister
方法会在注销操作完成时调用传入的completionHandler
。比如在cluster范围内注销consumer需要消耗一定的时间在集群内传播,因此第二个版本的方法就会派上用场。我们来看一下其实现,它们最后都是调用了HandlerRegistration
类的doUnregister
方法:
|
|
如果设定了超时定时器(timeoutID
合法),那么Vert.x会首先将定时器关闭。接着Vert.x会判断是否需要调用endHandler
。那么endHandler
又是什么呢?前面我们提到过MessageConsumer
接口继承了ReadStream
接口,而ReadStream
接口定义了一个endHandler
方法用于绑定一个endHandler
,当stream中的数据读取完毕时会调用。而在Event Bus中,消息源源不断地从一处发送至另一处,因此只有在某个consumer
被unregister的时候,其对应的stream才可以叫“读取完毕”,因此Vert.x选择在doUnregister
方法中调用endHandler
。
接着Vert.x会判断此consumer是否已注册消息处理函数Handler
(通过检查registered
标志位),若已注册则将对应的Handler
从Event Bus中的handlerMap
中移除并将registered
设为false
;若还未注册Handler
且提供了注销结束时的回调completionHandler
(注意不是HandlerRegistration
类的成员变量completionHandler
,而是之前第二个版本的unregister
中传入的Handler
,用同样的名字很容易混。。。),则通过callCompletionHandlerAsync
方法调用回调函数。
从Event Bus中移除Handler
的逻辑位于EventBusImpl
类的removeRegistration
方法中:
|
|
其真正的unregister
逻辑位于removeLocalRegistration
方法中。首先需要从handlerMap
中获取地址对应的Handlers
实例handlers
,如果handlers
不为空,为了防止并发问题,Vert.x需要对其加锁后再进行操作。Vert.x需要遍历handlers
中的列表,遇到与传入的HandlerRegistration
相匹配的HandlerHolder
就将其从列表中移除,然后调用对应holder
的setRemoved
方法标记其为已注销并记录Metrics数据(handlerUnregistered
)。如果移除此HandlerHolder
后handlers
没有任何注册的Handler
了,就将该地址对应的Handlers
实例从handlerMap
中移除并保存刚刚移除的HandlerHolder
。另外,由于已经将此consumer注销,在undeploy verticle的时候不需要再进行unregister,因此这里还要将之前注册到context的hook移除。
调用完removeLocalRegistration
方法以后,Vert.x会调用另一个版本的removeRegistration
方法,调用completionHandler
(用户在第二个版本的unregister
方法中传入的处理函数)对应的逻辑,其它的参数都没什么用。。。
这就是MessageConsumer
注销的逻辑实现。下面就到了本文的另一重头戏了 —— 发送消息相关的函数send
和publish
。
send
和publish
的逻辑相近,只不过一个是发送至目标地址的某一消费者,一个是发布至目标地址的所有消费者。Vert.x使用一个标志位send
来代表是否为点对点发送模式。
几个版本的send
和publish
最终都归结于生成消息对象然后调用sendOrPubInternal
方法执行逻辑,只不过send
标志位不同:
|
|
两个方法中都是通过createMessage
方法来生成对应的消息对象的:
|
|
createMessage
方法接受5个参数:send
即上面提到的标志位,address
为发送目标地址,headers
为设置的header,body
代表发送的对象,codecName
代表对应的Codec(消息编码解码器)名称。createMessage
方法首先会确保地址不为空,然后通过codecManager
来获取对应的MessageCodec
。如果没有提供Codec(即codecName
为空),那么codecManager
会根据发送对象body
的类型来提供内置的Codec实现(具体逻辑请见此处)。准备好MessageCodec
后,createMessage
方法就会创建一个MessageImpl
实例并且返回。
这里我们还需要了解一下MessageImpl
的构造函数:
|
|
createMessage
方法并没有设置回复地址replyAddress
。如果用户指定了replyHandler
的话,后边sendOrPubInternal
方法会对此消息实体进行加工,设置replyAddress
并生成回复逻辑对应的HandlerRegistration
。
我们看一下sendOrPubInternal
方法的源码:
|
|
它接受三个参数:要发送的消息message
,发送配置选项options
以及回复处理函数replyHandler
。首先sendOrPubInternal
方法要检查Event Bus是否已启动,接着如果绑定了回复处理函数,Vert.x就会调用createReplyHandlerRegistration
方法给消息实体message
包装上回复地址,并且生成对应的reply consumer。接着Vert.x创建了一个包装消息的SendContextImpl
实例并调用了其next
方法。
我们一步一步来解释。首先是createReplyHandlerRegistration
方法:
|
|
createReplyHandlerRegistration
方法首先检查传入的replyHandler
是否为空(是否绑定了replyHandler
,回复处理函数),如果为空则代表不需要处理回复,直接返回null
;若replyHandler
不为空,createReplyHandlerRegistration
方法就会从配置中获取reply的最大超时时长(默认30s),然后调用generateReplyAddress
方法生成对应的回复地址replyAddress
:
|
|
生成回复地址的逻辑有点简单。。。。EventBusImpl
实例中维护着一个AtomicLong
类型的replySequence
成员变量代表对应的回复地址。每次生成的时候都会使其自增,然后转化为String。也就是说生成的replyAddress
都类似于”1”、”5”这样,而不是我们想象中的直接回复至sender的地址。。。
生成完毕以后,createReplyHandlerRegistration
方法会将生成的replyAddress
设定到消息对象message
中。接着Vert.x会通过convertHandler
方法对replyHandler
进行包装处理并生成类型简化为Handler<Message<T>>
的simpleReplyHandler
,它用于绑定至后面创建的reply consumer上。接着Vert.x会创建对应的reply consumer。关于reply
操作的实现,我们后边会详细讲述。下面Vert.x就通过handler
方法将生成的回复处理函数simpleReplyHandler
绑定至创建好的reply consumer中,其底层实现我们之前已经分析过了,这里就不再赘述。最后此方法返回生成的registration
,即对应的reply consumer。注意这个reply consumer是一次性的,也就是说Vert.x会在其接收到回复或超时的时候自动对其进行注销。
OK,现在回到sendOrPubInternal
方法中来。下面Vert.x会创建一个SendContextImpl
实例并调用其next
方法。SendContextImpl
类实现了SendContext
接口,它相当于一个消息的封装体,并且可以与Event Bus中的interceptors
(拦截器)结合使用。
SendContext
接口定义了三个方法:
message
: 获取当前SendContext
包装的消息实体next
: 调用下一个消息拦截器send
: 代表消息的发送模式是否为点对点模式在Event Bus中,消息拦截器本质上是一个Handler<SendContext>
类型的处理函数。Event Bus内部存储着一个interceptors
列表用于存储绑定的消息拦截器。我们可以通过addInterceptor
和removeInterceptor
方法进行消息拦截器的添加和删除操作。如果要进行链式拦截,则在每个拦截器中都应该调用对应SendContext
的next
方法,比如:
|
|
我们来看一下SendContextImpl
类中next
方法的实现:
|
|
我们可以看到,SendContextImpl
类中维护了一个拦截器列表对应的迭代器。每次调用next
方法时,如果迭代器中存在拦截器,就将下个拦截器取出并进行相关调用。如果迭代器为空,则代表拦截器都已经调用完毕,Vert.x就会调用EventBusImpl
类下的sendOrPub
方法进行消息的发送操作。
sendOrPub
方法仅仅在metrics模块中记录相关数据(messageSent
),最后调用deliverMessageLocally(SendContextImpl<T>)
方法执行消息的发送逻辑:
|
|
这里面又套了一层。。。它最后其实是调用了deliverMessageLocally(MessageImpl)
方法。此方法返回值代表发送消息的目标地址是否注册有MessageConsumer
,如果没有(false
)则记录错误并调用sendContext
中保存的回复处理函数处理错误(如果绑定了replyHandler
的话)。
deliverMessageLocally(MessageImpl)
方法是真正区分send
和publish
的地方,我们来看一下其实现:
|
|
首先Vert.x需要从handlerMap
中获取目标地址对应的处理函数集合handlers
。接着,如果handlers
不为空的话,Vert.x就会判断消息实体的send
标志位。如果send
标志位为true
则代表以点对点模式发送,Vert.x就会通过handlers
的choose
方法(之前提到过),按照轮询算法来获取其中的某一个HandlerHolder
。获取到HandlerHolder
之后,Vert.x会通过deliverToHandler
方法将消息分发至HandlerHolder
中进行处理;如果send
标志位为false
则代表向所有消费者发布消息,Vert.x就会对handlers
中的每一个HandlerHolder
依次调用deliverToHandler
方法,以便将消息分发至所有注册到此地址的Handler
中进行处理。
消息处理的真正逻辑就在deliverToHandler
方法中。我们来看一下它的源码:
|
|
首先deliverToHandler
方法会复制一份要发送的消息,然后deliverToHandler
方法会调用metrics
的scheduleMessage
方法记录对应的Metrics信息(计划对消息进行处理。此函数修复了Issue 1480)。接着deliverToHandler
方法会从传入的HandlerHolder
中获取对应的Vert.x Context,然后调用runOnContext
方法以便可以让消息处理逻辑在Vert.x Context中执行。为防止对应的handler在处理之前被移除,这里还需要检查一下holder
的isRemoved
属性。如果没有移除,那么就从holder
中获取对应的handler
并调用其handle
方法执行消息的处理逻辑。注意这里获取的handler
实际上是一个HandlerRegistration
。前面提到过HandlerRegistration
类同时实现了MessageConsumer
接口和Handler
接口,因此它兼具这两个接口所期望的功能。另外,之前我们提到过Vert.x会自动注销接收过回复的reply consumer,其逻辑就在这个finally块中。Vert.x会检查holder
中的handler
是否为reply handler(reply consumer),如果是的话就调用其unregister
方法将其注销,来确保reply consumer为一次性的。
之前我们提到过MessageConsumer
继承了ReadStream
接口,因此HandlerRegistration
需要实现flow control(back-pressure)的相关逻辑。那么如何实现呢?我们看到,HandlerRegistration
类中有一个paused
标志位代表是否还继续处理消息。ReadStream
接口中定义了两个函数用于控制stream的通断:当处理速度小于读取速度(发生拥塞)的时候我们可以通过pause
方法暂停消息的传递,将积压的消息暂存于内部的消息队列(缓冲区)pending
中;当相对速度正常的时候,我们可以通过resume
方法恢复消息的传递和处理。
我们看一下HandlerRegistration
中handle
方法的实现:
|
|
果然。。。handle
方法在处理消息的基础上实现了拥塞控制的功能。为了防止资源争用,需要对自身进行加锁;首先handle
方法会判断当前的consumer
是否为paused
状态,如果为paused
状态,handle
方法会检查当前缓冲区大小是否已经超过给定的最大缓冲区大小maxBufferedMessages
,如果没超过,就将收到的消息push到缓冲区中;如果大于或等于阈值,Vert.x就需要丢弃超出的那部分消息。如果当前的consumer
为正常状态,则如果缓冲区不为空,就将收到的消息push到缓冲区中并从缓冲区中pull队列首端的消息,然后调用deliver
方法执行真正的消息处理逻辑。注意这里是在锁之外执行deliver
方法的,这是为了保证在multithreaded worker context下可以并发传递消息(见Bug 473714
)。由于multithreaded worker context允许在不同线程并发执行逻辑(见官方文档),如果将deliver
方法置于synchronized
块之内,其他线程必须等待当前锁被释放才能进行消息的传递逻辑,因而不能做到“delivery concurrently”。
deliver
方法是真正执行“消息处理”逻辑的地方:
|
|
首先Vert.x会调用checkNextTick
方法来检查消息队列(缓冲区)中是否存在更多的消息等待被处理,如果有的话就取出队列首端的消息并调用deliver
方法将其传递给handler
进行处理。这里仍需要注意并发问题,相关实现:
|
|
检查完消息队列以后,Vert.x会接着根据message
判断消息是否仅在本地进行处理并给local
标志位赋值,local
标志位将在记录Metrics数据时用到。
接下来我们看到Vert.x从消息的headers
中获取了一个地址creditsAddress
,如果creditsAddress
存在就向此地址发送一条消息,body为1
。那么这个creditsAddress
又是啥呢?其实,它与flow control有关,我们会在下面详细分析。发送完credit
消息以后,接下来就到了调用handler
处理消息的时刻了。在处理消息之前需要调用metrics
的beginHandleMessage
方法记录消息开始处理的metrics数据,在处理完消息以后需要调用endHandleMessage
方法记录消息处理结束的metrics数据。
嗯。。。到此为止,消息的发送和处理过程就已经一目了然了。下面我们讲一讲之前代码中出现的creditsAddress
到底是啥玩意~
之前我们提到过,Vert.x定义了两个接口作为 flow control aware object 的规范:WriteStream
以及ReadStream
。对于ReadStream
我们已经不再陌生了,MessageConsumer
就继承了它;那么大家应该可以想象到,有MessageConsumer
就必有MessageProducer
。不错,Vert.x中的MessageProducer
接口对应某个address
上的消息生产者,同时它继承了WriteStream
接口,因此MessageProducer
的实现类MessageProducerImpl
同样具有flow control的能力。我们可以把MessageProducer
看做是一个具有flow control功能的增强版的EventBus
。我们可以通过EventBus
接口的publisher
方法创建一个MessageProducer
。
对MessageProducer
有了初步了解之后,我们就可以解释前面deliver
方法中的creditsAddress
了。MessageProducer
接口的实现类 —— MessageProducerImpl
类的流量控制功能是基于credit
的,其内部会维护一个credit
值代表“发送消息的能力”,其默认值等于DEFAULT_WRITE_QUEUE_MAX_SIZE
:
|
|
在采用点对点模式发送消息的时候,MessageProducer
底层会调用doSend
方法进行消息的发送。发送依然利用Event Bus的send
方法,只不过doSend
方法中添加了flow control的相关逻辑:
|
|
与MessageConsumer
类似,MessageProducer
内部同样保存着一个消息队列(缓冲区)用于暂存堆积的消息。当credits
大于0的时候代表可以发送消息(没有出现拥塞),Vert.x就会调用Event Bus的send
方法进行消息的发送,同时credits
要减1;如果credits
小于等于0,则代表此时消息发送的速度太快,出现了拥塞,需要暂缓发送,因此将要发送的对象暂存于缓冲区中。大家可能会问,credits
值不断减小,那么恢复消息发送能力(增大credits
)的逻辑在哪呢?这就要提到creditsAddress
了。我们看一下MessageProducerImpl
类的构造函数:
|
|
MessageProducerImpl
的构造函数中生成了一个creditAddress
,然后给该地址绑定了一个Handler
,当收到消息时调用doReceiveCredit
方法执行解除拥塞,恢复消息发送的逻辑。MessageProducerImpl
会将此MessageConsumer
保存,以便在关闭消息生产者流的时候将其注销。接着构造函数会往options
的headers
中添加一条记录,保存对应的creditAddress
,这也就是上面我们在deliver
函数中获取的creditAddress
:
|
|
这样,发送消息到creditsAddress
的逻辑也就好理解了。由于deliver
函数的逻辑就是处理消息,因此这里向creditsAddress
发送一个 1 其实就是将对应的credits
值加1。恢复消息发送的逻辑位于MessageProducerImpl
类的doReceiveCredit
方法中:
|
|
逻辑一目了然。首先给credits
加上发送过来的值(正常情况下为1),然后恢复发送能力,将缓冲区的数据依次取出、发送然后减小credits
。同时如果MessageProducer
绑定了drainHandler
(消息流不拥塞的时候调用的逻辑,详见官方文档),并且MessageProducer
发送的消息不再拥塞(credits >= maxSize / 2
),那么就在Vert.x Context中执行drainHandler
中的逻辑。
怎么样,体会到Vert.x中flow control的强大之处了吧!官方文档中MessageProducer
的篇幅几乎没有,只在介绍WriteStream
的时候提了提,因此这部分也可以作为MessageProducer
的参考。
最后就是消息的回复逻辑 —— reply
方法了。reply
方法的实现位于MessageImpl
类中,最终调用的是reply(Object, DeliveryOptions, Handler<AsyncResult<Message<R>>>)
这个版本:
|
|
这里reply
方法同样调用EventBus
的createMessage
方法创建要回复的消息实体,传入的replyAddress
即为之前讲过的生成的非常简单的回复地址。然后再将消息实体、配置以及对应的replyHandler
(如果有的话)传入sendReply
方法进行消息的回复。最后其实是调用了Event Bus中的四个参数的sendReply
方法,它的逻辑与之前讲过的sendOrPubInternal
非常相似:
|
|
参数中replyMessage
代表回复消息实体,replierMessage
则代表回复者自身的消息实体(sender)。
如果地址为空则抛出异常;如果地址不为空,则先调用createReplyHandlerRegistration
方法创建对应的replyHandlerRegistration
。createReplyHandlerRegistration
方法的实现之前已经讲过了。注意这里的createReplyHandlerRegistration
其实对应的是此replier的回复,因为Vert.x中的 Request-Response 消息模型不限制相互回复(通信)的次数。当然如果没有指定此replier的回复的replyHandler
,那么此处的replyHandlerRegistration
就为空。最后sendReply
方法会创建一个ReplySendContextImpl
并调用其next
方法。
ReplySendContextImpl
类同样是SendContext
接口的一个实现(继承了SendContextImpl
类)。ReplySendContextImpl
比起其父类就多保存了一个replierMessage
。next
方法的逻辑与父类逻辑非常相似,只不过将回复的逻辑替换成了另一个版本的sendReply
方法:
|
|
然而。。。sendReply
方法并没有用到传入的replierMessage
,所以这里最终还是调用了sendOrPub
方法(尼玛,封装的ReplySendContextImpl
貌似并没有什么卵用,可能为以后的扩展考虑?)。。。之后的逻辑我们都已经分析过了。
这里再强调一点。当我们发送消息同时指定replyHandler
的时候,其内部为reply创建的reply consumer(类型为HandlerRegistration
)指定了timeout
。这个定时器从HandlerRegistration
创建的时候就开始计时了。我们回顾一下:
|
|
计时器会在超时的时候记录错误并强制注销当前consumer。由于reply consumer是一次性的,当收到reply的时候,Vert.x会自动对reply consumer调用unregister
方法对其进行注销(实现位于EventBusImpl#deliverToHandler
方法中),而在注销逻辑中会关闭定时器(参见前面对doUnregister
方法的解析);如果超时,那么计时器就会触发,Vert.x会调用sendAsyncResultFailure
方法注销当前reply consumer并处理错误。
大家可能看到为了防止race condition,Vert.x底层大量使用了synchronized
关键字(重量级锁)。这会不会影响性能呢?其实,如果开发者遵循Vert.x的线程模型和开发规范(使用Verticle)的话,有些地方的synchronized
对应的锁会被优化为 偏向锁 或 轻量级锁(因为通常都是同一个Event Loop线程获取对应的锁),这样性能总体开销不会太大。当然如果使用Multi-threaded worker verticles就要格外关注性能问题了。。。
我们来简略地总结一下Event Bus的工作原理。当我们调用consumer
绑定一个MessageConsumer
时,Vert.x会将它保存至Event Bus实例内部的Map中;当我们通过send
或publish
向对应的地址发送消息的时候,Vert.x会遍历Event Bus中存储consumer的Map,获取与地址相对应的consumer集合,然后根据相应的策略传递并处理消息(send
通过轮询策略获取任意一个consumer并将消息传递至consumer中,publish
则会将消息传递至所有注册到对应地址的consumer中)。同时,MessageConsumer
和MessageProducer
这两个接口的实现都具有flow control功能,因此它们也可以用在Pump
中。
Event Bus是Vert.x中最为重要的一部分之一,探索Event Bus的源码可以加深我们对Event Bus工作原理的理解。作为开发者,只会使用框架是不够的,能够理解内部的实现原理和精华,并对其进行改进才是更为重要的。本篇文章分析的是Local模式下的Event Bus,下篇文章我们将来探索一下生产环境中更常用的 Clustered Event Bus 的实现原理,敬请期待!
]]>Vert.x 蓝图系列 的第三篇教程出炉咯!这篇教程是微服务实战相关的主题。篇幅较长,team给了模板用于渲染对应的文档,因此这里就直接放链接了:
对应的GitHub Repository: sczyh30/vertx-blueprint-microservice
]]>kue-http
模块的实现。
kue-http
模块中只有一个类KueHttpVerticle
,作为整个REST API以及UI服务的实现。对REST API部分来说,如果看过我们之前的 Vert.x 蓝图 | 待办事项服务开发教程 的话,你应该对这一部分非常熟悉了,因此这里我们就不详细解释了。有关使用Vert.x Web实现REST API的教程可参考 Vert.x 蓝图 | 待办事项服务开发教程。
除了REST API之外,我们还给Vert.x Kue提供了一个用户界面。我们复用了Automattic/Kue的用户界面所以我们就不用写前端代码了(部分API有变动的地方我已进行了修改)。我们只需要将前端代码与Vert.x Web适配即可。
首先,前端的代码都属于静态资源,因此我们需要配置路由来允许访问静态资源:
|
|
这样我们就可以直接访问静态资源咯~
注意到Kue UI使用了Jade(最近貌似改名叫Pug了)作为模板引擎,因此我们需要一个Jade模板解析器。好在Vert.x Web提供了一个Jade模板解析的实现: io.vertx:vertx-web-templ-jade
,所以我们可以利用这个实现来渲染UI。首先在类中定义一个JadeTemplateEngine
并在start
方法中初始化:
|
|
然后我们就可以写一个处理器方法来根据不同的任务状态来渲染UI:
|
|
首先我们需要给渲染引擎指定我们前端代码的地址 (1)。然后我们从Redis中获取其中所有的任务类型,然后向解析器context中添加任务状态、网页标题、任务类型等信息供渲染器渲染使用 (2)。接着我们就可以调用engine.render(context, path, handler)
方法进行渲染 (3)。如果渲染成功,我们将页面写入HTTP Response (4)。
现在我们可以利用render
方法去实现其它的路由函数了:
|
|
然后我们给它绑个路由就可以了:
|
|
是不是非常方便呢?不仅如此,Vert.x Web还提供了其它各种模板引擎的支持,比如 FreeMaker, Pebble 以及 Thymeleaf 3。如果感兴趣的话,你可以查阅官方文档来获取详细的使用指南。
是不是等不及要看UI长啥样了?现在我们就来展示一下!首先构建项目:
gradle build
kue-http
需要kue-core
运行着(因为kue-core
里注册了Event Bus服务),因此我们先运行kue-core
,再运行kue-http
。不要忘记运行Redis:
redis-server
java -jar kue-core/build/libs/vertx-blueprint-kue-core.jar -cluster -ha -conf config/config.json
java -jar kue-http/build/libs/vertx-blueprint-kue-http.jar -cluster -ha -conf config/config.json
为了更好地观察任务处理的流程,我们再运行一个示例:
java -jar kue-example/build/libs/vertx-blueprint-kue-example.jar -cluster -ha -conf config/config.json
好啦!现在在浏览器中访问http://localhost:8080
,我们的Kue UI就呈现在我们眼前啦!
欢迎回到Vert.x 蓝图系列~在本教程中,我们将利用Vert.x开发一个基于消息的应用 - Vert.x Kue,它是一个使用Vert.x开发的优先级工作队列,数据存储使用的是 Redis 。Vert.x Kue是Automattic/kue的Vert.x实现版本。我们可以使用Vert.x Kue来处理各种各样的任务,比如文件转换、订单处理等等。
通过本教程,你将会学习到以下内容:
本教程是 Vert.x 蓝图系列 的第二篇教程,对应的Vert.x版本为 3.3.3 。本教程中的完整代码已托管至GitHub。
既然我们要用Vert.x开发一个基于消息的应用,那么我们先来瞅一瞅Vert.x的消息系统吧~在Vert.x中,我们可以通过 Event Bus 来发送和接收各种各样的消息,这些消息可以来自不同的Vertx
实例。怎么样,很酷吧?我们都将消息发送至Event Bus上的某个地址上,这个地址可以是任意的字符串。
Event Bus支持三种消息机制:发布/订阅(Publish/Subscribe)、点对点(Point to point)以及请求/回应(Request-Response)模式。下面我们就来看一看这几种机制。
在发布/订阅模式中,消息被发布到Event Bus的某一个地址上,所有订阅此地址的Handler
都会接收到该消息并且调用相应的处理逻辑。我们来看一看示例代码:
|
|
我们可以通过vertx.eventBus()
方法获取EventBus
的引用,然后我们就可以通过consume
方法订阅某个地址的消息并且绑定一个Handler
。接着我们通过publish
向此地址发送消息。如果运行上面的例子,我们会得到一下结果:
|
|
如果我们把上面的示例中的publish
方法替代成send
方法,上面的实例就变成点对点模式了。在点对点模式中,消息被发布到Event Bus的某一个地址上。Vert.x会将此消息传递给其中监听此地址的Handler
之一。如果有多个Handler
绑定到此地址,那么就使用轮询算法随机挑一个Handler
传递消息。比如在此示例中,程序只会打印2: +1s
或者1: +1s
之中的一个。
当我们绑定的Handler
接收到消息的时候,我们可不可以给消息的发送者回复呢?当然了!当我们通过send
方法发送消息的时候,我们可以同时指定一个回复处理函数(reply handler)。然后当某个消息的订阅者接收到消息的时候,它就可以给发送者回复消息;如果发送者接收到了回复,发送者绑定的回复处理函数就会被调用。这就是请求/回应模式。
好啦,现在我们已经粗略了解了Vert.x中的消息系统 - Event Bus的基本使用,下面我们就看看Vert.x Kue的基本设计。有关更多关于Event Bus的信息请参考Vert.x Core Manual - Event Bus。
在我们的项目中,我们将Vert.x Kue划分为两个模块:
kue-core
: 核心组件,提供优先级队列的功能kue-http
: Web组件,提供Web UI以及REST API另外我们还提供一个示例模块kue-example
用于演示以及阐述如何使用Vert.x Kue。
既然我们的项目有两个模块,那么你一定会好奇:两个模块之间是如何进行通信的?并且如果我们写自己的Kue应用的话,我们该怎样去调用Kue Core中的服务呢?不要着急,谜底将在后边的章节中揭晓:-)
回顾一下Vert.x Kue的作用 - 优先级工作队列,所以在Vert.x Kue的核心模块中我们设计了以下的类:
Job
- 任务(作业)数据实体JobService
- 异步服务接口,提供操作任务以及获取数据的相关逻辑KueWorker
- 用于处理任务的VerticleKue
- 工作队列前边我们提到过,我们的两个组件之间需要一种通信机制可以互相通信 - 这里我们使用Vert.x的集群模式,即以clustered的模式来部署Verticle。这样的环境下的Event Bus同样也是集群模式的,因此各个组件可以通过集群模式下的Event Bus进行通信。很不错吧?在Vert.x的集群模式下,我们需要指定一个集群管理器ClusterManager
。这里我们使用默认的HazelcastClusterManager
,使用Hazelcast作为集群管理。
在Vert.x Kue中,我们将JobService
服务发布至分布式的Event Bus上,这样其它的组件就可以通过Event Bus调用该服务了。我们设计了一个KueVerticle
用于注册服务。Vert.x提供了Vert.x Service Proxy(服务代理组件),可以很方便地将服务注册至Event Bus上,然后在其它地方获取此服务的代理并调用。我们将在下面的章节中详细介绍Vert.x Service Proxy。
在我们的Vert.x Kue中,大多数的异步方法都是基于Future
的。如果您看过蓝图系列的第一篇文章的话,您一定不会对这种模式很陌生。在Vert.x 3.3.2中,我们的Future
支持基本的响应式的操作,比如map
和compose
。它们用起来非常方便,因为我们可以将多个Future
以响应式的方式组合起来而不用担心陷入回调地狱中。
正如我们在Vert.x Kue 特性介绍中提到的那样,Vert.x Kue支持两种级别的事件:任务事件(job events) 以及 队列事件(queue events)。在Vert.x Kue中,我们设计了三种事件地址:
vertx.kue.handler.job.{handlerType}.{addressId}.{jobType}
: 某个特定任务的任务事件地址vertx.kue.handler.workers.{eventType}
: (全局)队列事件地址vertx.kue.handler.workers.{eventType}.{addressId}
: 某个特定任务的内部事件地址在特性介绍文档中,我们提到了以下几种任务事件:
start
开始处理一个任务 (onStart
)promotion
一个延期的任务时间已到,提升至工作队列中 (onPromotion
)progress
任务的进度变化 (onProgress
)failed_attempt
任务处理失败,但是还可以重试 (onFailureAttempt
)failed
任务处理失败并且不能重试 (onFailure
)complete
任务完成 (onComplete
)remove
任务从后端存储中移除 (onRemove
)队列事件也相似,只不过需要加前缀job_
。这些事件都会通过send
方法发送至Event Bus上。每一个任务都有对应的任务事件地址,因此它们能够正确地接收到对应的事件并进行相应的处理逻辑。
特别地,我们还有两个内部事件:done
和done_fail
。done
事件对应一个任务在底层的处理已经完成,而done_fail
事件对应一个任务在底层的处理失败。这两个事件使用第三种地址进行传递。
在Vert.x Kue中,任务共有五种状态:
INACTIVE
: 任务还未开始处理,在工作队列中等待处理ACTIVE
: 任务正在处理中COMPLETE
: 任务处理完成FAILED
: 任务处理失败DELAYED
: 任务延时处理,正在等待计时器时间到并提升至工作队列中我们使用状态图来描述任务状态的变化:
以及任务状态的变化伴随的事件:
为了让大家对Vert.x Kue的架构有大致的了解,我用一幅图来简略描述整个Vert.x Kue的设计:
现在我们对Vert.x Kue的设计有了大致的了解了,下面我们就来看一看Vert.x Kue的代码实现了~
我们来开始探索Vert.x Kue的旅程吧!首先我们先从GitHub上clone源代码:
git clone https://github.com/sczyh30/vertx-blueprint-job-queue.git
然后你可以把项目作为Gradle项目导入你的IDE中。(如何导入请参考相关IDE帮助文档)
正如我们之前所提到的,我们的Vert.x Kue中有两个功能模块和一个实例模块,因此我们需要在Gradle工程文件中定义三个子工程。我们来看一下本项目中的build.gradle
文件:
|
|
(⊙o⊙)…比之前的待办事项服务项目中的长不少诶。。。我们来解释一下:
configure(allprojects)
作用域中,我们配置了一些全局信息(对所有子工程都适用)。kue-core
、kue-http
以及kue-example
。这里我们来解释一下里面用到的依赖。在kue-core
中,vertx-redis-client
用于Redis通信,vertx-service-proxy
用于Event Bus上的服务代理。在kue-http
中,我们将kue-core
子工程作为它的一个依赖。vertx-web
和vertx-web-templ-jade
用于Kue Web端的开发。annotationProcessing
用于注解处理(Vert.x Codegen)。我们已经在上一篇教程中介绍过了,这里就不展开讲了。我们还需要在 settings.gradle
中配置工程:
|
|
看完了配置文件以后,我们再来浏览一下我们的项目目录结构:
|
|
在Gradle中,项目的源码都位于{projectName}/src/main/java
目录内。这篇教程是围绕Vert.x Kue Core的,所以我们的代码都在kue-core
目录中。
好啦!现在我们已经对Vert.x Kue项目的整体结构有了大致的了解了,下面我们开始源码探索之旅!
Vert.x Kue是用来处理任务的,因此我们先来看一下代表任务实体的Job
类。Job
类位于io.vertx.blueprint.kue.queue
包下。代码可能有点长,不要担心,我们把它分成几部分,分别来解析。
我们先来看一下Job
类中的成员属性:
|
|
我去。。。好多属性!我们一个一个地解释:
address_id
: 一个UUID序列,作为Event Bus的地址id
: 任务的编号(id)type
: 任务的类型data
: 任务携带的数据,以 JsonObject
类型表示priority
: 任务优先级,以 Priority
枚举类型表示。默认优先级为正常(NORMAL
)delay
: 任务的延迟时间,默认是 0state
: 任务状态,以 JobState
枚举类型表示。默认状态为等待(INACTIVE
)attempts
: 任务已经尝试执行的次数max_attempts
: 任务尝试执行次数的最大阈值removeOnComplete
: 代表任务完成时是否自动从后台移除zid
: zset
操作对应的编号(zid),保持先进先出顺序ttl
: TTL(Time to live)backoff
: 任务重试配置,以 JsonObject
类型表示progress
: 任务执行的进度result
: 任务执行的结果,以 JsonObject
类型表示还有这些统计数据:
created_at
: 代表此任务创建的时间promote_at
: 代表此任务从延时状态被提升至等待状态时的时间updated_at
: 代表任务更新的时间failed_at
: 代表任务失败的时间started_at
: 代表任务开始的时间duration
: 代表处理任务花费的时间,单位为毫秒(ms
)你可能注意到在 Job
类中还存在着几个静态成员变量:
|
|
对于 logger
对象,我想大家应该都很熟悉,它代表一个Vert.x Logger实例用于日志记录。但是你一定想问为什么 Job
类中存在着一个Vertx
类型的静态成员。Job
类不应该是一个数据对象吗?当然咯!Job
类代表一个数据对象,但不仅仅是一个数据对象。这里我模仿了一些Automattic/kue的风格,把一些任务相关逻辑方法放到了Job
类里,它们大多都是基于Future
的异步方法,因此可以很方便地去调用以及进行组合变换。比如:
|
|
由于我们不能在Job
类被JVM加载的时候就获取Vertx
实例,我们必须手动给Job
类中的静态Vertx
成员赋值。这里我们是在Kue
类中对其进行赋值的。当我们创建一个工作队列的时候,Job
类中的静态成员变量会被初始化。同时为了保证程序的正确性,我们需要一个方法来检测静态成员变量是否初始化。当我们在创建一个任务的时候,如果静态成员此时未被初始化,那么日志会给出警告:
|
|
我们还注意到 Job
类也是由@DataObject
注解修饰的。Vert.x Codegen可以处理含有@DataObject
注解的类并生成对应的JSON转换器,并且Vert.x Service Proxy也需要数据对象。
在Job
类中我们有四个构造函数。其中address_id
成员必须在一个任务被创建时就被赋值,默认情况下此地址用一个唯一的UUID字符串表示。每一个构造函数中我们都要调用_checkStatic
函数来检测静态成员变量是否被初始化。
正如我们之前所提到的那样,我们通过一个特定的地址vertx.kue.handler.job.{handlerType}.{addressId}.{jobType}
在分布式的Event Bus上发送和接收任务事件(job events)。所以我们提供了两个用于发送和接收事件的辅助函数emit
和on
(类似于Node.js中的EventEmitter
):
|
|
在后面的代码中,我们将频繁使用这两个辅助函数。
在我们探索相关的逻辑函数之前,我们先来描述一下Vert.x Kue的数据在Redis中是以什么样的形式存储的:
vertx_kue
命名空间下(以vertx_kue:
作为前缀)vertx:kue:job:{id}
: 存储任务实体的mapvertx:kue:ids
: 计数器,指示当前最大的任务IDvertx:kue:job:types
: 存储所有任务类型的列表vertx:kue:{type}:jobs
: 指示所有等待状态下的某种类型任务的列表vertx_kue:jobs
: 存储所有任务zid
的有序集合vertx_kue:job:{state}
: 存储所有指定状态的任务zid
的有序集合vertx_kue:jobs:{type}:{state}
: 存储所有指定状态和类型的任务zid
的有序集合vertx:kue:job:{id}:log
: 存储指定id
的任务对应日志的列表OK,下面我们就来看看Job
类中重要的逻辑函数。
我们之前提到过,Vert.x Kue中的任务一共有五种状态。所有的任务相关的操作都伴随着任务状态的变换,因此我们先来看一下state
方法的实现,它用于改变任务的状态:
|
|
首先我们先创建了一个Future
对象。然后我们调用了 client.transaction().multi(handler)
函数开始一次Redis事务 (1)。在Vert.x 3.3.2中,所有的Redis事务操作都移至RedisTransaction
类中,所以我们需要先调用client.transaction()
方法去获取一个事务实例,然后调用multi
代表事务块的开始。
在multi
函数传入的Handler
中,我们先判定当前的任务状态。如果当前任务状态不为空并且不等于新的任务状态,我们就将Redis中存储的旧的状态信息移除 (2)。为了方便起见,我们提供了一个RedisHelper
辅助类,里面提供了一些生成特定地址以及编码解码zid
的方法:
|
|
所有的key都必须在vertx_kue
命名空间下,因此我们封装了一个getKey
方法。我们还实现了createFIFO
和stripFIFO
方法用于生成zid
以及解码zid
。zid
的格式使用了Automattic/Kue中的格式。
回到state
方法来。我们使用zrem(String key, String member, Handler<AsyncResult<String>> handler)
方法将特定的数据从有序集合中移除。两个key分别是vertx_kue:job:{state}
以及 vertx_kue:jobs:{type}:{state}
;member
对应着任务的zid
。
接下来我们使用hset
方法来变更新的状态 (3),然后用zadd
方法往vertx_kue:job:{state}
和 vertx_kue:jobs:{type}:{state}
两个有序集合中添加此任务的zid
,同时传递一个权重(score)。这个非常重要,我们就是通过这个实现优先级队列的。我们直接使用priority
对应的值作为score
。这样,当我们需要从Redis中获取任务的时候,我们就可以通过zpop
方法获取优先级最高的任务。我们会在后面详细讲述。
不同的新状态需要不同的操作。对于ACTIVE
状态,我们通过zadd
命令将zid
添加至vertx_kue:jobs:ACTIVE
有序集合中并赋予优先级权值 (4)。对于DELAYED
状态,我们通过zadd
命令将zid
添加至vertx_kue:jobs:DELAYED
有序集合中并赋予提升时间(promote_at
)权值 (5)。对于INACTIVE
状态,我们向vertx:kue:{type}:jobs
列表中添加一个元素 (6)。这些操作都是在Redis事务块内完成的。最后我们通过exec
方法一并执行这些事务操作 (7)。如果执行成功,我们给future
赋值(当前任务)。最后我们返回future
并且与updateNow
方法相组合。
updateNow
方法非常简单,就是把updated_at
的值设为当前时间,然后存到Redis中:
|
|
这里我们来看一下整个Job
类中最重要的方法之一 - save
方法,它的作用是保存任务至Redis中。
|
|
首先,任务类型不能为空所以我们要检查type
是否为空 (1)。接着,如果当前任务的id大于0,则代表此任务已经存储过(因为id是存储时分配),此时只需执行更新操作(update
)即可 (2)。然后我们创建一个Future
对象,然后使用incr
方法从vertx_kue:ids
字段获取一个新的id
(3)。同时我们使用RedisHelper.createFIFO(id)
方法来生成新的zid
(4)。接着我们来判断任务延时是否大于0,若大于0则将当前任务状态设置为DELAYED
。然后我们通过sadd
方法将当前任务类型添加至vertx:kue:job:types
列表中 (5) 并且保存任务创建时间(created_at
)以及任务提升时间(promote_at
)。经过这一系列的操作后,所有的属性都已准备好,所以我们可以利用hmset
方法将此任务实体存储至vertx:kue:job:{id}
哈希表中 (6)。如果存储操作成功,那么将当前任务实体赋给future
,否则记录错误。最后我们返回此future
并且将其与update
方法进行组合。
update
方法进行一些更新操作,它的逻辑比较简单:
|
|
可以看到update
方法只做了三件微小的工作:存储任务更新时间、存储zid
以及更改当前任务状态(组合state
方法)。
最后总结一下将一个任务存储到Redis中经过的步骤:save -> update -> state
:-)
移除任务非常简单,借助zrem
和del
方法即可。我们来看一下其实现:
|
|
注意到成功移除任务时,我们会向Event Bus上的特定地址发送remove
任务事件。此事件包含着被移除任务的id
。
我们可以通过几种 onXXX
方法来监听任务事件:
|
|
注意到不同的事件,对应接收的数据类型也有差异。我们来说明一下:
onComplete
、onPromotion
以及 onStart
: 发送的数据是对应的Job
对象onFailure
and onFailureAttempt
: 发送的数据是JsonObject
类型的,其格式类似于:
|
|
onProgress
: 发送的数据是当前任务进度onRemove
: 发送的数据是JsonObject
类型的,其中id
代表被移除任务的编号我们可以通过progress
方法来更新任务进度。看一下其实现:
|
|
progress
方法接受两个参数:第一个是当前完成的进度值,第二个是完成状态需要的进度值。我们首先计算出当前的进度 (1),然后向特定地址发送progress
事件 (2)。最后我们将进度存储至Redis中并更新时间,返回Future
(3)。
当一个任务处理失败时,如果它有剩余的重试次数,Vert.x Kue会自动调用failAttempt
方法进行重试。我们来看一下failAttempt
方法的实现:
|
|
(⊙o⊙)非常简短吧~实际上,failAttempt
方法是三个异步方法的组合:error
、failed
以及attemptInternal
。当一个任务需要进行重试的时候,我们首先向Event Bus发布 error
队列事件并且在Redis中记录日志,然后将当前的任务状态置为FAILED
,最后重新处理此任务。
我们先来看一下error
方法:
|
|
它的逻辑很简单:首先我们向Event Bus发布 错误 事件,然后记录错误日志即可。这里我们封装了一个发布错误的函数emitError
:
|
|
其中发送的错误信息格式类似于下面的样子:
|
|
接下来我们再来看一下failed
方法的实现:
|
|
非常简单,首先我们更新任务的更新时间和失败时间,然后通过state
方法将当前任务状态置为FAILED
即可。
任务重试的核心逻辑在attemptInternal
方法中:
|
|
在我们的Job
数据对象中,我们存储了最大重试次数max_attempts
以及已经重试的次数attempts
,所以我们首先根据这两个数据计算剩余的重试次数remaining
(1)。如果还有剩余次数的话,我们就先调用attemptAdd
方法增加一次已重试次数并 (2),然后我们调用reattempt
方法执行真正的任务重试逻辑 (3)。最后返回这两个异步方法组合的Future
。如果其中一个过程出现错误,我们就发布error
事件 (4)。如果没有剩余次数了或者超出剩余次数了,我们直接返回错误。
在我们解析reattempt
方法之前,我们先来回顾一下Vert.x Kue中的任务失败恢复机制。Vert.x Kue支持延时重试机制(retry backoff),并且支持不同的策略(如 fixed 以及 exponential)。之前我们提到Job
类中有一个backoff
成员变量,它用于配置延时重试的策略。它的格式类似于这样:
|
|
延时重试机制的实现在getBackoffImpl
方法中,它返回一个Function<Integer, Long>
对象,代表一个接受Integer
类型(即attempts
),返回Long
类型(代表计算出的延时值)的函数:
|
|
首先我们从backoff
配置中获取延迟重试策略。目前Vert.x Kue支持两种策略:fixed
和 exponential
。前者采用固定延迟时间,而后者采用指数增长型延迟时间。默认情况下Vert.x Kue会采用fixed
策略 (1)。接下来我们从backoff
配置中获取延迟时间,如果配置中没有指定,那么就使用任务对象中的延迟时间delay
(2)。接下来就是根据具体的策略进行计算了。对于指数型延迟,我们计算[delay * 0.5 * 2^attempts]
作为延迟时间 (3);对于固定型延迟策略,我们直接使用获取到的延迟时间 (4)。
好啦,现在回到“真正的重试”方法 —— reattempt
方法来:
|
|
首先我们先检查backoff
配置是否存在,若存在则计算出对应的延时时间 (1) 并且设定delay
和promote_at
属性的值然后保存至Redis中 (2)。接着我们通过delayed
方法将任务的状态设为延时(DELAYED
) (3)。如果延时重试配置不存在,我们就通过inactive
方法直接将此任务置入工作队列中 (4)。
这就是整个任务重试功能的实现,也不是很复杂蛤?观察上面的代码,我们可以发现Future
组合无处不在。这种响应式的组合非常方便。想一想如果我们用回调的异步方式来写代码的话,我们很容易陷入回调地狱中(⊙o⊙)。。。几个回调嵌套起来总显得不是那么优美和简洁,而用响应式的、可组合的Future
就可以有效地避免这个问题。
不错!到现在为止我们已经探索完Job
类的源码了~下面我们来看一下JobService
类。
在本章节中我们来探索一下JobService
接口及其实现 —— 它包含着各种普通的操作和统计Job
的逻辑。
我们的JobService
是一个通用逻辑接口,因此我们希望应用中的每一个组件都能访问此服务,即进行RPC。在Vert.x中,我们可以将服务注册至Event Bus上,然后其它组件就可以通过Event Bus来远程调用注册的服务了。
传统的RPC有一个缺点:消费者需要阻塞等待生产者的回应。你可能想说:这是一种阻塞模型,和Vert.x推崇的异步开发模式不相符。没错!而且,传统的RPC不是真正面向失败设计的。
还好,Vert.x提供了一种高效的、响应式的RPC —— 异步RPC。我们不需要等待生产者的回应,而只需要传递一个Handler<AsyncResult<R>>
参数给异步方法。这样当收到生产者结果时,对应的Handler
就会被调用,非常方便,这与Vert.x的异步开发模式相符。并且,AsyncResult
也是面向失败设计的。
所以讲到这里,你可能想问:到底怎么在Event Bus上注册服务呢?我们是不是需要写一大堆的逻辑去包装和发送信息,然后在另一端解码信息并进行调用呢?不,这太麻烦了!有了Vert.x 服务代理,我们不需要这么做!Vert.x提供了一个组件 Vert.x Service Proxy 来自动生成服务代理。有了它的帮助,我们就只需要按照规范设计我们的异步服务接口,然后用@ProxyGen
注解修饰即可。
[NOTE @ProxyGen
注解的限制 | @ProxyGen
注解的使用有诸多限制。比如,所有的异步方法都必须是基于回调的,也就是说每个方法都要接受一个Handler<AsyncResult<R>>
类型的参数。并且,类型R
也是有限制的 —— 只允许基本类型以及数据对象类型。详情请参考官方文档。 ]
我们来看一下JobService
的源码:
|
|
可以看到我们还为JobService
接口添加了@VertxGen
注解,Vert.x Codegen可以处理此注解生成多种语言版本的服务。
在JobService
接口中我们还定义了两个静态方法:create
用于创建一个任务服务实例,createProxy
用于创建一个服务代理。
JobService
接口中包含一些任务操作和统计的相关逻辑,每个方法的功能都已经在注释中阐述了,因此我们就直接来看它的实现吧~
JobService
接口的实现位于JobServiceImpl
类中,代码非常长,因此这里就不贴代码了。。。大家可以对照GitHub中的代码读下面的内容。
getJob
: 获取任务的方法非常简单。直接利用hgetall
命令从Redis中取出对应的任务即可。removeJob
: 我们可以将此方法看作是getJob
和Job#remove
两个方法的组合。existsJob
: 使用exists
命令判断对应id
的任务是否存在。getJobLog
: 使用lrange
命令从vertx_kue:job:{id}:log
列表中取出日志。rangeGeneral
: 使用zrange
命令获取一定范围内的任务,这是一个通用方法。[NOTE zrange
操作 | zrange
返回某一有序集合中某个特定范围内的元素。详情请见ZRANGE - Redis。 ]
以下三个方法复用了rangeGeneral
方法:
jobRangeByState
: 指定状态,对应的key为vertx_kue:jobs:{state}
。jobRangeByType
: 指定状态和类型,对应的key为vertx_kue:jobs:{type}:{state}
。jobRange
: 对应的key为vertx_kue:jobs
。这两个通用方法用于任务数量的统计:
cardByType
: 利用zcard
命令获取某一指定状态和类型下任务的数量。card
: 利用zcard
命令获取某一指定状态下任务的数量。下面五个辅助统计方法复用了上面两个通用方法:
completeCount
failedCount
delayedCount
inactiveCount
activeCount
接着看:
getAllTypes
: 利用smembers
命令获取vertx_kue:job:types
集合中存储的所有的任务类型。getIdsByState
: 使用zrange
获取某一指定状态下所有任务的ID。getWorkTime
: 使用get
命令从vertx_kue:stats:work-time
中获取Vert.x Kue的工作时间。既然完成了JobService
的实现,接下来我们来看一下如何利用Service Proxy将服务注册至Event Bus上。这里我们还需要一个KueVerticle
来创建要注册的服务实例,并且将其注册至Event Bus上。
打开io.vertx.blueprint.kue.queue.KueVerticle
类的源码:
|
|
首先我们需要定义一个地址用于服务注册 (1)。在start
方法中,我们创建了一个任务服务实例 (2),然后通过ping
命令测试Redis连接 (3)。如果连接正常,那么我们就可以通过ProxyHelper
类中的registerService
辅助方法来将服务实例注册至Event Bus上 (4)。
这样,一旦我们在集群模式下部署KueVerticle
,服务就会被发布至Event Bus上,然后我们就可以在其他组件中去远程调用此服务了。很奇妙吧!
Kue
类代表着工作队列。我们来看一下Kue
类的实现。首先先看一下其构造函数:
|
|
这里我们需要注意两点:第一点,我们通过createProxy
方法来创建一个JobService
的服务代理;第二点,之前提到过,我们需要在这里初始化Job
类中的静态成员变量。
我们的JobService
是基于回调的,这是服务代理组件所要求的。为了让Vert.x Kue更加响应式,使用起来更加方便,我们在Kue
类中以基于Future的异步模式封装了JobService
中的所有异步方法。这很简单,比如这个方法:
|
|
可以这么封装:
|
|
其实就是加一层Future
。其它的封装过程也类似所以我们就不细说了。
process
和processBlocking
方法用于处理任务:
|
|
两个process
方法都类似 —— 它们都是使用Event Loop线程处理任务的,其中第一个方法还可以指定同时处理任务数量的阈值。我们来回顾一下使用Event Loop线程的注意事项 —— 我们不能阻塞Event Loop线程。因此如果我们需要在处理任务时做一些耗时的操作,我们可以使用processBlocking
方法。这几个方法的代码看起来都差不多,那么区别在哪呢?之前我们提到过,我们设计了一种Verticle - KueWorker
,用于处理任务。因此对于process
方法来说,KueWorker
就是一种普通的Verticle;而对于processBlocking
方法来说,KueWorker
是一种Worker Verticle。这两种Verticle有什么不同呢?区别在于,Worker Verticle会使用Worker线程,因此即使我们执行一些耗时的操作,Event Loop线程也不会被阻塞。
创建及部署KueWorker
的逻辑在processInternal
方法中,这三个方法都使用了processInternal
方法:
|
|
首先我们创建一个KueWorker
实例 (1)。我们将在稍后详细介绍KueWorker
的实现。然后我们根据提供的配置来部署此KueWorker
(2)。processInternal
方法的第三个参数代表此KueWorker
是否为worker verticle。如果部署成功,我们就监听complete
事件。每当接收到complete
事件的时候,我们获取收到的信息(处理任务消耗的时间),然后用incrby
增加对应的工作时间 (3)。
再回到前面三个处理方法中。除了部署KueWorker
以外,我们还调用了setupTimers
方法,用于设定定时器以监测延时任务以及监测活动任务TTL。
Vert.x Kue支持延时任务,因此我们需要在任务延时时间到达时将任务“提升”至工作队列中等待处理。这个工作是在checkJobPromotion
方法中实现的:
|
|
首先我们从配置中获取监测延时任务的间隔(job.promotion.interval
,默认1000ms)以及提升数量阈值(job.promotion.limit
,默认1000)。然后我们使用vertx.setPeriodic
方法设一个周期性的定时器 (3),每隔一段时间就从Redis中获取需要被提升的任务 (4)。这里我们通过zrangebyscore
获取每个需要被提升任务的id
。我们来看一下zrangebyscore
方法的定义:
|
|
key
: 某个有序集合的key,即vertx_kue:jobs:DELAYED
min
and max
: 最小值以及最大值(按照某种模式)。这里min
是0,而max
是当前时间戳我们来回顾一下Job
类中的state
方法。当我们要把任务状态设为DELAYED
的时候,我们将score设为promote_at
时间:
|
|
因此我们将max
设为当前时间(System.currentTimeMillis()
),只要当前时间超过需要提升的时间,这就说明此任务可以被提升了。
options
: range和limit配置。这里我们需要指定LIMIT
值所以我们用new RangeLimitOptions(new JsonObject().put("offset", 0).put("count", limit)
创建了一个配置zrangebyscore
的结果是一个JsonArray
,里面包含着所有等待提升任务的zid
。获得结果后我们就将每个zid
转换为id
,然后分别获取对应的任务实体,最后对每个任务调用inactive
方法来将任务状态设为INACTIVE
(5)。如果任务成功提升至工作队列,我们就发送promotion
事件 (6)。
我们知道,Vert.x支持多种语言(如JS,Ruby),因此如果能让我们的Vert.x Kue支持多种语言那当然是极好的!这没有问题~Vert.x Codegen可以处理含@VertxGen
注解的异步接口,生成多语言版本。@VertxGen
注解同样限制异步方法 —— 需要基于回调,因此我们设计了一个CallbackKue
接口用于提供多语言支持。CallbackKue
的设计非常简单,其实现复用了Kue
和jobService
的代码。大家可以直接看源码,一目了然,这里就不细说了。
注意要生成多语言版本的代码,需要添加相应的依赖。比如要生成Ruby版本的代码就要向build.gradle
中添加compile("io.vertx:vertx-lang-ruby:${vertxVersion}")
。
好啦,我们已经对Vert.x Kue Core的几个核心部分有了大致的了解了,现在是时候探索一下任务处理的本源 - KueWorker
了~
每一个worker都对应一个特定的任务类型,并且绑定着特定的处理函数(Handler
),所以我们需要在创建的时候指定它们。
在KueWorker
中,我们使用prepareAndStart
方法来准备要处理的任务并且开始处理任务的过程:
|
|
代码比较直观。首先我们通过getJobFromBackend
方法从Redis中按照优先级顺序获取任务 (1)。如果成功获取任务,我们就把获取到的任务保存起来 (2) 然后通过process
方法处理任务 (3)。如果中间出现错误,我们需要发送error
错误事件,其中携带错误信息。
我们来看一下我们是如何从Redis中按照优先级顺序获取任务实体的:
|
|
之前我们已经了解到,每当我们保存一个任务的时候,我们都会向vertx_kue:{type}:jobs
列表中插入一个新元素表示新的任务可供处理。因此这里我们通过blpop
命令来等待可用的任务 (1)。一旦有任务可供处理,我们就利用zpop
方法取出高优先级的任务的zid
(2)。zpop
命令是一个原子操作,用于从有序集合中弹出最小score值的元素。注意Redis没有实现zpop
命令,因此我们需要自己实现。
Redis官方文档介绍了一种实现zpop
命令的简单方法 - 利用 WATCH
。这里我们利用另外一种思路实现zpop
命令:
|
|
在我们的zpop
的实现中,我们首先开始了一个事务块,然后依次执行zrange
和zremrangebyrank
命令。有关这些命令的详情我们就不细说了,可以参考Redis官方文档。然后我们提交事务,如果提交成功,我们会获得一个JsonArray
类型的结果。正常情况下我们都可以通过res.getJsonArray(0).getString(0)
获取到对应的zid
值。获取到zid
值以后我们就可以将其转换为任务的id
了,最后我们将id
置于Future
内(因为zpop
也是一个异步方法)。
接着回到getJobFromBackend
方法中。获取到对应的id
之后,我们就可以通过Kue
的getJob
函数获取任务实体了 (3)。由于getJobFromBackend
也是一个异步方法,因此我们同样将结果置于Future
中。
前边讲了那么多,都是在为处理任务做准备。。。不要着急,现在终于到了真正的“处理”逻辑咯!我们看一下process
方法的实现:
|
|
到了最核心的函数了!首先我们先给开始时间赋值 (1) 然后将任务状态置为ACTIVE
(2)。如果这两个操作成功的话,我们就向Event Bus发送任务开始(start
)事件 (3)。接下来我们调用真正的处理逻辑 - 之前绑定的jobHandler
(4)。如果处理过程中抛出异常的话,Vert.x Kue就会调用job.done(ex)
方法发送done_fail
内部事件来通知worker任务处理失败。但是似乎没有看到在哪里接收并处理done
和done_fail
事件呢?就在这 (5)!一旦Vert.x Kue接收到这两个事件,它就会调用对应的handler
去进行任务完成或失败的相应操作。这里的handler
是由createDoneCallback
方法生成的:
|
|
任务处理有两种情况:完成和失败,因此我们先来看任务成功处理的情况。我们首先给任务的用时(duration
)赋值 (2),并且如果任务产生了结果,也给结果(result
)赋值 (3)。然后我们调用job.complete
方法将状态设置为COMPLETE
(4)。如果成功的话,我们就检查removeOnComplete
标志位 (5) 并决定是否将任务从Redis中移除。然后我们向Event Bus发送任务完成事件(complete
)以及队列事件job_complete
(6)。现在这个任务的处理过程已经结束了,worker需要准备处理下一个任务了,因此最后我们调用prepareAndStart
方法准备处理下一个Job
。
人生不如意事十之八九,任务处理过程中很可能会遇见各种各样的问题而失败。当任务处理失败时,我们调用KueWorker
中的fail
方法:
|
|
面对失败时,我们首先通过failedAttempt
方法尝试从错误中恢复 (1)。如果恢复失败(比如没有重试次数了)就向Event Bus发送error
队列事件 (2)。如果恢复成功,我们就根据是否还有剩余重试次数来发送对应的事件(failed
或者failed_attempt
)。搞定错误以后,worker同样需要准备处理下一个任务了,因此最后我们调用prepareAndStart
方法准备处理下一个Job
(5)。
这就是KueWorker
的全部实现,是不是很有趣呢?看了这么久的代码也有些累了,下面是时候来写个Kue应用跑一下咯~
在io.vertx.blueprint.kue.example
包下(kue-example
子工程)创建一个LearningVertxVerticle
类,然后编写如下代码:
|
|
通常情况下,一个Vert.x Kue应用可以分为几部分:创建工作队列、创建任务、保存任务以及处理任务。我们推荐开发者把应用写成Verticle
的形式。
在这个例子中,我们要模拟一个学习Vert.x的任务!首先我们通过Kue.createQueue
方法创建一个工作队列并且通过on(error, handler)
方法监听全局错误(error
)事件。接着我们通过kue.createJob
方法创建学习任务,将优先级设定为HIGH
,并且监听complete
、failed
以及progress
事件。然后我们需要保存任务,保存完毕以后我们就可以通过processBlocking
方法来执行耗时任务了。在处理逻辑中,我们首先通过job.progress
方法将进度设为10
,然后使用vertx.setTimer
方法设一个3秒的定时器,定时器时间到以后赋予结果并完成任务。
像往常一样,我们还需要在build.gradle
中配置一下。我们需要将kue-example
子工程中的Main-Verticle
属性设为刚才写的io.vertx.blueprint.kue.example.LearningVertxVerticle
:
|
|
好了,到了展示时间了!打开终端,构建项目:
gradle build
当然不要忘记运行Redis:
redis-server
然后我们先运行Vert.x Kue Core部分:
java -jar kue-core/build/libs/vertx-blueprint-kue-core.jar -cluster -ha -conf config/config.json
然后再运行我们的实例:
java -jar kue-example/build/libs/vertx-blueprint-kue-example.jar -cluster -ha -conf config/config.json
这时终端应该会依次显示输出:
|
|
当然你也可以在Vert.x Kue的Web端查看任务情况。
棒极了!我们终于结束了我们的Vert.x Kue核心部分探索之旅~~!从这篇超长的教程中,你学到了如何利用Vert.x去开发一个基于消息的应用!太酷了!
如果想了解kue-http
的实现,请移步Vert.x 蓝图 | Vert.x Kue 教程(Web部分)。如果想了解更多的关于Vert.x Kue的特性,请移步Vert.x Kue 特性介绍。
Vert.x能做的不仅仅是这些。想要了解更多的关于Vert.x的知识,请参考Vert.x 官方文档 —— 这永远是资料最齐全的地方。
]]>在本教程中,我们会使用Vert.x来一步一步地开发一个REST风格的Web服务 - Todo Backend,你可以把它看作是一个简单的待办事项服务,我们可以自由添加或者取消各种待办事项。
通过本教程,你将会学习到以下的内容:
Verticle
是什么,以及如何使用Verticle
本教程是 Vert.x 蓝图系列 的第一篇教程,对应的Vert.x版本为3.3.3。本教程中的完整代码已托管至GitHub。
朋友,欢迎来到Vert.x的世界!初次听说Vert.x,你一定会非常好奇:这是啥?让我们来看一下Vert.x的官方解释:
Vert.x is a tool-kit for building reactive applications on the JVM.
(⊙o⊙)哦哦。。。翻译一下,Vert.x是一个在JVM上构建 响应式 应用的 工具集 。这个定义比较模糊,我们来简单解释一下:工具集 意味着Vert.x非常轻量,可以嵌入到你当前的应用中而不需要改变现有的结构;另一个重要的描述是 响应式 —— Vert.x就是为构建响应式应用(系统)而设计的。响应式系统这个概念在 Reactive Manifesto 中有详细的定义。我们在这里总结4个要点:
500
错误等等)的时候保持响应的能力,所以它必须要为 异常处理 而设计。Vert.x是 事件驱动的,同时也是非阻塞的。首先,我们来介绍 Event Loop 的概念。Event Loop是一组负责分发和处理事件的线程。注意,我们绝对不能去阻塞Event Loop线程,否则事件的处理过程会被阻塞,我们的应用就失去了响应能力。因此当我们在写Vert.x应用的时候,我们要时刻谨记 异步非阻塞开发模式 而不是传统的阻塞开发模式。我们将会在下面详细讲解异步非阻塞开发模式。
我们的应用是一个REST风格的待办事项服务,它非常简单,整个API其实就围绕着 增删改查 四种操作。所以我们可以设计以下的路由:
POST /todos
GET /todos/:todoId
GET /todos
PATCH /todos/:todoId
DELETE /todos/:todoId
DELETE /todos
注意我们这里不讨论REST风格API的设计规范(仁者见仁,智者见智),因此你也可以用你喜欢的方式去定义路由。
下面我们开始开发我们的项目!High起来~~~
Vert.x Core提供了一些较为底层的处理HTTP请求的功能,这对于Web开发来说不是很方便,因为我们通常不需要这么底层的功能,因此Vert.x Web应运而生。Vert.x Web基于Vert.x Core,并且提供一组更易于创建Web应用的上层功能(如路由)。
首先我们先来创建我们的项目。在本教程中我们使用Gradle作为构建工具,当然你也可以使用其它诸如Maven之类的构建工具。我们的项目目录里需要有:
src/main/java
文件夹(源码目录)src/test/java
文件夹(测试目录)build.gradle
文件(Gradle配置文件)
|
|
我们首先来创建 build.gradle
文件,这是Gradle对应的配置文件:
|
|
你可能不是很熟悉Gradle,这不要紧。我们来解释一下:
targetCompatibility
和 sourceCompatibility
这两个值都设为1.8,代表目标Java版本是Java 8。这非常重要,因为Vert.x就是基于Java 8构建的。dependencies
中,我们声明了我们需要的依赖。vertx-core
和 vert-web
用于开发REST API。搞定build.gradle
以后,我们开始写代码!
首先我们需要创建我们的数据实体对象 - Todo
实体。在io.vertx.blueprint.todolist.entity
包下创建Todo
类,并且编写以下代码:
|
|
我们的 Todo
实体对象由序号id
、标题title
、次序order
、地址url
以及代表待办事项是否完成的一个标识complete
组成。我们可以把它看作是一个简单的Java Bean。它可以被编码成JSON格式的数据,我们在后边会大量使用JSON(事实上,在Vert.x中JSON非常普遍)。同时注意到我们给Todo
类加上了一个注解:@DataObject
,这是用于生成JSON转换类的注解。
@DataObject
注解被
@DataObject
注解的实体类需要满足以下条件:拥有一个拷贝构造函数以及一个接受一个JsonObject
对象的构造函数。
我们利用Vert.x Codegen来自动生成JSON转换类。我们需要在build.gradle
中添加依赖:
|
|
由于Vert.x Codegen仅在编译期生成代码,因此我们这里使用了compileOnly
(相当于Maven中的provided
。需要Gradle 2.12及以上版本)。同时,我们需要在io.vertx.blueprint.todolist.entity
包中添加package-info.java
文件来指引Vert.x Codegen生成代码:
|
|
Vert.x Codegen本质上是一个注解处理器(annotation processing tool),因此我们还需要在build.gradle
中配置apt。往里面添加以下代码:
|
|
这样,每次我们在编译项目的时候,Vert.x Codegen都会自动检测含有 @DataObject
注解的类并且根据配置生成JSON转换类。在本例中,我们应该会得到一个 TodoConverter
类,然后我们可以在Todo
类中使用它。
下面我们来写我们的应用组件。在io.vertx.blueprint.todolist.verticles
包中创建SingleApplicationVerticle
类,并编写以下代码:
|
|
我们的SingleApplicationVerticle
类继承了AbstractVerticle
抽象类。那么什么是 Verticle
呢?在Vert.x中,一个Verticle
代表应用的某一组件。我们可以通过部署Verticle
来运行这些组件。如果你了解 Actor 模型的话,你会发现它和Actor非常类似。
当Verticle
被部署的时候,其start
方法会被调用。我们注意到这里的start
方法接受一个类型为Future<Void>
的参数,这代表了这是一个异步的初始化方法。这里的Future
代表着Verticle
的初始化过程是否完成。你可以通过调用Future的complete
方法来代表初始化过程完成,或者fail
方法代表初始化过程失败。
现在我们Verticle
的轮廓已经搞好了,那么下一步也就很明了了 - 创建HTTP Client并且配置路由,处理HTTP请求。
我们来给start
方法加点东西:
|
|
(⊙o⊙)…一长串代码诶。。是不是看着很晕呢?我们来详细解释一下。
首先我们创建了一个 Router
实例 (1)。这里的Router
代表路由器,相信做过Web开发的开发者们一定不会陌生。路由器负责将对应的HTTP请求分发至对应的处理逻辑(Handler)中。每个Handler
负责处理请求并且写入回应结果。当HTTP请求到达时,对应的Handler
会被调用。
然后我们创建了两个Set
:allowHeaders
和allowMethods
,并且我们向里面添加了一些HTTP Header以及HTTP Method,然后我们给路由器绑定了一个CorsHandler
(2)。route()
方法(无参数)代表此路由匹配所有请求。这两个Set
的作用是支持 CORS,因为我们的API需要开启CORS以便配合前端正常工作。有关CORS的详细内容我们就不在这里细说了,详情可以参考这里。我们这里只需要知道如何开启CORS支持即可。
接下来我们给路由器绑定了一个全局的BodyHandler
(3),它的作用是处理HTTP请求正文并获取其中的数据。比如,在实现添加待办事项逻辑的时候,我们需要读取请求正文中的JSON数据,这时候我们就可以用BodyHandler
。
最后,我们通过vertx.createHttpServer()
方法来创建一个HTTP服务端 (4)。注意这个功能是Vert.x Core提供的底层功能之一。然后我们将我们的路由处理器绑定到服务端上,这也是Vert.x Web的核心。你可能不熟悉router::accept
这样的表示,这是Java 8中的 方法引用,它相当于一个分发路由的Handler
。当有请求到达时,Vert.x会调用accept
方法。然后我们通过listen
方法监听8082端口。因为创建服务端的过程可能失败,因此我们还需要给listen
方法传递一个Handler
来检查服务端是否创建成功。正如我们前面所提到的,我们可以使用future.complete
来表示过程成功,或者用future.fail
来表示过程失败。
到现在为止,我们已经创建好HTTP服务端了,但我们还没有见到任何的路由呢!不要着急,是时候去声明路由了!
下面我们来声明路由。正如我们之前提到的,我们的路由可以设计成这样:
POST /todos
GET /todos/:todoId
GET /todos
PATCH /todos/:todoId
DELETE /todos/:todoId
DELETE /todos
路径参数
在URL中,我们可以通过
:name
的形式定义路径参数。当处理请求的时候,Vert.x会自动获取这些路径参数并允许我们访问它们。拿我们的路由举个例子,/todos/19
将todoId
映射为19
。
首先我们先在 io.vertx.blueprint.todolist
包下创建一个Constants
类用于存储各种全局常量(当然也可以放到其对应的类中):
|
|
然后我们将start
方法中的TODO
标识处替换为以下的内容:
|
|
代码很直观、明了。我们用对应的方法(如get
,post
,patch
等等)将路由路径与路由器绑定,并且我们调用handler
方法给每个路由绑定上对应的Handler
,接受的Handler
类型为Handler<RoutingContext>
。这里我们分别绑定了六个方法引用,它们的形式都类似于这样:
|
|
我们将在稍后实现这六个方法,这也是我们待办事项服务逻辑的核心。
我们之前提到过,Vert.x是 异步、非阻塞的 。每一个异步的方法总会接受一个 Handler
参数作为回调函数,当对应的操作完成时会调用接受的Handler
,这是异步方法的一种实现。还有一种等价的实现是返回Future
对象:
|
|
其中,Future
对象代表着一个操作的结果,这个操作可能还没有进行,可能正在进行,可能成功也可能失败。当操作完成时,Future
对象会得到对应的结果。我们也可以通过setHandler
方法给Future
绑定一个Handler
,当Future
被赋予结果的时候,此Handler
会被调用。
|
|
Vert.x中大多数异步方法都是基于Handler的。而在本教程中,这两种异步模式我们都会接触到。
现在是时候来实现我们的待办事项业务逻辑了!这里我们使用 Redis 作为数据持久化存储。有关Redis的详细介绍请参照Redis 官方网站。Vert.x给我们提供了一个组件—— Vert.x-redis,允许我们以异步的形式操作Redis数据。
如何安装Redis?
请参照Redis官方网站上详细的安装指南。
Vert.x Redis允许我们以异步的形式操作Redis数据。我们首先需要在build.gradle
中添加以下依赖:
|
|
我们通过RedisClient
对象来操作Redis中的数据,因此我们定义了一个类成员redis
。在使用RedisClient
之前,我们首先需要与Redis建立连接,并且需要配置(以RedisOptions
的形式),后边我们再讲需要配置哪些东西。
我们来实现 initData
方法用于初始化 RedisClient
并且测试连接:
|
|
当我们在加载Verticle的时候,我们会首先调用initData
方法,这样可以保证RedisClient
可以被正常创建。
我们知道,Redis支持各种格式的数据,并且支持多种方式存储(如list
、hash map
等)。这里我们将我们的待办事项存储在 哈希表(map) 中。我们使用待办事项的id
作为key,JSON格式的待办事项数据作为value。同时,我们的哈希表本身也要有个key,我们把它命名为 VERT_TODO,并且存储到Constants
类中:
|
|
正如我们之前提到的,我们利用了生成的JSON数据转换类来实现Todo
实体与JSON数据之间的转换(通过几个构造函数),在后面实现待办事项服务的时候可以广泛利用。
我们首先来实现获取待办事项的逻辑。正如我们之前所提到的,我们的处理逻辑方法需要接受一个RoutingContext
类型的参数。我们看一下获取某一待办事项的逻辑方法(handleGetTodo
):
|
|
首先我们先通过getParam
方法获取路径参数todoId
(1)。我们需要检测路径参数获取是否成功,如果不成功就返回 400 Bad Request
错误 (2)。这里我们写一个函数封装返回错误response的逻辑:
|
|
这里面,end
方法是非常重要的。只有我们调用end
方法时,对应的HTTP Response才能被发送回客户端。
再回到handleGetTodo
方法中。如果我们成功获取到了todoId
,我们可以通过hget
操作从Redis中获取对应的待办事项 (3)。hget
代表通过key从对应的哈希表中获取对应的value,我们来看一下hget
函数的定义:
|
|
第一个参数key
对应哈希表的key,第二个参数field
代表待办事项的key,第三个参数代表当获取操作成功时对应的回调。在Handler
中,我们首先检查操作是否成功,如果不成功就返回503
错误。如果成功了,我们就可以获取操作的结果了。结果是null
的话,说明Redis中没有对应的待办事项,因此我们返回404 Not Found
代表不存在。如果结果存在,那么我们就可以通过end
方法将其写入response中 (4)。注意到我们所有的RESTful API都返回JSON格式的数据,所以我们将content-type
头设为JSON
。
获取所有待办事项的逻辑handleGetAll
与handleGetTodo
大体上类似,但实现上有些许不同:
|
|
这里我们通过hvals
操作 (1) 来获取某个哈希表中的所有数据(以JSON数组的形式返回,即JsonArray
对象)。在Handler中我们还是像之前那样先检查操作是否成功。如果成功的话我们就可以将结果写入response了。注意这里我们不能直接将返回的JsonArray
写入response。想象一下返回的JsonArray
包括着待办事项的key以及对应的JSON数据(字符串形式),因此此时每个待办事项对应的JSON数据都被转义了,所以我们需要先把这些转义过的JSON数据转换成实体对象,再重新编码。
我们这里采用了一种响应式编程思想的方法。首先我们了解到JsonArray
类继承了Iterable<Object>
接口(是不是感觉它很像List
呢?),因此我们可以通过stream
方法将其转化为Stream
对象。注意这里的Stream
可不是传统意义上讲的输入输出流(I/O stream),而是数据流(data flow)。我们需要对数据流进行一系列的变换处理操作,这就是响应式编程的思想(也有点函数式编程的思想)。我们将数据流中的每个字符串数据转换为Todo
实体对象,这个过程是通过map
算子实现的。我们这里就不深入讨论map
算子了,但它在函数式编程中非常重要。在map
过后,我们通过collect
方法将数据流“归约”成List<Todo>
。现在我们就可以通过Json.encodePrettily
方法对得到的list进行编码了,转换成JSON格式的数据。最后我们将转换后的结果写入到response中 (3)。
经过了上面两个业务逻辑实现的过程,你应该开始熟悉Vert.x了~现在我们来实现创建待办事项的逻辑:
|
|
首先我们通过context.getBodyAsString()
方法来从请求正文中获取JSON数据并转换成Todo
实体对象 (1)。这里我们包装了一个处理Todo
实例的方法,用于给其添加必要的信息(如URL):
|
|
对于没有ID(或者为默认ID)的待办事项,我们会给它分配一个ID。这里我们采用了自增ID的策略,通过AtomicInteger
来实现。
然后我们通过Json.encodePrettily
方法将我们的Todo
实例再次编码成JSON格式的数据 (2)。接下来我们利用hset
函数将待办事项实例插入到对应的哈希表中 (3)。如果插入成功,返回 201
状态码 (4)。
[NOTE 201 状态码? | 正如你所看到的那样,我们将状态码设为201
,这代表CREATED
(已创建)。另外,如果不指定状态码的话,Vert.x Web默认将状态码设为 200 OK
。]
同时,我们接收到的HTTP请求首部可能格式不正确,因此我们需要在方法中捕获DecodeException
异常。这样一旦捕获到DecodeException
异常,我们就返回400 Bad Request
状态码。
如果你想改变你的计划,你就需要更新你的待办事项。我们来实现更新待办事项的逻辑,它有点小复杂(或者说是,繁琐?):
|
|
唔。。。一大长串代码诶。。。我们来看一下。首先我们从 RoutingContext
中获取路径参数 todoId
(1),这是我们想要更改待办事项对应的id。然后我们从请求正文中获取新的待办事项数据 (2)。这一步也有可能抛出 DecodeException
异常因此我们也需要去捕获它。要更新待办事项,我们需要先通过hget
函数获取之前的待办事项 (3),检查其是否存在。获取旧的待办事项之后,我们调用之前在Todo
类中实现的merge
方法将旧待办事项与新待办事项整合到一起 (5),然后编码成JSON格式的数据。然后我们通过hset
函数更新对应的待办事项 (6)(hset
表示如果不存在就插入,存在就更新)。操作成功的话,返回 200 OK
状态。
这就是更新待办事项的逻辑~要有耐心哟,我们马上就要见到胜利的曙光了~下面我们来实现删除待办事项的逻辑。
删除待办事项的逻辑非常简单。我们利用hdel
函数来删除某一待办事项,用del
函数删掉所有待办事项(实际上是直接把那个哈希表给删了)。如果删除操作成功,返回204 No Content
状态。
这里直接给出代码:
|
|
啊哈!我们实现待办事项服务的Verticle已经完成咯~一颗赛艇!但是我们该如何去运行我们的Verticle
呢?答案是,我们需要 部署并运行 我们的Verticle。还好Vert.x提供了一个运行Verticle的辅助工具:Vert.x Launcher,让我们来看看如何利用它。
要通过Vert.x Launcher来运行Verticle,我们需要在build.gradle
中配置一下:
|
|
jar
区块中,我们配置Gradle使其生成 fat-jar,并指定启动类。fat-jar 是一个给Vert.x应用打包的简便方法,它直接将我们的应用连同所有的依赖都给打包到jar包中去了,这样我们可以直接通过jar包运行我们的应用而不必再指定依赖的 CLASSPATH
Main-Class
属性设为io.vertx.core.Launcher
,这样就可以通过Vert.x Launcher来启动对应的Verticle了。另外我们需要将Main-Verticle
属性设为我们想要部署的Verticle的类名(全名)。配置好了以后,我们就可以打包了:
|
|
万事俱备,只欠东风。是时候运行我们的待办事项服务了!首先我们先启动Redis服务:
|
|
然后运行服务:
|
|
如果没问题的话,你将会在终端中看到 Succeeded in deploying verticle
的字样。下面我们可以自由测试我们的API了,其中最简便的方法是借助 todo-backend-js-spec 来测试。
键入 http://127.0.0.1:8082/todos
:
测试结果:
当然,我们也可以用其它工具,比如 curl
:
|
|
啊哈~我们的待办事项服务已经可以正常运行了,但是回头再来看看 SingleApplicationVerticle
类的代码,你会发现它非常混乱,待办事项业务逻辑与控制器混杂在一起,让这个类非常的庞大,并且这也不利于我们服务的扩展。根据面向对象解耦的思想,我们需要将控制器部分与业务逻辑部分分离。
下面我们来设计我们的业务逻辑层。就像我们之前提到的那样,我们的服务需要是异步的,因此这些服务的方法要么需要接受一个Handler
参数作为回调,要么需要返回一个Future
对象。但是想象一下很多个Handler
混杂在一起嵌套的情况,你会陷入 回调地狱,这是非常糟糕的。因此,这里我们用Future
实现我们的待办事项服务。
在 io.vertx.blueprint.todolist.service
包下创建 TodoService
接口并且编写以下代码:
|
|
注意到getCertain
方法返回一个Future<Optional<Todo>>
对象。那么Optional
是啥呢?它封装了一个可能为空的对象。因为数据库里面可能没有与我们给定的todoId
相对应的待办事项,查询的结果可能为空,因此我们给它包装上 Optional
。Optional
可以避免万恶的 NullPointerException
,并且它在函数式编程中用途特别广泛(在Haskell中对应 Maybe Monad)。
既然我们已经设计好我们的异步服务接口了,让我们来重构原先的Verticle吧!
我们创建一个新的Verticle。在 io.vertx.blueprint.todolist.verticles
包中创建 TodoVerticle
类,并编写以下代码:
|
|
很熟悉吧?这个Verticle
的结构与我们之前的Verticle相类似,这里就不多说了。下面我们来利用我们之前编写的服务接口实现每一个控制器方法。
首先先实现 initData
方法,此方法用于初始化存储结构:
|
|
首先我们从配置中获取服务的类型,这里我们有两种类型的服务:redis
和jdbc
,默认是redis
。接着我们会根据服务的类型以及对应的配置来创建服务。在这里,我们的配置都是从JSON格式的配置文件中读取,并通过Vert.x Launcher的-conf
项加载。后面我们再讲要配置哪些东西。
接着我们给service.initData()
方法返回的Future
对象绑定了一个Handler
,这个Handler
将会在Future
得到结果的时候被调用。一旦初始化过程失败,错误信息将会显示到终端上。
其它的方法实现也类似,这里就不详细解释了,直接放上代码,非常简洁明了:
|
|
是不是和之前的Verticle很相似呢?这里我们还封装了两个Handler
生成器:resultHandler
和 deleteResultHandler
。这两个生成器封装了一些重复的代码,可以减少代码量。
嗯。。。我们的新Verticle写好了,那么是时候去实现具体的业务逻辑了。这里我们会实现两个版本的业务逻辑,分别对应两种存储:Redis 和 MySQL。
之前我们已经实现过一遍Redis版本的服务了,因此你应该对其非常熟悉了。这里我们仅仅解释一个 update
方法,其它的实现都非常类似,代码可以在GitHub上浏览。
回想一下我们之前写的更新待办事项的逻辑,我们会发现它其实是由两个独立的操作组成 - get
和 insert
(对于Redis来说)。所以呢,我们可不可以复用 getCertain
和 insert
这两个方法?当然了!因为Future
是可组合的,因此我们可以将这两个方法返回的Future
组合到一起。是不是非常方便呢?我们来编写此方法:
|
|
首先我们调用了getCertain
方法,此方法返回一个Future<Optional<Todo>>
对象。同时我们使用compose
函数将此方法返回的Future
与另一个Future
进行组合(1),其中compose
函数接受一个T => Future<U>
类型的lambda。然后我们接着检查旧的待办事项是否存在,如果存在的话,我们将新的待办事项与旧的待办事项相融合,然后更新待办事项。注意到insert
方法返回Future<Boolean>
类型的Future
,因此我们还需要对此Future的结果做变换,这个变换的过程是通过map
函数实现的(2)。map
函数接受一个T => U
类型的lambda。如果旧的待办事项不存在,我们返回一个包含null的Future
(3)。最后我们返回组合后的Future
对象。
Future
的本质在函数式编程中,
Future
实际上是一种Monad
。有关Monad
的理论较为复杂,这里就不进行阐述了。你可以简单地把它看作是一个可以进行变换(map
)和组合(compose
)的包装对象。我们把这种特性叫做 monadic。
下面来实现MySQL版本的待办事项服务。
我们使用Vert.x-JDBC和MySQL来实现JDBC版本的待办事项服务。我们知道,数据库操作都是阻塞操作,很可能会占用不少时间。而Vert.x-JDBC提供了一种异步操作数据库的模式,很神奇吧?所以,在传统JDBC代码下我们要执行SQL语句需要这样:
|
|
而在Vert.x JDBC中,我们可以利用回调获取数据:
|
|
这种异步操作可以有效避免对数据的等待。当数据获取成功时会自动调用回调函数来执行处理数据的逻辑。
首先我们需要向build.gradle
文件中添加依赖:
|
|
其中第二个依赖是MySQL的驱动,如果你想使用其他的数据库,你需要自行替换掉这个依赖。
在Vert.x JDBC中,我们需要从一个JDBCClient
对象中获取数据库连接,因此我们来看一下如何创建JDBCClient
实例。在io.vertx.blueprint.todolist.service
包下创建JdbcTodoService
类:
|
|
我们使用JDBCClient.createShared(vertx, config)
方法来创建一个JDBCClient
实例,其中我们传入一个JsonObject
对象作为配置。一般来说,我们需要配置以下的内容:
jdbc:mysql://localhost/vertx_blueprint
com.mysql.cj.jdbc.Driver
我们将会通过Vert.x Launcher从配置文件中读取此JsonObject
。
现在我们已经创建了JDBCClient
实例了,下面我们需要在MySQL中建这样一个表:
|
|
我们把要用到的数据库语句都存到服务类中(这里我们就不讨论如何设计表以及写SQL了):
|
|
OK!一切工作准备就绪,下面我们来实现我们的JDBC版本的服务~
所有的获取连接、获取执行数据的操作都要在Handler
中完成。比如我们可以这样获取数据库连接:
|
|
由于每一个数据库操作都需要获取数据库连接,因此我们来包装一个返回Handler<AsyncResult<SQLConnection>>
的方法,在此回调中可以直接使用数据库连接,可以减少一些代码量:
|
|
获取数据库连接以后,我们就可以对数据库进行各种操作了:
query
: 执行查询(raw SQL)queryWithParams
: 执行预编译查询(prepared statement)updateWithParams
: 执行预编译DDL语句(prepared statement)execute
: 执行任意SQL语句所有的方法都是异步的所以每个方法最后都接受一个Handler
参数,我们可以在此Handler
中获取结果并执行相应逻辑。
现在我们来编写初始化数据库表的initData
方法:
|
|
此方法仅会在Verticle初始化时被调用,如果todo
表不存在的话就创建一下。注意,最后一定要关闭数据库连接。
下面我们来实现插入逻辑方法:
|
|
我们使用updateWithParams
方法执行插入逻辑,并且传递了一个JsonArray
变量作为预编译参数。这一点很重要,使用预编译语句可以有效防止SQL注入。
我们再来实现getCertain
方法:
|
|
在这个方法里,当我们的查询语句执行以后,我们获得到了ResultSet
实例作为查询的结果集。我们可以通过getColumnNames
方法获取字段名称,通过getResults
方法获取结果。这里我们通过getRows
方法来获取结果集,结果集的类型为List<JsonObject>
。
其余的几个方法:getAll
, update
, delete
以及 deleteAll
都遵循上面的模式,这里就不多说了。你可以在GitHub上浏览完整的源代码。
重构完毕,我们来写待办事项服务对应的配置,然后再来运行!
首先我们在项目的根目录下创建一个 config
文件夹作为配置文件夹。我们在其中创建一个config_jdbc.json
文件作为 jdbc
类型服务的配置:
|
|
你需要根据自己的情况替换掉上述配置文件中相应的内容(如 JDBC URL,JDBC 驱动 等)。
再建一个config.json
文件作为redis
类型服务的配置(其它的项就用默认配置好啦):
|
|
我们的构建文件也需要更新咯~这里直接给出最终的build.gradle
文件:
|
|
好啦好啦,迫不及待了吧?~打开终端,构建我们的应用:
|
|
然后我们可以运行Redis版本的待办事项服务:
|
|
我们也可以运行JDBC版本的待办事项服务:
|
|
同样地,我们也可以使用todo-backend-js-spec来测试我们的API。由于我们的API设计没有改变,因此测试结果应该不会有变化。
我们也提供了待办事项服务对应的Docker Compose镜像构建文件,可以直接通过Docker来运行我们的待办事项服务。你可以在仓库的根目录下看到相应的配置文件,并通过 docker-compose up -- build
命令来构建并运行。
哈哈,恭喜你完成了整个待办事项服务,是不是很开心?~在整个教程中,你应该学到了很多关于 Vert.x Web
、 Vert.x Redis
和 Vert.x JDBC
的开发知识。当然,最重要的是,你会对Vert.x的 异步开发模式 有了更深的理解和领悟。
另外,Vert.x 蓝图系列已经发布至Vert.x官网:Vert.x Blueprint Tutorials。其中第二个Blueprint是关于消息应用的,第三个Blueprint是关于微服务的,有兴趣的朋友可以参考后面几篇蓝图教程。
更多关于Vert.x的文章,请参考Blog on Vert.x Website。官网的资料是最全面的 :-)
之前你可能用过其它的框架,比如Spring Boot。这一小节,我将会用类比的方式来介绍Vert.x Web的使用。
在Spring Boot中,我们通常在控制器(Controller)中来配置路由以及处理请求,比如:
|
|
在Spring Boot中,我们使用 @RequestMapping
注解来配置路由,而在Vert.x Web中,我们是通过 Router
对象来配置路由的。并且因为Vert.x Web是异步的,我们会给每个路由绑定一个处理器(Handler
)来处理对应的请求。
另外,在Vert.x Web中,我们使用 end
方法来向客户端发送HTTP response。相对地,在Spring Boot中我们直接在每个方法中返回结果作为response。
如果之前用过Play Framework 2的话,你一定会非常熟悉异步开发模式。在Play Framework 2中,我们在 routes
文件中定义路由,类似于这样:
|
|
而在Vert.x Web中,我们通过Router
对象来配置路由:
|
|
this::handleGetCertain
是处理对应请求的方法引用(在Scala里可以把它看作是一个函数)。
Play Framework 2中的异步开发模式是基于Future
的。每一个路由处理函数都返回一个Action
对象(实质上是一个类型为Request[A] => Result
的函数),我们在Action.apply
(或Action.async
)闭包中编写我们的处理逻辑,类似于这样:
|
|
而在Vert.x Web中,异步开发模式基本上都是基于回调的(当然也可以用Vert.x RxJava)。我们可以这么写:
|
|
你可能想在Vert.x中使用其它的持久化存储框架或库,比如MyBatis ORM或者Jedis,这当然可以啦!Vert.x允许开发者整合任何其它的框架和库,但是像MyBatis ORM这种框架都是阻塞型的,可能会阻塞Event Loop线程,因此我们需要利用blockingHandler
方法去执行阻塞的操作:
|
|
Vert.x会使用Worker线程去执行blockingHandler
方法(或者Worker Verticles)中的操作,因此不会阻塞Event Loop线程。
一个分布式系统中,我们给每个数据副本都赋予一票。假设一共有 V 个数据副本,那么总共就有 V 个票数。每个操作必须要获得读票数(完成读操作所需要读取的最小副本数,read quorum, V(r) )或写票数(完成写操作所需要读取的最小副本数write quorum, V(w) )才能够对数据进行读或写。票数需要遵循以下规则:
第一条规则有两个作用:第一个作用是保证了一个数据不会被同时读写。当请求一个写操作时,它需要的得到 V(w) 读票数,而剩下的票数为 V - V(w) < V(r),因此不再允许读操作。请求读操作时也是同理;第二个作用是保证了强一致性。根据 鸽巢原理,写数据操作与读新数据操作之间是有重叠的,这就确保至少有一个读操作是可以读到最新数据的。
第二条规则保证了数据的串行化修改,同一个数据不能同时被两个写操作并发修改。
Quorum投票机制非常有用。比如一份数据在5个结点上存有副本,进行一次写操作的时候,必须等待五个结点的写操作都完成,整个写操作才返回(因为可以从任意结点读取)。这样会导致写操作负载太高,而有了Quorum机制以后,我们可以让写操作在至少3个结点上完成就可以返回,另外的结点可以等待后台同步,而读操作V(r)也需要大于 V-V(w) 才能确保至少一个读操作可以读到最新数据。
2017-12-04:待重新总结。。。
这篇文章里我们来总结一下 Netflix Hystrix 的工作流程(版本为 1.4.x)。这是官方提供的流程图(来自 GitHub):
我们来根据流程图来分析一下工作流程。
首先我们需要创建一个 HystrixCommand
或 HystrixObservableCommand
实例来代表向其它组件发出的操作请求(指令),然后通过相关的方法执行操作指令。这里有4个方法,前两个对应HystrixCommand
,后两个对应HystrixObservableCommand
:
execute()
:阻塞型方法,返回单个结果(或者抛出异常)queue()
:异步方法,返回一个 Future
对象,可以从中取出单个结果(或者抛出异常)observe()
和 toObservable()
都返回对应的 Observable
对象,代表(多个)操作结果。注意 observe
方法在调用的时候就开始执行对应的指令(hot observable 加了层 buffer 代理),而 toObservable
方法相当于是 observe
方法的lazy版本,当我们去 subscribe
的时候,对应的指令才会被执行并产生结果
|
|
从底层实现来讲,HystrixCommand
也是利用Observable
实现的(看Hystrix源码的话可以发现里面大量使用了RxJava),尽管它只返回单个结果。HystrixCommand
的queue
方法实际上是调用了toObservable().toBlocking().toFuture()
,而execute
方法实际上是调用了queue().get()
。
执行操作指令时,Hystrix首先会检查缓存内是否有对应指令的结果,如果有的话,将缓存的结果直接以Observable
对象的形式返回。如果没有对应的缓存,Hystrix会检查Circuit Breaker的状态。如果Circuit Breaker的状态为开启状态,Hystrix将不会执行对应指令,而是直接进入失败处理状态(图中8 Fallback)。如果Circuit Breaker的状态为关闭状态,Hystrix会继续进行线程池、任务队列、信号量的检查(图中5),确认是否有足够的资源执行操作指令。如果资源满,Hystrix同样将不会执行对应指令并且直接进入失败处理状态。
如果资源充足,Hystrix将会执行操作指令。操作指令的调用最终都会到这两个方法:
HystrixCommand.run()
HystrixObservableCommand.construct()
如果执行指令的时间超时,执行线程会抛出TimeoutException
异常。Hystrix会抛弃结果并直接进入失败处理状态。如果执行指令成功,Hystrix会进行一系列的数据记录,然后返回执行的结果。
同时,Hystrix会根据记录的数据来计算失败比率,一旦失败比率达到某一阈值将自动开启Circuit Breaker。
最后我们再来看一下Hystrix是如何处理失败的。如果我们在Command中实现了HystrixCommand.getFallback()
方法(或HystrixObservableCommand.resumeWithFallback()
方法,Hystrix会返回对应方法的结果。如果没有实现这些方法的话,从底层看Hystrix将会返回一个空的Observable
对象,并且可以通过onError
来终止并处理错误。从上层看:
execute
方法将会抛出异常queue
方法将会返回一个失败状态的Future
对象observe()
和toObservable()
方法都会返回上述的Observable
对象Hystrix中的Circuit Breaker的实现比较明了。整个HystrixCircuitBreaker
接口一共有三个方法和三个静态类:
其中allowRequest()
方法表示是否允许指令执行,isOpen()
方法表示断路器是否为开启状态,markSuccess()
用于将断路器关闭。
Factory
静态类相当于Circuit Breaker Factory,用于获取相应的HystrixCircuitBreaker
。我们来看一下其实现:
|
|
Hystrix在Factory
类中维护了一个ConcurrentHashMap
用于存储与每一个HystrixCommandKey
相对应的HystrixCircuitBreaker
。每当我们通过getInstance
方法从中获取HystrixCircuitBreaker
的时候,Hystrix首先会检查ConcurrentHashMap
中有没有对应的缓存的断路器,如果有的话直接返回。如果没有的话就会新创建一个HystrixCircuitBreaker
实例,将其添加到缓存中并且返回。
HystrixCircuitBreakerImpl
静态类是HystrixCircuitBreaker
接口的实现。我们可以看到HystrixCircuitBreakerImpl
类中有四个成员变量。其中properties
是对应HystrixCommand
的属性类,metrics
是对应HystrixCommand
的度量数据类。由于会工作在并发环境下,我们用一个AtomicBoolean
类型的变量circuitOpen
来代表断路器的状态(默认是false
代表关闭,这里没有特意实现Half-Open这个状态),并用一个AtomicLong
类型的变量circuitOpenedOrLastTestedTime
记录着断路恢复计时器的初始时间,用于Open状态向Close状态的转换。
我们首先来看一下isOpen
方法的实现:
|
|
首先通过circuitOpen.get()
获取断路器的状态,如果是开启状态(true
)则返回true
。否则,Hystrix会从Metrics数据中获取HealthCounts
对象,然后检查对应的请求总数(totalCount
)是否小于属性中的请求容量阈值(circuitBreakerRequestVolumeThreshold
),如果是的话表示断路器可以保持关闭状态,返回false
。如果不满足请求总数条件,就再检查错误比率(errorPercentage
)是否小于属性中的错误百分比阈值(circuitBreakerErrorThresholdPercentage
,默认 50),如果是的话表示断路器可以保持关闭状态,返回 false
;如果超过阈值,Hystrix会判定服务的某些地方出现了问题,因此通过CAS操作将断路器设为开启状态,并记录此时的系统时间作为定时器初始时间,最后返回 true
。
我们再来看一下判断Open状态下计时器的实现方法allowSingleTest
:
|
|
首先获取断路恢复计时器记录的初始时间circuitOpenedOrLastTestedTime
,然后判断以下两个条件是否同时满足:
circuitOpen.get() == true
)circuitBreakerSleepWindowInMilliseconds
(默认为 5 秒)如果同时满足的话,表示可以从Open
状态向Close
状态转换。Hystrix会通过CAS操作将circuitOpenedOrLastTestedTime
设为当前时间,并返回true
。如果不同时满足,返回false
,代表断路器关闭或者计时器时间未到。
有了这个函数以后,我们再来看一下allowRequest
的实现:
|
|
非常直观。首先先读取属性中的强制设定值(可以强制设定状态),如果没有设定的话,就判断断路器是否关闭或者断路恢复计时器是否到达时间,只要满足其中一个条件就返回true
,即允许执行操作指令。
最后就是markSuccess
方法了,它用于关闭断路器并重置统计数据。代码非常直观,就不多说了:
|
|
Hystrix的Circuit Breaker可以用以下的图来总结:
至于Hystrix在底层执行Command时是如何利用HystrixCircuitBreaker
的,可以看AbstractCommand
类中toObservable
方法和getRunObservableDecoratedForMetricsAndErrorHandling
方法的源码,后边再总结。
这里给出的是 Treiber Stack 的一个简化版的 Java 实现:
|
|
我们使用了 AtomicReference
来实现 Treiber Stack。每当我们 push
进去一个元素的时候,我们首先根据要添加的元素创建一个 Node
,然后获取原栈顶结点,并将新结点的下一个结点指向原栈顶结点。此时我们使用 CAS 操作来更改栈顶结点,如果此时的栈顶和之前的相同,代表 CAS 操作成功,那么就把新插入的元素设为栈顶;如果此时的栈顶和之前的不同(即其他线程改变了栈顶结点),CAS 操作失败,那么需要重复上述操作(更新当前的栈顶元素并且重设 next),直到成功。pop
操作的原理也相似。
CAP Theory阐述了分布式系统中的一个事实:一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance)不能同时保证。三个只能选择两个
假设有两台机器A、B,两者之间互相同步保持数据的一致性。现在B由于网络原因不能与A通信(Network Partition),假设某个client向A写入数据,现在有两种选择:
Network Partition是必然的,网络非常可能出现问题(断线、超时),因此CAP理论一般只能AP或CP,而CA一般较难实现。
当然,在上面的例子中,A可以先允许写入,等B的网络恢复以后再同步至B(根据CAP原理这样不能保证强一致性了,但是可以考虑实现最终一致性)。
分布式Key-Value Store中的key映射问题。
hash(x) % N
算法的弊端:不利于架构的伸缩性最复杂的情况:自己发的包被截;对方发的包自己收不到;内部有节点捣乱,造成不一致。
Impossibility of Distributed Consensus with One Faulty Process 这篇论文提到:
No completely asynchronous consensus protocol can tolerate even a single unannounced process death.
假设节点只有崩溃这一种异常行为,网络是可靠的,并且不考虑异步通信下的时序差异。FLP Impossibility指出在异步网络环境中只要有一个故障节点, 任何Consensus算法都无法保证行为正确。
Lease(租约)机制应用非常广泛:
租约的一个关键点就是有效期,过了有效期可以续约。如果不可用就收回租约,给另一台服务器权限。
实际应用:
思考:Lease == Lock?
多数表决机制在分布式系统中通常有两个应用场景:
理论基础:鸽巢原理
2PC在proposer和某个voter都挂掉的时候会阻塞(原因:别的节点没有对应voter的消息,只能阻塞等待此voter恢复)
3PC添加了一个 prepare-commit 阶段用于准备提交工作,这里面可以实现事务的回滚。
缺点:效率貌似很低。。。分布式事务用2PC会特别蛋疼
推演:
Paxos引入了Log ID (num, value),共有三个角色,两个阶段。
分布式一致性算法(Paxos, Raft, Chubby, Zab)待详细总结。。。
一般我们不关心分布式系统中某个过程的绝对时间,而只关注两个事件之间的相对时间。
在一个系统的事件集合E上定义一种偏序关系->
,满足:
a -> b
a -> b
a -> b, b -> c
,则a -> c
定义并发:a -> b
与b -> a
均不成立则为并发情况
引入Lamport逻辑时钟。一个时钟本质上是一个事件到实数的映射(假设时间是连续的)。对于每一个进程Pi,都有其对应的时钟Ci。
分布式系统中的全局信息实际上是对各个实体信息的叠加(Q:重合怎么办?)
可以看到Lamport Timestamp必须要求两个事件有先后顺序关系,因而在时序图上不好表示concurrent。由此引入Vector Clock。
Vector Clock是对Lamport Timestamp的演进。它不仅保存了自身的timestamp,而且还保留了根节点的timestamp。
Vector Clock(Version Vector)只能用于发现数据冲突,但是想要解决数据冲突还要留给用户去定夺(就好比git commit出现conflicts,需要手工解决一样),当然也可以设置某种策略来直接解决冲突(保留最新或集群内多数表决)。
结合时序图理解会更好(图来自维基百科):
可能出现的问题:Vector Clock过多。解决方案:剪枝(如果超过某个阈值就把最初的那个给扔掉;要是现在还依赖最初的那个clock的话可能就会造成一些问题(思考:如何解决?)
对应论文:Dynamo: Amazon’s Highly Available Key-value Store, Section 4.4.
假设我们有一个应用以MySQL作为数据存储,如果没有编排工具的话,我们在构建此应用的Docker镜像时必须将MySQL一同打包进镜像中,这样不仅会使镜像体积臃肿,而且不利于分布式架构的实现(假如要做读写分离、主从复制之类的)。而有了Docker Compose,我们就可以创建两个镜像:单独的应用镜像和MySQL镜像。在运行时,分别创建两个容器,并且将两个容器链接(link)在一起,使它们之间可以按照配置相互通信。这样就将我们的单体应用拆分成了多个组件构成的应用(其实这就是微服务的思想),从而更有利于服务间的解耦以及分布式架构的实现。
下面举一个例子,完整实现可见vertx-blueprint-todo-backend | GitHub。现有一服务Vert.x Blueprint Todo Backend
已打包成jar包,该服务以Redis作为数据存储。该服务以及Redis监听的地址和端口通过JSON配置文件来提供。我们可以设计两个镜像:服务镜像(通过Dockerfile
构建)以及官方Redis镜像,运行时分别创建一个容器实例,然后通过Docker Compose将两个容器组合起来。首先来看一下我们的Dockerfile
:
|
|
服务容器运行时对外暴露8082端口。再看一下服务配置文件:
|
|
注意我们将Redis的host设为redis
,这个redis
是对应的访问路径,后面会提到。下面来看Docker Compose的配置文件docker-compose.yml
:
|
|
其中version: "2"
代表对应镜像版本,最新的需要Docker 1.10.0支持。在services
中,我们定义了两个service
:redis
和vertx-todo-backend
。
先来看redis
中的配置。container_name
代表容器名称,image
代表对应的镜像,expose
代表在集群内暴露的端口号(不对外暴露)。其它容器通过服务名redis
访问此镜像。
再来看vertx-todo-backend
。我们的服务需要依赖Redis,因此容器的启动顺序应该是redis -> vertx-todo-backend
,因此我们配置了depends_on
选项,此选项下的所有容器都将会在本容器启动之前启动(注意只是启动,并不是其它容器初始化完成后本容器才启动。如果需要等待其它容器初始化完毕,则需要另写脚本)。build
对应着Dockerfile
文件的路径,links
代表链接的镜像,ports
代表对外暴露的端口。
配置好以后,我们在目录下执行docker-compose up --build
,一会就可以看到容器集群运行起来了,非常方便。
更多的有关Docker Compose的信息,参考官方文档。
]]>