复杂单页应用的数据层设计

服务端推送

如果要引入服务端推送,怎么调整?

考虑一个典型场景,WebIM,如果要在浏览器中实现这么一个东西,通常会引入WebSocket作更新的推送。

对于一个聊天窗口而言,它的数据有几个来源:

  • 初始查询
  • 本机发起的更新(发送一条聊天数据)
  • 其他人发起的更新,由WebSocket推送过来
视图展示的数据 := 初始查询的数据 + 本机发起的更新 + 推送的更新

<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<tbody>
<tr class="odd">
<td><div class="crayon-nums-content" style="font-size: 13px !important; line-height: 15px !important;">
<div class="crayon-num" data-line="crayon-5b8f4b62cb7b7061328078-1">
1
</div>
</div></td>
<td><div class="crayon-pre" style="font-size: 13px !important; line-height: 15px !important; -moz-tab-size:4; -o-tab-size:4; -webkit-tab-size:4; tab-size:4;">
<div id="crayon-5b8f4b62cb7b7061328078-1" class="crayon-line">
视图展示的数据 := 初始查询的数据 + 本机发起的更新 + 推送的更新
</div>
</div></td>
</tr>
</tbody>
</table>

这里,至少有两种编程方式。

查询数据的时候,我们使用类似Promise的方式:

JavaScript

getListData().then(data => { // 处理数据 })

1
2
3
getListData().then(data => {
  // 处理数据
})

而响应WebSocket的时候,用类似事件响应的方式:

JavaScript

ws.on(‘data’, data => { // 处理数据 })

1
2
3
ws.on(‘data’, data => {
  // 处理数据
})

这意味着,如果没有比较好的统一,视图组件里至少需要通过这两种方式来处理数据,添加到列表中。

如果这个场景再跟上一节提到的多视图共享结合起来,就更复杂了,可能很多视图里都要同时写这两种处理。

所以,从这个角度看,我们需要有一层东西,能够把拉取和推送统一封装起来,屏蔽它们的差异。

使用RxJS,你可以用Observer 对象来表示多个异步数据流
(那些来自多个数据源的,比如,股票报价,微博,计算机事件,
网络服务请求,等等。),还可以用Observer
对象订阅事件流。无论事件何时触发,Observable 对象都会通知订阅它的
Observer对象。

主流框架对数据层的考虑

一直以来,前端框架的侧重点都是视图部分,因为这块是普适性很强的,但在数据层方面,一般都没有很深入的探索。

  • React, Vue
    两者主要侧重数据和视图的同步,生态体系中有一些库会在数据逻辑部分做一些事情
  • Angular,看似有Service这类可以封装数据逻辑的东西,实际上远远不够,有形无实,在Service内部必须自行做一些事情
  • Backbone,做了一些业务模型实体和关联关系的抽象,更早的ExtJS也做了一些事情

综合以上,我们可以发现,几乎所有现存方案都是不完整的,要么只做实体和关系的抽象,要么只做数据变化的封装,而我们需要的是实体的关系定义和数据变更链路的封装,所以需要自行作一些定制。

那么,我们有怎样的技术选型呢?

单返回值 多返回值
Pull/Synchronous/Interactive Object Iterables (Array / Set / Map / Object)
Push/Asynchronous/Reactive Promise Observable

综合场景

以上,我们述及四种典型的对前端数据层有诉求的场景,如果存在更复杂的情况,兼有这些情况,又当如何?

Teambition的场景正是这么一种情况,它的产品特点如下:

  • 大部分交互都以对话框的形式展现,在视图的不同位置,存在大量的共享数据,以任务信息为例,一条任务数据对应渲染的视图可能会有20个这样的数量级。
  • 全业务都存在WebSocket推送,把相关用户(比如处于同一项目中)的一切变更都发送到前端,并实时展示
  • 很强调无刷新,提供一种类似桌面软件的交互体验

比如说:

当一条任务变更的时候,无论你处于视图的什么状态,需要把这20种可能的地方去做同步。

当任务的标签变更的时候,需要把标签信息也查找出来,进行实时变更。

甚至:

  • 如果某个用户更改了自己的头像,而他的头像被到处使用了?
  • 如果当前用户被移除了与所操作对象的关联关系,导致权限变更,按钮禁用状态改变了?
  • 如果别人修改了当前用户的身份,在管理员和普通成员之间作了变化,视图怎么自动变化?

当然这些问题都是可以从产品角度权衡的,但是本文主要考虑的还是如果产品角度不放弃对某些极致体验的追求,从技术角度如何更容易地去做。

我们来分析一下整个业务场景:

  • 存在全业务的细粒度变更推送 => 需要在前端聚合数据
  • 前端聚合 => 数据的组合链路长
  • 视图大量共享数据 => 数据变更的分发路径多

这就是我们得到的一个大致认识。

RxJS可与诸如数组,集合和映射之类的同步数据流以及诸如Promises之类的单值异步计算进行互补和顺畅的互操作,如下图所示:

技术诉求

以上,我们介绍了业务场景,分析了技术特点。假设我们要为这么一种复杂场景设计数据层,它要提供怎样的接口,才能让视图使用起来简便呢?

从视图角度出发,我们有这样的诉求:

  • 类似订阅的使用方式(只被上层依赖,无反向链路)。这个来源于多视图对同一业务数据的共享,如果不是类似订阅的方式,职责就反转了,对维护不利
  • 查询和推送的统一。这个来源于WebSocket的使用。
  • 同步与异步的统一。这个来源于缓存的使用。
  • 灵活的可组合性。这个来源于细粒度数据的前端聚合。

根据这些,我们可用的技术选型是什么呢?

因为可观察序列是数据流,你可以用Observable的扩展方法实现的标准查询运算符来查询它们。从而,你可以使用这些标准查询运算符轻松筛选,投影(project),聚合,撰写和执行基于时间轴(time-based)的多个事件的操作。此外,还有一些其他反应流特定的操作符允许强大的查询写入。
通过使用Rx提供的扩展方法,还可以正常处理取消,异常和同步。

具体方案

以上我们谈了以RxJS为代表的数据流库的这么多好处,彷佛有了它,就像有了民主,人民就自动吃饱穿暖,物质文化生活就自动丰富了,其实不然。任何一个框架和库,它都不是来直接解决我们的业务问题的,而是来增强某方面的能力的,它刚好可以为我们所用,作为整套解决方案的一部分。

至此,我们的数据层方案还缺失什么东西吗?

考虑如下场景:

某个任务的一条子任务产生了变更,我们会让哪条数据流产生变更推送?

分析子任务的数据流,可以大致得出它的来源:

subtask$ = subtaskQuery$ + subtaskUpdate$

看这句伪代码,加上我们之前的解释(这是一个reduce操作),我们得到的结论是,这条任务对应的subtask$数据流会产生变更推送,让视图作后续更新。

仅仅这样就可以了吗?并没有这么简单。

从视图角度看,我们还存在这样的对子任务的使用:那就是任务的详情界面。但这个界面订阅的是这条子任务的所属任务数据流,在其中任务数据包含的子任务列表中,含有这条子任务。所以,它订阅的并不是subtask$,而是task$。这么一来,我们必须使task$也产生更新,以此推动任务详情界面的刷新。

那么,怎么做到在subtask的数据流变更的时候,也推动所属task的数据流变更呢?这个事情并非RxJS本身能做的,也不是它应该做的。我们之前用RxJS来封装的部分,都只是数据的变更链条,记得之前我们是怎么描述数据层解决方案的吗?

实体的关系定义和数据变更链路的封装

我们前面关注的都是后面一半,前面这一半,还完全没做呢!

实体的变更关系如何做呢,办法其实很多,可以用类似Backbone的Model和Collection那样做,也可以用更加专业的方案,引入一个ORM机制来做。这里面的实现就不细说了,那是个相对成熟的领域,而且说起来篇幅太大,有疑问的可以自行了解。

需要注意的是,我们在这个里面需要考虑好与缓存的结合,前端的缓存很简单,基本就是一种精简的k-v数据库,在做它的存储的时候,需要做到两件事:

  • 以集合形式获取的数据,需要拆分放入缓存,比如Task[],应当以每个Task的TaskId为索引,分别单独存储
  • 有时候后端返回的数据可能是不完整的,或者格式有差异,需要在储存之间作正规化(normalize)

总结以上,我们的思路是:

  • 缓存 => 基于内存的微型k-v数据库
  • 关联变更 => 使用ORM的方式抽象业务实体和变更关系
  • 细粒度推送 => 某个实体的查询与变更先合并为数据流
  • 从实体的变更关系,引出数据流,并且所属实体的流
  • 业务上层使用这些原始数据流以组装后续变更

RxJS字面意思就是:JavaScript的响应式扩展(Reactive Extensions for
JavaScript)。

RxJS

遍观流行的辅助库,我们会发现,基于数据流的一些方案会对我们有较大帮助,比如RxJS,xstream等,它们的特点刚好满足了我们的需求。

以下是这类库的特点,刚好是迎合我们之前的诉求。

  • Observable,基于订阅模式
  • 类似Promise对同步和异步的统一
  • 查询和推送可统一为数据管道
  • 容易组合的数据管道
  • 形拉实推,兼顾编写的便利性和执行的高效性
  • 懒执行,不被订阅的数据流不执行

这些基于数据流理念的库,提供了较高层次的抽象,比如下面这段代码:

JavaScript

function getDataO(): Observable<T> { if (cache) { return
Observable.of(cache) } else { return Observable.fromPromise(fetch(url))
} } getDataO().subscribe(data => { // 处理数据 })

1
2
3
4
5
6
7
8
9
10
11
12
function getDataO(): Observable<T> {
  if (cache) {
    return Observable.of(cache)
  }
  else {
    return Observable.fromPromise(fetch(url))
  }
}
 
getDataO().subscribe(data => {
  // 处理数据
})

这段代码实际上抽象程度很高,它至少包含了这么一些含义:

  • 统一了同步与异步,兼容有无缓存的情况
  • 统一了首次查询与后续推送的响应,可以把getDataO方法内部这个Observable也缓存起来,然后把推送信息合并进去

我们再看另外一段代码:

JavaScript

const permission$: Observable<boolean> = Observable
.combineLatest(task$, user$) .map(data => { let [task, user] = data
return user.isAdmin || task.creatorId === user.id })

1
2
3
4
5
6
const permission$: Observable<boolean> = Observable
  .combineLatest(task$, user$)
  .map(data => {
    let [task, user] = data
    return user.isAdmin || task.creatorId === user.id
  })

这段代码的意思是,根据当前的任务和用户,计算是否拥有这条任务的操作权限,这段代码其实也包含了很多含义:

首先,它把两个数据流task$和user$合并,并且计算得出了另外一个表示当前权限状态的数据流permission$。像RxJS这类数据流库,提供了非常多的操作符,可用于非常简便地按照需求把不同的数据流合并起来。

我们这里展示的是把两个对等的数据流合并,实际上,还可以进一步细化,比如说,这里的user$,我们如果再追踪它的来源,可以这么看待:

某用户的数据流user$ := 对该用户的查询 +
后续对该用户的变更(包括从本机发起的,还有其他地方更改的推送)

如果说,这其中每个因子都是一个数据流,它们的叠加关系就不是对等的,而是这么一种东西:

  • 每当有主动查询,就会重置整个user$流,恢复一次初始状态
  • user$等于初始状态叠加后续变更,注意这是一个reduce操作,也就是把后续的变更往初始状态上合并,然后得到下一个状态

这样,这个user$数据流才是“始终反映某用户当前状态”的数据流,我们也就因此可以用它与其它流组合,参与后续运算。

这么一段代码,其实就足以覆盖如下需求:

  • 任务本身变化了(执行者、参与者改变,导致当前用户权限不同)
  • 当前用户自身的权限改变了

这两者导致后续操作权限的变化,都能实时根据需要计算出来。

其次,这是一个形拉实推的关系。这是什么意思呢,通俗地说,如果存在如下关系:

JavaScript

c = a + b //
不管a还是b发生更新,c都不动,等到c被使用的时候,才去重新根据a和b的当前值计算

1
c = a + b     // 不管a还是b发生更新,c都不动,等到c被使用的时候,才去重新根据a和b的当前值计算

如果我们站在对c消费的角度,写出这么一个表达式,这就是一个拉取关系,每次获取c的时候,我们重新根据a和b当前的值来计算结果。

而如果站在a和b的角度,我们会写出这两个表达式:

JavaScript

c = a1 + b // a1是当a变更之后的新值 c = a + b1 // b1是当b变更之后的新值

1
2
c = a1 + b     // a1是当a变更之后的新值
c = a + b1    // b1是当b变更之后的新值

这是一个推送关系,每当有a或者b的变更时,主动重算并设置c的新值。

如果我们是c的消费者,显然拉取的表达式写起来更简洁,尤其是当表达式更复杂时,比如:

JavaScript

e = (a + b ) * c – d

1
e = (a + b ) * c – d

如果用推的方式写,要写4个表达式。

所以,我们写订阅表达式的时候,显然是从使用者的角度去编写,采用拉取的方式更直观,但通常这种方式的执行效率都较低,每次拉取,无论结果是否变更,都要重算整个表达式,而推送的方式是比较高效精确的。

但是刚才RxJS的这种表达式,让我们写出了形似拉取,实际以推送执行的表达式,达到了编写直观、执行高效的结果。

看刚才这个表达式,大致可以看出:

permission$ := task$ + user$

这么一个关系,而其中每个东西的变更,都是通过订阅机制精确发送的。

有些视图库中,也会在这方面作一些优化,比如说,一个计算属性(computed
property),是用拉的思路写代码,但可能会被框架分析依赖关系,在内部反转为推的模式,从而优化执行效率。

此外,这种数据流还有其它魔力,那就是懒执行。

什么是懒执行呢?考虑如下代码:

JavaScript

const a$: Subject<number> = new Subject<number>() const b$:
Subject<number> = new Subject<number>() const c$:
Observable<number> = Observable.combineLatest(a$, b$) .map(arr
=> { let [a, b] = arr return a + b }) const d$:
Observable<number> = c$.map(num => { console.log(‘here’) return
num + 1 }) c$.subscribe(data => console.log(`c: ${data}`))
a$.next(2) b$.next(3) setTimeout(() => { a$.next(4) }, 1000)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const a$: Subject<number> = new Subject<number>()
const b$: Subject<number> = new Subject<number>()
 
const c$: Observable<number> = Observable.combineLatest(a$, b$)
  .map(arr => {
    let [a, b] = arr
    return a + b
  })
 
const d$: Observable<number> = c$.map(num => {
  console.log(‘here’)
  return num + 1
})
 
c$.subscribe(data => console.log(`c: ${data}`))
 
a$.next(2)
b$.next(3)
 
setTimeout(() => {
  a$.next(4)
}, 1000)

注意这里的d$,如果a$或者b$中产生变更,它里面那个here会被打印出来吗?大家可以运行一下这段代码,并没有。为什么呢?

因为在RxJS中,只有被订阅的数据流才会执行。

主题所限,本文不深究内部细节,只想探讨一下这个特点对我们业务场景的意义。

想象一下最初我们想要解决的问题,是同一份数据被若干个视图使用,而视图侧的变化是我们不可预期的,可能在某个时刻,只有这些订阅者的一个子集存在,其它推送分支如果也执行,就是一种浪费,RxJS的这个特性刚好能让我们只精确执行向确实存在的视图的数据流推送。

无论你在用
Node.js编写一个web端应用还是服务端应用,你都必须经常处理异步和基于事件的编程。Web应用程序和Node.js应用程序都会碰到I
/
O操作和计算耗时的任务,这些任务可能需要很长时间才能完成,并可能会阻塞主线程。而且,处理异常,取消和同步也很麻烦,并且容易出错。

缓存的使用

如果说我们的业务里,有一些数据是通过WebSocket把更新都同步过来,这些数据在前端就始终是可信的,在后续使用的时候,可以作一些复用。

比如说:

在一个项目中,项目所有成员都已经查询过,数据全在本地,而且变更有WebSocket推送来保证。这时候如果要新建一条任务,想要从项目成员中指派任务的执行人员,可以不必再发起查询,而是直接用之前的数据,这样选择界面就可以更流畅地出现。

这时候,从视图角度看,它需要解决一个问题:

  • 如果要获取的数据未有缓存,它需要产生一个请求,这个调用过程就是异步的
  • 如果要获取的数据已有缓存,它可以直接从缓存中返回,这个调用过程就是同步的

如果我们有一个数据层,我们至少期望它能够把同步和异步的差异屏蔽掉,否则要使用两种代码来调用。通常,我们是使用Promise来做这种差异封装的:

JavaScript

function getDataP() : Promise<T> { if (data) { return
Promise.resolve(data) } else { return fetch(url) } }

1
2
3
4
5
6
7
function getDataP() : Promise<T> {
  if (data) {
    return Promise.resolve(data)
  } else {
    return fetch(url)
  }
}

这样,使用者可以用相同的编程方式去获取数据,无需关心内部的差异。

推送模式 vs 拉取模式

在交互式编程中,应用程序为了获取更多信息会主动遍历一个数据源,通过检索一个代表数据源的序列。这种行为就像是JavaScript数组,对象,集合,映射等的迭代器模式。在交互式编程中,必须通过数组中的索引或通过ES6
iterators
来获取下一项。

在拉取模式中,应用程序在数据检索过程中处于活动状态:
它通过自己主动调用next来决定检索的速度。
此枚举模式是同步的,这意味着在轮询数据源时可能会阻止您的应用程序的主线程。
这种拉取模式好比是你在图书馆翻阅一本书。
你阅读完成这本书后,你才能去读另一本。

另一方面在响应式编程中,应用程序通过订阅数据流获得更多的信息
(在RxJS中称为可观测序列),数据源的任何更新都传递给可观测序列。这种模式下应用是被动接收数据:除了订阅可观察的来源,并不会主动查询来源,而只是对推送给它的数据作出反应。事件完成后,信息来源将向用户发送通知。这样,您的应用程序将不会被等待源更新阻止。

这是RxJS采用的推送模式。
这好比是加入一个图书俱乐部,在这个图书俱乐部中你注册了某个特定类型的兴趣组,而符合你兴趣的书籍在发布时会自动发送给你。
而不需要排队去搜索获取你想要的书籍。
在重UI应用中,使用推送数据模式尤其有用,在程序等待某些事件时,UI线程不会被阻塞,这使得在具有异步要求的JavaScript运行环境中非常重要。
总之,利用RxJS,可使应用程序更具响应性。

Observable / Observer的可观察模式就是Rx实现的推送模型。
Observable对象会自动通知所有观察者状态变化。
请使用Observablesubscribe方法来订阅,subscribe方法需要Observer对象并返回Disposable对象。
这使您能够跟踪您的订阅,并能够处理订阅。
您可以将可观察序列(如一系列的鼠标悬停事件)视为普通的集合。
RxJS对可观察序列的内置实现的查询,允许开发人员在基于推送序列(如事件,回调,Promise,HTML5地理定位API等等)上组合复杂的事件处理。有关这两个接口的更多信息,请参阅探索
RxJS的主要概念

复杂单页应用的数据层设计

2017/01/11 · JavaScript
·
单页应用

原文出处: 徐飞   

很多人看到这个标题的时候,会产生一些怀疑:

什么是“数据层”?前端需要数据层吗?

可以说,绝大部分场景下,前端是不需要数据层的,如果业务场景出现了一些特殊的需求,尤其是为了无刷新,很可能会催生这方面的需要。

我们来看几个场景,再结合场景所产生的一些诉求,探讨可行的实现方式。

RxJS是一个利用可观察(observable)序列和LINQ查询操作符来处理异步以及基于事件程序的一个库。通过RxJS,
开发人员用Observables表示
异步数据流,用LINQ运算符查询
异步数据流,并使用Schedulers参数化
异步数据流中的并发。简而言之,Rx = Observables + LINQ + Schedulers。

1. 视图的极度轻量化。

我们可以看到,如果视图所消费的数据都是来源于从核心模型延伸并组合而成的各种数据流,那视图层的职责就非常单一,无非就是根据订阅的数据渲染界面,所以这就使得整个视图层非常薄。而且,视图之间是不太需要打交道的,组件之间的通信很少,大家都会去跟数据层交互,这意味着几件事:

  • 视图的变更难度大幅降低了
  • 视图的框架迁移难度大幅降低了
  • 甚至同一个项目中,在必要的情况下,还可以混用若干种视图层方案(比如刚好需要某个组件)

我们采用了一种相对中立的底层方案,以抵抗整个应用架构在前端领域日新月异的情况下的变更趋势。

独立数据层的优势

回顾我们整个数据层方案,它的特点是很独立,从头到尾,做掉了很长的数据变更链路,也因此带来几个优势:

视图间的数据共享

所谓共享,指的是:

同一份数据被多处视图使用,并且要保持一定程度的同步。

如果一个业务场景中,不存在视图之间的数据复用,可以考虑使用端到端组件。

什么是端到端组件呢?

我们看一个示例,在很多地方都会碰到选择城市、地区的组件。这个组件对外的接口其实很简单,就是选中的项。但这时候我们会有一个问题:

这个组件需要的省市区域数据,是由这个组件自己去查询,还是使用这个组件的业务去查好了传给这个组件?

两者当然是各有利弊的,前一种,它把查询逻辑封装在自己内部,对使用者更加有利,调用方只需这么写:

XHTML

<RegionSelector
selected=“callback(region)”></RegionSelector>

1
<RegionSelector selected=“callback(region)”></RegionSelector>

外部只需实现一个响应取值事件的东西就可以了,用起来非常简便。这样的一个组件,就被称为端到端组件,因为它独自打通了从视图到后端的整个通道。

这么看来,端到端组件非常美好,因为它对使用者太便利了,我们简直应当拥抱它,放弃其他所有。

端到端组件示意图:

A | B | C ——— Server

1
2
3
A | B | C
———
Server

可惜并非如此,选择哪种组件实现方式,是要看业务场景的。如果在一个高度集成的视图中,刚才这个组件同时出现了多次,就有些尴尬了。

尴尬的地方在哪里呢?首先是同样的查询请求被触发了多次,造成了冗余请求,因为这些组件互相不知道对方的存在,当然有几个就会查几份数据。这其实是个小事,但如果同时还存在修改这些数据的组件,就麻烦了。

比如说:在选择某个实体的时候,发现之前漏了配置,于是点击“立刻配置”,新增了一条,然后回来继续原流程。

例如,买东西填地址的时候,发现想要的地址不在列表中,于是点击弹出新增,在不打断原流程的情况下,插入了新数据,并且可以选择。

这个地方的麻烦之处在于:

组件A的多个实例都是纯查询的,查询的是ModelA这样的数据,而组件B对ModelA作修改,它当然可以把自己的那块界面更新到最新数据,但是这么多A的实例怎么办,它们里面都是老数据,谁来更新它们,怎么更新?

这个问题为什么很值得说呢,因为如果没有一个良好的数据层抽象,你要做这个事情,一个业务上的选择和会有两个技术上的选择:

  • 引导用户自己刷新界面
  • 在新增完成的地方,写死一段逻辑,往查询组件中加数据
  • 发一个自定义业务事件,让查询组件自己响应这个事件,更新数据

这三者都有缺点:

  • 引导用户刷新界面这个,在技术上是比较偷懒的,可能体验未必好。
  • 写死逻辑这个,倒置了依赖顺序,导致代码产生了反向耦合,以后再来几个要更新的地方,这里代码改得会很痛苦,而且,我一个配置的地方,为什么要管你后续增加的那些查询界面?
  • 自定义业务事件这个,耦合是减少了,却让查询组件自己的逻辑膨胀了不少,如果要监听多种消息,并且合并数据,可能这里更复杂,能否有一种比较简化的方式?

所以,从这个角度看,我们需要一层东西,垫在整个组件层下方,这一层需要能够把查询和更新做好抽象,并且让视图组件使用起来尽可能简单。

另外,如果多个视图组件之间的数据存在时序关系,不提取出来整体作控制的话,也很难去维护这样的代码。

添加了数据层之后的整体关系如图:

A | B | C ———— 前端的数据层 ———— Server

1
2
3
4
5
A | B | C
————
前端的数据层
————
  Server

那么,视图访问数据层的接口会是什么样?

我们考虑耦合的问题。如果要减少耦合,很必然的就是这么一种形式:

  • 变更的数据产生某种消息
  • 使用者订阅这个消息,做一些后续处理

因此,数据层应当尽可能对外提供类似订阅方式的接口。

发表评论

电子邮件地址不会被公开。 必填项已用*标注