Fleet
More Than a Code Editor
Fleet 后台探秘,第三部分 — 状态管理
在本系列博文中,我们将以多个部分为您介绍构建 Fleet 这款由 JetBrains 打造的下一代 IDE。
在本系列的前几部分中,我们介绍了 Fleet 的总体架构,并探讨了编辑器后台用到的算法和数据结构。 在这一部分中,我们将介绍实现状态管理的方式。 这是一个复杂的主题,因此我们特别准备了多篇博文。 本篇的重点是应用程序状态元素的表示和存储, 下一部分将更细致地探讨 Fleet 中围绕状态管理的事务机制。
Fleet 有很多移动部件,也执行着许多不同的操作,包括:
- 呈现 UI 元素并与用户互动。
- 与其他服务交互以获取数据和更新 UI 元素。
- 处理文件,例如保存、加载、解析文件以及显示其差异。
- 编排处理代码洞察、补全和搜索结果的后端。
许多操作较为复杂,可能会降低界面的响应能力。 同时,由于 Fleet 是分布式应用程序,可能有多个分布在网络上的前端,使整个过程更加复杂。 尽管如此,我们还是必须持续为用户正确显示所有信息,确保用户可以在前端之间稳定地工作。
在状态管理上,操作分为读取状态和更新状态。 UI 元素读取状态后向用户提供实际数据,用户则通过编辑文档和移动内容来更新状态。 这样的操作每分钟有成千上万次, 也让正确的状态管理成为 Fleet 的关键要素。
我们的原则
JetBrains 在 IDE 开发上已有 20 多年的历史。 我们基于经验得出以下有关 Fleet 状态管理的指导原则:
原则 1:不要阻塞任何人
在并发环境下需要多加留意。 在 Kotlin(和 Fleet)中,我们使用称为协同程序的轻量级并发基元组织并发代码。 虽然同时从多个协同程序读取状态几乎不会产生任何问题,但更改它们可能带来危险。 传统方式是为单个写入器线程获取锁,这会导致读取某些内容的等待队列很长。 我们认为这并不合适,读取器应该能够无延迟地读取可能稍微过时的状态。 为了实现这种行为,我们使用 MVCC(多版本并发控制)模型的一个变体访问协同程序中的状态元素。 这些协同程序要么读取某个版本的状态,要么通过提供新版本的状态更改状态。 在 MVCC 下,我们更容易在事务中读取状态和更改状态。
原则 2:高效做出反应
状态一直在变化,UI 应该立即反映这些变化。 只要您用自己掌握的第一门编程语言编写过动画,就知道如何做到这一点:擦除所有内容,从头开始重新绘制。 然而,完全重绘需要很多时间。 更好的做法是重新绘制有变化的部分。 为此,我们需要有能力确定到底是什么发生了变化。 变化越少越好。 找到状态有变化的部分后,就要尽快决定什么依赖于该部分并执行相应的协同程序。 我们必须高效地对状态变化做出反应。
原则 3:明智地表示数据
如果没有第三条原则,前两条原则不过是好听的声明。 我们必须认真思考存储和处理数据的方式。 具有高效查找和更改操作的存储已不再是数据库系统实现器的独有领域。 Fleet 作为分布式 IDE 也需要这些。 为了满足需求,我们必须开发自己的既灵活又高效的内部数据库解决方案。
什么是状态?
对于 Fleet 中的状态,我们需要考虑三个想法。
首先,它被表示为持久数据结构,具有不同版本,模型随时间变化。 一种描述方式是一个接一个的线性周期序列,即周期时间模型。 所有相关方(协同程序!)都会读取其中一个周期,但不一定是最近的周期。
其次,我们的状态是一个实体数据库,包含屏幕上以及后台所有内容的相关信息。 与许多数据库一样,这些实体以各种方式相互关联。
第三,状态及其更改归为基本的三元组,即 datom。它们是基元数据条目,让我们能够实现所需效率。 接下来,我们将详细讨论这些想法。
周期时间模型
很长一段时间里,我们的程序都会更改状态。 然而,仅仅更新一个变量几乎从不足够。 通常,我们必须一致地逐一做出大量更改。 如果有人观察到我们不成熟的状态,甚至试图更改它,该怎么办? 假如我们增加了字符串的长度,但没有提供新内容。 用户绝对不应该看到这一点。 这时,应在某些遮蔽后隐藏不一致的状态。 从一个一致状态需要经过一段时间才能到下一个状态。 这就像一个周期跟随另一个周期。
Rich Hickey 在他的精彩演讲 Are We There Yet(查看语音稿)中首次向更广泛的编程社区解释了周期时间模型,展示了他关于实现 Clojure 编程语言的想法。 他讲到,在一段时间内,我们的程序可以存在于一个不可变的一致世界中。 不可变性使许多东西都更容易实现,但没有什么可以永远处于同一个世界里。 由于状态写入器的活动,一个新的不可变的一致世界总是跟随前一个世界。
Fleet 的状态可以通过不可变快照的形式访问,快照是所有状态元素的集合并且状态元素间的一致性得到保证。 在这个模型中,更新状态会创建一个新快照。 为了保证状态变化的一致性,我们实现了事务。
Fleet 有一个被称为内核的组件,它负责根据状态写入器的活动转换快照并提供对最新快照的引用。 相关各方,无论是读取器还是写入器,都可以在需要时获得此引用,但无法确定此引用在使用时是否与世界的最新版本相对应。 内核还负责向依赖它们的各方广播变化。 好在我们不需要手动订阅,只要读取一些值,然后在未来获得其变化的通知就够了。
写入器排队创建新快照,但读取器永远不会被阻塞。 然而,它们可能会收到稍微过时的信息。
我们状态的数据模型
现在我们将回答这个问题:我们的状态中有什么? 它包含所有东西:文档内容及其相应文件信息、该内容的所有推断信息、文本光标位置、加载的插件及其配置、视图和面板位置等。 相应数据模型在 Fleet 中通过 Kotlin 接口描述为:
interface DocumentFileEntity : SharedEntity { @Unique @CascadeDeleteBy var document: DocumentEntity @Unique var fileAddress: FileAddress var readCharset: DetectedCharset // ... } interface DocumentEntity : SharedEntity { var text: Text var writable: Boolean // ... }
注意:Text 类型实际上是本系列前一部分中介绍的绳索。
我们使用属性注解描述实体组件和它们之间的关系。 在此示例中,文档文件实体描述了磁盘驱动器上唯一文件与我们从中读取的唯一文档之间的关系。 当文档文件实体被删除时,相应文档实体应被删除。
为了维护此类实体数据库,我们实现了自己的数据库引擎 RhizomeDB。 RhizomeDB 不会对实体施加层次结构,因此名为 Rhizome,这是一种地下植物茎,从节点发出根和芽。
为了将实体作为从接口实现属性的对象访问,如上例所示,RhizomeDB 提供了一个 API。 例如,我们可以根据给定文件地址获取一个文档,如下所示:
val document = lookupOne(DocumentFileEntity::fileAddress, fileAddress)?.document
文档对象已经实现了 DocumentEntity 接口,我们可以使用它来访问 Fleet 中所加载文档的内容。
我们的实体数据模型相当灵活,不仅表示数据,还会表示数据模型本身。 假设我们要开发插件(我们将在本系列后续部分讨论 Fleet 的插件)。 加载的插件构成 Fleet 状态的一部分。 所有插件共享与应用程序无缝集成所需的部分通用数据。 然而,每个插件都有自己的状态,以其自己的数据模型描述。 这对 RhizomeDB 而言不成问题。 我们可以通过实体表示插件的数据模型。 加载插件时,我们也将其数据模型加载为新实体。 随后,Fleet 的状态管理系统即可接受插件的状态数据。
状态为一组三元组
尽管 API 为我们提供了处理实体的对象,但我们并没有将其存储起来。 相反,我们用三元组表示它们:[entity_id, attribute, value]
。 我们将这些三元组称为 datom(术语来自 Datomic 数据库,我们以此为基础对数据结构进行了建模)。
假设引用文档的某个特定文件的实体 ID 为 18,相应文档的实体 ID 为 19。 数据将被存储为三元组:
[18 :type DocumentFile]
[18 :document 19]
[18 :fileAddress "~/file.kt"]
[18 :readCharset "UTF-8"]
注意,接口的属性将成为三元组的特性。 还有多种特性,如具有特殊含义的 :type
。 值的类型取决于属性的类型。 引用其他实体时,属性值是 ID。
在查找数据时,三元组看似原始的结构非常有效。 我们的引擎能够以掩码的形式快速返回查询的答案:[entity_id?, attribute?, value?]
,其中任何组件都可能存在或缺失。 查询的结果始终是一组能够满足给定掩码的 datom。
例如,我们可以查询当前加载文档文件的所有文件名:
[? :fileAddress ?]
或者,查找 entity_id,它对应于给定名称的文件:
[? :fileAddress "~/file.kt"]
在第二个查询上,由于唯一性约束,结果集中不应有多个答案。
为了使查询足够快,RhizomeDB 维护四个索引(每个都实现为哈希树):
- 实体 | 特性 | 值
- 特性 | 实体 | 值
- 值 | 特性 | 实体
- 特性 | 值 | 实体
RhizomeDB API 中的 lookup*
系列函数对这些索引进行操作,查找相应三元组并构建结果实体对象。
RhizomeDB 受到了 Datomic 的极大影响,但也添加了新的想法,如读取跟踪和查询反应性,适用于我们的用例。 这些功能有助于处理状态变化。
什么是变化?
不可变状态几乎没什么新奇的。 只有在我们改变某些东西时,事情才会有趣起来。 我们想知道状态中发生了什么变化以及哪些 UI 元素需要更新。 为了应对变化,我们实现了以下三个想法:
- 我们将确切变化的内容记录为变化的新颖点。
- 我们跟踪读取器查询的内容。
- 我们确定哪些查询会因这种变化而产生新的结果。
我们来进一步讨论一下这些想法,看看它们在 Fleet 中是如何运作的。
新颖点值
请记住,我们尽可能实现不可变,因此我们不可更改值。 另外,我们的状态为快照形式,包含一组具有实体 ID、特性及其值的三元组,表示相应数据实体。 对于任何变化,我们都不会更改特性的值,而是生成新的状态快照,包含我们想要更改的特性的新值。 然后,发生的变化仅仅是移除旧值并添加新值。 例如,要重命名文件,我们执行以下操作:
- [18 :fileAddress "~/file.kt"] + [18 :fileAddress "~/newFile.kt"]
注意,这两个操作必须在事务内部执行。 否则,您将观察到完全没有文件名的状态。 运行此类事务会产生一个带有新文件名的新状态快照。
因此,任何变化都只是 datom 的一组移除和添加。 事务可能导致针对不同实体和特性的许多此类移除和添加。 另外,两个快照之间的差异也是这样的一组移除和添加。 从变更集中的实体 ID 和特性,我们可以准确知道哪些状态组件在事务期间发生了变化。 这些被称为变化的新颖点。 执行事务后,我们会记录这些新颖点值。
读取跟踪和查询反应性
我们知道,读取器通过查询访问状态中的数据。 查询具有掩码的形式。 对特定函数,可以跟踪所有掩码。 获得所有函数的这一信息后,我们就可以确定哪些函数依赖于哪个掩码。
每次变化后,我们都会获得其新颖点值。 检查所有查询的掩码,即可了解哪些查询受到变化的影响。 得益于读取跟踪,我们能知道哪些函数受到了影响。 因此,我们可以使调用这些函数的 UI 元素无效。 这将使 UI 反应非常高效。
我们不仅仅将读取跟踪用于更新 UI 元素。 这是一种非常通用的机制,可供在反应式编程中使用实用模式。 例如,如果有一个查询状态的函数,我们很容易把它变成异步流。 每当状态的变化影响此类函数的结果时,我们就会发出流的新元素。 我们还可以安全地缓存查询结果,不会面临缓存值过时的风险。 在状态中的值更新后,我们会立即知悉。
总结
在 Fleet 构建方式系列的这一部分中,我们通过一系列不可变快照运行了一个周期时间模型,并构建了智能数据表示来维护我们的状态。 我们的数据存在于两个层面:作为便于开发者使用的数据实体,以及适合高效查找的三元组。 进行更改时,我们会记录更改的内容,确定对这些特定更改感兴趣的相关方,并促使其更新相应 UI 元素。
基于这一背景,我们接下来就将讨论 Fleet 状态的分布式特性,以及允许我们一致地进行更改的事务机制。 敬请期待本系列的下一篇博文。 敬请关注!
本博文英文原作者: