当前位置: 首页 > 科技观察

Tubes响应式数据系统设计与原理

时间:2023-03-23 01:37:07 科技观察

Tubes是一套支持灵活扩展、极致性能、高稳定性的C端构建场景的终端渲染解决方案。目前广泛应用于淘宝、天猫,包括:11、618会场、淘宝新人版首页等业务场景。响应式数据系统的引入,意味着程序在使用系统提供的数据的同时,会自动订阅自己使用的数据。当订阅的数据发生变化时,使用(订阅)该数据的程序会对数据进行更改。回复。响应式是Vue.js最有特色的特性(之前叫responsiveness,Vue3翻译成responsiveness,我觉得responsiveness这个词挺好听的),但是Tubes的响应式原理和Vue.js略有不同。响应式数据系统在Tubes中的作用为什么Tubes需要响应式数据系统,这来自于一个重要的业务问题:“如何解决会场分屏渲染的问题”,从技术角度来说,这个问题要解决解决的是:“Tubemulti-Adesignissueforthisexecution”。正在阅读本文的您可能不了解Tubes系统或Tube是什么。没关系,你可以把问题想象成:如何解决Express.js中间件的多次执行,以及如何解决Webpack的Loader的多次执行。分屏渲染是指在构建系统中,输出页面会先渲染第一屏的模块,再渲染第二屏的模块。在Tubes系统中,从第一个Tube开始执行,执行到最后一个Tube完成一次渲染(这个有点类似Express的中间件机制,中间件结束),如下图所示:第一个Tube到最后一个Tube,模块的渲染完成一次。在会场多屏渲染的场景中,通常第一轮执行完成意味着第一屏模块的渲染已经完成。那么如果要渲染副屏模块,只需要再执行一轮Tube渲染副屏模块即可。那么问题来了,如何更优雅的设计Tubemultipleexecution的机制呢?我们设计了两种方案:“方案一:基于循环系统的实现”和“方案二:基于响应式数据系统的实现”。下面对比一下两种方案的区别:?方案一:基于循环系统实现循环系统,Tube可以逐圈执行。在第一轮渲染过程中,Tube开发者可以在内部调用API通知Tubes-Engine当前轮渲染完成后需要进行下一轮渲染。通过这样的机制,实现了第一屏模块的第一轮渲染,第二屏模块的第二轮渲染。这里的问题是并不是所有的Tubes都需要执行多次,所以Tubes-Engine需要为Tubes提供一些属性和方法(例如:当前执行的圈数,once方法等)方便Tube执行根据“圈数”做一些逻辑,或者用once声明你只需要在第一轮执行一次,后面的其他轮不参与执行。其实只有和“第一屏”和“第二屏”渲染逻辑强相关的tube才需要执行多次(比如:envtube的逻辑就是初始化一些环境信息,比如是否是Android或者iOS、PC或者Mobile,这个逻辑只需要在第一轮执行时执行一次)。在基于循环系统的实现下,开发者需要明确声明Tube只需要执行一次(即如果开发者自己判断Tube只需要执行一次,那么需要做一些额外的处理对于这个管)。下面总结一下这个方案的优缺点:优点:Tubes-Engine内部实现简单(也就是不容易出错)缺点:1.Tube开发者需要对Tube的运行机制(wheelsystem)有深刻理解)以及为什么要设计成“轮子”Circlesystem”2.每个Tube都需要处理与circlesystem相关的逻辑,例如:Tubethatonlyneededtobeexecutedonce:youneedtouseoncetodeclarethatyou只需要执行一次和需要执行多次的Tube:需要根据圈数判断这一轮做什么,是否再做一轮,调用API通知Tubes-Engine再执行一次round,这个方案很直观,也解决了问题。但是也可以看出这个方案对于Tube开发者来说成本非常高,不管Tube是否需要多次执行,开发者都需要深入理解其中的原理e后面的轮子,对自己开发的Tube做出相应的判断(是否需要多次执行)和处理。?方案二:在基于响应式数据系统设计之初,Tube的本质是一个具有“幂等性”的执行单元(函数)。它的一侧是输入,另一侧是输出。上一个Tube的输出是下一个OneTube的输入,多个Tube组合起来完成页面渲染。Tube是用来处理渲染的执行单元(函数)。Tubes的设计理念是用几个简单的执行单元(Tube)让计算结果逐渐递进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。——Tubes官网是“幂等的”,也就是说当输入相同时,输出一定相同。也就是说,当输入不变时,输出不变,所以不需要重新执行。只有当输入发生变化时,才意味着输出会发生变化。这时需要重新执行Tube,重新计算并输出新的结果。解决方案的核心思想是“根据输入输出的变化,决定应该重新执行哪个Tube”,而实现这一能力的关键技术是“响应式数据系统”。因此,基于响应式数据的系统意味着只有当某个Tube使用的数据“发生变化”时,才“按顺序”重新执行该Tube。“顺序”执行是指如果多个Tube使用相同的数据,并且数据发生变化,那么这些Tube按照Tube排列的顺序执行。例如:第一屏渲染完成后,“DataTube”请求第二屏的数据并将第二屏的数据放入Store,那么放入数据的操作会触发一次数据更新,然后使用Tubes使用此数据的Tube将再次执行,并且只有使用此数据的Tube才会再次执行。重新执行的Tube可能会再次修改数据,后续依赖这个数据的Tube会被加入到“执行队列”中再次执行,从而实现一个“基于依赖的执行链”即:上一个Tube修改数据并写入Responsive数据系统,读取该数据的Tube会再次执行并将重新计算的新数据写入响应数据系统,而这种写入行为会再次触发其他后续Tube的执行,直到完成最终渲染。现在总结一下这种方案的优缺点:优点:对Tube没有额外的负担(没有新的方法,没有新的属性,不需要配合Tubes-Engine实现Tube内部的流程控制)第一次执行是无意识的,只执行需要执行的Tube(不是先执行,然后中间件内部判断是否跳过)。缺点:内部实现复杂(容易出错)基于响应式数据系统,可以智能“选择”哪些管子应该执行,这种方案不会给管子开发者增加额外的负担,它只执行需要执行的管子,执行效率更好。最终,我们选择使用反应式数据系统。响应式数据系统的设计与原理历史上,为了将性能优化到极致,Tubes的响应式数据系统设计了三个版本的实现原理。由于第二版是第一版的改进版,本节详细介绍第二版和第三版的实现原理。?经典实现当我们研究响应式数据系统的原理时,本质上是在研究三个东西:响应式数据、收集依赖、触发依赖。响应式数据响应式数据最关键的两个核心是:监听器和依赖存储。监听器所谓监听器就是指我们用什么样的方法来拦截用户对数据的操作。主流实现方式如下:Getter/SetterProxyCustomSet/GetAPITubes采用第三种方式实现,因为TubeDevelopers希望在不触发响应的情况下在原始数据中修改数据,例如:constdata=this.store.get('数据');//希望这里只绑定依赖constname=data.name;//希望这个操作不要触发getterdata.name=name+'!';//我希望这个操作不会触发Setterthis.store.set('data',data);//希望执行这行代码触发响应管开发者想自由控制什么时候触发响应。在这个场景中,Tubes使用自定义的API来拦截用户对数据的操作。依赖存储当监听器拦截到用户对某个数据的操作时,需要找到该数据对应的“依赖”。如何找到数据对应的“依赖”,主要看“依赖”是如何存储的。有很多方法可以实现它。传统的实现方式如Vue2直接存储了对数据的“依赖”。当你找到数据时,你会找到数据对应的“依赖”。不得不说这很方便。Tubes本来就是这样实现的,如下图所示:如上图所示,dependencies存储在__dep__属性中,而这个属性直接存储在data中。CollectionDependency关于依赖收集,我们需要解决的本质问题是“什么时候收集”、“谁收集”、“如何收集”、“在哪里收集”等问题。何时收集何时收集?答案是:“读取数据时收集依赖项”。当侦听器拦截读取数据时,依赖项收集就可以开始了。谁收藏谁收藏谁?回答这个问题看似没那么简单,其实这个问题并没有标准答案。如果我们用publish&subscriber模型来理解,那么我们收集的就是“订阅者”。当数据发生变化时,我们需要通知已经订阅了这条数据的“订阅者”,但是订阅者是谁呢?这取决于我们的具体需求,即我们想用反应式数据系统做什么。在Tubes中,我们希望响应式数据系统能够帮助我们选择需要再次执行的Tubes。那么我们的“订阅者”就是Tubes。一旦数据发生变化,我们就可以找到这个数据的依赖关系(也就是Tubes)。然后可以将这些Tube发送到调度系统执行。在Vue.js中,我希望响应式数据系统可以帮助选择需要重新渲染的组件。这时,订阅者就是组件。数据变化后,可以找到数据依赖(即组件),然后使用新的数据重新渲染组件。其实Tubes中的订阅者并不是Tubes,因为Tubes需要支持并行执行。如果Tubes的并行执行同时使用了某些数据,那么在收集的时候需要维护并行Tubes的数据结构。所以在Tubes内部,并行执行的Tubes是一个组,订阅者其实就是这个组。也就是先把Tube加入这个组,再把这个组加入依赖列表。假设并行执行的只有一个管使用了某个数据,那么这个数据的订阅者是一组,那么这个组中只有一个管,如果并行执行的多个管使用了某个数据,那么在这个组中有多个Tube。如何领取知道了“什么时候领取”、“谁领取”、“哪里领取”之后,我们就来详细介绍一下如何领取。下面用一个例子来说明:constdata=ctx.store.get('a.d');在Tubes中,由于同时执行的只有一个Tube,没有嵌套关系,所以不难定位是哪个Tube执行了ctx.store.get。伪代码如下:functionget(keypath){this.ctx.tb.store.effect=effect;this.ctx.tb.store.tube=管;constres=this.ctx.tb.store.get(keypath);this.ctx.tb.store.tube=null;this.ctx.tb.store.effect=null;returnres;}上面的伪代码展示了当用户调用this.store.get时如何定位当前正在执行的Tube和Tube所属的组(effect)。收集到哪里在讲响应式数据的时候,我们说依赖直接存在于数据上,因为修改数据的时候可以方便的找到数据对应的依赖,但是仅仅保存修改数据上的依赖是不够的。想象一个场景,TubeA使用数据a.d.这时候就相当于订阅了数据a.d。现在另一个Tube修改数据a或数据a.d.x。这个时候TubeA应该回应吗?答案是:是的!因此,我们得出一个结论:dependencies不仅保存到目标数据中,还保存到目标数据的所有parents和所有children中,如下图:上图中红圈处表示需要的数据待订阅,绿色圆圈表示保存依赖的位置,右边表示Tube读取a.d时,需要同时订阅a、a.d、a.d.e、a.d.f。这是一个细节。图中绿色圆圈比我们要订阅的数据高一级。这是因为基础类型的数据会出现在数据的最底层,而基础类型的数据无法加载额外的属性,所以我们选择添加每条数据的依赖关系存储在父节点中。读取依赖和触发依赖的时机是“数据被修改时”。当监听器拦截到用户修改数据时,找到对应数据的依赖就可以了。在Tubes中,找到依赖项并将其发送到调度系统以执行。?基于哈希表的响应式数据系统这种实现方式的原理和核心思想与传统方式没有太大区别。本质上,我们还是在研究三个东西:响应式数据、收集依赖、触发依赖。顾名思义,这个实现最大的特点就是使用哈希表来存储依赖关系,如下图所示:为什么要使用哈希表来存储依赖关系使用哈希表存储依赖关系和存储依赖关系的主要区别直接依赖于数据的是操作依赖于“速度”。前面我们讲到收集依赖,除了保存当前读取数据中的依赖外,还需要保存所有parents和所有children中的依赖。保存所有parents中的依赖对性能影响不大,但是保存所有children中的依赖需要遍历到每个child和child的children。如果数据非常大,这个遍历过程的开销会很大,尤其是在低端机器上。使用哈希表存储依赖关系,操作依赖关系的速度不受数据大小的影响。存储依赖不再需要遍历数据的每个子层级。无论是保存依赖还是读取依赖,都不受数据大小的影响。你可以在一瞬间完成。下面我们详细介绍如何使用哈希表来存储和读取依赖关系。存储依赖关系使用哈希表存储依赖关系非常简单。你只需要使用keypath作为Key,value是一个列表,你可以将依赖追加到列表中。这里需要注意去重的逻辑,避免重复添加相同的依赖到链表中。一图胜千言:如上图所示,用户在执行ctx.store.get('a.d')时,直接将a.d作为哈希表中的KEY将依赖追加到链表中,逻辑简单,执行效率高。值得注意的是,哈希表中还使用了一个列表来保存依赖对应的ID,用于避免在列表中追加重复的依赖。读取依赖关系使用哈希表存储依赖关系时,如何读取某条数据对应的依赖关系?这是一个很好的问题。当依赖存储在数据上时,只要在读取数据时找到数据,就找到了数据对应的依赖。逻辑非常简单高效。但是用哈希表来存储依赖关系并没有存储在数据中,所以找到数据并不意味着找到依赖关系,所以我们需要额外的一段逻辑来获取数据对应的依赖关系。我们先回顾一下之前的逻辑。我们说过,Tube读取a.d时,需要同时订阅a、a.d、a.d.e和a.d.f。也就是说,当我们修改某个数据的时候,这个数据的所有parents和所有children都需要响应,除了这个数据,现在这个逻辑依然适用。因此不难得出结论,我们在修改a.d的时候,需要在哈希表中取出a、a.d、a.d.*的依赖关系,其中a,是parent,a.d是数据本身,a.d.*都是孩子。如下图所示:值得注意的是,同一个KEY下的依赖会去重,但不同KEY之间可能存在重复依赖。为了避免重复响应,需要对检索到的数据进行去重。性能改进已得到验证。与传统实现相比,使用哈希表存储依赖关系的性能提升在低端机器上尤为明显。在保持写入速度不变的情况下,读取数据的速度提升了“1,349.8”倍。在业务上的表现是会场首屏速度提升了452.6ms。以下为详细数据:读取速度:提升1349.8倍,安卓低端机从64.79ms(10倍平均值)降低至0.048ms(10倍平均值)。写入速度:保持当前速度不变时间复杂度:老算法会随着数据的大小“增加写入时间”,而新算法始终保持写入速度在0ms~0.2ms范围内老算法:读取数据O(N)新算法:O(1)读取数据对会场首屏速度的影响(新旧算法渲染10次取平均值):增加452.6ms旧算法:2,924.7ms新算法:1,917.5ms测试环境:低端机:Redmi7系统:Android9页面:ActivityCenterpage总结基于哈希表的响应式数据系统与传统实现没有区别。区别在于存储依赖的方式不同,不同的算法依赖存储和读取,不同的算法决定了执行效率。总结本文详细介绍了响应式数据系统在Tubes中的应用,以及响应式数据系统的三种不同设计和原理。遗憾的是,该项目目前只对阿里内部开源,但部分技术思路还是可以供大家参考学习。