2029选举项目存储架构解析:Firestore与JSON文件的双重实现

本文深入探讨了2029选举网站的存储架构,详细介绍了Firestore和JSON文件双重存储实现方案,包括数据模型设计、跨环境一致性处理、哈希清单机制以及核心与非核心模型的引用更新策略。

选举2029:存储

自上一篇关于数据模型的文章以来,我稍微简化了一些内容——基本上在写文章时想到的改进现在已经实现了。我不会详细说明变更的细节,因为它们并不重要,但这解释了为什么一些示例可能看起来缺少内容。

目前我有两种存储实现:

  • Firestore,为测试、预发布和生产环境分别使用命名的数据库
  • 磁盘上的JSON文件,用于开发目的,这样我就不必每次在本地启动网站时都访问Firestore。(“测试”Firestore数据库使用不多——但能够轻松切换到使用它来测试任何Firestore存储实现变更,然后再部署到预发布环境,这很好。)

正如我之前提到的,数据模型在内存中是不可变的。我有一个接口(IElectionStorage)用于抽象存储方面(至少大部分),因此几乎没有代码(网站本身也没有代码)需要知道或关心正在使用哪种实现。该接口最初相当细粒度,为ElectionContext的不同部分提供了单独的方法。但现在它非常简单:

1
2
3
4
5
public interface IElectionStorage
{
    Task StoreElectionContext(ElectionContext context, CancellationToken cancellationToken);
    Task<ElectionContext> LoadElectionContext(CancellationToken cancellationToken);
}

这篇文章将深入探讨如何实现这一点——主要关注Firestore方面,因为这更有趣。

存储类

在选择将数据模型存储为JSON或XML时,我通常最终遵循三种广泛模式之一:

  1. 直接在代码中显式生成和使用JSON/XML(使用JObjectXDocument等)
  2. 分离“存储”和“使用”——引入一组大致镜像代码其余部分使用的数据模型的并行类型;这些特定于存储的类仅用作执行序列化和反序列化的更简单方式
  3. 直接序列化和反序列化代码其余部分使用的数据模型

这三种方法我都成功使用过——例如,我的教堂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.jsonby-elections.jsoncandidates.jsonconstituencies.jsondata-providers.jsonelectoral-commission-parties.jsonnotional-results-2019.jsonparties.jsonparty-changes.jsonpolls.jsonpostcodes.jsonprojection-sets.jsonresults-2024.jsonresults-2029.jsontimestamp.json。目前略低于4MB。磁盘上的测试环境用于一些自动化测试以及本地开发,因此它被检入源代码控制。这对于数据如何更改的历史很有用。

Firestore存储——初步思考

Firestore稍微复杂一些。Firestore数据库由集合中的文档组成——文档也可以包含自己的集合。因此,Firestore文档的路径(相对于数据库根)类似于collection1/document1/collection2/document2。那么,我们应该如何在Firestore中存储选举数据?

有四个重要要求:

  1. 启动时加载整个上下文必须相当快
  2. 更改后使用我们想要的任何内存缓存加载新上下文必须快
  3. 我们必须一致:当新上下文可用时,加载它应该加载新上下文中的所有内容。(例如,我们不想在存储完成之前开始加载新上下文。)
  4. 我很吝啬:即使网站变得流行,它也必须便宜运行

首先,考虑我们文档的“块状性”。Firestore定价主要基于(至少在我们的情况下)我们执行的文档读取次数。一种选择是使每个集合中的每个对象成为自己的文档——因此每个选区、每个政党、每个候选人、每个结果等都会有一个文档。这将在启动服务器时读取数千个文档,最终可能相对昂贵——并且我预计它会比读取相同数量的数据但文档数量少得多要慢。

在块状性的另一端,我们可以尝试将整个上下文存储在单个文档中。由于Firestore存储限制,这不会工作:每个文档(不包括嵌套集合中的文档)的最大大小约为1MB。(有关文档大小计算方式的详细信息,请参阅文档。)

在Firestore中以与磁盘上大致相同的方式处理数据效果很好——将每个集合放在自己的文档中。这里我唯一担心的文档是投影集集合。每个投影集包含最多650个选区(更常见的是632个,因为很少有投影集包括北爱尔兰的投影)的投影详细信息。目前,这没问题——但如果选举前有很多投影集,我们可能会达到1MB的文档限制。我相信我可以优化存储的数据量——政党ID、字段名等有很多冗余——但如果可以避免,我宁愿不这样做。因此,我选择将每个投影集存储在自己的文档中。

那么,我们应该如何存储上下文?最初我只是使用一组静态文档名称(通常是Firestore集合中名为上下文集合名称的“all-values”文档——因此results-2024/all-valuesresults-2029/all-values等),但这不满足一致性要求……至少不是以简单的方式。Firestore支持事务,因此理论上我可以在同一时间提交新上下文的所有数据,然后也在事务中读取,以有效地在某个时间点获得一致快照。我相信这会工作,但使用事务至少比不使用要麻烦一些。

另一个选项是为每个上下文有一个集合——因此每当我进行任何更改时,我会创建一个新集合,其中包含一组完整的文档来表示上下文。我们仍然需要找出如何避免在存储上下文时开始读取,但至少我们不会修改任何文档,因此更容易推理。再次,我相信这会工作——但当我们考虑数据的一个非常重要点时,它感觉真的很浪费:

几乎所有数据的变化都非常缓慢。

很少出现新政党、新选区、新补选等。2019年和2024年选举的数据此时不会改变,除非模式更改等。现有的民意调查和投影集几乎从不修改(尽管如果处理代码有错误或某些元数据不良,可能会发生)。换句话说,“新”上下文中的绝大多数数据与“旧”上下文相同。

基于清单的存储

考虑到这一点,我想到了为上下文设置“清单”并将其存储在一个文档中的想法。上下文中的每个集合仍然是一个文档,但该文档的名称将基于哈希(当前实现中为SHA-256)——清单记录哪些文档在上下文中。存储新上下文时,如果具有我们将写入的相同名称的文档已经存在,我们可以跳过它。(我假设不会有任何哈希冲突——这感觉合理。)因此,例如,“添加民意调查”的常见操作只需要为“所有民意调查”写入一个新文档(诚然,这与之前的“所有民意调查”文档大部分相同),然后写入引用该新文档的新清单。对于席位投影,有一个文档列表而不是单个文档。

从头加载上下文需要加载清单,然后加载它引用的所有文档。但重要的是,加载新上下文可以非常高效:

  • 查看是否有新清单。如果没有,完成。
  • 否则,加载新清单。
  • 对于新清单中的每个文档,查看它是否与旧清单中的文档相同。如果是,数据没有更改,因此我们可以使用旧数据模型,适应更新任何交叉引用。(更多内容见下文。)如果实际上是新文档,我们需要正常反序列化它。

这留下了“垃圾”文档,就旧清单和仅由旧清单引用的旧文档而言。这些实际上没有任何危害——占用的存储量非常小——但它们确实使在Firestore控制台中检查数据稍微困难。幸运的是,定期修剪存储很简单:删除创建时间超过10分钟的任何清单(以防任何服务器仍在从最近创建但不是最新的清单加载数据),并删除未从剩余清单引用的任何文档。这是访问存储的代码需要知道它正在与哪种实现交谈的唯一时间——因为修剪操作仅存在于Firestore。

交叉引用

正如上一篇文章中讨论的,上下文中有两层数据模型:核心和非核心。核心模型首先加载,并且不相互引用。非核心模型不相互引用,但可以引用核心模型。

如果清单中任何核心集合已更改,我们需要创建新的核心上下文。如果它们都没有更改,我们可以保持整个“旧”核心上下文不变(即重用现有的ElectionCoreContext对象)。在这种情况下,任何在存储中未更改的非核心模型也可以重用——它们的所有引用仍然有效。

如果核心上下文已更改——因此我们有了新的ElectionCoreContext——那么任何在存储中未更改的非核心模型需要重新创建以引用新的核心上下文。(例如,如果候选人更改了名称,那么显示2024年选举结果应显示该新名称,即使选举结果本身没有更改。)

这在代码中很容易做到,并且以通用方式,通过引入两个接口和一些扩展方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface ICoreModel<T>
{
    /// <summary>
    /// 返回来自不同核心上下文的等效模型(例如具有相同ID)。
    /// </summary>
    T InCoreContext(ElectionCoreContext coreContext);
}

public interface INonCoreModel<T>
{
    /// <summary>
    /// 返回引用给定核心上下文元素的新但等效模型。
    /// </summary>
    T WithCoreContext(ElectionCoreContext coreContext);
}

public static class ContextModels
{
    [return: NotNullIfNotNull(nameof(list))]
    public static ImmutableList<T> InCoreContext<T>(
        this ImmutableList<T> list, ElectionCoreContext coreContext)
        where T : ICoreModel<T> => [.. list.Select(m => m.InCoreContext(coreContext))];
    
    [return: NotNullIfNotNull(nameof(list))]
    public static ImmutableList<T> WithCoreContext<T>(
        this ImmutableList<T> list, ElectionCoreContext coreContext)
        where T : INonCoreModel<T> => [.. list.Select(m => m.WithCoreContext(coreContext))];
}

我们最终有很多“有些样板”的代码来实现这些接口。Result是一个简单的例子,通过调用其直接核心上下文依赖项的InCoreContext和每个CandidateResultWithCoreContext来实现WithCoreContext。所有独立数据(字符串、数字、日期、时间戳等)可以直接复制:

1
2
3
4
public Result WithCoreContext(ElectionCoreContext coreContext) =>
    new(Constituency.InCoreContext(coreContext), Date, WinningParty.InCoreContext(coreContext),
        CandidateResults?.WithCoreContext(coreContext), SpoiltBallots, RegisteredVoters,
        IngestionTime, DeclarationTime);

当然,有另一种选择。我们可以每次加载新清单时从头重新创建整个ElectionContext。我们可以保留清单引用的所有文档的缓存,这样就不需要再次实际访问存储,只需反序列化。这可能涉及更少的代码行——但可能更棘手的代码才能正确。在内存和CPU方面,它可能也效率较低,但我 frankly 并不太担心这一点。即使在选举之夜,我们也不太可能每30秒创建一次新清单。

结论

所有这些可能仍然会改变,但目前我对数据的存储方式非常满意。它没有什么特别创新的地方,但Firestore存储的特性非常好——即使网站最终变得非常流行,Cloud Run启动多个实例,我也不期望在网站的整个生命周期内为Firestore支付大量费用。换句话说:预期成本如此之低,以至于任何进一步优化它的努力几乎肯定会在时间上花费更多,而不是在节省上实现。(诚然,对于一个我享受花费时间的业余项目,“时间成本”很难量化。)当然,我正在密切关注我的GCP支出,以确保现实与理论相符。

我相信我可以在Google Cloud Storage(blob存储)中几乎微不足道地实现Firestore存储方法——特别是考虑到本地存储的JSON序列化已经实现。我可以为不同环境使用单独的存储桶,就像我在Firestore中使用单独数据库一样。除了好奇它是否如我想象的那样容易之外,我没有特别理由这样做。(我猜查看I/O方面的任何性能差异也可能很有趣。)完全迁移远离Firestore意味着我只需要处理一种序列化格式(JSON),这反过来可能让我重新考虑拥有单独存储类的设计决策——至少对于某些类型。目前这不足以成为迁移的动机,但谁知道未来四年可能带来什么变化。

在查看了我们如何存储模型之后,下一篇文章将是关于记录和集合的。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计