vthinkxie / blog

https://xieyadong.com
13 stars 1 forks source link

响应式编程与实时计算:从 RxJS 到 Flink #7

Open vthinkxie opened 3 years ago

vthinkxie commented 3 years ago

前端开发在开发什么

大家在前端开发的过程中,可能会想过这样一个问题:前端开发究竟是在开发什么?在我看来,前端开发的本质是让网页视图能够正确的响应相关事件。在这句话中有三个关键字:"网页视图","正确的响应"和"相关事件"。

"相关事件"可能包括页面点击,鼠标滑动,定时器,服务端请求等等,"正确的响应"意味着我们要根据相关的事件来修改一些状态,而"网页视图"就是我们前端开发中最熟悉的部分了。

按照这样的观点我们可以给出这样 视图 = 响应函数(事件)的公式:

View = reactionFn(Event)

在前端开发中,需要被处理事件可以归类为以下三种:

这样我们的公式就可以进一步推导为

View = reactionFn(UserEvent | Timer | Remote API)

应用中的逻辑处理

为了能够更进一步理解这个公式与前端开发的关系,我们以新闻网站举例,该网站有以下三个要求

如果从前端的角度分析,这三种需求分别对应着

MVVM

在 MVVM 的模式下,对应上文的响应函数(reactionFn)会在 Model 与 ViewModel 或者 View 与 ViewModel 之间进行被执行,而事件 (Event) 会在 View 与 ViewModel 之间进行处理。

mvvm

MVVM 可以很好的抽象视图层与数据层,但是响应函数(reactionFn)会散落在不同的转换过程中,这会导致数据的赋值与收集过程难以进行精确追踪。另外因为事件 (Event) 的处理在该模型中与视图部分紧密相关,导致 View 与 ViewModel 之间对事件处理的逻辑复用困难。

Redux

在 Redux 最简单的模型下,若干个事件 (Event) 的组合会对应到一个 Action 上,而 reducer 函数可以被直接认为与上文提到的响应函数 (reactionFn) 对应。

redux

但是在 Redux 中

响应式编程与 RxJS

维基百科中是这样定义响应式编程

在计算中,响应式编程或反应式编程(英语:Reactive programming)是一种面向数据流和变化传播的声明式编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。

以数据流维度重新考虑用户使用该应用的流程

以 Marbles 图表示

marbles

拆分上图逻辑,就会得到使用响应式编程开发当前新闻应用时的三个步骤:

  1. 定义源数据流
  2. 组合/转换数据流
  3. 消费数据流并更新视图

我们分别来进行详细描述

定义源数据流

使用 RxJS,我们可以很方便的定义出各种 Event 数据流

click$ = fromEvent<MouseEvent>(document.querySelector('button'), 'click');
change$ = fromEvent(document.querySelector('input'), 'change');
touchstart$ = fromEvent<TouchEvent>(document, 'touchstart');

touchend$ = fromEvent<TouchEvent>(document, 'touchend');

touchmove$ = fromEvent<TouchEvent>(document, 'touchmove');
interval$ = interval(5000);
fetch$ = fromFetch('https://randomapi.azurewebsites.net/api/users');

组合/转换数据流

点击刷新事件流:在点击刷新时,我们希望短时间内多次点击只触发最后一次,这通过 RxJS 的 debounceTime operator 就可以实现

clickRefresh
clickRefresh$ = this.click$.pipe(debounceTime(300));

自动刷新流:使用 RxJS 的 switchMap 与之前定义好的 interval$ 数据流配合

auto refresh
autoRefresh$ = change$.pipe(
  switchMap(enabled => (enabled ? interval$ : EMPTY))
);

下拉刷新流:结合之前定义好的 touchstart$ touchmove$touchend$ 数据流

pull refresh
pullRefresh$ = touchstart$.pipe(
  switchMap(touchStartEvent =>
    touchmove$.pipe(
      map(touchMoveEvent => touchMoveEvent.touches[0].pageY - touchStartEvent.touches[0].pageY),
      takeUntil(touchend$)
    )
  ),
  filter(position => position >= 300),
  take(1),
  repeat()
);

最后,我们通过 merge 函数将定义好的 clickRefresh$ autoRefresh$pullRefresh$ 合并,就得到了刷新数据流

merge
refresh$ = merge(clickRefresh$, autoRefresh$, pullRefresh$));

消费数据流并更新视图

将刷新数据流直接通过 switchMap 打平到在第一步到定义好的 fetch$,我们就获得了视图数据流

view stream

可以通过在 Angular 框架中可以直接 async pipe 将视图流直接映射为视图

<div *ngFor="let user of view$ | async">
</div>

在其他框架中可以通过 subscribe 获得数据流中的真实数据,再更新视图。

至此,我们就使用响应式编程完整的开发完成了当前新闻应用,示例代码由 Angular 开发,行数不超过 160 行。

我们总结一下,使用响应式编程思想开发前端应用时经历的三个过程与第一节中公式的对应关系

View = reactionFn(UserEvent | Timer | Remote API)

响应式编程相对于 MVVM 或者 Redux 有什么优点呢?

如果我们将 RxJS 的 Marbles 图的时间轴模糊,并在每次视图更新时增加纵切面,我们就会发现这样两件有趣的事情

redux_marbles

难怪我们可以在 Redux 官网中有这样一句话:如果你已经使用了 RxJS,很可能你不再需要 Redux 了

The question is: do you really need Redux if you already use Rx? Maybe not. It's not hard to re-implement Redux in Rx. Some say it's a two-liner using Rx.scan() method. It may very well be!

写到这里,我们对网页视图能够正确的响应相关事件这句话是否可以进行进一步的抽象呢?

所有事件 --找到--> 相关事件 --做出--> 响应

而按时间顺序发生的事件,本质上就是数据流,进一步拓展就可变成

源数据流 --转换--> 中间数据流 --订阅--> 消费数据流

这正是响应式编程在前端能够完美工作的基础思想。但是该思想是否只在前端开发中有所应用呢?

答案是否定的,该思想不仅可以应用于前端开发,在后端开发乃至实时计算中都有着广泛的应用。

打破信息之墙

在前后端开发者之间,通常由一面叫 REST API 的信息之墙隔开,REST API 隔离了前后端开发者的职责,提升了开发效率。但它同样让前后端开发者的眼界被这面墙隔开,让我们试着来推倒这面信息之墙,一窥同样的思想在实时计算中的应用。

实时计算 与 Apache Flink

在开始下一部分之前,让我们先介绍一下 Flink。 Apache Flink 是由 Apache 软件基金会开发的开源流处理框架,用于在无边界和有边界数据流上进行有状态的计算。它的数据流编程模型在有限和无限数据集上提供单次事件(event-at-a-time)处理能力。

flink_stream

在实际的应用中,Flink 通常用于开发以下三种应用:

我们这里以计算电商平台双十一每小时成交额为例,看下我们在之前章节得到方案是否仍然可以继续使用。

在这个场景中我们首先要获取用户购物下单数据,随后计算每小时成交数据,然后将每小时的成交数据转存到数据库并被 Redis 缓存,最终通过接口获取后展示在页面中。

在这个链路中的数据流处理逻辑为 用户下单数据流 --转换--> 每小时成交数据流 --订阅--> 写入数据库 与之前章节中介绍的 源数据流 --转换--> 中间数据流 --订阅--> 消费数据流 思想完全一致

如果我们用 Marbles 描述这个过程,就会得到这样的结果,看起来很简单,似乎使用 RxJS 的 window operator 也可以完成同样的功能,但是事实真的如此吗?

window_marbles

被隐藏的复杂度

真实的实时计算比前端中响应式编程的复杂度要高很多,我们在这里举几个例子

frontend_backend

事件乱序

在前端开发过程中,我们也会碰到事件乱序的情况,最经典的情况先发起的请求后收到响应,可以用如下的 Marbles 图表示。这种情况在前端有很多种办法进行处理,我们在这里就略过不讲。

late_response

我们今天想介绍的是数据处理时面临的时间乱序情况。在前端开发中,我们有一个很重要的前提,这个前提大幅度降低了开发前端应用的复杂度,那就是:前端事件的发生时间和处理时间相同。

process_time

想象一下,如果用户执行页面动作,例如 click, mousemove 等事件都变成了异步事件,并且响应时间未知,那整个前端的开发复杂度会如何。

但是事件的发生时间与处理时间不同,在实时计算领域是一个重要的前提。我们仍以每小时成交额计算为例,当原始数据流经过层层传输之后,在计算节点的数据的先后顺很可能已经乱序了。

event_time

如果我们仍然以数据的到来时间来进行窗口划分,最后的计算结果就会产生错误

late_event

为了让 window2 的窗口的计算结果正确,我们需要等待 late event 到来之后进行计算,但是这样我们就面临了一个两难问题:

Flink 引入了 Watermark 机制来解决这个问题,Watermark 定义了什么时候不再等待 late event,本质上提供了实时计算的准确性和实时性的折中方案。 关于 Watermark 有个形象的比喻:上学的时候,老师会将班级的门关上,然后说:“从这个点之后来的同学都算迟到了,统统罚站“。在 Flink 中,Watermark 充当了老师关门的这个动作。

watermark

数据反压

在浏览器中使用 RxJS 时,不知道大家有没有考虑这样一种情况:observable 产生的速度快于 operator 或者 observer 消费的速度时,会产生大量的未消费的数据被缓存在内存中。这种情况被称为反压,幸运的是,在前端产生数据反压只会导致浏览器内存被大量占用,除此之外不会有更严重的后果。

但是在实时计算中,当数据产生的速度高于中间节点处理能力,或者超过了下游数据的消费能力时,应当如何处理?

backpressure

对于许多流应用程序来说,数据丢失是不可接受的,为了保证这一点,Flink 设计了这样一种机制:

backpressure

Checkpoint

实时计算领域,每秒钟处理的数据可能有数十亿条,这些数据的处理不可能由单台机器独立完成。事实上,在 Flink 中,operator 运算逻辑会由不同的 subtask 在 不同的 taskmanager 上执行,这时我们就面临了另外一个问题,当某台机器发生问题时,整体的运算逻辑与状态该如何处理才能保证最后运算结果的正确性?

backpressure

Flink 中引入了 checkpoint 机制用于保证可以对作业的状态和计算位置进行恢复,checkpoint 使 Flink 的状态具有良好的容错性。Flink 使用了 Chandy-Lamport algorithm 算法的一种变体,称为异步 barrier 快照(asynchronous barrier snapshotting)。

当开始 checkpoint 时,它会让所有 sources 记录它们的偏移量,并将编号的 checkpoint barriers 插入到它们的流中。这些 barriers 会经过每个 operator 时标注每个 checkpoint 前后的流部分。

backpressure backpressure

当发生错误时,Flink 可以根据 checkpoint 存储的 state 进行状态恢复,保证最终结果的正确性。

冰山一角

backpressure

由于篇幅的关系,今天介绍的部分只能是冰山一角,不过 源数据流 --转换--> 中间数据流 --订阅--> 消费数据流 的模型无论在响应式编程还是实时计算都是通用的,希望这篇文章能够让大家对数据流的思想有更多的思考。

vthinkxie commented 3 years ago

视频版本:https://www.bilibili.com/video/BV1Hz4y1y7iL/