构建应用程序太难了。即使是熟练的程序员,如果不专门从事应用开发,也很难构建出一个简单的交互工具。我们认为,使应用开发变得困难的一个很重要的原因是管理状态:响应用户操作和传播变化。
我们正在探索一种管理应用程序数据的新方法,将应用程序的所有状态(包括用户界面的状态)存储在单个反应式数据库中。用户不需要通过命令式代码从数据库中获取数据,而是编写反应式查询,每当依赖关系发生变化时,就用新的结果进行更新。
在最初的原型中,我们围绕 SQLite 建立了一个反应层,在 React 应用中保存数据,并使用它来构建一个音乐库应用。当我们在一个真实的应用中践行我们的宏大愿景时,我们学到了很多东西;这篇文章分享了我们学到的内容,以及这个项目后续的发展方向。
即使是在这个有限的原型中,我们也发现,将应用程序视为查询是一个强大的框架,为调试、持久化和跨应用的互操作性开辟了新的途径。我们也认识了 SQL 的局限性和 Web 平台面临的性能挑战。
总之,我们的想法和实验表明,我们可以让这一框架更进一步。我们勾勒了一个愿景,即把应用程序的每一层,从事件日志到屏幕上的像素,都看作是单个大型查询的一部分。此外,我们认为,构建这样一个系统的关键组件已经存在于为增量视图维护和细粒度出处跟踪开发的工具中。虽然到目前为止我们只触及了表面,但我们认为,基于这些想法实现一个框架是有可能的,而且使用起来也很简单。
简介
现如今,构建交互式应用是如此困难,甚至在软件开发人员中也是一种专门的技能。熟练的计算机技术用户,包括科学家和系统程序员,都需要费很大的劲才能构建出简单的应用,技术水平较低的终端用户则完全没有能力。我们认为,存在让专家和新手都能方便地开发应用的可能。
考虑一下像 iTunes 这样的音乐播放器应用。核心用户界面很简单:它管理一个音乐收藏夹,并显示各种按专辑、艺术家或流派等不同属性组织的自定义视图。然而,用户界面在概念上的简单性并不意味着该应用实际上很容易构建。我们认为,类似这种“以数据为中心”的应用有数以百万计的缺口,因为就它们的受众规模来说,构建它们太难了。
iTunes 是一个以数据为中心的应用的典型示例。
在以数据为中心的应用中,构建和修改应用的大部分复杂性来自于管理和传播状态。
这里有一个有趣的思维实验。许多软件开发者认为,构建命令行工具比 GUI 应用甚或是文本用户界面(TUI)应用要容易得多。为什么呢?
一个答案是,命令行工具在用户执行的多个命令之间通常是无状态的。用户给程序一些指令,它就执行这些指令,然后再把控制权交还给用户之前丢弃所有的隐藏状态。相比之下,大多数应用都有某种持久化的状态——通常还相当多——需要随着用户的操作而维护和更新。
从某种意义上说,状态管理是使应用成为应用的主要因素,这是应用开发与数据可视化等相关任务的差别所在。iTunes 所做的大部分工作是显示一堆动态状态并提供编辑工具。什么歌正在播放,在排队的又是什么歌?播放列表里有哪些歌曲,以什么顺序播放?很有可能,你喜欢的 GUI 应用——一个幻灯片编辑器、一个运动追踪器、一个笔记工具——也有同样的基本结构。
我们发现,状态管理往往非常痛苦。在传统的桌面应用程序中,状态有的在应用程序的内存中,有的在外部存储(如文件系统和嵌入式数据库)中,协调起来非常麻烦。在 Web 应用中,情况更糟:应用开发人员必须将状态从后端数据库传到前端,再传回来。一个“简单”的 Web 应用可能使用一个通过 SQL 查询的关系数据库,一个部署在后端服务器上的 ORM,一个通过 HTTP 请求调用的 REST API,以及一个富客户端应用程序中的对象,并通过 JavaScript 完成进一步操作:
需要完成所有这些层上的工作,导致了巨大的复杂性。在应用中添加一个新的功能往往需要在多个层上用多种语言编写代码。理解整个系统的行为需要跨进程和网络边界追踪代码和数据依赖关系。为了优化性能,开发人员必须在每个层上都精心设计缓存和索引策略。
如何才能简化这个技术栈呢?
我们认为本地优先架构是一个有希望的模式。在这种架构中,所有的数据都存储在客户端,可以随时自由编辑,并且只要网络可用就可以在客户端之间进行同步。这种架构允许对应用状态做各种低延迟的访问,为管理状态开启了全新的模式。如果应用程序的开发者有一个足够强大的本地状态管理层可以依赖,那么 UI 代码就可以直接读写本地数据,而不用考虑同步数据、发送 API 请求、缓存或其他应用程序开发中的杂事。
除了能给开发者带来好处外,本地优先架构也能为最终用户提供帮助,增强他们对自己的数据的所有权和控制权,并使应用程序在网络不稳定或不存在时仍然可用。
这就提出了一个问题:这样一个强大的状态管理层会是什么样子?事实是,研究人员和工程师已经在专门管理状态的系统(数据库)上工作了几十年。我们认为,客户端应用开发中的许多技术挑战都可以通过源自数据库社区的想法来解决。举个简单的例子,前端程序员通常会创建专门的数据结构来按特定的属性进行查找;数据库通过索引来解决同样的问题,它提供了更为强大、自动化程度更高的解决方案。除了这个简单的示例之外,我们还看到,用更好的关系语言来表达计算,以及用增量视图维护来有效地保持查询更新等最新研究的巨大应用前景。
“数据库”这个词可能会让人联想到一种特定的系统,但在这篇文章中,这个词是指任何专门管理状态的系统。一个传统的关系型数据库包含许多部分:存储引擎、查询优化器、查询执行引擎、数据模型、访问控制管理器、并发控制系统。每个部分都提供了不同的价值。在我们看来,一个系统甚至不需要提供长期持久化就可以称为数据库。
在 Riffle 项目中,我们的目标是应用本地优先软件和数据库研究的思想,从根本上简化应用开发。在这篇文章中,我们首先提出了一些为了达到这种简化目的而要遵循的设计原则。我们认为,关系模型、快速反应性和以相同方式处理所有状态的统一方法构成了强力三重奏,我们称之为反应性关系模型。我们还使用 SQLite 和 React 专门构建了一个实现这个想法的原型,并使用这个原型构建了一些我们可以实际使用的应用程序。
到目前为止,我们已经发现了一些令人鼓舞的迹象,即反应式关系模型可以帮助开发者更容易地调试应用程序,为终端用户提供更好的应用程序,并为应用程序之间以数据为中心的互操作性提供令人感兴趣的可能性。我们也遇到了一些挑战,试图用现有的工具来实现这个愿景。最后,我们勾勒出一个更激进的方法,将整个全栈应用表示为单个反应式关系查询。我们认为,这可能是使应用开发对专家和新手都更容易的一大步。
原则
声明式查询应可以阐明应用结构
大多数应用程序都有一些典型的、规范化的基础状态,在填充到用户界面之前,必须对其做进一步的查询、去规范化和改造重塑。例如,在一个音乐应用中,如果有一个曲目和专辑列表需要跨客户端同步,那么用户界面可能就需要把这些集合合并在一起,并对数据进行过滤/分组后显示出来。
在现有的应用程序架构中,大量的精力和代码被花费在收集和重塑数据上。在传统的 Web 应用中,数据可能得首先从 SQL 风格的元组转换为 Ruby 对象,然后再转换为 JSON HTTP 响应,最后在浏览器中转换为前端 JavaScript 对象。每一次转换都是单独进行的,新增一列需要贯穿所有这些层,开发者往往需要付出相当大的努力。
在本地优先应用程序中,所有查询都可以直接在客户端进行。这就提出了一个问题:这些查询应该如何构建?我们猜测,对于许多应用来说,在客户端用户界面中直接使用关系查询模型就是一个很好的答案。任何用过关系型数据库的人都知道,使用声明式查询表达复杂的数据重塑操作是多么方便。与命令式代码相比,声明式查询可以更简洁地表达出意图,并允许查询规划器独立于应用开发者的工作来设计有效的执行策略。
正如我们将在这篇文章中讨论的那样,作为关系模型的具体实例,SQL 存在一些缺陷。这使得人们往往会在 SQL 外围添加一些层,比如 ORM 和 GraphQL。然而,原则上讲,一个足够符合人体工学的 SQL 替代品可以消除对这些附加层的需求。
一个音乐应用以规范化的格式存储一个曲目列表,其中曲目表和专辑表是单独的,两者之间通过一个外键关联。该应用通过联合查询来读取数据,这样用户就可以对任何曲目属性进行过滤,包括专辑名称。与其他方法相比(如嵌套的 API 调用),在关系查询模型中,数据的依赖关系更清晰。
在 Web 后端开发中,这个说法没有什么争议,因为 SQL 在那里很常见。这也是桌面和移动开发中非常典型的一个方法——许多复杂的应用使用SQLite作为嵌入式数据存储,包括 Adobe Lightroom、Apple Photos、Google Chrome 和Facebook Messenger。
然而,我们观察到,数据库查询的主要用途是处理持久化:也就是说,向磁盘存储数据及从磁盘检索数据。我们设想的关系型数据库的作用更加广泛,即使是通常保存在内存数据结构中的数据,逻辑上也会在“数据库中”维护。在这个意义上,我们的方法让人想起了Datascript和LINQ等工具,它们在内存数据结构上提供了一个查询接口。这也与Airtable等面向终端用户的工具有相似之处:Airtable 用户用一种类似电子表格的公式语言来表达数据的依赖关系,这种语言主要是对表格而不是标量数据进行操作。
快速反应式查询应可以提供一个简洁的思维模型
反应式系统跟踪数据之间的依赖关系,并自动更新下游数据,这样开发者就不需要手动传播状态变化了。像React、Svelte和Solid这样的框架已经在 Web UI 开发中普及了这种风格,终端用户在电子表格中构建复杂的反应式程序也有几十年了。
然而,核心的反应式循环中往往不包含数据库查询。如果查询后端数据库需要一个成本高昂的网络请求,保持查询实时不断更新就不现实;相反,数据库的读写必须建模为与反应式系统交互的副作用(side effects)。许多应用程序只在用户提出明确请求时(比如重新加载一个页面)才会提取新的数据;保持数据的实时更新通常需要在服务器和客户端之间手动发送差异。这就限制了反应性的范围,即可以保证用户界面显示最新的本地状态,但不能显示整个系统的最新状态。
在本地优先架构中,查询的运行成本要低得多,我们可以采用不同的方法。开发人员可以注册反应式查询,系统会保证这些查询随着数据的变化而更新。反应式查询也可以相互依赖,系统将决定一个有效的执行顺序,并确保数据保持更新。无需开发人员管理副作用,就可以保证 UI 准确反映数据库的内容。
这种方法类似于Pushpin中引入的文档函数反应式编程(DFRP)模型,只是我们使用关系型数据库而不是 JSON CRDT 作为数据存储,并使用查询语言而不是 JavaScript 等前端语言来访问它们。我们还可以从UI元素树之外的数据创建反应式派生值,就像在 React 状态管理框架(如Jotai和Recoil)中一样。
这还和云反应式数据存储(如Firebase和Meteor)类似,但是将数据存储在设备而不是服务器上,支持完全不同的使用模式。
当查询发生在本地时,它们的速度足以在核心反应循环中运行。在一个帧的时间跨度内(在 60HZ 的显示器上为 16 毫秒,或者现代 120HZ 的显示器上为 8 毫秒),我们有足够的时间①向数据库写入一条新的跟踪数据,②重新运行查询,获取新增的跟踪数据,③传播这些更新到 UI。从开发人员和用户的角度来看,不存在中间无效状态。
低延迟是反应式系统的一个关键属性。通常,小的电子表是即时更新的,这意味着用户永远不需要担心数据过时;只有传播更改时有几秒钟的延迟将带来完全不同的体验。UI 状态管理系统的目标应该是,在写入后一个帧的时间内将所有查询聚合到一个新的结果中;这意味着开发人员不需要考虑暂时不一致的加载状态,用户可以获得反应灵敏的软件。
这个性能要求是很高的,但是,我们有理由相信使用本地关系型数据库可以实现。
在与正在使用中的应用的开发者讨论这些想法时,我们发现,许多在 Web 环境中使用数据库的人直觉地认为数据库很慢。这令人吃惊,因为即使是像 SQLite 这样的原始数据库在现代硬件上也运行得很快:在我们用于演示的应用程序中,许多查询在几年前的笔记本电脑上运行也只需几百微秒。
我们推测,这是因为开发者习惯于通过网络与数据库进行交互,而这就存在网络延迟。另外,开发者对数据库性能的直觉是在硬件慢得多的时代形成的——现代存储的速度很快,即使是在移动设备上,数据集也往往适合装入主存。最后,许多关系型数据库管理系统在构建时并不是以低延迟为目标——许多数据库是为了处理大型数据集上的分析工作负载,在这种情况下,额外多点延迟对整体性能没什么影响。
数据库社区已经花了大量的精力来加快关系型查询的执行速度;许多 SQLite 查询在不到一毫秒的时间内就可以完成。此外,人们在增量式维护关系型查询(例如Materialize、Noria、SQLive和Differential Datalog)方面已经做了大量的工作,使用查询的小规模更新比从头重新运行快很多。
在一个系统中管理所有状态以提供更大的灵活性
传统上,我们认为,应将临时性的“UI 状态”(如文本输入框的内容,)与“应用状态”(如音乐收藏夹中的曲目列表)分开。这样做是考虑到它们的性能特点不同——让一个文本输入框依赖于网络往返,甚至因为磁盘写入而阻塞是不切实际的。
有了快速的数据库,这种划分就没必要了。如果我们将“UI 状态”和“应用状态”合并到一个状态管理系统中会怎样?这种统一的方法有助于管理反应式查询系统——如果查询需要对 UI 状态作出反应,那么数据库就需要以某种方式知道 UI 状态。另外,这样一个系统也为开发人员提供了一个统一的系统模型,例如,让他们可以在调试器中查看 UI 的全部状态。
从概念上讲,每个 UI 元素的状态都存储在与正常应用程序状态相同的反应式数据库中,所以可以使用相同的工具来查看和修改。这使得管理 UI 元素和应用程序域的核心对象(例如,音乐应用程序中的曲目和专辑)之间的交互变得特别容易。
按照不同的维度配置状态仍然是必不可少的:持久性、跨用户共享等等。但在一个统一的系统中,它们可能只是轻量级的复选框,而不是完全不同的系统。这将使我们很容易决定持久化一些 UI 状态,比如应用中当前活动的页签。UI 状态甚至可以在客户端之间共享——在实时协作型应用程序中,共享光标位置、当前字符文本内容以及其他传统上被归入本地 UI 状态的状态通常很有用。
数据库并不是一项新技术,所以我们很想知道,为什么它们没有被广泛应用于状态管理,就像我们设想的那样。不过,我们不是数据库历史方面的专家,所以探讨这个问题对于我们而言是有些冒险的。
我们将部分原因归咎于 SQL:正如我们在构建原型时所了解到的,对于应用程序开发中的许多任务来说,它并不符合人体工学。我们还认为,这是鸡和蛋的问题。因为数据库在应用开发中的作用并不像我们想象的那样突出,所以似乎没有人构建一个基于应用的工作负载做多方面性能权衡的数据库。例如,很少有数据库针对交互式应用程序的低延迟需求进行优化。最后,我们认为,当现代化的 UI 范式很多时,使用数据库来存储特定于 UI 的短暂状态或许是不可能的。然而,现代硬件真的非常非常快,这为新的架构提供了可能。
原型系统:SQLite + React
我们构建了 Riffle 的初始原型:一个面向 Web 浏览器应用的状态管理器。对于这个原型,我们的目标是快速探索使用本地数据构建 Web 应用的经验,所以我们缩小了范围,构建了一个“纯本地(local-only)”原型,不做任何设备间同步。跨设备同步 SQLite 数据库的问题别人已经解决了(例如 James Long 在Actual Budget中使用的基于 CRDT 的方法),所以我们相信,那可以实现。关于如何设计同步系统,我们也还有一些其他的想法,我们将在下一篇文章中分享。
我们将原型实现为一个以嵌入式关系型数据库 SQLite 为基础的反应层。该反应层在 UI 线程中运行,向设备上本地运行的 SQLite 数据库发送查询。对于渲染,我们使用了 React,它通过自定义钩子与 Riffle 交互。
为了在浏览器中运行应用程序(如下图所示),我们在一个 Web Worker 中运行 SQLite 数据库,并使用SQL.js和absurd-sql将数据持久化到 IndexedDB。我们还有一个基于Tauri(Electron 的竞争者,使用原生 WebView 而不是捆绑 Chromium)的桌面应用版本;在该架构中,我们在 WebView 中运行前端 UI,在本地进程中运行 SQLite,持久化到设备文件系统。
在本节中,我们将演示下我们的原型,说明如何使用它来构建一个简化版的 iTunes 音乐应用程序。我们的音乐收藏夹非常契合关系模式,它包含几个可以通过外键连接的规范化表。每首歌曲都有一个 ID 和名字,并且属于且仅属于一个专辑:
tracks(曲目)
albums(专辑)
artists(艺术家)
第一个反应式查询
在我们的应用中,我们想显示一个列表视图,其中每个曲目一行,同时显示专辑和艺术家。使用 SQL,我们可以直接加载每个曲目的名称,并加载其专辑和艺术家的名字。我们可以用声明的方式来做这件事,指定要连接的表,以及每个表特定的连接策略。
上述查询的结果如下:
这里,SQL 的一个缺点是连接语法很冗长;在类似 GraphQL 这样的语言中,遍历这个关联的语法更紧凑。
写好这个查询,就已经完成了显示这个 UI 的大部分工作。接下来只要简单地提取结果并在 React 组件中使用 JSX 模板来渲染数据。下面是一个经过简化的代码片段:
我们也可以用视觉形式来表示这个组件。目前,它包含一个依赖于部分全局应用状态表的 SQL 查询,以及一个视图模板。
UI 类似下面这样:
重要的是,这个查询并不只是在应用启动时执行一次。它是一个反应式查询,所以,每当数据库的相关内容发生变化,该组件都会重新渲染新的结果。例如,当我们向数据库中添加一个新的曲目时,列表会自动更新。
目前,我们的原型实现了最简单的反应式方法:当依赖关系发生变化时,从头开始重新运行所有查询。通常,速度还是足够快的,因为 SQLite 可以在 1 毫秒内运行许多普通查询。如果有必要,作为改进,我们计划引入表粒度的反应性。
将 UI 状态保存到数据库
接下来,让我们添加一些交互式功能,当用户点击列标题时,对表格进行排序。当前的排序属性和方向代表了一个新状态,需要在应用中加以管理。典型的 React 解决方案可能是用useState
钩子引入一些本地组件状态。但 Riffle 惯常的解决方案是避免使用 React 状态,而是将 UI 状态存储在数据库中。
我们的原型有一个机制来存储与 UI 组件相关的本地状态。每类组件都有一个关系表,其模式定义了该组件的本地状态。表中的每一行都与该组件的一个特定实例相关联,由一个称为组件键的唯一 ID 来标识。
如何选择组件实例的 ID?应用开发者可以在以下几个策略中做出选择:
临时 ID:每次 React 加载一个新的组件实例时,都随机生成一个新的 ID。这复制了我们熟悉的 React 本地状态的行为。一旦组件卸载,我们就可以安全地从表回收其状态。
单例 ID:总是分配相同的 ID,这样表格中就只有一行。对于全局性
App
组件,或任何我们希望所有组件类型的实例共享状态的情况,这很有用。自定义 ID:开发者可以选择一个自定义键来识别一个组件的多次加载。例如,曲目列表可以通过它所显示的播放列表来标识。然后,用户可以在两个不同的曲目列表之间来回切换,同时分别保留每个列表中的排序状态。
我们这个例子中的应用足够简单,到目前为止,只需要管理一个全局曲目列表的状态;我们可以使用单例 ID 策略,并将表限制在一行,像下面这样:component__TrackList
在代码中,我们可以使用 Riffle 的useComponentState
钩子通过 getter 和 setter 函数来操作状态。这个钩子类似于 React 的钩子useState
,但实现方式是简单的数据库查询。getter 是反应式查询,包含组件实例的键;setter 是更新语句的语法糖,也包含组件的键。
接下来,我们需要实际使用这个状态对曲目进行排序。我们可以在获取曲目的 SQL 查询中插入排序属性和排序顺序:
这个查询不只是读取当前值,它还创建了一个反应式依赖关系。代码有点难读,因为我们使用了字符串插值,而这是因为 SQLite 的 SQL 方言没法通过关系动态地控制排序顺序。如果使用 SQL 之外的语言,则可以考虑用一种更为关系型的写法,完全不涉及字符串插值。
这个获取有序曲目的新查询以组件本地状态以及原来的曲目查询为基础:
现在,如果我们把这个查询的结果填充到曲目列表,当点击表头时,就会看到表格动态更新(观看视频)。
当然,这个功能在普通的 React 应用中也很容易构建。那么,Riffle 的方法有什么实际的好处吗?
首先,更容易理解系统中发生了什么,因为系统运行时的数据流是结构化的,可以看出计算结果的由来。如果我们想知道为什么曲目会以这样的方式显示,就可以检查查询,并依次检查查询的依赖关系,就像在电子表格中一样。
其次,将计算下推到数据库,可以使执行更高效。例如,我们可以在数据库中维护索引,从而避免在应用程序代码中对数据进行排序,或者手动维护临时索引。
最后,UI 状态默认是持久化的。对于终端用户来说,将排序顺序或滚动位置等状态持久化通常很方便,但添加这些特性需要应用开发者积极的工作。在 Riffle 中,持久化没什么代价,不过,通过设置相应组件的键,仍然可以轻松实现临时状态。
全文搜索
接下来,我们添加一个搜索框,用户可以输入曲目、专辑或艺术家的名称来过滤曲目列表。我们可以在曲目列表的组件状态中新增一个当前搜索词的列:component__TrackList
然后,我们可以将一个输入元素连接到数据库中的这个新状态。我们使用标准的 React 受控输入,它将输入元素视为应用状态的无状态视图,而不是固有的有状态 DOM 元素。
接下来,我们需要连接搜索框,实际地过滤曲目列表。SQLite 有一个扩展,我们可以用它在曲目表上创建一个全文索引;我们将索引命名为tracks_full_text
。然后,我们可以重写查询,使用这个索引根据当前搜索框中的搜索词过滤查询:
再来看一下我们的依赖查询图,现在新增了一个层:
现在,当用户在搜索框中输入时,搜索词就会出现,并过滤曲目列表(观看视频)。
有趣的是,由于我们使用的是受控组件,用户输入时的每一个按键在屏幕上显示之前都必须经过 Riffle 数据库,这就对数据库延迟提出了很高的要求:理想情况下,我们希望在几毫秒内更新输入和所有下游的依赖关系。
用户输入在屏幕上显示之前要先经过数据库,这种情况并不多见,但这种方法有一个很大的优势。如果我们能够始终如一地达成这种性能预期,并同步刷新我们的反应式查询,那么应用程序就会变得更容易推理,因为它在任何时间点都总是显示单个一致的状态。例如,我们不必考虑这样的情况:输入文本已经改变,但应用程序的其他部分还没有反应。
到目前为止,根据我们的经验,SQLite 运行大多数查询的速度足以支撑这一方法(我们将在稍后讨论速度不够快的时候怎么办)。关于本地数据存储的速度,另一个例子是在数据库中存储当前选中的曲目。使用鼠标或键盘选择曲目时感觉很灵敏,尽管每次选择变化时都要往返数据库(观看视频)。
虚拟化列表渲染
个人音乐收藏夹可能会变得很大——随着时间的推移,一个人收藏数十万首歌曲是很常见的。对于一个庞大的收藏夹,将列表的所有行渲染到 DOM 中太慢了,所以我们需要使用虚拟化列表渲染:只将实际可见的行放到 DOM 中,把前后相邻的行放一些到缓冲区:
在目前的原型中,我们还是得限制数据库写入,所以每隔 50 毫秒数据库中才会写入一条滚动状态的新记录。在下文中,我们讨论了性能局限的源头;在未来的系统中,我们希望能去除这种节流限制。
使用 Riffle,从头开始实现一个简单的虚拟化列表视图只需要几行代码。首先,我们将列表中滚动条的当前位置表示为曲目列表组件上的一个新状态列,即scrollIndex
。当用户滚动时,我们使用 DOM 上的事件处理器来更新这个值,本质上是将 DOM 上滚动条的位置镜像到数据库中。
然后,我们可以使用这个滚动索引状态编写一个反应式数据库查询,只返回当前可见窗口及相邻的行,借助 SQL limit
和offset
。然后,我们可以再做一点数学运算,使得行与容器顶部保持适当的距离。
事实证明,这种简单的虚拟化列表渲染方法速度很快,足以支撑一个大型曲目收藏夹的快速滚动(观看视频)。
因为所有的数据都可以从本地获取,而且可以快速查询,所以我们不需要考虑手动缓存或下载经过分页的数据批次;我们只要简单地在视图的当前状态下声明式地查询想要的数据。
构建一个复杂的应用?
到目前为止,我们只是展示了一个非常简单的例子,但这种方法如何扩展到一个更复杂的应用呢?为了回答这个问题,我们中有个人一直在使用 Riffle 的一个版本构建一个全功能的音乐管理应用程序,名为 MyTunes。它有一个功能更丰富的用户界面,可以显示播放列表、专辑、艺术家、当前播放状态等。它还可以从 Spotify 同步数据,通过他们的 API 传输音乐,所以我们平时一直用它代替 Spotify 作为音乐播放器。下面是最近的一张截图:
构建一个更复杂的应用程序面临几个挑战。其中一个问题是将 Riffle 的反应式查询与 React 本身的反应性整合在一起,而又不能给开发者造成混乱。另一个挑战是,即使应用程序的复杂性增加,延迟还是要低。最后,还有很多对开发者日常体验非常重要的细节,包括 API 设计、查询结果的 TypeScript 类型推断以及模式/迁移管理。
我们仍然在设法克服这些挑战,所以还不能说我们的原型已经为开发一个全功能的应用提供了很好的体验。然而,让我们感到鼓舞的是,到目前为止,整体的反应式关系模型看起来确实可以扩展到更大的应用上。
发现
以下是我们开发、使用原型系统时的一些思考。
结构化查询让应用更容易理解
Riffle 模型产生了一个高度结构化的应用。每个组件包含:
本地关系状态
转换数据的反应式查询
渲染 DOM 节点、注册事件处理器的视图模板
这些组件被组织成一棵树。它们可以将对其查询(和状态)的访问传递给它们的子节点:
在某种意义上,视图模板也是一个“查询”:它是一个由查询返回数据的纯函数,它的表示方式是声明式的,而不是命令式的。因此,我们可以把查询和模板看作是一个大型的、树状结构的数据视图——数据源是基础表,汇点是 DOM 模板,两者之间通过查询树连接。系统在运行时知道所有的依赖关系。
总的来说,我们发现,在探讨我们的应用时,使用这些术语使得其行为更容易推理。可以设想一下,将上图作为一个可视化的调试器视图实时显示。我们花了几天时间,开发了一个非常基础的调试器原型:
这个调试器窗格:①左边是各种数据库表的列表(包含应用程序状态和用户界面状态);②中间是表格数据的实时视图;③右边是在运行时动态生成的 SQL 查询的实时视图。
事实证明,即使是这个基本的调试器也非常有用,因为我们就可以问“为什么这个 UI 元素会以这种方式显示出来?”,并通过查询树追溯计算链。这让我们想起了电子表格调试,纯粹的公式就总是可以解释表格的内容。由于查询与 UI 组件紧密相连,能够查看“UI 背后的数据”使我们更容易找到转换管道中存在问题的特定步骤。
这个调试器的用户界面还不够丰富,尚不能完全兑现承诺,但我们乐观地认为,状态模型的底层结构将简化全功能调试视图的开发:
举例来说,Observable的依赖关系可视化为在数据流图中进行调试提供了贴心的用户界面。
将这种集合式调试与命令式程序中的调试器进行比较非常有趣。命令式调试器可以通过 for 循环(或 map)进行迭代,但我们通常无法一次性看到所有数据。关系型查询的普遍使用似乎更适合调试数据密集型程序,而我们只是触及了问题的表面。
以数据为中心的设计促进了互操作性
我们发现,我们的原型有一项特别有趣的能力,就是能够以数据库为中介从外部控制应用。
当使用应用的桌面版本时,数据库存储在磁盘上的一个 SQLite 文件中,可以在TablePlus这样的通用 SQL 工具中打开。这对调试很有帮助,因为我们可以查看应用或 UI 的任何状态。而且,我们还可以更进一步:修改应用的 UI 状态!从这段视频中,我们可以看到,当使用 TablePlus 编辑一个搜索词并改变排序顺序时 UI 的反应。
在 TablePlus 中,用户必须按下 Cmd+S 显式地提交每个更改。在用户提交更改后,音乐应用会迅速做出反应。
这种编辑工作并不一定需要人在通用用户界面上手动操作;也可以通过脚本或另一个 UI 视图以编程方式完成。我们已经创建了一个以数据为中心的脚本 API,它可以有效地与应用程序进行交互,而不需要原应用程序显式地暴露一个 API。我们认为,这为互操作性提供了令人着迷的可能性。
自从面向对象编程出现以来,大多数互操作性都是“基于动词的”:也就是说,以程序使用 API 相互调用为基础。事实上,新人程序员经常被告知,要尽可能地把数据隐藏在 API 后面,以实现状态封装。遗憾的是,基于动词的API会导致n-to-n问题:每个应用都需要知道如何调用其他应用的 API。相比之下,基于数据的互操作性可以直接使用共享数据:只要应用知道如何读取一个数据格式,它就可以读取该数据,而不用管是哪个应用生成的。
许多熟悉标准 UNIX 工具和约定的用户非常喜欢使用“纯文本”数据格式,尽管它有很多缺点。我们觉得,纯文本是一种令人遗憾的数据存储方式,但我们知道这些用户希望实现什么效果:一种在应用程序之外可读的真相来源,可能用应用程序开发人员从未预料到的方式。正如我们在 TablePlus 演示中看到的那样,除了这些优势外,基于数据的互操作性还有一个优势是结构化文件格式。
我们还探索了将这种思路应用于与外部服务集成。在全功能 MyTunes 应用中,我们已经构建了播放 Spotify 音乐的功能;通常,这会涉及应用程序对 Spotify API 的命令式调用。然而,这些命令式调用很棘手——例如,如果用户在 MyTunes 中点击播放,而在 Spotify 应用中点击暂停,会发生什么?我们采用了一种不同的方法,将其建模为一个共享状态的问题:我们的应用程序和 Spotify 都是读取/写入同一个 SQLite 数据库。当用户执行某项操作时,我们将该操作作为一个事件写入数据库,然后由一个后台守护进程使用必要的 Spotify API 进行同步。反过来说,当 Spotify 发生什么时,我们会把事件写入本地数据库,而应用就会像应用触发的写入那样进行反应式更新。总的来说,我们认为,对于与外部服务集成来说,在许多情况下,共享状态是一个比消息传递更好的抽象概念。
用户和开发人员都可以从统一的状态受益
我们发现,将所有数据同等对待,无论是临时性的“UI 数据”还是持久化的“应用数据”,并将持久性视为某些数据的轻量级属性,而不是数据模型的基本组成部分,这样很好。
UI 状态默认持久化经常(而且出乎意料地)给我们带来欣喜。在大多数应用中,关闭窗口是一个破坏性的操作,但我们重启应用后会欣喜地发现,自己看到的还是之前的播放列表。对我们这些终端用户来说,关闭或以其他方式“失去”窗口比以前更安全了。
不可否认的是,这种持久性有时候也会让我们这些开发者感到沮丧:当 UI 状态有问题时,重启应用就没什么效果了。我们经常发现自己在数据库中搜索,为的是删除有问题的行。不过,我们也从中观察到:在这个模型中,我们可以将应用重启与状态重置解耦。由于系统是完全反应式的,所以我们可以在不关闭应用的情况下完全重置 UI 状态。
另一个挑战是将复合 UI 状态(如嵌套对象或序列)融入关系模型。现在,我们解决这个问题的方法是,将这种状态序列化为关系数据库中的一个标量值。然而,这种方法让人觉得有些随意,找到更符合人体工学的关系模式来存储常见的 UI 状态类型似乎更重要。
SQL 在 UI 开发方面是一种平庸的语言
最初,我们非常热衷于在 Web 应用中充分发挥 SQL 的功能。SQL 中有很多我们喜欢的东西:关系模型提供了很多优势,查询优化器非常强大,很多人,包括很多非“软件开发人员”,也可以理解甚至编写。
有各种支持嵌套的 SQL 扩展,但其中很多都不是很好,而好的又不是广泛可用。
尽管如此,在这个项目中,SQL 始终是我们的眼中钉。SQL 的缺陷众所周知,所以我们这里就不再赘述了。对我们来说,有这样几个关键的痛点:
标准 SQL 不支持嵌套,甚至在投影步骤(描述结果形状)也不支持。我们非常喜欢数据规范化,但是在生成输出时,可以嵌套数据会非常方便。
SQL 语法冗长且不统一。SQL 使困难的事情成为可能,但却使简单的事情变复杂。通常,对查询做个小更改就需要完全重写。
SQL 的标量表达式语言既奇怪又有局限性。通常,我们希望把标量表达式提取出来以供重用,但是在 SQLite 中,这项工作非常烦人,所以我们通常不这样做。
SQL 没有很好的工具用来进行元编程以及在运行时改变查询的形状,例如,根据数据库中的某些数据添加或删除“where”过滤语句。这迫使我们经常求助于 JavaScript 字符串插值。
我们认为,这些问题是 SQL 特有的缺点,而不是关系查询语言的一般思想。好点的关系语言可以使 UI 开发更符合人体工学,并避免笨拙的 ORM 层。此外,在前端 UI 这种 SQL 还没有占据首要地位的领域,取代 SQL 的前景似乎比较现实。
虽然我们试图在原型中使用众所周知的技术,如 SQL,但是,我们对Imp、Datalog 等相对较新的关系语言所表现出来的潜力感到兴奋。
性能是现有工具面临的一大挑战
原则上,声明式查询在默认情况下应该是通向良好应用性能的一步。应用程序开发人员可以对数据进行概念建模,然后由数据库提供一种有效的方法来实现应用程序的读写访问模式。但在实践中,结果好坏参半。
从好的方面来看,核心数据库本身的速度已经非常快了。即使是在使用 WebAssembly 的浏览器中运行,SQLite 的速度也足够快。在几个连接数万行数据的情况下,大多数查询可以在不到一毫秒的时间内完成。对于有限的例外,我们通过创建物化视图来解决,这些视图会在主同步反应循环之外重新计算。
我们在追溯时发现,慢查询是因为 SQLite查询优化器的局限。例如,它没有跨子查询边界进行优化,而我们大量使用子查询来模块化查询。另外,它只执行简单的嵌套循环连接,对于大表连接可能很慢。作为实验,我们尝试用DuckDB替换 SQLite。这是一种相对较新的嵌入式数据库,主要针对分析型查询工作负载,提供了最先进的优化器。我们看到,有些慢查询的运行时间降为原来的二十分之一,但其他一些查询由于当前优化器已知存在的一些限制而变得更慢。最终,我们计划探索增量视图维护技术。这样,平常的应用就很少需要考虑慢查询或缓存技术。
然而,在数据库之外,当我们在试图将反应式查询系统与现有的前端 Web 开发工具高效地集成到一起时遇到了挑战。
其中一个挑战是进程间通信。反应图在 UI 线程中运行,而 SQLite 数据库在 Web Worker 或本机进程上,因此,每个查询都会产生一个必须序列化和反序列化数据的异步调用。我们发现,当试图在一个动画帧中运行几十个快速查询时,这种开销会成为延迟的主要来源。我们正在探索的一种解决方案是,在 UI 线程中同步运行 SQLite,并将更改异步镜像到持久化数据库。
另一个挑战是集成 React。在理想情况下,写入将导致 Riffle 以原子方式一次性地更新查询图,并对所有相关模板做最低限度的更新。
React 的一些替代方案,如Svelte和SolidJS,采用了不同的方法:跟踪细粒度的依赖关系(在编译时或运行时),而不是分析虚拟 DOM 的差异。我们认为这种反应性风格很适合 Riffle,但现在,我们选择使用 React 构建原型,因为这是我们最熟悉的 UI 框架。
然而我们发现,为了保留惯用的 React 模式(如使用 props 传递组件依赖关系),有时需要通过多次传递来响应一个更新——发生写入,Riffl 查询更新,React 渲染 UI 树并向下传递新的 props,Riffle 用新参数更新查询,然后 React 再次渲染树,诸如此类。我们仍然在寻找最佳模式,以一种快速而又不那么令人意外的方式与 React 集成。
渲染到 DOM 是性能问题的另一个来源。我们已经见过这样的情况:音乐播放列表数据的加载可以在不到 1 毫秒内完成,但浏览器需要数百毫秒来计算 CSS 样式和布局。
我们认为,每一项性能挑战单独都有合理的解决方案,但我们觉得,最好的解决方案是一个更加一体化的系统,而不是基于 SQlite 和 React 等现有的层构建。
迁移是一项挑战
根据我们的经验,使用 SQL 数据库时,迁移始终是一件痛苦的事情。然而,我们的原型带来了全新的痛苦,因为模式的改变频率。
在传统架构中,每次程序重新运行,由前端管理的状态都会被自动丢弃。我们的原型将所有状态存储在数据库中,包括通常只存在于主对象图中的临时性 UI 状态。因此,临时性状态的任何布局更改都必须进行迁移。在大多数情况下,我们会选择简单地删除相关表,并在开发过程中重新创建,这实质上是用临时性状态重建传统的工作流。
这个问题让人想起 Smalltalk 镜像的一些挑战,其中的代码与状态快照是结合在一起的。
当然,Riffle 并不是第一个与迁移作斗争的系统;事实上,我们有个人已经在本地优先软件的迁移方面做了大量的工作。我们认为,要使数据库管理状态与前端管理状态一样符合人体工学,使迁移过程更简单、更符合人体工学是一项关键要求。
更进一步
到目前为止,我们认为,在应用中使用反应式关系查询来存储和重塑数据简化了技术栈。然而,如果扩大范围,放眼应用构建的所有步骤,我们就会意识到,到目前为止,我们实际上只解决了问题的一部分。
在本节中,我们将介绍一种更激进的方法,沿技术栈向上向下扩展反应式关系查询。这些想法都是纯理论层面的,我们还没有用具体的实现来验证,但我们觉得值得探索。
视图模板即查询
到目前为止,我们的原型是通过 React 渲染。数据库的职责仅限于更新派生数据视图;React 负责将这些内容渲染到视图模板中,并将更新应用到 DOM:
这对于快速创建原型非常有用,但这两个系统的结合会导致问题。我们很难从整体上理解系统行为,而且系统的运行速度也比我们需要的慢。如果我们将 React(或其他任何渲染库)从技术栈中剔除,并使用反应式关系查询来直接渲染视图模板,会怎么样呢?
很难想象在 SQL 这样的语言中这样做,但是,借助一种不同的关系语言和一种经过深思熟虑的模板方法,这似乎是可行的。Jamie Brandon 在关系型UI中探索了这个方向。
CRDT 即查询
在这篇文章中,我们还忽略了另一部分内容。在许多协作型应用中,我们需要将表示用户操作的事件转换为所有用户都能看到的基本状态。实现这一步骤的一种常见方法是使用无冲突复制数据类型(CRDT),它确保所有用户看到的状态都相同,即使他们的事件以不同的顺序应用:
通常来说,开发 CRDT 是为了通过非常仔细地推理交换性属性来维护特定类型的数据结构。但是,有一种用更通用的方式可以用来表示 CRDT 的优雅理念:作为一种声明式的关系查询,将一组事件转换为最终状态,正如 Martin Kleppmann 在文章”使用Datalog实现文本CRDT“中介绍的那样。也就是说,将 CRDT 纳入到全栈关系查询中是可能的:
在最极端的情况下,我们最终会得到一个奇特的交互式应用模型——一种全栈查询。用户的操作将被添加到一个无序事件集合中,然后 DOM 在响应中进行最低限度地更新。这中间的整个计算过程由一个关系查询来处理。
将整个技术栈压缩到一个查询中可能为我们带来什么?
这似乎是一个优雅的概念,但有一个很自然的问题是,它是否真的会让我们的应用开发变得更简单、快速和强大。我们认为,关键的好处是更容易跨技术栈的不同层次进行推理。
例如,我们考虑下性能。用户对即使是少量的延迟都会非常敏感,我们相信,低延迟是这类创造性工具让我们感到兴奋的关键属性。构建高性能应用的一个关键挑战是执行增量更新:通常,与弄清楚如何更新 UI 以响应新的事件相比,描述如何从头构建 UI 要简单得多,但像在即时模式 GUI 工具中那样从头开始逐帧重建 UI 的成本过于高昂。事实上,在使用 React 和其他基于虚拟 DOM 的工具时,我们得出了一条重要的经验,就是找到一种方法,自动地将从头构建 UI 的描述转换为增量描述。
在过去的二十年中,编程语言和数据库社区的研究人员开发了各种自动增量计算工具。其中许多技术都试图解决关系型数据库的增量视图维护问题(当有新的写入操作时,动态维护数据视图)。
增量视图维护是指在数据发生变化时更新查询结果。简单索引可以视为一种特别容易维护的视图——根据索引键排序的数据。这是个数据库社区已经研究了几十年的基本问题。最近,从一般的增量计算框架(如差分数据流)又衍生出了解决增量视图维护问题的新方法。
如果 UI 表示能够采用一种对其中一种自动增量维护技术友好的方式,那么,作为一种声明式数据视图,我们或许能够以一种声明式的、从头构建的方式表示用户界面,同时又具备增量更新的性能优势。该领域的其他工作,如Incremental和inco -dom库,已经在这些方向上取得了相当大的成功。
虽然这似乎纯粹是技术上的好处,但我们还是相信,用户界面技术栈的统一性也有概念上的好处。许多用于增量维护的系统通过跟踪数据出处来工作:它们记得某项特定的计算从哪里得到输入,这样它就知道什么时候需要重新运行这个计算。我们认为,理解数据出处也是理解应用行为的基本工具,不管是对试图调试应用的应用开发者,还是对试图扩展应用的终端用户,都是如此。
想象一下,一个浏览器式的开发者控制台,点击一个 UI 元素,可以看到它是由什么组件生成的。在一个可以端到端溯源的系统中,我们可以在更深的层次上确定这个元素是如何形成的,我们不仅仅能够回答“什么组件模板生成了这个元素?”这样的问题,还能回答“什么查询结果导致这个组件被包含在内?”,甚至是“什么事件导致这些查询结果看起来是这个样子?”这样的问题。在查询调试器视图中,我们看了一个早期的例子,但我们相信,这可以走得更远。在许多方面,数据出处跟踪似乎都是实现Whyline愿景的关键一步。在 Whyline 中,你可以查看应用程序的任何部分,从而确定它为什么处于那个状态。
未来展望
启动这个项目时,我们想知道应用数据本地优先如何改变和简化应用开发。关于这一点,我们留下的问题比答案多。但是,我们看到了一种方法的轮廓:用户界面表示为查询,查询由一个快速、高性能的增量维护系统执行,而增量维护为我们提供了整个系统的详细数据出处(detailed data provenance)。总之,这些想法似乎可以让应用开发变得更简单、更容易上手,甚至可能简单到不擅长应用开发的用户也能够“想到做到”。
我们的很多灵感来自电子表格等工具。电子表格可以说是反应式编程模型的源头,而 Airtable 的灵感则来自关系模型。这些工具在它们各自的领域内都具有很高的生产力;根据我们的经验,即使是对于经验丰富的软件工程师,它们也比传统的开发工具更有效率。尽管如此,它们在技术和概念上都有很大的局限性;你不能用 Airtable 来编写 iTunes。我们希望后退一步,开发一些关键的抽象,实现“通用”编程工具的完备表达能力,并极大地简化它们,对于专家和新手都是如此。
到目前为止,作为一款面向终端用户的工具,Airtable 提供了最完美的关系模型表示。根据我们的经验,除了计算机办公技能之外没有其他任何技术背景的用户,用上几个月后就可以变得很高效。尽管如此,Airtable 还是有一些明显的局限性。它的查询功能仅限于可以在视图 UI 中表示的内容,并不具备完全的关系查询表示能力——例如,它不支持通用连接,甚至不支持过滤谓词嵌套。此外,当单个数据库接近我们最感兴趣的中型数据集时,它的性能会迅速下降,而且它有每个库50000条记录的硬限制。
我们对这种潜力感到兴奋,更让我们兴奋的是,我们可能已经拥有了基本的技术部件,可以让这一愿景成为现实。
原文链接:
Building data-centric apps with a reactive relational database
相关阅读:
评论