Skip to content
Go back

EF Core 的现代映射玩法

很多团队一说起 EF Core,脑子里还是那张老图:实体、主键、外键、Include,再加一点迁移。能跑,没问题,但视野会变窄。

麻烦就在这里。你的领域模型不只会长出“实体之间的关系”,它还会长出值对象、原始类型集合、只读投影、拆开的表结构、临时扩展字段,甚至那种根本不想专门写 CLR 类型的字典式数据。要是还拿十年前那套映射思路硬套,代码会越来越别扭。

Ricardo Peres 这篇文章有意思的地方,不是又列了一遍 API 名单,而是把 EF Core 这些年补齐的映射能力放在一条线上看。你会发现,EF Core 早就不是“把类映成表”这么简单了。

不是所有对象都该当实体

文章一开头先把对象分成了三类,这个切分很有用。

一类是单值类型,比如 intstringGuidDateTime。一类是有身份的对象,比如 CustomerOrderProduct。还有一类经常被忽略,它们也有结构,也有多个属性,但没有独立身份,比如 AddressCoordinate

这第三类对象,过去在 EF Core 里一直有点尴尬。你可以勉强把它们当实体,也可以靠转换器把它们塞成字符串,可两种办法都不自然。前者把不该有身份的东西硬做成身份对象,后者又把查询能力一起埋了。

这就是现代映射真正解决的问题:让模型语义和数据库映射别互相拖后腿。

复杂属性和拥有实体,差别不只是 API 名字

如果一个 Customer 只有一个地址,而且你根本不关心地址自己的身份,那它更像值对象,不像实体。

public class Customer
{
    public int Id { get; set; }
    public Address Address { get; set; }
}

public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string Country { get; set; }
    public string POBox { get; set; }
}

到了 EF Core 8 之后,ComplexProperty 给这类模型一个更干净的位置:

modelBuilder.Entity<Customer>()
    .ComplexProperty(x => x.Address);

它的含义很明确:Address 不是独立实体,没有自己的表,没有自己的键,它只是 Customer 的一部分。落库时,地址里的字段会直接展开到 Customer 表里。

很多人会问,那和 OwnsOne 有什么区别?区别在语义。

拥有实体(owned entity)虽然也能共享同一张表,但它本质上还是实体语义,哪怕键是隐藏的。官方文档也专门提醒过,同一个实例被多个拥有导航共享时,跟踪和保存会出问题。复杂属性就没有这层包袱,它更像真正的值对象。

文章里这个判断我很认同:你关心“值”,就用复杂属性;你关心“依附于聚合根但仍有实体语义”,再考虑拥有实体。名字看起来像亲戚,脾气差很多。

一旦是集合,存储策略立刻变了

单个复杂属性展开成列,很自然。可一旦 Customer 有多个地址,事情就没那么直给了。

public class Customer
{
    public int Id { get; set; }
    public List<Address> Addresses { get; set; } = [];
}

在文章假定的 EF Core 10 场景里,这类复杂集合可以直接放进 JSON 列:

modelBuilder.Entity<Customer>()
    .ComplexCollection(x => x.Addresses, options =>
    {
        options.ToJson();
    });

这里有个很现实的前提:数据库得支持 JSON 列,SQL Server 2025 这类新版本才会比较顺手,兼容级别也得跟上。老环境想照搬,十有八九会撞墙。

如果你用的是拥有实体集合,选择会更多一点。你可以继续放到拥有者表的 JSON 列里,也可以拆去单独的表:

modelBuilder.Entity<Customer>()
    .OwnsMany(x => x.Addresses, options =>
    {
        options.HasKey("Id");
    });

这就是复杂属性和拥有实体的第二层差异。复杂属性强调“它只能是拥有者内部的一部分”;拥有实体还能进一步演化成带独立存储形态的依赖对象。

选哪一个,不是看哪个更新潮,而是看你的对象到底有没有身份边界。

原始类型集合终于不用再手搓转换器

以前只要模型里出现 List<string>string[]List<DateOnly> 这种字段,很多人的第一反应都是:哦,又要写 ValueConverter 了。

现在不用急着受苦了。

public class Product
{
    public int Id { get; set; }
    public List<string> Tags { get; set; } = [];
}

EF Core 现在能直接映射这类原始类型集合,通常会放进 JSON 列,而且还能查询:

ctx.Products
    .Where(x => x.Tags.Contains("blue"))
    .ToList();

这类能力看起来像“省了点配置”,实际上影响很大。因为一旦框架知道集合里的元素类型,它就能生成更像样的 SQL。官方文档里举过不少例子,像 SQL Server 会把参数数组转成 JSON,再配合 OPENJSON 做过滤和比较;SQLite 则会用 json_each->> 这类 JSON 操作。

不过也别一激动就把一切都塞成原始集合。标签、日期片段、小型枚举列表很适合。真正需要独立生命周期、需要约束、需要 join 的数据,老老实实建实体。能映射,不代表该这么建模。

视图、SQL 和函数,不必总让实体背锅

有些数据根本不是为了写回去,它们就是查询投影。

比如你想把 CustomerOrderProduct 聚成一个只读结果:

public record OrderCustomer(
    string CustomerName,
    DateTime OrderTimestamp,
    int ProductCount);

这时候强行给它做完整实体,经常是在给自己找麻烦。EF Core 提供了三条路:映射到视图、映射到一段 SQL、映射到表值函数。文章里分别用了 ToViewToSqlQueryToFunction

modelBuilder.Entity<OrderCustomer>()
    .ToView("OrderCustomer")
    .HasNoKey();

HasNoKey 很关键,它等于在告诉 EF Core:别追踪,别更新,别幻想这个东西能 SaveChanges

这类能力特别适合报表、后台列表、聚合读取模型。很多时候你真正需要的不是“更多实体”,而是一个说得清楚的只读模型。读模型和写模型别总绑成一坨,系统会轻松很多。

拆表这件事,EF Core 现在也玩得很熟

文章后半段讲了两个经常被忽略的能力:table splitting 和 entity splitting。

先看 table splitting。它的意思是,同一张表拆成两个实体来映射。比如 Order 只保留核心信息,OrderDetail 放次要细节。两者共用同一行数据,但在代码层面分成两个对象。这样你在大部分路径里只碰轻量对象,重信息只在需要时才进来。

modelBuilder.Entity<Order>(x =>
{
    x.ToTable("Order");
});

modelBuilder.Entity<OrderDetail>(x =>
{
    x.ToTable("Order");
    x.HasOne(o => o.Order)
        .WithOne(o => o.Detail)
        .HasForeignKey<OrderDetail>(o => o.Id);
});

反过来,entity splitting 是把一个实体拆到多张表里。这个适合老库,也适合那种业务上永远成套出现、但物理上已经分表的数据结构。

modelBuilder.Entity<Order>(x =>
{
    x.ToTable("Order")
        .SplitToTable("OrderDetail", y =>
        {
            y.Property(o => o.DispatchDate);
            y.HasOne(o => o.Customer).WithMany();
            y.HasMany(o => o.Products).WithMany();
        });
});

这两种映射都不是“炫技功能”。碰到遗留数据库、宽表、冷热字段分离、读写路径差异明显的场景,它们很实用。你不用为了迁就表结构,把领域模型写得像数据库注释。

阴影属性和索引器属性,适合那些你不想暴露出来的数据

有些列就是不想挂在实体公开 API 上,比如 LastUpdated、软删除标记、某些系统字段。这时候阴影属性(shadow property)很顺手:

builder.Property<DateTime?>("LastUpdated")
    .HasDefaultValueSql("GETUTCDATE()")
    .ValueGeneratedOnAddOrUpdate();

你仍然能在跟踪器里访问它,也能在查询里用 EF.Property

var query = ctx.Products
    .Where(x => EF.Property<DateTime>(x, "LastUpdated").Year == 2025);

如果你希望实体本身像一个可扩展字典,那就可以走索引器属性:

builder.IndexerProperty<string>("Colour");
builder.IndexerProperty<string>("Make");

配合 CLR 索引器,写法会很直接:

public class Product
{
    private readonly Dictionary<string, object> _data = new();

    public object this[string key]
    {
        get => _data[key];
        set => _data[key] = value;
    }
}

这种设计很适合“字段集合会变,但你又不想每次都改实体类”的场景。动态商品属性、可配置扩展项、半结构化后台数据,都能用。

属性包实体,连 CLR 类都可以不写

再往前一步,EF Core 甚至允许你把一个实体直接建成 Dictionary<string, object>。这就是 property bag entity type,也叫 shared-type entity。

public class Context : DbContext
{
    public DbSet<Dictionary<string, object>> KeyValuePairs =>
        Set<Dictionary<string, object>>("KeyValuePairs");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
            "KeyValuePairs",
            options =>
            {
                options.Property<int>("Id");
                options.Property<string>("A");
                options.Property<int>("B");
                options.Property<DateTime>("C");
                options.HasKey("Id");
            });
    }
}

这听起来有点野,但在某些场景真的有用。比如后台导入表、弱结构化扩展模型、临时适配外部数据源。

当然,它也不是银弹。官方文档明确写了限制:只有 Dictionary<string, object> 受支持,继承不支持,索引器导航不支持,很多强类型体验也没了。能用,但别把它当默认方案。你要的是弹性,不是把类型系统整个请出门。

什么时候该用哪一种

真到设计模型的时候,可以把选择压缩成下面这张表:

场景更合适的做法
没有身份、只表达值ComplexProperty / ComplexCollection
依附于聚合根,但希望保留实体语义OwnsOne / OwnsMany
少量原始值集合原始类型集合
只读报表或聚合结果ToView / ToSqlQuery / ToFunction
一张表拆成多个对象Table splitting
一个对象拆到多张表Entity splitting
不想暴露系统字段Shadow properties
想让实体支持动态扩展键值Indexer properties
连 CLR 类型都不想建Property bag entity types

这张表背后其实只有一个问题:你想表达的到底是身份、值,还是投影?一旦这个问题答对了,映射方式基本就不会离谱。

EF Core 已经不是“轻量版 ORM”了

文章最后提到,EF Core 现在已经补上了很多过去更像 NHibernate 专长的能力。我觉得这话不夸张。

它当然还不是全能选手,也不是每一种映射都该拿出来用。但如果你还把 EF Core 理解成“只会普通 CRUD 的 ORM”,那判断会落后很多。复杂属性、JSON、表拆分、属性包这些能力一旦用对,建模自由度会高不少。

模型该表达业务,不该只是数据库的投影。EF Core 现在给你的工具,已经够把这件事做得更像样了。

参考


Tags


Previous

Async 不等于可扩展性

Next

模块化单体里,模块之间到底该怎么通信?