选举2029:存储
自上次关于数据模型的文章发布以来,我稍微简化了一些内容——基本上在写文章时想到的改进现在已经实现了。我不会详细讨论这些变更的细节,因为它们并不重要,但这解释了为什么有些示例看起来可能缺少内容。
目前我有两种存储实现:
- Firestore,为测试、预发布和生产环境分别使用命名的独立数据库
- 磁盘上的JSON文件,用于开发目的,这样我就不必每次在本地启动站点时都访问Firestore。(“测试”Firestore数据库使用不多——但能够轻松切换到使用它来测试任何Firestore存储实现变更,然后再部署到预发布环境,这很好。)
正如我之前提到的,数据模型在内存中是不可变的。我使用一个接口(IElectionStorage
)来抽象存储方面(至少大部分),这样几乎没有任何代码(站点本身中没有代码)需要知道或关心正在使用哪种实现。该接口最初相当细粒度,为ElectionContext
的不同部分提供了单独的方法。但现在它非常简单:
|
|
本文将深入探讨如何实现这一点——主要关注Firestore方面,因为这更有趣。
存储类
在选择将数据模型存储为JSON或XML时,我通常最终遵循三种广泛模式之一:
- 直接在代码中显式生成和使用JSON/XML(使用
JObject
或XDocument
等) - 分离“存储”和“使用”——引入一组大致镜像其余代码使用的数据模型的并行类型;这些特定于存储的类仅用作执行序列化和反序列化的更简单方式
- 直接序列化和反序列化其余代码使用的数据模型
这三种方法我都成功使用过——例如,我的教堂A/V系统使用了最后一种。第一种(最显式)方法我有时用于XML,但Json.NET(和System.Text.Json)使得将类型序列化和反序列化为JSON变得容易,我很少在那里使用它。(我知道存在XML序列化选项,但我从未发现它们像JSON序列化那样易于使用。)
中间选项——拥有一组仅用于序列化/反序列化的单独类——感觉是一个最佳点。它确实涉及重复——当我向核心数据模型之一添加新属性时,我也必须将其添加到我的存储类中。但这相对较少发生,并且它使事情更加灵活,并将所有存储关注点排除在核心数据模型之外。它也帮助我抵制围绕易于存储的内容设计数据模型。
关于JSON库选择的说明
对于我的选举站点,我使用Json.NET(又名Newtonsoft.Json)。毫无疑问,System.Text.Json在性能方面有一些好处,但我个人仍然更习惯使用Json.NET,已经使用它多年。所有JSON代码都不是性能敏感的,所以我选择了熟悉的。
数据模型不可变的事实也鼓励使用单独的存储类。虽然Json.NET和System.Text.Json都支持反序列化到记录,但Firestore的反序列化代码有些限制。我可以编写自定义转换器来使用构造函数(并且我添加了一些自定义转换器),但如果我们分离存储和使用,只需使存储类可变即可。
最初——实际上很长一段时间——我为JSON(在必要时用[JsonProperty]
装饰)和Firestore(用[FirestoreData]
和[FirestoreProperty]
装饰)分别有单独的存储类。这为我提供了为文件和JSON使用不同存储表示的灵活性。但过了一段时间后,很明显我实际上不需要这种灵活性——所以现在只有一个类型集用于两种存储实现。如果我希望,仍然有机会为特定数据使用不同类型——有一段时间,除了邮政编码映射之外,我有一个共享表示。
存储表示
文件存储
在磁盘上,上下文中的每个集合都有自己的JSON文件——并且有一个单独的timestamp.json
文件用于整个上下文的时间戳。所以文件是ballots-2029.json
、by-elections.json
、candidates.json
、constituencies.json
、data-providers.json
、electoral-commission-parties.json
、notional-results-2019.json
、parties.json
、party-changes.json
、polls.json
、postcodes.json
、projection-sets.json
、results-2024.json
、results-2029.json
和timestamp.json
。目前刚好不到4MB。磁盘上的测试环境用于一些自动化测试以及本地开发,因此它被检入源代码控制。这对于数据如何更改的历史记录很有用。
Firestore存储——初步思考
Firestore稍微复杂一些。Firestore数据库由集合中的文档组成——文档也可以包含自己的集合。因此,Firestore文档的路径(相对于数据库根)类似于collection1/document1/collection2/document2
。那么,我们应该如何在Firestore中存储选举数据?
有四个重要要求:
- 必须在启动时合理快速地加载整个上下文
- 在使用任何内存缓存进行更改后,必须快速加载新上下文
- 我们必须一致:当新上下文可用时,加载它应该从新上下文加载所有内容。(例如,我们不想在存储完成之前开始加载新上下文。)
- 我很吝啬:它必须运行便宜,即使站点变得流行
首先,考虑文档的“块状性”。Firestore定价主要基于(至少在我们的情况下)我们执行的文档读取次数。一种选择是使每个集合中的每个对象成为自己的文档——因此每个选区、每个政党、每个候选人、每个结果等都会有一个文档。这将在启动服务器时读取数千个文档,最终可能相对昂贵——并且我预计它会比读取相同数据量但文档数量少得多的情况慢。
在块状性谱系的另一端,我们可以尝试将整个上下文存储在单个文档中。由于Firestore存储限制,这不会工作:每个文档(不包括嵌套集合中的文档)的最大大小约为1MB。(有关文档大小计算方式的详细信息,请参阅文档。)
在Firestore中处理数据的方式与在磁盘上大致相同——将每个集合放在自己的文档中——效果很好。这里我唯一担心的文档是投影集集合。每个投影集都有多达650个选区(更常见的是632个,因为很少有投影集包括北爱尔兰的投影)的投影详细信息。目前,这没问题——但如果选举前有很多投影集,我们可能会达到1MB文档限制。我相信我可以优化存储的数据量——政党ID、字段名等有很多冗余——但如果可以避免,我宁愿不这样做。因此,我选择将每个投影集存储在自己的文档中。
那么,我们应该如何存储上下文?最初我只是使用一组静态文档名称(通常是Firestore集合中名为上下文集合的“所有值”文档——因此results-2024/all-values
、results-2029/all-values
等),但这不满足一致性要求……至少不是以简单的方式。Firestore支持事务,因此理论上我可以在同一时间提交新上下文的所有数据,然后也在事务中读取,以有效地在某个时间点获得一致快照。我相信这会工作,但使用事务至少比不使用要更繁琐。
另一个选项是为每个上下文有一个集合——因此每当我进行任何更改时,我会创建一个新集合,其中包含一组完整的文档来表示上下文。我们仍然需要找出如何避免在存储上下文时开始读取,但至少我们不会修改任何文档,因此更容易推理。同样,我相信这会工作——但当我们考虑数据的一个非常重要点时,它感觉真的很浪费:
几乎所有数据的变化都非常缓慢。
很少有新政党、新选区、新补选等。2019年和2024年选举的数据此时不会改变,除非模式更改等。现有的民意调查和投影集几乎从不修改(尽管如果处理代码有错误或某些元数据不好,可能会发生)。换句话说,“新”上下文中的绝大多数数据与“旧”上下文相同。
基于清单的存储
考虑到这一点,我想到了为上下文有一个“清单”并将其存储在一个文档中的想法。上下文中的每个集合仍然是一个文档,但该文档的名称将基于哈希(当前实现中的SHA-256)——清单记录哪些文档在上下文中。当存储新上下文时,如果具有我们将写入的相同名称的文档已经存在,我们可以跳过它。(我假设不会有任何哈希冲突——这感觉合理。)因此,例如,“添加民意调查”的常见操作只需要为“所有民意调查”写入一个新文档(诚然,这与之前的“所有民意调查”文档大部分相同),然后写入一个引用该新文档的新清单。对于席位投影,有一个文档列表而不是单个文档。
从头加载上下文需要加载清单,然后加载它引用的所有文档。但重要的是,加载新上下文可以非常高效:
- 查看是否有新清单。如果没有,完成。
- 否则,加载新清单。
- 对于新清单中的每个文档,查看它是否与旧清单中的文档相同。如果是,数据没有改变,因此我们可以使用旧数据模型,适应更新任何交叉引用。(更多内容见下文。)如果它实际上是一个新文档,我们需要正常反序列化它。
这留下了“垃圾”文档,就旧清单和仅由旧清单引用的旧文档而言。这些实际上没有任何危害——占用的存储量非常小——但它们确实使在Firestore控制台中检查数据稍微困难。幸运的是,定期修剪存储很简单:删除任何创建超过10分钟的清单(以防任何服务器仍在从最近创建但不是最新的清单加载数据的过程),并删除任何未被剩余清单引用的文档。这是代码访问存储需要知道它正在与哪种实现交谈的唯一时间——因为修剪操作仅存在于Firestore。
交叉引用
正如上一篇文章中讨论的,上下文中有两层数据模型:核心和非核心。核心模型首先加载,并且不相互引用。非核心模型不相互引用,但可以引用核心模型。
如果清单中任何核心集合已更改,我们需要创建一个新的核心上下文。如果它们都没有更改,我们可以保持整个“旧”核心上下文不变(即重用现有的ElectionCoreContext
对象)。在这种情况下,任何在存储中未更改的非核心模型也可以重用——它们的所有引用仍然有效。
如果核心上下文已更改——因此我们有一个新的ElectionCoreContext
——那么任何在存储中未更改的非核心模型需要重新创建以引用新的核心上下文。(例如,如果候选人更改了名称,那么显示2024年选举结果应显示该新名称,即使选举结果本身没有更改。)
这在代码中很容易做到,并且以通用方式,通过引入两个接口和一些扩展方法:
|
|
我们最终有相当多的“有些样板”代码来实现这些接口。Result
是一个简单的例子,通过在其直接核心上下文依赖项上调用InCoreContext
,以及为每个CandidateResult
调用WithCoreContext
来实现WithCoreContext
。所有独立数据(字符串、数字、日期、时间戳等)可以直接复制:
|
|
当然,有另一种选择。我们可以每次加载新清单时从头重新创建整个ElectionContext
。我们可以保留清单引用的所有文档的缓存,这样我们就不需要再次实际访问存储,只需反序列化。这可能涉及更少的代码行——但可能更繁琐的代码才能正确。在内存和CPU方面,它可能也效率较低,但我 frankly 并不太担心。即使在选举之夜,我们也不太可能每30秒创建一次新清单。
结论
所有这些可能仍然会改变,但目前我对数据的存储方式非常满意。它没有什么特别创新的,但Firestore存储的特性非常好——即使站点最终变得非常流行,Cloud Run启动了几个实例,我也不期望在站点的整个生命周期内为Firestore支付大量费用。换句话说:预期成本如此之低,任何进一步优化它的努力几乎肯定在时间上的成本会比它在节省上实现的更多。(诚然,对于一个我享受花费时间的业余项目,时间的“成本”很难量化。)当然,我一直在关注我的GCP支出,以确保现实与理论相符。
我相信我可以在Google Cloud Storage(blob存储)中几乎微不足道地实现Firestore存储方法——特别是考虑到本地存储的JSON序列化已经实现。我可以为不同环境使用单独的存储桶,就像我在Firestore中使用单独数据库一样。除了好奇它是否像我想象的那样容易之外,我没有特别的理由这样做。(我猜查看I/O方面的任何性能差异也可能很有趣。)完全迁移离开Firestore将意味着我只需要处理一种序列化格式(JSON),这反过来可能让我重新考虑拥有单独存储类的设计决策——至少对于某些类型。目前这不足以成为迁移的动机,但谁知道未来四年可能带来什么变化。
在查看了我们如何存储模型之后,下一篇文章将是关于记录和集合的。