深入解析选举数据模型架构与C#不可变记录应用

本文详细探讨了选举网站的数据模型设计,包括核心模型与视图模型的分离、C#不可变记录的应用、项目结构设计以及数据一致性处理方案,为开发者提供实用的架构参考。

选举2029:数据模型

数据模型(及视图模型)及其使用方式
我曾考虑在文章标题中使用“架构”一词,但觉得对于这个网站的规模来说显得过于浮夸。虽然我能为此找到理由,但每次使用这个术语都会让我感到不适。不过,本文基本上将描述我在选举网站中处理数据的方法以及这些数据的具体内容。

在许多系统中,数据有多种用途,系统设计需要考虑所有这些用途。对于我的选举网站,我只需要关心两种用途:

  • 网站本身(纯只读)
  • 维护数据的工具(读写)

工具仅由我在家中运行,因此无需担心多个并发写入的情况。但我需要确保网站在我写入过程中检查更新时始终读取一致的数据视图——这将在后续文章中详细讨论。

本文仅涉及数据的内部表示,其中我基本控制所有内容。大部分网站数据来自外部数据源,这些内容也将在后续单独文章中讨论。

网站如何使用数据

网站代码涉及以下项目(暂时忽略测试项目)。此列表按依赖顺序排列:列表中任何项目可能依赖较早项目,但不依赖较晚项目。

  • Election2029.Common:工具代码
  • Election2029.Models:核心数据模型,全部不可变(主要为密封记录)
  • Election2029.Storage:纯存储代码,用于文件系统或Firestore,但提供通用接口
  • Election2029.ViewModels:模型的不可变包装器,简化视图代码(无存储依赖)
  • Election2029.Web:ASP.NET Core代码,包含Razor页面等

Razor页面通常没有代码隐藏,而是注入相应的视图模型,通过简单的Razor语法渲染为HTML。目的是保持视图代码非常简单——我可以接受偶尔的循环,但大部分视图应该是HTML而非C#代码。此外,将任何“实际”逻辑放在视图模型中使它们更易于测试,这也是我拥有全面单元测试套件的原因。

每个页面都注入“该视图的当前世界状态视图模型”。此视图模型在底层数据更改之前被重用,这种情况相对较少。但确实会更改,这意味着视图模型不能作为单例注入。我将在后续文章中重新讨论数据重新加载机制。(我意识到我经常这么说。随着文章进展,这种情况自然会减少。)页面渲染结果也会被缓存,但仅缓存10秒,以平衡数据新鲜度和过多工作重复。理论上我可以说“缓存直到有新视图模型”,但这可能比值得的更复杂。

工具如何使用数据

工具代码涉及以下项目,以及Election2029.Common、Election2029.Models、Election2029.Storage(与网站相同)。

  • Election2029.Tools.Common:所有工具的实用代码(因为除了此处讨论的“数据管理器”外还有其他工具)
  • Election2029.Tools.DemocracyClub.Models:Democracy Club数据的模型——我从中导入的外部源
  • Election2029.Tools.Parliament.Members:议会成员API的模型和客户端代码——另一个外部源
  • Election2029.Tools.DataManager:命令行工具,用于各种数据管理任务,包括添加投票、从外部数据源更新数据等

重要的是,这完全不使用Election2029.ViewModels。在开始写这篇文章之前,DataManager项目对ViewModels项目存在依赖,这让我有些紧张……但在试探性移除后,我发现只有少数引用,我们轻松修复了。(可以说DataManager能够执行一些简单文本格式化仍然有用,例如“格式化此投票的实地调查日期”——我将来可能会将其移至模型项目。)

模型

模型本身不仅仅是记录定义,尽管我在这里展示的是这些。我认为数据模型具有以下附加成员是合理的:

  • 简单的便利计算。例如,通过求和每个政党的投票数来计算单个选区结果的总投票数。
  • 基于其余数据的查找。例如,“投影集”包含按选区划分的投影结果列表;我们有一个从选区到“该选区结果”的查找。

这些可以放在视图模型中,但它们通常被多个视图模型使用,因此放在模型中更有意义。

模型被分组。有一个ElectionCoreContext包含不相互引用的独立模型,然后是ElectionContext包含所有内容——它有一个ElectionCoreContext,然后其他模型可以引用核心上下文中的模型,但不能引用其他非核心模型。这意味着创建ElectionContext包括:

  1. 独立创建核心上下文的每个元素
  2. 从这些元素创建核心上下文
  3. 创建每个非核心元素,以核心上下文为参考
  4. 从核心上下文和非核心元素创建整体上下文

这使得数据模型比元素不能引用其他模型时更容易使用——例如,这意味着PartyChange可以引用Candidate和Party模型,而不仅仅包含候选人的ID和旧/新政党ID。核心/非核心分割意味着所有这一切都可以在没有任何循环依赖的情况下完成。

在以下所有声明中,我省略了记录实现的任何额外接口以及记录内的任何代码。

快速术语说明:这里的“上下文”只是我对“世界最新状态”的术语。它与Entity Framework无关,与DDD有界上下文的任何相似之处至少有些巧合。(或者我怀疑您可以将整个ElectionContext视为单个有界上下文——系统足够小,一个边界围绕所有内容。我对DDD的了解不足以理解系统与它的契合程度。)

核心模型

ElectionCoreContext具有以下声明:

1
2
3
4
5
6
public sealed record ElectionCoreContext(
    ImmutableList<Constituency> Constituencies,
    ImmutableList<Candidate> Candidates,
    ImmutableList<Party> Parties,
    ImmutableList<ElectoralCommissionParty> ElectoralCommissionParties,
    ImmutableList<DataProvider> DataProviders);

这些元素包括:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public sealed record Constituency(string Name, string Code, string? Code2023, string HexRQ, ConstituencyLinks Links);
public sealed record ConstituencyLinks(string? WhoCanIVoteFor2024, string? WhoCanIVoteFor2029, int ParliamentId);

public sealed record Candidate(int Id, string Name, int? MySocietyId, int? ParliamentId);

public sealed record Party(
    string Id, string BriefName, string FullName, ImmutableList<string> ElectoralCommisionIds,
    string? ParliamentAbbr, string CssPrefix, ImmutableList<string> ColorsByStrength);

public sealed record ElectoralCommissionParty(string ElectoralCommissionId, string Name);

public sealed record DataProvider(
    string Id, string Name, bool Enabled,
    string DescriptionHtml, string? Link, string? DataDirectory);

关于这些的一些说明:

  • 通常我发现,以列表而非集合或字典的形式建模记录更容易保持数据一致性……即使集合的顺序在逻辑上无关紧要(例如对于选区),拥有规范顺序非常方便,以便相等性测试更简单,差异易于阅读等。
  • ConstituencyLinks可以内联到Constituency中(在存储中确实如此)。与Candidate内联外部数据源ID的方式不一致。这可能会随时间改变。
  • “核心元素不相互引用”的方面在一定程度上被Party具有选举委员会ID列表所污染。理论上我可以增加模型中的“层级”数量,以便政党可以拥有ImmutableList……但事实证明我很少需要引用选举委员会政党。(背景是选举委员会注册了大量政党。大多数时候我们只需要主要政党,并且不想或不需要区分,例如“绿党”和“苏格兰绿党”——或者实际上两个都称为“保守与统一党”的选举委员会政党。)
  • DataProvider的DataDirectory部分仅由工具使用……这感觉有点奇怪,但并非特别不合理。
  • DataProvider(基本上用于投票和席位投影)有一个Enabled标志,允许我在获得在网站上使用权限的同时从提供者获取数据而不显示它。

非核心模型

到目前为止,相对简单。完整的ElectionContext相当大:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public sealed record ElectionContext(
    ElectionCoreContext CoreContext,
    PostcodeMapping PostcodeMapping,
    ImmutableList<ByElection> ByElections,
    ImmutableList<Ballot> Ballots2029,
    ResultSet Results2029,
    ResultSet Results2024,
    ImmutableList<NotionalResult> NotionalResults2019,
    ImmutableList<ProjectionSet> ProjectionSets,
    ImmutableList<Poll> Polls,
    ImmutableList<PartyChange> PartyChanges,
    ImmutableList<CurrentRepresentation> CurrentRepresentation)

这些元素包括:

 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
29
30
31
32
33
34
35
36
37
38
39
40
41
public sealed record PostcodeMapping(ImmutableList<OutcodeMapping> OutcodeMappings);
public sealed record OutcodeMapping(string Outcode, ImmutableList<Constituency> Constituencies, ReadOnlyMemory<byte> Map);

public sealed record ByElection(LocalDate Date, Result Result);
public sealed record Result(
    Constituency Constituency, Party WinningParty, ImmutableList<Candidacy>? CandidateResults,
    int? SpoiltBallots, int? RegisteredVoters, Instant IngestionTime, Instant? DeclarationTime);

public sealed record Candidacy(Candidate Candidate, ElectoralCommissionParty Party, int? Votes);

public sealed record Ballot(Constituency Constituency, ImmutableList<Candidacy> Candidacies);

public sealed record ResultSet(ImmutableList<Result> Results, Instant? LastUpdated);

public sealed record NotionalResult(
    Constituency Constituency,
    ImmutableList<NotionalCandidacy> CandidateResults,
    int AbsoluteMajority,
    decimal Turnout);

public sealed record ProjectionSet(
    string Id, DataProvider Provider, string Name, string Abbreviation, LocalDate FieldworkStart,
    LocalDate FieldworkEnd, LocalDate PublicationDate, string? ArticleLink, string? DataLink,
    ImmutableList<Projection> Projections, ImmutableList<ProjectionDistribution> Distributions);
public sealed record Projection(Constituency Constituency, Party Party,
    ProjectionStrength Strength, string? Link,
    string? Description, ImmutableList<ProjectionMeasure>? VictoryChances, ImmutableList<ProjectionMeasure>? VoteShares);
public enum ProjectionStrength { Tossup, Lean, Likely, Safe };
public sealed record ProjectionMeasure(Party Party, decimal Percentage);

public sealed record Poll(
    string Id, DataProvider Provider, ImmutableList<PollValue> Values, string? PreviousId,
    LocalDate FieldworkStart, LocalDate FieldworkEnd, LocalDate PublicationDate,
    string? ArticleLink, string? DataLink);
public sealed record PollValue(Party Party, decimal Share, decimal? ShareChange);

public sealed record PartyChange(
    Candidate Candidate, Constituency Constituency,
    Party OldParty, Party? NewParty, LocalDate Date, string Description);

public sealed record CurrentRepresentation(Constituency Constituency, Candidate? Member, Party? Party);

更多说明:

  • ReadOnlyMemory不一定是不可变的(例如它可以包装byte[]);我可能会在某个时候将其更改为ImmutableList,尽管在实践中不会引起任何问题。邮政编码数据的结构本身就是一个有趣的话题——您猜对了——我将在另一篇文章中讨论。
  • 对于非英国读者,补选是正常周期外的选举,由议员辞职、死亡或通过请愿被驱逐引起。(补选也发生在非议会选区环境中,但我的网站只处理议会选区。)
  • 在系统中,“投票”一词实际上是“选区中的候选人列表”;在日常使用中,它也可以表示“单张选票”,但我想不出更好的术语,这是Democracy Club使用的术语。
  • 没有ResultSet的LastUpdated属性我们可能也能管理,但将其作为自己的类型仍然有意义,因为它包括从选区到结果的简化映射。对于2019年名义结果来说(尚未)值得这样做,因为它们在更少的地方使用。
  • 说到2019年名义结果……这些是“特殊的”,因为2019年和2024年之间发生了重大的选区边界变化。名义结果是对2019年实际结果在新边界下会是什么样子的预测。更多细节,请查阅关于该主题的“Rallings & Thrasher”文档。
  • Poll.PreviousId属性用于计算ShareChange。它可能应该消失——在网站中任何地方都不使用。(写这些文章的一个好副作用是代码审查的效果。在我实际发布这篇文章时可能已经删除了它。)
  • PartyChange没有尽可能规范化。可以说我们应该记录给定人物的政党历史,然后通过考虑“在任何给定时间谁是议员,以及他们在任何给定时间代表哪个政党”的历史在选区视图中反映这一点——这绝对是我在未来几年可能重新审视的领域。(一旦我们有了第一次实际补选,这将很快发生,推理起来会更容易。)
  • CurrentRepresentation类似地有点奇怪,因为我们应该能够从2024年选举结果、补选和政党变化的组合中推导出数据。目前这很方便,但随着时间的推移可能会变得不那么方便。

关于PartyChange和CurrentRepresentation的方面让我想知道我是否可能引入一个不在任何地方存储的ConstituencyHistory模型,而是在ElectionContext构造时推导出来。

最终想法

如前所述,模型和视图模型都是不可变的。在我尝试使用不可变性的其他项目中,我遇到了相当多的摩擦——但这是一个完全只读的网站,加上C#记录的功能,使其变得非常简单。我一直了解的好处(使推理其他代码可能做什么更容易,不必担心线程安全)仍然像以往一样有价值……但在这个特定项目中,限制和挫折并不是问题。C#记录有一些挑战和烦恼,但没有什么真正的问题。

我现在要强调的一个方面,并在以后专门发布一些性能时讨论,是在单个ElectionContext中,模型的引用相等性是我们所需要的。如果您有两个引用不同对象(但属于同一ElectionContext)的ProjectionSet引用,它们肯定是“不同”的投影集。有极少数模型这只是巧合成立,例如ProjectionMeasure,但这些没有任何逻辑身份。所有“主要”模型确实有某种形式的身份,在ElectionContext中引用相等性等同于逻辑身份。

在描述了数据模型和网站的MVVM(至少名义上)方法之后,我现在有很多选择可以写下一篇内容。我想我可能会深入存储方面……

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