第三章:实体与值对象——实体类设计原则、值对象模式、复杂类型配置、实体拆分与继承映射

好,咱们进入第三章。这一章聊的是实体与值对象,说白了就是「哪些东西该单独建表,哪些东西该内嵌进去」。我见过太多项目,一上来就把所有字段都塞进一个大实体里,结果后期改起来想死的心都有。嗯,咱们今天就把这个事彻底理清楚。

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,构造函数里就把值定死了,后面只能读不能改。这就是值对象的典型写法。

我的习惯:值对象一定要重写 EqualsGetHashCode。否则 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_StreetHomeAddress_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);
});

这样 UserUserProfile 都映射到 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