数据模型(和视图模型)及其使用方式
我考虑过在文章标题中使用"架构"这个词,但觉得对这个网站的规模来说显得过于浮夸。虽然我可以为此找到理由,但每次使用这个术语都会让我感到不适。不过,本文将基本描述我如何处理选举网站中的数据,以及这些数据的具体内容。
在许多系统中,数据有多种用途,系统设计需要考虑所有这些用途。对我的选举网站而言,我只需要关注两种用途:
- 网站本身,纯粹是只读的
- 维护数据的工具,可读写
工具仅由我在家中运行,因此不需要担心多个并发写入的情况。但我需要关注并发性,确保网站在我写入过程中检查更新时始终读取数据的一致性视图,这将在后续文章中讨论。
本文仅涉及数据的内部表示,在这里我基本控制一切。大部分网站数据来源于外部数据源,我将在后续的另一篇文章中介绍如何处理这些数据源。
网站如何使用数据
网站代码涉及以下项目(暂时忽略测试项目)。此列表按依赖顺序排列:列表中的任何项目可能依赖于较早的项目,但不依赖于较晚的项目。
- 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包括:
- 独立创建核心上下文的每个元素
- 从这些元素创建核心上下文
- 创建每个非核心元素,以核心上下文为参考
- 从核心上下文和非核心元素创建整体上下文
这使得数据模型比元素不能引用其他模型时更容易使用,例如,这意味着PartyChange可以引用Candidate和Party模型,而不仅仅包含候选人的ID和旧/新政党ID。核心/非核心分割意味着所有这一切都可以在没有任何循环依赖的情况下完成。
在下面的所有声明中,我省略了记录实现的任何额外接口,以及记录中的任何代码。
快速术语说明:这里的"上下文"只是我用来表示"世界最新状态"的术语。它与Entity Framework无关,与DDD有界上下文的任何相似之处至少有些巧合。(或者我怀疑你可以将整个ElectionContext视为单个有界上下文,系统足够小,一个边界围绕一切。我对DDD的了解不足以理解系统与它的契合程度。)
核心模型
ElectionCoreContext具有以下声明:
|
|
这些元素包括:
|
|
关于这些的一些说明:
- 通常我发现,用列表而不是集合或字典来建模记录更容易保持数据一致性,即使集合的顺序在逻辑上无关紧要(例如对于选区),拥有规范顺序非常方便,使得相等性测试更简单,差异易于阅读等。
- ConstituencyLinks可以内联到Constituency中(在存储中确实如此)。与Candidate内联外部数据源ID的方式不一致。这可能会随时间改变。
- “核心元素不相互引用"的方面有些被Party具有选举委员会ID列表所污染。理论上,我可以增加模型中的"层级"数量,使Party可以拥有ImmutableList
,但事实证明我很少需要引用选举委员会政党。 - DataProvider的DataDirectory部分仅由工具使用,这感觉有点奇怪,但并非特别不合理。
- DataProvider(基本上用于投票和席位投影)有一个Enabled标志,允许我在获得在网站上使用的许可时,从提供者获取数据而不显示它。
非核心模型
到目前为止,相对简单。完整的ElectionContext相当大:
|
|
这些元素包括:
|
|
更多说明:
- ReadOnlyMemory
不一定是不可变的(例如,它可以包装byte[]);我可能在某些时候将其更改为ImmutableList ,尽管在实践中它不会引起任何问题。邮政编码数据的结构本身就是一个有趣的话题,你猜对了,我将在另一篇文章中介绍。 - 对于非英国读者,补选是在正常周期之外进行的选举,由议员辞职、死亡或通过请愿被驱逐引起。
- 在系统中,术语"投票"实际上是"选区候选人列表”;在日常使用中,它也可以表示"单张选票”,但我想不出更好的术语,这是Democracy Club使用的术语。
- 没有ResultSet的LastUpdated属性我们可能也能管理,但将其作为自己的类型仍然有意义,因为它包括从选区到结果的简化映射。对于2019年名义结果来说,这样做(还)不值得,因为它们在更少的地方使用。
- 说到2019年名义结果,这些是"特殊的",因为2019年和2024年之间有显著的选区边界变化。名义结果是对新边界下2019年实际结果会是什么样子的预测。
- Poll.PreviousId属性用于计算ShareChange。它可能应该消失,在网站的任何地方都没有使用。
- PartyChange没有尽可能规范化。可以说,我们应该记录给定人物的政党历史,然后通过考虑"在任何给定时间谁是议员,以及他们在任何给定时间代表哪个政党"的历史在选区视图中反映这一点,这绝对是我在未来几年可能重新审视的领域。
- CurrentRepresentation类似地有点奇怪,因为我们应该能够从2024年选举结果、补选和政党变化的组合中推导出数据。目前恰好方便,但可能随时间变得不那么方便。
关于PartyChange和CurrentRepresentation的方面让我想知道我是否可能引入一个不存储在任何地方的ConstituencyHistory模型,而是在构建时在ElectionContext中推导出来。
最终想法
如前所述,模型和视图模型都是不可变的。在我尝试使用不可变性的其他项目中,我遇到了相当多的摩擦,但这是一个完全只读的网站,加上C#记录的功能,使得它真的相当简单。我一直了解的好处(使推理其他代码可能做什么更容易,不必担心线程安全)仍然像以往一样有价值,但在这个特定项目中,限制和挫折不是问题。C#记录有一些挑战和烦恼,但没有什么真正的问题。
我现在要强调的一个方面,并在以后专门发布一些性能问题时讨论,是在单个ElectionContext中,模型的引用相等性是我们所需要的全部。如果你有两个引用不同对象(但属于同一ElectionContext)的ProjectionSet引用,它们肯定是"不同"的投影集。有极少数模型这只是巧合为真,例如ProjectionMeasure,但这些没有任何逻辑身份。所有"主要"模型确实都有某种形式的身份,在ElectionContext中,引用相等性等同于逻辑身份。
在描述了数据模型和网站的MVVM(至少名义上)方法之后,我现在有很多选择可以选择接下来写什么。我想我可能会深入存储方面的事情。