MeloGuo, translation
Back

实体 VS 值对象:最根本的区别

我原来写过关于实体值对象的一些内容。但在这篇文章里,我想谈谈关于实体和值对象不同之处的更多细节。

我知道这个话题并不新鲜,并且网上已经有很多文章讨论过了。但是寻寻觅觅,我并没有找到一篇能详尽、全面的解释上述问题的文章,所以我决定自己写一篇。

1. 实体 vs 值对象: 相等的几种类型

想要定义实体和值对象的区别,我们需要介绍下三种在比较对象是否相等时会用到相等类型。

引用相等性意味着,如果两个对象引用自同一内存地址则被视为相等:

检查引用相等的代码:

object object1 = new object();
object object2 = object1;
bool areEqual = object.ReferenceEquals(object1, object2); // returns true

标识相等性代表着一个类有一个 id 字段作为标识,而这个类的两个实例如果有相同的 id 则被视为相等:

最后一个是结构相等性,即两个对象的所有成员属性值都相等:

实体和值对象的主要区别在于比较它们实例是否相等的方式。实体通过标识相等性来相互确定,而值对象则是通过结构相等性。换句话说,实体本身拥有标识而值对象则没有。

在实践中这意味着值对象是没有标识符字段的,并且如果两个值对象拥有完全相同的属性值,则可以认为它们是可相互替换的。然而,如果两个实体的实例的属性值也完全相同(除了 Id 属性),则不会把它们视为相等。

你可以通过这个类似的例子来理解,你不会认为两个有着相同名字的人是同一个人。每个人都有他们自己本身的标识(译者注:例如身份证号)。然而,如果一个人有一张 1 元钞票,他并不会在乎这张钞票在物理上是否和昨天他手里的那张 1 元钞票为同一张。只要面额依然是 1 元,他就完全可以用另外一张 1 元替换这一张。在这个例子里,钞票就是一个值对象。

2. 实体 vs 值对象:生命周期

这两个概念的另一个区别就是它们实例的生命周期不同。可以说,实体是运行在连续体当中。它在连续的生命周期中知道(即使没有被存储下来)自己发生了什么,以及自己是如何变化的。

然而,值对象是没有生命周期的。我们可以随意的创建和销毁他们,这就直接让值对象拥有了可替换性。如果这个 1 元钞票和另一个完全相同,那还费什么劲?我们可以直接用一个新实例化的对象替换现有的对象,然后把旧的销毁。

从这个区别中引申出的一个原则就是:值对象不能自己独立存在,他们应该总是附属于一个或多个实体。一个值对象的数据只有在一个相关实体的上下文中才是有意义的。例如上文中人和钱的例子,如果直接问“多少钱?”这是一个没有意义的问题,因为它没有传递合适的上下文信息。然而,如果问“皮特有多少钱?”或者“我们的用户一共有多少钱?”就完全说得通了。

引申出的另一个原则就是我们不会单独存储值对象。唯一对值对象持久化的方式就是将它们关联到一个实体上(稍后会介绍更多)。

实体 vs 值对象: 不可变性

下一个不同是不可变性。值对象在某种意义上来说应该是不可变的,如果需要去改变一个对象,我们会基于现有的对象构造出一个新的实例而不是去改变它本身。与此相反,实体几乎总是可变的。

关于值对象是否应该总是不可变是一个有争议的观点。一些开发者认为这条规则不必像上一条生命周期中的那样严格,所以值对象在一些情况下确实是可变的。我过去也曾支持这种观点。

但是现在,我发现 不可变性 与 用一个值对象替换另一个的能力 之间的关系比我之前认为的更加深刻。如果使值对象的实例可变,那你相当于假设它是有自己的生命周期的。然而这个假设会推出这样一个结论,即值对象有自己本身的标识,这与 DDD 的理念是相矛盾的。

这个简单的逻辑证明了不可变性是值对象内在的一部分。如果我们接受值对象是没有生命周期的,这就意味着它们只是一些状态的快照仅此而已,并且我们也不得不承认它们只能表示某一时刻的状态。这就引申出了一条经验法则:如果你不能让一个值对象是不可变的,那么它就不是一个值对象

4. 如何在你的领域模型里识别出值对象?

一个概念在你的领域模型里到底是实体还是值对象并不总是十分明确的。并且不幸的是,这也并没有一个客观的属性来判断它。一个概念是否为值对象完全取决于问题的领域:在这个领域模型里这个概念可能是一个实体,而在另一个领域中就可能是一个值对象。

在上文的例子中,我们把钱视作可替换的,所以它是一个值对象。然而,如果我们要构建一个在全国范围内追踪现金流的软件,那么我们就需要分别处理每张钞票来收集数据。在这个例子里,钱的就是一个实体,虽然我们可能会把它命名为 Note 或 Bill。

尽管缺乏客观标准,但你依然可以使用一些技巧来识别一个概念是实体还是值对象。我们讨论过标识符的概念:如果你能放心的用一个类的另一个有着相同属性的实例来替换当前实例,那么就可以说这是一个值对象。

更简单的一个技巧就是用值对象和整数进行比较。你会在乎当前的整数 5 和之前你在另一个方法里使用过的整数 5 是不是同一个吗?绝对不会,在你的应用程序里所有的 5 都是一样的,不管它们是如何实例化的。这在本质上就使得整数是一个值对象。现在问问你自己,这个在你领域里的概念看起来像一个整数吗?如果答案为是,那么它就是一个值对象。

5. 如何在数据库中存储值对象?

让我们来看看在领域模型中的两个类:Person 实体和 Address 值对象

// Entity
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public Address Address { get; set; }
}
// Value Object
public class Address
{
public string City { get; set; }
public string ZipCode { get; set; }
}

在这个例子里数据库结构看起来应该是怎么样的?一种选择是为它们分别创建一张表,像这样:

类似这样的设计,虽然从数据库的视角看起来很完美,但是它有两个主要的缺点。首先,Address 表包含了一个标识符。这意味着我们必须在 Address 值对象中加上 Id 字段来和表中匹配,从而相当于我们给 Address 类提供了一个标识,而这样则违反了值对象的定义。

这个方案的另一个缺点就是我们将值对象从实体上分离开来了。Address 值对象现在可以自己独立存在了,因为我们能从数据库中直接删除一个 Person 而不删除对应的 Address。这就违反了另一条规则:值对象的存在应该完全依赖于它们父实体的存在。

事实证明最好的方案是将 Address 表中的字段放到 Person 表中,像这样:

这样就能解决前面提到的所有问题:Address 不再有额外的标识符以及它的存在完全依赖于 Person 实体的生命周期。

如果你像我之前建议的那样把 Address 的字段想象成一个整数,这个设计也是行得通的。你会为一个整数额外创建一张表吗?当然不会,你只会把它放到指定表的字段中。对于值对象也是同样的道理,不要为值对象创建额外的表,只要将它们存储到父实体的表中即可。

6. 优先选择值对象而不是实体

当面临选择使用实体还是值对象时,一个重要的准则:永远优先选择使用值对象而不是实体。值对象是不可变的并且比实体更加轻量化,所以它们及其容易被使用。理想情况下,你应该总是把大部分业务逻辑放入值对象中。在这种情况下,实体的行为更像是一个包裹它们的容器,并且提供更多高阶的功能。

而且,很有可能你最开始认为是一个实体的概念其实是值对象。例如,最开始 Address 类在你的代码中可能被视为一个实体。因为它拥有自己的 Id 字段并且在数据库中有一个单独的表。在重新审视后你可能会注意到,在你的领域中,地址信息实际上并没有自己原本的标识符并且它们的是可以被相互替换的。在这种情况下不要犹豫,直接重构你的领域模型将实体转换成值对象。

7. 总结

好了,我认为关于实体和值对象的各个方面话题我都已经涉及到了。让我们来总结一下:

8. 相关文章

著作权声明

本文译自 Entity vs Value Object: the ultimate list of differences · Enterprise Craftsmanship 译者 郭梓梁,首次发布于 MeloGuo Blog,转载请保留以上链接。


GitHub · guoziliang199606@gmail.com · 微信
CC BY-NC 4.0 © Melo Guo.RSS