基础模型和数据库实体设计注意事项






4.76/5 (9投票s)
本文基于 Martin Fowler 的 PEAA 和 DDD,探讨了使用 MV* 模式的数据库应用程序的基本模型和实体设计注意事项。
引言
关于数据库应用程序设计的权限,有三种基本情况:
- 你同时拥有应用程序和数据库的所有权。
- 你正在为一个数据库可协商变更的应用程序进行开发。
- 你正在为一个数据库不可协商变更的应用程序进行开发。
本文主要针对第一种情况。我知道这三种情况之间有很多重叠之处,但也确实存在差异。你可能正在与一位“DBA”合作,他宣称唯一约束与主键是同一回事,从而破坏了你的 ORM 工具,进而导致你不得不对代码进行大幅调整(是的,我亲身经历过)。
在设计数据库应用程序时,数据库和模型的设计应该齐头并进。这并不是说这两个独立的部分应该耦合在一起,而是如果你在设计它们时相互考虑,那么双方的设计整体质量和开发便利性都会得到提高。
你心中的那位遵循 SOLID 原则的程序员可能会对此产生疑问,所以为了进一步阐明这一点,请思考一下反面情况……想想看,当模型和数据库极度脱节时会发生什么……是的,情况会迅速变得很糟糕……
本文将涵盖实体及其相应模型的几个常见设计注意事项。目标是提出解决这些基本问题的常用方法。
背景
如果你不熟悉 Martin Fowler,建议去研究一下他的作品……Martin Fowler 是现代架构,特别是 MV* 模式以及现代软件开发实践的主要贡献者之一。作为他贡献的一个具体例子,Martin Fowler 是《敏捷宣言》的最初签署人之一。
Martin Fowler 还在 2002 年写了《企业应用架构模式》(PEAA)。这本书主要基于 DDD(领域驱动设计),虽然他不是 DDD 的创始人,但他在书中介绍的实现 DDD 的模式和实践直接促成了许多最新的行业模式。例如,DDD 涵盖了 Repository 对象类型,而 Martin Fowler 的实现帮助演化出了现代的 Repository 模式。
如果你不熟悉 DDD,最好在阅读本文前花些时间学习一下。我会在文章中介绍一部分内容,但理解我未涉及的部分将有助于理解本文的材料。
文中的设计建议基于 PEAA 和 DDD 的建议,但它们更倾向于 MVVM 和具体的数据库实现细节。PEAA 和 DDD 都是在 MVVM 和 IOC 蓬勃发展之前编写的。话虽如此,这确实导致本文的建议与 PEAA 和 DDD 略有不同。
SQL 还是 NoSQL?
在撰写 PEAA 时,企业级应用程序数据库虽然不限于此,但基本上都在 MySQL、Oracle、SQL Server 之间选择,它们都是关系型数据库。大多数文献都将基于水平导向的数据库概念;然而,自那时以来,数据库已经随着大数据和 NoSQL 的发展而极大地扩展了。
注意: 我认为 Informix 和 DB2 是被忽视的旁支。
我无法真正谈论大数据的考量,因为那是我不涉及的领域,但无论 SQL 还是 NoSQL,在与模型协调时都有相同的基本设计考量。无论有无模式(Schema),在这两种情况下,你仍然需要决定实体中包含什么。无论有无模式,在这两种情况下,你仍然需要将数据库模型映射到业务模型,或反之。无论有无模式,你如何协调它们都会影响你工作的难易程度。
无模式(Schema-less)也不仅仅适用于 NoSQL。即使在关系型数据库中,你也可以创建一个垂直的,即实体-属性-值(Entity-Attribute-Value)数据库。这同样可以在没有离散列来表示实体中字段的情况下,创造出实体所包含内容的类似灵活性。
从 MVVM 的角度来看,无模式数据库通常更难处理。基本上,你的数据库模型与业务模型差异越大,将它们映射在一起的工作就越多。此外,这些映射器还必须处理因这种灵活性而可能出现的各种情况。
如果你打算选择一个无模式数据库,请确保这样做是值得的……如果你没有一个非常充分的理由不使用关系型数据库,那么就默认使用关系型数据库。
主键
设计表时的首要决定之一是选择主键。根据一些数据库 101 的书本知识,候选主键有三种分类:
- 自然键(Natural)
- 复合
- 代理键(Surrogate)
对于现代应用程序,默认的建议是使用代理键。代理键对于 ORM 工具和数据库安全来说是最佳选择。虽然大多数表的主键不需要安全性,但选择代理键也能在整个设计中保持一致性——代理键总是可以被创建的。它一旦被分配,也不应该需要更改或修改,而使用自然数据则可能发生这种情况。
无论如何,不要使用复合键。除了在两边都更难处理之外,一些 ORM 工具对复合键的处理非常糟糕。
既然我们已经确定代理主键是最佳选择,下一个决定就是代理键的数据类型。基本上有两个选项:
Guid
- 数值
除非有极端性能方面的考量需要满足,否则你应该默认使用 Guid
,因为:
Guid
天然支持并发插入。Guid
天然支持唯一标识任何实体。
对于处理并发问题的人来说,这种天然支持是非常巨大的优势。协调数值的种子/键值有时非常麻烦,并可能在应用程序端造成额外的延迟和争用。使用 Guid
——立马搞定。
如果你正在开发一个没有任何并发问题的小型应用程序,那么 Guid
可能有点大材小用;然而,在这种情况下,你可能也不必担心一个设计良好的数据库会带来的性能问题。仍然建议默认使用 Guid
。
关于唯一标识,如果我想检查我的模型对象是否等于另一个模型对象,如果主键是 Guid
,我只需比较主键即可。对于可能包含不同模型集合的应用程序来说,这非常有帮助。如果你使用数值,你就得编写额外的代码,因为不同实体之间显然可以有重复的键。虽然这不是什么大问题,但我更喜欢不必这样做。
对于非常大的表,Guid
可能会降低性能。它会减少单个页面可以容纳的记录数量,从而降低索引的性能。在有数百万条记录且高并发争用的表中,使用自增的 long
类型确实可能是更好的选择。
默认使用 Guid
的一个例外是当你创建一个小型引用表时。在这种情况下,绝对应该使用数值主键。
当使用数值作为主键时,请确保选择的大小与你将在表中创建的记录数量相匹配。例如,对于等同于 enum
的表,使用 smallint
。这样做将有助于优化前面提到的页面大小和性能。
模型
在 DDD 中,有三种类型的模型:
- 实体
- Aggregate
- 值对象(Value Object)
实体对象(Entity object)通常被称为领域级对象或顶层对象。聚合(Aggregate)是具有共同根实体(Entity)的对象集合,在脱离该实体的情况下几乎没有意义。值对象(Value Object)是不可变的对象,例如枚举值。
注意: 这里的术语可能有点令人困惑,因为“实体”(entity)也是类和/或数据库表的一个通用术语。为了区分,当指代 DDD 中的实体时,我将使用大写的“实体”(Entity)。
在设计模型时,一个实体(Entity)永远不应该包含另一个实体。该实体应该只包含对另一个实体的引用。如果一个可变的实体被存储在多个模型中,那么每个包含该实体的模型都需要更新/维护该实体。这可能需要一个到数千个对象来同步它们的更新!
一个实体可以也应该包含聚合对象。聚合是一组具有共同根的对象集合。除了聚合在实体之外没有多大意义之外,它还有助于通过深度读取来强制进行数据库优化。如果实体必须单独检索其聚合对象,这很容易且不必要地增加数据库的往返次数。
下面是实体和聚合的例子:
// This is an Entity
public class Recipe
{
public Guid RecipeId { get; set; }
public string Name { get; set; }
// Collection of Aggregates
public List<RecipeStep> RecipeSteps { get; set; }
}
// This is an Aggregate
public class RecipeStep
{
public Guid RecipeStepId { get; set; }
// Reference to an Entity
public Guid RecipeId { get; set; }
// Reference to an Entity
public Guid UtensilId { get; set; }
public int Order { get; set; }
public string Name { get; set; }
public string Instructions { get; set; }
}
// This is an Entity
public class Utensil
{
public Guid UtensilId { get; set; }
public string Name { get; set; }
}
在聚合的例子中,单个的 RecipeStep
有什么业务价值呢?基本没有……你永远不会在脱离 Recipe
上下文的情况下执行一个 RecipeStep
。
但在 Utensil
的例子中,你可能有一个 Kitchen
对象,它存储了你所有可用的 Utensil
对象。应用程序可以允许用户管理他们 Kitchen
中有哪些 Utensil
。这样,应用程序就可以根据你拥有的厨具来筛选你看到的 Recipe
,或者高亮显示你需要购买或替换的 Utensil
。所以在 Utensil
的例子中,它可以在 RecipeStep
范围之外的多个对象中被引用。
我想花点时间来区分聚合和集合中的实体。虽然一个 Kitchen
可以有一个 Utensil
的集合,但一个 Utensil
并不是一个聚合。在这种情况下,将 Kitchen
建模成这样非常重要:
public class Kitchen { public Guid KitchenId { get; set; } // Collection of Utensil references! public List<Guid> Utensils { get; set; } }
这种区分的重点在于,由于 RecipeStep
和 Kitchen
都引用了 Utensil
,它们将都负责维护该对象,这是不好的。一开始可能看起来没什么大不了,但随着应用程序的不断增长,两个引用变成十个时,会严重阻碍开发。
实体也可以包含值对象,但如前所述,值对象是不可变的,所以它们不需要被更新或维护,而这正是实体包含其他实体所面临的大问题。值对象与数据库中的引用表关系最密切,在模型中有两种基本实现方式,如下所示:
注意: 希望不用说(虽然我现在说了),在现实世界中你会选择一种实现方式——我很清楚 enum
和 class
的名字冲突了。
public class Utensil { public Guid UtensilId { get; set; } // Option 1 public UtensilType UtensilType { get; set; } // Option 2 public int UtensilTypeId { get; set; } public string Name { get; set; } } // Option 1 implementation public enum UtensilType { General = 0, // optional 'Unknown', but the '0' value should be the default Mixer = 1, Turner = 2, Cutter = 3 } // Option 2 implementation public class UtensilType { public int UtensilTypeId { get; set; } public string Name { get; set; } }
一般来说,enum
实现对于简单、相对静态的枚举是最佳选择。就 Utensil
而言,它应该是相对静态的,但不一定简单。你如何描述一个叉勺(spork)?一个压蒜器呢?一个更简单的静态枚举例子是车身类型。汽车行业偶尔会推出新的车身类型(例如,跨界车),但它是一个不常变化的简单分类。
一般来说,class
实现对于复杂或变化的枚举是最佳选择。class
允许你将元数据与枚举值关联起来。你还可以从仓储(repository)中动态获取值,如果枚举发生变化,也无需更改代码。但你如何声明一个默认值呢?你如何在应用程序中限制值?
就 UtensilType
而言,我认为没有一个明确的最佳方法,但每种方法都肯定有其明确的优缺点。应根据每个值对象选择最佳方法。
在这两种情况下,值对象和实体之间的重要区别在于它们是不可变的。搅拌器永远不会变成切割器,掀背车永远不会变成敞篷车。当然,我敢打赌你可以用 KitchenAid 搅拌碗做成一把小刀,但那是改变了搅拌器本身,而不是它的类型。就像一辆车,你可以锯掉车顶把它变成敞篷车,但掀背车仍然是掀背车,敞篷车也仍然是敞篷车。
在所有这些设计模型的方法中,无论是什么 DDD 类型,模型基本上都是 DTO(数据传输对象)。建议模型至少接近 POCO(简单传统 C# 对象)或 POJO(简单传统 Java 对象),因为:
- 模型在公共程序集中共享,不应依赖于特定技术。
- 模型经常需要传输,因此需要可序列化且体积小。
如果我们看所有的模型示例,会发现没有任何来自 ORM 的对象,没有附加的事件,也没有对容器的依赖等。这使得模型尽可能地具有广泛的可分发性。
广泛的分发和实现指南要求数据库模型和业务模型是不同的类。这从很多方面来看都是好事,包括关注点分离和优化,但如果将数据库模型用作业务模型,那么这些模型的消费者将被迫也包含数据库/ORM 库,从而使共享的模型依赖于特定技术。
如果你使用的是不同版本的 ORM 怎么办?如果你正在一个模型技术不支持的平台上工作怎么办?这些问题都可能导致模型库不兼容。
DDD 中的事件(Event)和服务(Service)是控制模型行为并处理模型之间交互的对象。需要重点注意的是,DDD 中的服务不一定是指外部服务,如 RESTful API。它们也包括在业务层中对模型进行操作的内部对象。通过将这些功能从模型中分离出来,我们可以减少和/或消除技术依赖。
注意: 还有工厂(Factory)对象类型,但我故意不提,因为现代应用程序更偏向于 IOC(控制反转)和组合。
审计
企业应用程序一个非常常见的需求是能够生成变更的审计追踪。许多开发人员会将审计/历史记录放在与原始源相同的表/模型中;然而,我强烈建议将历史记录保存在单独的模型和表中。
这种方法的好处:
- 你可以在不影响当前表功能的情况下按需添加审计/历史记录。
- 它简化了应用程序逻辑。
- 处理当前数据的数据库性能得到提升。
- 你可以通过只记录可编辑的字段来最小化数据库内存。
让我们深入探讨一下这些点……
如果我有一个设计为当前表的表,后来决定添加历史记录,如果我的历史记录是完全分离的,那就没问题——我当前的应用程序逻辑保持不变。如果决定将历史记录包含在内,我就必须重构现有逻辑,以便在查找当前条目时忽略历史记录。
特别是在实时和易变数据的应用程序中,当前条目与历史记录相比通常是少数对多数。在这种情况下,拥有数百万条记录而不是少至数百条记录,会对数据库造成巨大且不必要的压力。
实际上,审计/历史记录有两个层面——编辑当前记录和编辑历史记录。对于任何给定的模型,你显然需要决定你需要什么,但遵循上述建议,每个层面都是它自己的表/模型。
我将第一层称为历史记录(historical),第二层称为审计(audit)。原因是历史记录可能仅仅因为系统变更就需要维护——不一定需要完整的审计。审计意味着历史记录可以被操纵,因此你也需要跟踪这一点。至少在我看来,审计是关于用户更改历史记录,而不是系统正常的数据流。
话虽如此,为了让这种方法奏效,你每次添加或更新当前记录时都必须创建一条历史记录。同样,如果你要审计历史记录,每次更新历史记录时都必须创建一条审计记录。
下面是这在模型中的样子:
public abstract class EditableEntity { public DateTime CreatedDateTime { get; set; } public DateTime? UpdatedDateTime { get; set; } public Guid CreatedBy { get; set; } public Guid? UpdatedBy { get; set; } public string Notes { get; set; } public EditableEntity() { CreatedBy = User.SystemUser.UserId; CreatedDateTime = DateTime.UtcNow; } } public abstract class HistoryEntity : EditableEntity { public HistoryEntity(EditableEntity editableEntity, Guid userId) : this() { CreatedDateTime = editableEntity.CreatedDateTime; CreatedBy = editableEntity.CreatedBy; UpdatedDateTime = editableEntity.UpdatedDateTime; UpdatedBy = editableEntity.UpdatedBy; Notes = editableEntity.Notes; } } // NOTE: Not derived because it should contain only the historical fields that can change. public abstract class HistoryAuditEntity { // NOTE: There is no CreatedDateTime because this can't change, audits are purely updates. public DateTime UpdatedDateTime { get; set; } public Guid UpdatedBy { get; set; } public string Notes { get; set; } public HistoryAuditEntity(HistoryEntity historyEntity) : this() { UpdatedBy = historyEntity.UpdatedBy.Value; UpdatedDateTime = historyEntity.UpdatedDateTime.Value; Notes = historyEntity.Notes; } }
关于这个模型结构的简要评论:
- 数据库将反映相同的结构。
- 更新信息是否可空,或设置为创建信息,这一点可以两说。
- 我包含了
Notes
,因为通常当人们更改某些内容时,你希望允许他们提供有关更改的详细信息。
当你进行审计时,你需要能够将一个 User
与一个变更关联起来。你可能已经注意到主键再次是 Guid
,而不是 SID 或 string
……因为用户来源可能存在多个和/或变化的来源,我强烈建议你的应用程序在自己的数据库中存储用户。你可以在实体中放入任何你想要的外部引用,但在你自己的数据库中使用 Guid
可以保证唯一性和应用程序的一致性。
在代码示例中,SystemUser
是一个静态类属性,具有一个默认的、随机分配的 Guid
。我强烈不鼓励使用 Guid.Empty
作为默认值或 SystemUser.UserId
,因为你可能会在实际是用户分配遗漏/错误的情况下,错误地分配了 SystemUser
。
我们即将进入仓储模式(Repository Pattern),但我必须在这里先简单提一下……历史记录和审计记录的控制方式应该包含在仓储的实现中。想想看……如果不包含在仓储中,那么任何调用仓储的地方都必须:
- 知道一个模型是否拥有/需要历史和/或审计条目。
- 知道如何协调它们的更新(可能包括事务等)。
- 如果需要,创建自己的代码来创建和插入历史和/或审计条目。
如果仓储不处理它,这就变成了一个严重的 GRASP 原则违规……
仓储模式
仓储模式始于实现一个带有期望操作(例如 CRUD)并返回通用模型对象的 interface
。该 interface
应该与模型在同一级别声明,以便也可以共享。
注意: 仓储 interface
和模型在同一个程序集中声明是很常见的。
绝对不要将你的仓储实现放在同一个程序集中。同样,它可能看起来像是“通用代码”,但它会产生技术依赖。此外,有很多情况下需要自定义的仓储工作,所以它应该被分离。
从模块化的角度来看,我偏好将仓储实现放在它自己的库中。这样,如果需要多个实现,消费者只需选择它需要的那个,而无需担心要拿出所有东西或进行重构。
注意: 将接口和实现放在同一个库中违背了 IOC 的大部分目的——不要这样做。
除了 CRUD 之外,仓储最大的职责是将业务模型与数据库模型进行相互映射。有很多工具可以用来进行自动映射(例如 AutoMapper);然而,我现在要说,我强烈不鼓励在仓储中使用此类工具。
映射器的优点:
- 需要维护的代码更少。
映射器的缺点:
- 效率较低。
- 可能存在许多需要自定义映射覆盖的问题。
我过去常常默认使用映射器,但当数据库设计变更导致一个未预见/未检测到的冲突时,会带来很多痛苦。因此,我已经开始默认编写自己的映射代码。是的,这很繁琐,但在最初的投入之后,它往往更安全。话虽如此,我仍然在应用程序的其余部分使用映射工具。
如前所述,仓储的另一个职责是处理历史和可审计条目。在这些情况下,实现逻辑应遵循以下步骤:
- 查询现有记录
- 如果记录存在,检查创建/更新时间戳。
- 如果是新的或更新的值,添加/更新当前记录。
- 创建一条历史记录。
注意:如果你使用的是事务性数据库,这些操作显然应该包含在同一个事务中。
一个需要讨论的重要部分是第 2 步。我知道这并不涵盖所有的企业应用程序,但我处理过的企业应用程序有大量来自许多外部源的实时数据。这意味着有可能接收到过时的数据。
以一个报告股票价格的应用程序为例……该值可能通过 UDP 传输报告,频率变化可能达到毫秒级。应用程序有可能在接收到最新值之后,接收到一个旧的值。如果我的应用程序想要记录历史值(是的,我知道这些信息在许多可用来源中都有记录,但请忍受我这个例子),我不想自动假设我收到的任何值都是最新的,并更新当前记录。我需要像描述的那样检查并更新它。
这里的重点是,协调数据库中实体之间关系的逻辑应该驻留在仓储实现中。我知道这与大多数人在数据层中已经实现的内容相差不远,只是想确保这一点是明确的。
存储计算数据
在存储计算数据时,有几条规则可以让生活变得容易得多:
- 对于任何给定的单位类型(即百分比、温度、压力、重量),在整个数据库中都存储相同的单位。
- 存储
DateTime
时,始终以 UTC 格式存储。
根据你的工作所处理的数据源,将数据从模型保存到数据库可能需要大量的转换工作;然而,另一种选择可能是混乱。这个字段中的重量是千克,那个字段中的重量是磅,好吧,在我将两个重量字段相加之前,我需要将它们适当地转换……不!千万别!
你可能认为你的公司使用的单位足够清晰(比如千克和毫克),“永远不会有问题”,但相信我……当多年后你意识到有异常值,并且没有好办法区分它们时,这真的会给你带来大麻烦。你不知道它是否曾引起任何问题,也无法判断它现在是否正在影响任何人。听起来很有趣,对吧?
始终存储 UTC 也是类似思路。你的模型和查询将永远不需要去猜测它是什么……查询逻辑和业务逻辑可以无缝地协同工作……
但为什么不都用本地时间存储呢,即使你所有的数据都是以本地时间查看的?
首先,应用夏令时(DST)的本地时间不是唯一的。存在冲突的可能性,而且就像单位一样,一旦你离数据创建的时间足够远,可能就无法找出真相了。
另一个大问题是,本地时间是相对于你的服务器和/或数据库所在地的。如果它们移动时区会怎样?如果它们跨越不同时区会怎样?如果你有多个时区的用户会怎样?
我来告诉你将会发生什么……情况会变得一团糟,然后每个人都试图弄清楚如何迁移数据并重构以使用 UTC。
如果所有计算数据都是一致的,那么数据库查询和业务逻辑就可以自由地处理来自所有来源的数据。用户可能期望数据以标准单位显示,但在生成报告或在 UI 中显示时进行转换真的很容易。
这种方法的问题在于,无论在数据库端还是模型端,你都无法阻止数据以其他单位存储。必须在双方建立并同意维护所选的单位,但这也是协调数据库和模型重要性的一个关键点。
此时你可能会跳出来,提出一个受人尊敬的建议:存储一个单位字段。我同意这是一种替代方案,但让我们讨论一下需要做些什么才能让它奏效……
Unit
表的前提是,任何数据记录都可以存储一个额外的字段来指定数据所使用的 Unit
。直接的问题是,这可能使你需要处理的数据记录字段数量翻倍。由于这个考虑,这种方法可能会降低生产力。
所有工作都可能需要在记录之间进行转换,特别是在高容量或高频率数据的情况下,转换成本会开始真正累积。此外,它还增加了通过网络发送的任何数据的体积。由于这两个考虑,它的性能并不好。
如果你认为这种方法是防止意外地在字段中存储错误单位的保险措施……嗯……也不是……首先,这依赖于数据被单位准确地表示。其次,这依赖于更新数据的工作是以相同的单位更新,或者将单位与新值同步。
权衡每种方法,我认为存储 Unit
与使用默认单位相比,并没有真正的附加价值。我不会说存储 Unit
是一种反模式,但它确实往往比使用默认值需要更多的工作。不过,如果多个团队拒绝在此问题上相互协调,后者可能是你唯一的选择。
最后,我想说,这是一个我见过会摧毁应用程序和生产力的领域——请高度重视它。如果阻止使用通用单位的唯一障碍是一次会议,或者是承担某个领域的所有权,那就去搞定它。
摘要
有趣的是,这篇文章我写了两遍,因为我不小心丢弃了草稿……哈!
因此,我现在就将这篇文章发布出来,没有一个充分的总结。
如果有任何主要的讨论点或请求,我会相应地更新文章。
请分享你的想法、评论和吐槽!
历史
- 2017-05-25 首次发布。
- 2017-05-25 添加单位主题(真不敢相信我忘了这个)。