使非法状态不可表示 - 面向数据编程 v1.1

一个以数据为中心的系统应该确保系统中只能表示数据的合法组合,因此面向数据编程的一项指导原则就是使非法状态不可表示。我们将在本文中探讨这一点,这是系列文章中的第四篇,该系列文章在 1.1 版本中细化了四个 DOP 原则。

世界是混乱的,每个规则似乎都有例外。“每个用户都有一个电子邮件地址”很快就会变成“每个注册用户都有一个电子邮件地址,但在注册过程中它可能缺失。”在建模时,你可能会遇到一个User,它有一个String email字段,该字段可以随时为null(或以其他方式缺失,例如使用Optional),而注册用户必须拥有电子邮件地址这一事实充其量是隐式的,但不再强制执行。

使用这种设计,你并没有给自己带来任何好处!在任何系统中,尤其是在以数据为中心的设计的系统中,你将从仅使合法状态可表示中受益。

如果一个User需要拥有一个电子邮件地址,那么构造函数应该确保这一点。如果没有任何产品可以同时拥有 ISBN 和电池续航时间,那么必须阻止这种情况 - 最好是通过对数据进行精确建模,以至于不存在同时拥有这两个字段的类型(有关详细信息,请参阅上一篇文章)。这种精确的类型不仅具有以下优点:它们的创建者不必编写构造函数和测试来验证非法组合不会发生,而且还有助于使用它们的开发人员。当他们看到一个Item时,他们不必问自己是否可以调用isbn()dimensions(),因为Item没有这些方法 - Book有一个,而Furniture有另一个。

因此,计划是

  • 使用精确建模的类型(通常是记录)来描述数据。
  • 在“或”情况下,避免使用具有相互排斥或条件要求的多个字段,而是创建一个密封接口来对备选方案进行建模,并将其用作强制字段的类型。
  • 只有当这些设计技术(编译器都支持)不足时,才在构造函数中使用运行时检查。

在边界处进行验证

当数据的属性无法以编译器强制执行的方式表达时,必须在运行时对其进行验证。但不仅是任何时候,它通常应该尽早发生,理想情况下是在外部世界和你的系统之间的边界处 - 无论是读取磁盘上的文件时、数据库响应查询时,还是另一个应用程序发送一些 JSON 时。

尽早验证数据可以确保没有损坏的数据进入系统,但确保系统不会生成损坏的数据也很重要。这意味着它创建的实例(以后可能会映射回 CSV、JSON、SQL 查询等)也必须经过验证。这使得这些类型的构造函数成为验证逻辑的理想位置。在更复杂的情况下,可能涉及工厂方法或类,在这种情况下,它们当然需要应用这些检查。

以下是一些此类验证逻辑的示例,为了简洁起见,它们放置在紧凑的构造函数

record Book(String title, ISBN isbn, List<Author> authors) {

	Book {
		Objects.requireNonNull(title);
		if (title.isBlank())
			throw new IllegalArgumentException("Title must not be blank");
		Objects.requireNonNull(isbn);
		Objects.requireNonNull(authors);
		if (authors.isEmpty())
			throw new IllegalArgumentException("There must be at least one author");

		// plus immutable copies as in the previous article
	}

}

建模变体

那么,如何处理在突然拥有电子邮件地址之前没有电子邮件地址的用户呢?

sealed interface User permits UnregisteredUser, RegisteredUser { }
record UnregisteredUser(/*...*/) { }
record RegisteredUser(/*...*/, Email email) {
	// constructor enforces presence of `email`
}

然后,电子邮件验证系统接受一个UnregisteredUser和一个Email,整个注册过程接受一个UnregisteredUser并返回一个RegisteredUser,新闻稿分发只接受RegisteredUser,任何可以处理这两种情况的 API 都使用User作为其参数。这不仅使用户类型保持精确,而且还允许各个子系统清楚地表达它们可以处理的用户。

有了这些,我们终于可以谈谈这些子系统以及它们如何处理数据 - 在下一篇文章中。

总结

大多数系统,尤其是以数据为中心的设计的系统,将从仅使合法状态可表示中受益。为了在面向数据编程中实现这一点,首先要对数据进行紧密建模,不要回避为“相同数据”的不同变体创建多个类型(如果存在变体,则不能完全相同)。在这些情况下或任何其他情况下,如果不同的数据相关联,请使用密封接口来对这些备选方案进行建模。无法通过类型捕获的每个数据属性都应该在构造期间进行验证。

在本系列文章中,了解有关面向数据编程 1.1 版本的更多信息