基于 RxJs 的前端数据层实践

近来前端社区有愈来愈多的人开始关注前端数据层的设计。DaoCloud 也遇到了这方面的问题。咱们调研了不少种解决方案,最终采用 RxJs 来设计一套数据层。这一想法并不是咱们的独创,社区里已有不少前辈、大牛分享过关于用 RxJs 设计数据层的构想和实践。站在巨人的肩膀上,才能走得更远。所以咱们也打算把咱们的经验公布给你们,也算是对社区的回馈吧。

做者简介


瞬光javascript

DaoCloud 前端工程师前端

一名中文系毕业的非典型程序员。vue

咱们遇到了什么困难

DaoCloud Enterprise(下文简称 DCE) 是 DaoCloud 的主要产品,它是一个应用云平台,也是一个很是复杂的单页面应用。它的复杂性主要体如今数据和交互逻辑两方面上。在数据方面,DCE 须要展现大量数据,数据之间依赖关系繁杂。在交互逻辑方面,DCE 中有着大量的交互操做,并且几乎每个操做几乎都是牵一发而动全身。可是交互逻辑的复杂最终仍是会表现为数据的复杂。由于每一次交互,本质上都是在处理数据。一开始的时候,为了保证数据的正确性,DCE 里写了不少处理数据、检测数据变化的代码,结果致使应用很是地卡顿,并且代码很是难以维护。java

在整理了应用数据层的逻辑后,咱们总结出了如下几个难点。本文会用较大的篇幅来描述咱们所遇到的场景,这是由于如此复杂的前端场景比较少见,只有充分理解咱们所遇到的场景,才能充分理解咱们使用这一套设计的缘由,以及这一套设计的优点所在。node

>>>> 应用的难点

数据来源多

DCE 的获取数据的来源不少,主要有如下几种:程序员

  1. 后端、 Docker 和 Kubernetes 的 APIweb

    API 是数据的主要来源,应用、服务、容器、存储、租户等等信息都是经过 API 获取的。ajax

  2. WebSocketredux

    后端经过 WebSocket 来通知应用等数据的状态的变化。后端

  3. LocalStorage

    保存用户信息、租户等信息。

  4. 用户操做

    用户操做最终也会反应为数据的变化,所以也是一个数据的来源。

数据来源多致使了两个问题:

  1. 复用处理数据的逻辑比较困难

    因为数据来源多,所以获取数据的逻辑经常分布在代码各处。好比说容器列表,展现它的时候咱们须要一段代码来格式化容器列表。可是容器列表以后还会更新,因为更新的逻辑和获取的逻辑不同,因此就很难再复用以前所使用的格式化代码。

  2. 获取数据的接口形式不统一

    现在咱们调用 API 时,都会返回一个 Promise。但并非全部的数据来源都能转换成 Promise,好比 WebSocket 怎么转换成 Promise 呢?结果就是在获取数据的时候,要先调用 API,而后再监听 WebSocket 的事件。或许还要同时再去监听用户的点击事件等等。等于说有多个数据源影响同一个数据,对每个数据源都要分别写一套对应的逻辑,十分啰嗦。

聪明的读者可能会想到:只要把处理数据的逻辑和获取数据的逻辑解耦不就能够了吗?很好,如今咱们有两个问题了。

数据复杂

DCE 数据的复杂主要体如今下面三个方面:

  1. 从后端获取的数据不能直接展现,要通过一系列复杂逻辑的格式化。

  2. 其中部分格式化逻辑还包括发送请求。

  3. 数据之间存在着复杂的依赖关系。所谓依赖关系是指,必需要有 B 数据才能格式化 A 数据。下图是 DCE 数据依赖关系的大致示意图。


以格式化应用列表为例,总共有这么几个步骤。读者不须要彻底搞清楚,领会大意便可:

  1. 获取应用列表的数据

  2. 获取服务列表的数据。这是由于应用是由服务组成的,应用的状态取决于服务的状态,所以要格式化应用的状态,就必须获取服务列表的数据。

  3. 获取任务列表的数据。服务列表里其实也不包含服务的状态,服务的状态取决于服务的任务的状态,所以要格式化服务的状态,就必须获取任务列表的数据。

  4. 格式化任务列表。

  5. 根据服务的 id 从任务列表中找到服务所对应的任务,而后根据任务的状态,得出服务的状态。

  6. 格式化 服务列表。

  7. 根据应用的 id 从服务列表中找到应用所对应的服务,而后根据服务的状态,得出应用的状态。顺便还要把每一个应用的服务的数据塞到每一个应用里,由于以后还要用到。

  8. 格式化应用列表。

  9. 完成!

这其中掺杂了同步和异步的逻辑,很是繁琐,很是难以维护(肺腑之言)。何况,这还只是处理应用列表的逻辑,服务、容器、存储、网络等等列表须要获取呢,而且逻辑也不比应用列表简单。因此说,要想解耦获取和处理数据的逻辑并不容易。由于处理数据这件事自己,就包括了获取数据的逻辑。

如此复杂的依赖关系,常常会发送重复的请求。好比说我以前格式化应用列表的时候请求过服务列表了,下次要获取服务列表的时候又得再请求一次服务列表。

聪明的读者会想:我把数据缓存起来保管到一个地方,每次要格式化数据的时候,不要从新去请求依赖的数据,而是从缓存里读取数据,而后一股脑传给格式化函数,这样不就能够了吗?很好!如今咱们有三个问题了!

数据更新困难

缓存是个很好的想法。可是在 DCE 里很难作,DCE 是一个对数据的实时性和一致性要求很是高的应用。

DCE 中几乎全部数据都是会被全局使用到的。好比说应用列表的数据,不只要在应用列表中显示,侧边栏里也会显示应用的数量,还有不少下拉菜单里面也会出现它。因此若是一处数据更新了,另外一处没更新,那就很是尴尬了。

还有就是以前提到的应用和服务的依赖关系。因为应用是依赖服务的,理论上来讲服务变了,应用也是要变的,这个时候也要更新应用的缓存数据。但事实上,由于数据的依赖树实在是太深了(好比上图中的应用和主机),有些依赖关系不那么明显,结果就会忘记更新缓存,数据就会不一致。

何时要使用缓存、缓存保存在哪里、什么时候更新缓存,这些是都是很是棘手的问题。

聪明读者又会想:我用 redux 之类的库,弄个全局的状态树,各个组件使用全局的状态,这样不就能保证数据的一致了吗?这个想法很好的,可是会遇到上面两个难点的阻碍。redux 在面对复杂的异步逻辑时就无能为力了。

>>>> 结论

结果咱们会发现这三个难点每一个单独看起来都有办法能够解决,可是合在一块儿彷佛就成了无解死循环。所以,在通过普遍调研以后,咱们选择了 RxJs。


为何 RxJs 能够解决咱们的困难

在说明咱们如何用 RxJs 解决上面三个难题以前,首先要说明 RxJs 的特性。毕竟 RxJs 目前仍是个比较新的技术,大部分人可能尚未接触过,因此有必要给你们普及一下 RxJs。

  1. 统一了数据来源

    RxJs 最大的特色就是能够把全部的事件封装成一个 Observable,翻译过来就是可观察对象。只要订阅这个可观察对象,就能够获取到事件源所产生的全部事件。想象一下,全部的 DOM 事件、ajax 请求、WebSocket、数组等等数据,通通能够封装成同一种数据类型。这就意味着,对于有多个来源的数据,咱们能够每一个数据来源都包装成 Observable,统一给视图层去订阅,这样就抹平了数据源的差别,解决了第一个难题。

  2. 强大的异步同步处理能力

    RxJs 还提供了功能很是强大且复杂的操做符( Operator) 用来处理、组合 Observable,所以 RxJs 拥有十分强大的异步处理能力,几乎能够知足任何异步逻辑的需求,同步逻辑更不在话下。它也抹平了同步和异步之间的鸿沟,解决了第二个难题。

  3. 数据推送的机制把拉取的操做变成了推送的操做

    RxJs 传递数据的方式和传统的方式有很大不一样,那就是改“拉取”为“推送”。本来一个组件若是须要请求数据,那它必须主动去发送请求才能得到数据,这称为“拉取”。若是像 WebSocket 那样被动地接受数据,这称为“推送”。若是这个数据只要请求一次,那么采用“拉取”的形式获取数据就没什么问题。可是若是这个数据以后须要更新,那么“拉取”就无能为力了,开发者不得不在代码里再写一段代码来处理更新。

    可是 RxJs 则不一样。RxJs 的精髓在于推送数据。组件不须要写请求数据和更新数据的两套逻辑,只要订阅一次,就能获得如今和未来的数据。这一点改变了咱们写代码的思路。咱们在拿数据的时候,不是拿到了数据就万事大吉了,还须要考虑将来的数据什么时候获取、如何获取。若是不考虑这一点,就很难开发出具有实时性的应用。

    如此一来,就能更好地解耦视图层和数据层的逻辑。视图层今后不用再操心任何有关获取数据和更新数据的逻辑,只要从数据层订阅一次就能够获取到全部数据,从而能够只专一于视图层自己的逻辑。

  4. BehaviorSubject 能够缓存数据。

    BehaviorSubject 是一种特殊的 Observable。若是 BehaviorSubject 已经产生过一次数据,那么当它再一次被订阅的时候,就能够直接产生上次所缓存的数据。比起使用一个全局变量或属性来缓存数据,BehaviorSubject 的好处在于它自己也是 Observable,因此异步逻辑对于它来讲根本不是问题。这样一来第三个难题也解决了。

这样一来三个问题是否是都没有了呢?不,这下其实咱们有了四个问题。


咱们是怎么用 RxJs 解决困难的

相信读者看到这里确定是一脸懵逼。这就是第四个问题。RxJs 学习曲线很是陡峭,能参考的资料也不多。咱们在开发的时候,甚至都不肯定怎么作才是最佳实践,能够说是摸着石头过河。建议你们阅读下文以前先看一下 RxJs 的文档,否则接下来确定十脸懵逼。

RxJs 真是太 TM 难啦!Observable、Subject、Scheduler 都是什么鬼啦!Operator 怎么有这么多啊!每一个 Operator 后面只是加个 Map 怎么变化这么大啊!都是 map,为何这个 map_.map 还不同啦!文档还只有英文哒(如今有中文了)!我昨天还在写 jQuery,怎么一会儿就要写这么难的东西啊啊啊!!!(划掉)

——来自实习生的吐槽

首先,给你们看一个总体的数据层的设计。熟悉单向数据流的读者应该不会以为太陌生。


  1. 从 API 获取一些必须的数据

  2. 由事件分发器来分发事件

  3. 事件分发器触发控制各个数据管道

  4. 视图层拼接数据管道,得到用来展现的数据

  5. 视图层经过事件分发器来更新数据管道

  6. 造成闭环

能够看到,咱们的数据层设计基本上是一个单向数据流,确切地说是“单向数据树”。

树的最上面是树根。树根会从各个 API 得到数据。树根的下面是树干。从树干分岔出一个个树枝。每一个树枝的终点都是一个能够供视图层订阅的 BehaviorSubject,每一个视图层组件能够按本身的需求来订阅各个数据。数据和数据之间也能够互相订阅。这样一来,当一个数据变化的时候,依赖它的数据也会跟着变化,最终将会反应到视图层上。

>>>> 设计详细说明

  1. root(树根)

    root 是树根。树根有许多触须,用来吸取营养。咱们的 root 也差很少。一个应用总有一些数据是关键的数据,好比说认证信息、许可证信息、用户信息。要使用咱们的应用,咱们首先得知道你登陆没登陆,付没付过钱对不对?因此,这一部分数据是最底层数据,若是不先获取这些数据,其余的数据便没法获取。而这些数据一旦改变,整个应用其余的数据也会发生根本的变化。比方说,若是登陆的用户改变了,整个应用展现的数据确定也会大变样。

    在具体的实现中,root 经过 zip 操做符汇总全部的 api 的数据。为了方便理解,本文中的代码都有所简化,实际场景确定远比这个复杂。

    // 从各个 API 获取数据
    const license$ = Rx.Observable.fromPromise(getLicense());
    const auth$ = Rx.Observable.fromPromise(getAuth());
    const systemInfo$ = Rx.Observable.fromPromise(getSystemInfo());
    // 经过 zip 拼接三个数据,当三个 API 所有返回时,root$ 将会发出这三个数据
    const root$ = Rx.Observable.zip(license$, auth$, systemInfo$);复制代码

    当全部必须的的数据都获取到了,就能够进入到树干的部分了。

  2. trunk(树干)

    trunk 是咱们的树干,全部的数据都首先流到 trunk ,trunk 会根据数据的种类,来决定这个数据须要流到哪个树枝中。简而言之,trunk 是一个事件分发器。全部事件首先都汇总到 trunk 中。而后由 trunk 根据事件的类型,来决定哪些数据须要更新。有点相似于 redux 中根据 action 来触发相应 reducer 的概念。

    之因此要有这么一个事件分发器,是由于 DCE 的数据都是牵一发而动全身的,一个事件发生时,每每须要触发多个数据的更新。此时有一个统一的地方来管理事件和数据之间的对应关系就会很是方便。一个统一的事件的入口,能够大大下降将来追踪数据更新过程的难度。

    在具体的实现中,trunk 是一个 Subject。由于 trunk 不但要订阅 WebSocket,同时还要容许视图层手动地发布一些事件。当有事件发生时,不管是 WebSocket 事件仍是视图层发布的事件,通过 trunk 的处理后,咱们均可以一视同仁。

    //一个产生 WebSocket 事件的 Observable
    const socket$ = Observable.webSocket('ws://localhost:8081');
    // trunk 是一个 Subject
    const trunk$ = new Rx.Subject()
     // 在 root 产生数据以前,trunk 不会发布任何值。trunk 以后的全部逻辑也都不会运行。
     .skipUntil(root$)
     // 把 WebSocket 推送过来的事件,合并到 trunk 中
     .merge(socket$)
     .map(event => {
       // 在实际开发过程当中,trunk 可能会接受来自各类事件源的事件
       // 这些事件的数据格式可能会大不相同,因此通常在这里还须要一些格式化事件的数据格式的逻辑。
     });复制代码
  3. branch(树枝)

    trunk 的数据最终会流到各个 branch。branch 到底是什么,下面就会提到。

    在具体的实现中,咱们在 trunk 的基础上,用操做符对 trunk 所分发的事件进行过滤,从而建立出各个数据的 Observable,就像从树干中分出的树枝同样。

    // trunk 格式化好的事件的数据格式是一个数组,其中是须要更新的数据的名称
    // 这里经过 filter 操做符来过滤事件,给每一个数据建立一个 Observable。至关于于从 trunk 分岔出多条树枝。
    // 好比说 trunk 发布了一个 ['app', 'services'] 的事件,那么 apps$ 和 services$ 就能获得通知
    const apps$ = trunk$.filter(events => events.includes('app'));
    const services$ = trunk$.filter(events => events.includes('service'));
    const containers$ = trunk$.filter(events => events.includes('container'));
    const nodes$ = trunk$.filter(events => events.includes('node'));复制代码

    仅仅如此,咱们的 branch 尚未什么实质性的内容,它仅仅能接受到数据更新的通知而已,后面还须要加上具体的获取和处理数据的逻辑,下面就是一个容器列表的 branch 的例子。

    // containers$ 就是从 trunk 分出来的一个 branch。
    // 当 containers$ 收到来自 trunk 的通知的时候,containers$ 后面的逻辑就会开始执行
    containers$
     // 当收到通知后,首先调用 API 获取容器列表
     .switchMap(() => Rx.Observable.fromPromise(containerApi.list()))
     // 获取到容器列表后,对每一个容器分别进行格式化。
     // 每一个容器都是做为参数传递给格式化函数的。格式化函数中不包含任何异步的逻辑。
     .map(containers => containers.map(container, container => formatContainer(container)));复制代码

    如今咱们就有了一个可以产生容器列表的数据的 containers$。咱们只要订阅 containers$就能够得到最新的容器列表数据,而且当 trunk 发出更新通知的时候,数据还可以自动更新。这是巨大的进步。

    如今还有一个问题,那就是如何处理数据之间的依赖关系呢?好比说,格式化应用列表的时候假如须要格式化好的容器列表和服务列表应该怎么作呢?这个步骤在之前一直都十分麻烦,写出来的代码犹如意大利面。由于这个步骤须要处理很多的异步和同步逻辑,这其中的顺序还不能出错,不然可能就会由于关键数据尚未拿到致使格式化时报错。

    实际上,咱们能够把 branch 想象成一个“管道”,或者“”。这两个概念都不是新东西,你们应该比较熟悉。

    We should have some ways of connecting programs like garden hose—screw in another segment when it becomes necessary to massage data in another way.

    ——Douglas McIlroy

    若是数据是以管道的形式存在的,那么当一个数据须要另外一个数据的时候,只要把管道接起来不就能够了吗?幸运的是,借助 RxJs 的 Operator,咱们能够很是轻松地拼接数据管道。下面就是一个应用列表拼接容器列表的例子。

    // apps$ 也是从 trunk 分出来的一个 branch
    apps$
     // 一样也从 API 获取数据
     .switchMap(() => Rx.Observable.fromPromise(appApi.list()))
     // 这里使用 combineLatest 操做符来把容器列表和服务列表的数据拼接到应用列表中
     // 当容器或服务的数据更新时,combineLatest 以后的代码也会执行,应用的数据也能获得更新。
     .combineLatest(containers$, services$)
       // 把这三个数据一块儿做为参数传递给格式化函数。
       // 注意,格式化函数中仍是没有任何异步逻辑,由于须要异步获取的数据已经在上面的 combineLatest 操做符中获得了。
     .map(([apps, containers, services]) => apps.map(app => formatApp(app, containers, services)));复制代码
  4. 格式化函数

    格式化函数就是上文中的 formatAppformatContainer。它没有什么特别的,和 RxJs 没什么关系。

    惟一值得一提的是,之前咱们的格式化函数中充斥着异步逻辑,很难维护。因此在用 RxJs 设计数据层的时候咱们刻意地保证了格式化函数中没有任何异步逻辑。即便有的格式化步骤须要异步获取数据,也是在 branch 中经过数据管道的拼接获取,再以参数的形式统一传递给格式化函数。这么作的目的就是为了将异步和同步解耦,毕竟异步的逻辑由 RxJs 处理更加合适,也更便于理解。

  5. fruit

    如今咱们只差缓存没有作了。虽然咱们如今只要订阅 apps$containers$ 就能获取到相应的数据,可是前提是 trunk 必须要发布事件才行。这是由于 trunk 是一个 Subject,假如 trunk 不发布事件,那么全部订阅者都获取不到数据。因此,咱们必需要把 branch 吐出来的数据缓存起来。 RxJs 中的 BehaviorSubject 就很是适合承担这个任务。

    BehaviorSubject 能够缓存每次产生的数据。当有新的订阅者订阅它时,它就会马上提供最近一次所产生的数据,这就是咱们要的缓存功能。因此对于每一个 branch,还须要用 BehaviorSubject 包装一下。数据层最终对外暴露的接口其实是 BehaviorSubject,视图层所订阅的也是 BehaviorSubject。在咱们的设计中,BehaviorSubject 叫做 fruit,这些通过层层格式化的数据,就好像果实同样。

    具体的实现并不复杂,下面是一个容器列表的例子。

    // 每一个数据流对外暴露的一个借口是 BehaviorSubject,咱们在变量末尾用$$,表示这是一个BehaviorSubject
    const containers$$ = new Rx.BehaviorSubject();
    // 用 BehaviorSubject 去订阅 containers$ 这个 branch
    // 这样 BehaviorSubject 就能缓存最新的容器列表数据,同时当有新数据的时它也能产生新的数据
    containers$.subscribe(containers$$);复制代码
  6. 视图层

    整个数据层到上面为止就完成了,可是在咱们用视图层对接数据层的时候,也走了一些弯路。通常状况下,咱们只须要用 vue-rx 所提供的 subscriptions 来订阅 fruit 就能够了。

    <template>
     <app-list :data="apps"></app-list> </template>
    
    <script>
    import app$$ from '../branch/app.branch';
    
    export default {
     name: 'app',
     subscriptions: {
       apps: app$$,
     },
    };
    </script>复制代码

    但有些时候,有些页面的数据很复杂,须要进一步处理数据。遇到这种状况,那就要考虑两点。一是这个数据是否在别的页面或组件中也要用,若是是的话,那么就应该考虑把它作进数据层中。若是不是的话,那其实能够考虑在页面中单独再建立一个 Observable,而后用 vue-rx 去订阅这个 Observable。

    还有一个问题就是,假如视图层须要更新数据怎么办?以前已经提到过,整个数据层的事件分发是由 trunk 来管理的。所以,视图层若是想要更新数据,也必须取道 trunk。这样一来,数据层和视图层就造成了一个闭环。视图层根本不用担忧数据怎么处理,只要向数据层发布一个事件就能所有搞定。

    methods: {
     updateApp(app) {
       appApi.update(app)
         .then(() => {
           trunk$.next(['app'])
         })
     },
    },复制代码

下面是整个数据层设计的全貌,供你们参考。


总结

以后的开发过程证实,这一套数据层很大程度上解决了咱们的问题。它最大的好处在于提升了代码的可维护性,从而使得开发效率大大提升,bug 也大大减小。

咱们对 RxJs 的实践也是刚刚开始,这一套设计确定还有不少可改进的地方。若是你们对本文有什么疑惑或建议,能够写邮件给 bowen.tan@daocloud.io,还望你们不吝赐教。

点击了解 DaoCloud Enterprise

相关文章
相关标签/搜索