第三章:实体与值对象——实体类设计原则、值对象模式、复杂类型配置、实体拆分与继承映射
好,咱们进入第三章。这一章聊的是实体与值对象,说白了就是「哪些东西该单独建表,哪些东西该内嵌进去」。我见过太多项目,一上来就把所有字段都塞进一个大实体里,结果后期改起来想死的心都有。嗯,咱们今天就把这个事彻底理清楚。
3.1 实体类设计原则:别把实体当垃圾桶
实体是什么?简单说,就是有唯一标识、有生命周期的东西。比如「订单」「用户」「商品」,它们有自己的ID,会经历创建、修改、删除的过程。
那设计实体类时,我一般遵循这几条原则:
- 每个实体必须有主键——这是底线。EF Core 默认会把名为
Id或类名+Id的属性当作主键。 - 实体只包含自身的数据——别把关联对象的数据直接塞进来。比如订单实体里放个
CustomerName,这就坏了规矩。 - 用导航属性表达关系——而不是用外键字段手动维护。EF Core 会自动帮你处理。
- 避免过度膨胀——如果一个实体有超过20个属性,我建议你停下来想想,是不是该拆分了。
核心原则:实体关注的是「谁」,值对象关注的是「是什么」。一个实体可以包含多个值对象,但值对象不能独立存在。
我在项目中遇到过最典型的反例:有人把「地址」拆成四个字段(省、市、区、详细地址)直接放在用户表里。结果后来要加个「收货地址」和「发票地址」,表结构改得一团糟。这就是没分清实体和值对象的后果。
3.2 值对象模式:那些没有ID的东西
值对象是什么?它没有自己的身份标识,只描述实体的某个属性。比如「金额」「颜色」「地址」「时间段」。
值对象有几个特点:
- 不可变——一旦创建就不能改。要改就新建一个。
- 没有独立的主键——它属于某个实体,不能单独被查询。
- 通过属性值比较相等——两个值对象如果所有属性都一样,它们就是相等的。
举个例子:
public class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
public bool Equals(Money other)
{
if (other is null) return false;
return Amount == other.Amount && Currency == other.Currency;
}
public override bool Equals(object obj) => Equals(obj as Money);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}
你看,这个 Money 类没有ID,构造函数里就把值定死了,后面只能读不能改。这就是值对象的典型写法。
我的习惯:值对象一定要重写 Equals 和 GetHashCode。否则 EF Core 在跟踪变更时可能会出问题。我曾经因为这个踩过坑,查了两天才发现是值对象比较逻辑不对。
3.3 复杂类型配置:EF Core 怎么存值对象
EF Core 5.0 之后,值对象的配置变得简单多了。主要有两种方式:
3.3.1 作为自有实体类型(Owned Entity Types)
这是最常用的方式。用 [Owned] 特性或者 Fluent API 配置:
// 方式一:用特性
[Owned]
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string ZipCode { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public Address HomeAddress { get; set; }
}
// 方式二:用 Fluent API
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>().OwnsOne(c => c.HomeAddress);
}
这样配置后,EF Core 会把 Address 的字段直接映射到 Customers 表里,字段名变成 HomeAddress_Street、HomeAddress_City 这种格式。
3.3.2 值集合怎么办?
如果一个实体有多个同类型的值对象,比如一个订单有多个「订单项」,那就需要用 OwnsMany:
modelBuilder.Entity<Order>().OwnsMany(o => o.OrderItems, item =>
{
item.WithOwner().HasForeignKey("OrderId");
item.ToTable("OrderItems");
});
这种情况下,EF Core 会为值对象单独建一张表,但不会生成独立的主键——它会用「外键+序号」作为联合主键。
注意:值对象集合不能延迟加载。如果你用了 OwnsMany,EF Core 默认会立即加载所有子项。这在数据量大时可能影响性能。我建议在查询时用 .Include() 显式控制。
3.4 实体拆分:一个实体拆成多张表
有时候,一个实体太大了,比如「用户」表里有几十个字段,但有些字段(比如「个人简介」「头像URL」)访问频率很低。这时候可以考虑实体拆分。
EF Core 支持两种拆分方式:
3.4.1 表拆分(Table Splitting)
把一张表拆成多个实体,但共享同一张物理表:
modelBuilder.Entity<User>().ToTable("Users");
modelBuilder.Entity<UserProfile>().ToTable("Users");
modelBuilder.Entity<User>(entity =>
{
entity.HasOne(u => u.Profile).WithOne()
.HasForeignKey<UserProfile>(p => p.UserId);
});
这样 User 和 UserProfile 都映射到 Users 表,但你可以分别查询它们。适合把「高频字段」和「低频字段」分开。
3.4.2 垂直拆分(Vertical Splitting)
把实体的部分属性映射到另一张表,通过一对一关系关联:
modelBuilder.Entity<User>(entity =>
{
entity.ToTable("Users");
entity.OwnsOne(u => u.Settings, settings =>
{
settings.ToTable("UserSettings");
});
});
这种方式适合把「可选字段」或「扩展字段」单独存放。我在做电商项目时,就把「商品详情」这种大文本字段单独拆了一张表,查询列表时性能提升很明显。
3.5 继承映射:TPH、TPT、TPC 怎么选
继承映射是 ORM 里的老话题了。EF Core 支持三种策略:
| 策略 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| TPH(一张表) | 所有子类存同一张表,用「鉴别器」区分 | 查询简单,不需要 JOIN | 表字段多,会有空值 |
| TPT(每类一张表) | 每个子类一张表,共享父类字段 | 结构清晰,没有空值 | 查询需要 JOIN,性能差 |
| TPC(每具体类一张表) | 每个子类一张表,包含所有字段 | 查询快,没有 JOIN | 字段重复,修改父类要改多张表 |
我个人习惯:默认用 TPH。除非你有非常明确的理由,比如子类之间字段差异太大,或者查询性能要求极高。
配置 TPH 很简单:
modelBuilder.Entity<Payment>()
.HasDiscriminator<string>("PaymentType")
.HasValue<CreditCardPayment>("CreditCard")
.HasValue<PayPalPayment>("PayPal");
EF Core 会在表中自动加一列 PaymentType,用来区分是哪种子类。
避坑指南:我曾经在一个项目里用了 TPT,结果查询一个父类列表时,EF Core 生成了 5 个 LEFT JOIN。数据量一上去,页面直接超时。后来改成 TPH,查询时间从 8 秒降到了 0.3 秒。所以,除非你真的需要,否则别碰 TPT。
3.6 实战建议:什么时候用什么
说了这么多,咱们总结一下:
- 有ID、有生命周期 → 实体。比如订单、用户、商品。
- 没ID、只描述属性 → 值对象。比如地址、金额、颜色。
- 实体太大、字段访问频率差异大 → 考虑实体拆分。
- 有继承关系、子类差异不大 → 用 TPH。
- 子类差异极大、查询模式固定 → 可以考虑 TPC。
嗯,这一章的内容就到这。下一章咱们聊「导航属性与关系配置」,到时候会讲到一对一、一对多、多对多怎么配才高效。到时候见。
公众号:蓝海资料掘金营,微信deep3321