
很多人刚接触 EF Core 的时候,最容易写得出来的是:
DbSetAdd / SaveChanges但一到真实项目,最核心的问题马上出现:
[Key]、[ForeignKey]、[Required]、[NotMapped] 到底该什么时候用?这篇文章不讲“Hello World”,我们专门聚焦 EF Core 中数据库最常用的关系配置,并且遵循一个原则:
优先使用最简单、最直观、最适合团队协作的特性标注方式(Data Annotation)来讲解。
也就是说,这篇文章重点不是上来就堆 Fluent API,而是先帮你建立对关系映射的正确认知:
文章结构固定:
EF Core 在简单场景下非常友好。
比如你写一个 User:
public class User
{
public int Id { get; set; }
public string Name { get; set; }
}然后 DbContext 里加一个:
public DbSet<User> Users { get; set; }迁移、建库、增删改查都很顺。
但只要一进入真实业务模型,复杂度立刻上升。
比如:
这时候很多项目会出现几类典型问题:
比如你以为写的是一对一,结果数据库里变成了一对多。
原因通常是:
例如迁移后发现数据库里多出:
UserId1RoleId1OrderId1这通常意味着:
EF Core 没看懂你真正想表达的关系,于是根据约定又帮你“猜”了一套关系。
很多人以为 EF Core 的多对多就只要写两个集合导航属性就行。
实际上这取决于你的业务需求:
这是实战里非常容易踩坑的地方。
团队里经常会出现:
[ForeignKey]OnModelCreating 里又重新配一遍所以本文会先以 最简单的特性标注 为主线讲明白,帮助你建立“关系映射的本质认知”。
要想把关系配明白,必须先理解 EF Core 是怎么“猜”关系的。
EF Core 配置关系主要依赖三种方式:
EF Core 会根据命名规则自动推断。
例如:
Id 或 {EntityName}Id 会被识别为主键UserId + User 导航属性,通常会被识别为外键关系这也是为什么有些代码你没写任何特性,EF Core 也能工作。
也就是本文重点。
常见特性包括:
[Key][Required][ForeignKey][NotMapped][MaxLength][Table][Column]特性的好处是:
在 OnModelCreating 中写:
modelBuilder.Entity<Order>()
.HasOne(o => o.User)
.WithMany(u => u.Orders)
.HasForeignKey(o => o.UserId);它比特性更强大,适用于复杂映射。
无论是一对一、一对多还是多对多,本质都离不开三个概念:
唯一标识一行数据。
一张表通过某个字段引用另一张表的主键。
让你在代码层面可以从一个实体访问关联实体。
比如:
public int UserId { get; set; }
public User User { get; set; }其中:
UserId 是外键字段User 是导航属性数据库真正维护关系的是 外键约束。
导航属性只是 EF Core 为了对象编程体验提供的“代码入口”。
也就是说:
数据库关系的底层是真实字段约束,EF Core 关系的上层是导航属性。
因为一对一不是光靠“两个实体互相引用”就能成立的。
例如:
public class User
{
public int Id { get; set; }
public UserProfile Profile { get; set; }
}
public class UserProfile
{
public int Id { get; set; }
public User User { get; set; }
}你觉得这看起来像一对一。
但从数据库角度讲,它还缺少一个非常关键的信息:
谁持有外键?这个外键是不是唯一?
如果外键不唯一,那数据库层面其实仍然允许一对多。
所以一对一的关键,不是“互相有导航属性”,而是:
在最常见的设计里,一对一通常有两种方式:
子表主键同时也是外键。
子表单独有外键列,但该列必须唯一。
对于“最简单特性标注”的讲法,我们通常更推荐理解 共享主键一对一,因为它语义最清晰。
下面所有示例都基于:
先创建一个项目:
dotnet new console -n EfCoreRelationDemo
cd EfCoreRelationDemo
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.DesignAppDbContext.csusing Microsoft.EntityFrameworkCore;
namespace EfCoreRelationDemo;
public class AppDbContext : DbContext
{
public DbSet<User> Users => Set<User>();
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<Role> Roles => Set<Role>();
public DbSet<UserRole> UserRoles => Set<UserRole>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(
"Server=localhost;Database=EfCoreRelationDemo;Trusted_Connection=True;TrustServerCertificate=True");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 这里暂时只补充多对多主键配置
modelBuilder.Entity<UserRole>()
.HasKey(ur => new { ur.UserId, ur.RoleId });
}
}注意:因为复合主键无法仅靠
[Key]特性优雅定义,所以多对多中间表这里会用到一点 Fluent API。 这不影响“主体讲解仍以最简单特性为主”。
一个用户有一份详细资料:
UserUserProfile例如:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace EfCoreRelationDemo;
public class User
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(50)]
public string UserName { get; set; } = string.Empty;
// 一对一导航属性
public UserProfile? Profile { get; set; }
}
public class UserProfile
{
// 共享主键:主键同时也是外键
[Key]
[ForeignKey(nameof(User))]
public int Id { get; set; }
[MaxLength(100)]
public string? Address { get; set; }
public DateTime? Birthday { get; set; }
// 反向导航属性
public User User { get; set; } = null!;
}这里最关键的是这一句:
[ForeignKey(nameof(User))]
public int Id { get; set; }意思是:
UserProfile.Id 本身是主键User 的外键这就实现了 共享主键一对一。
因为它天然表达了:
UserProfile不能脱离User单独存在,而且一个User最多只有一个UserProfile
数据库层面会更干净:
Users.IdUserProfiles.Id(既是 PK,也是 FK)不会额外多一个 UserId 字段。
using EfCoreRelationDemo;
using var db = new AppDbContext();
db.Database.EnsureCreated();
var user = new User
{
UserName = "alice",
Profile = new UserProfile
{
Address = "上海市浦东新区",
Birthday = new DateTime(1995, 5, 20)
}
};
db.Users.Add(user);
db.SaveChanges();
Console.WriteLine("一对一数据保存成功");因为:
User 是聚合根式入口Profile 是导航属性SaveChanges() 时会按依赖顺序插入先插 User,拿到 Id,再插 UserProfile。
一个用户可以有多个订单,一个订单只属于一个用户。
这是一对多里最经典、最常见的场景。
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace EfCoreRelationDemo;
public class Order
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(30)]
public string OrderNo { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
// 外键字段
[ForeignKey(nameof(User))]
public int UserId { get; set; }
// 多对一导航属性:多个订单属于一个用户
public User User { get; set; } = null!;
}同时扩展 User:
using System.ComponentModel.DataAnnotations;
namespace EfCoreRelationDemo;
public partial class User
{
// 一对多导航属性:一个用户有多个订单
public List<Order> Orders { get; set; } = new();
}如果你不想用
partial,也可以直接把Orders写回原始User类里。 这里仅为演示拆分。
核心代码:
[ForeignKey(nameof(User))]
public int UserId { get; set; }
public User User { get; set; } = null!;它表达的是:
Order.UserId 是外键Order.User 是引用导航User.Orders 是集合导航这就是一对多最标准的写法。
很多初学者喜欢只写导航属性:
public User User { get; set; }让 EF Core 自动生成影子外键(Shadow FK)。
虽然能工作,但不推荐在真实项目这么做,原因有三点:
你在代码里看不到真实外键字段。
查询、更新、日志输出时都不直观。
实际业务里经常直接传 UserId,而不是整个 User 对象。
所以建议:
一对多关系里,外键字段尽量显式声明。
using EfCoreRelationDemo;
using var db = new AppDbContext();
var user = new User
{
UserName = "bob",
Orders = new List<Order>
{
new Order
{
OrderNo = "ORD2024001",
TotalAmount = 199.00m
},
new Order
{
OrderNo = "ORD2024002",
TotalAmount = 299.00m
}
}
};
db.Users.Add(user);
db.SaveChanges();
Console.WriteLine("一对多数据保存成功");一个订单有多个订单项。
比如:
ORD2024001using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace EfCoreRelationDemo;
public class OrderItem
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(100)]
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
[ForeignKey(nameof(Order))]
public int OrderId { get; set; }
public Order Order { get; set; } = null!;
}同时扩展 Order:
using System.ComponentModel.DataAnnotations;
namespace EfCoreRelationDemo;
public partial class Order
{
public List<OrderItem> Items { get; set; } = new();
}因为订单项本身是业务实体,不是简单关联。
它有自己的属性:
也就是说,OrderItem 不是“订单和商品的纯关联表”,而是一个真实业务对象。
这点非常重要。
很多人看到“订单包含多个商品”,下意识会去设计多对多,但实际上:
只要中间对象有独立业务属性,它通常就应该被建模成独立实体,而不是隐式多对多。
using EfCoreRelationDemo;
using var db = new AppDbContext();
var order = new Order
{
OrderNo = "ORD2024003",
TotalAmount = 398.00m,
UserId = 1,
Items = new List<OrderItem>
{
new OrderItem
{
ProductName = "机械键盘",
Quantity = 1,
UnitPrice = 199.00m
},
new OrderItem
{
ProductName = "游戏鼠标",
Quantity = 1,
UnitPrice = 199.00m
}
}
};
db.Orders.Add(order);
db.SaveChanges();
Console.WriteLine("订单与订单项保存成功");一个用户可以拥有多个角色,一个角色也可以分配给多个用户。
例如:
这就是标准多对多。
虽然 EF Core 支持隐式多对多,但真实项目里我更推荐:
显式定义中间实体。
原因很简单:
using System.ComponentModel.DataAnnotations;
namespace EfCoreRelationDemo;
public class Role
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(50)]
public string RoleName { get; set; } = string.Empty;
public List<UserRole> UserRoles { get; set; } = new();
}扩展 User:
namespace EfCoreRelationDemo;
public partial class User
{
public List<UserRole> UserRoles { get; set; } = new();
}中间实体:
using System.ComponentModel.DataAnnotations.Schema;
namespace EfCoreRelationDemo;
public class UserRole
{
[ForeignKey(nameof(User))]
public int UserId { get; set; }
public User User { get; set; } = null!;
[ForeignKey(nameof(Role))]
public int RoleId { get; set; }
public Role Role { get; set; } = null!;
}[Key]?因为这里主键通常是复合主键:
UserId + RoleId而 [Key] 特性更适合单字段主键。
EF Core 当前对复合主键更推荐在 OnModelCreating 里配:
modelBuilder.Entity<UserRole>()
.HasKey(ur => new { ur.UserId, ur.RoleId });因为中间实体上的关系本身已经通过最简单的特性标明了:
[ForeignKey(nameof(User))][ForeignKey(nameof(Role))]只是 复合主键这个点 用特性不够优雅,所以借助一行 Fluent API。
这在工程实践里是完全合理的。
using EfCoreRelationDemo;
using var db = new AppDbContext();
var adminRole = new Role { RoleName = "Admin" };
var editorRole = new Role { RoleName = "Editor" };
var user = new User
{
UserName = "charlie",
UserRoles = new List<UserRole>
{
new UserRole { Role = adminRole },
new UserRole { Role = editorRole }
}
};
db.Users.Add(user);
db.SaveChanges();
Console.WriteLine("多对多数据保存成功");EF Core 支持直接这样写:
public class User
{
public List<Role> Roles { get; set; } = new();
}
public class Role
{
public List<User> Users { get; set; } = new();
}EF Core 会自动生成中间表。
这当然很方便,但问题是:
比如你后面想记录:
AssignedAtAssignedByStatus这时就必须改成显式中间实体。
很多团队协作时,看不到中间实体,不利于统一理解。
比如权限系统、多租户、软删除等,中间表经常需要更多控制。
所以我的建议是:
学习阶段可以了解隐式多对多,但真实业务建议优先显式中间实体。
下面把本文最常出现的特性做一次系统说明。
[Key]指定主键。
[Key]
public int Id { get; set; }如果你的命名就是 Id 或 {EntityName}Id,其实 EF Core 通常能自动识别。
但显式加 [Key] 有两个好处:
[Required]表示不能为空。
[Required]
public string UserName { get; set; } = string.Empty;它同时表达两层意思:
NOT NULL对于 C# 可空引用类型开启后,像:
public string UserName { get; set; } = string.Empty;很多时候 EF Core 已经能推断为必填。
但 [Required] 依然有文档化价值。
[ForeignKey]明确指定哪个字段是外键,或某导航属性对应的外键。
[ForeignKey(nameof(User))]
public int UserId { get; set; }因为它能显式告诉 EF Core:
这个字段对应的是
User导航属性的外键
这有助于避免 EF Core 因命名不规范而推断错误,防止生成多余字段。
[MaxLength]限制字符串最大长度。
[MaxLength(50)]
public string UserName { get; set; } = string.Empty;原因非常实际:
[NotMapped]表示该属性不映射到数据库。
[NotMapped]
public string DisplayName => $"{FirstName} {LastName}";有些属性只是计算属性、视图字段,不需要入库。
错误倾向:
public User User { get; set; }没有 UserId。
虽然 EF Core 可以用影子外键处理,但不推荐。
推荐始终显式声明外键字段。
很多人误以为:
public UserProfile Profile { get; set; }
public User User { get; set; }就是一对一。
其实不是。
必须保证数据库层面具备“一对一约束”,最简单方式就是共享主键。
所以别因为图省事就上来用隐式多对多。
尤其是这些场景:
中间表往往很快就会扩展字段。
建议团队统一规范:
优先 Data Annotation
用 Fluent API
不要同一个关系在两个地方都写,容易造成困惑。
例如:
建模时要先看:
这个“中间对象”是不是有自己的业务意义?
有的话,它就不是“纯关系表”。
很多新手在 EF Core 里写关系后,最喜欢这样查:
var users = db.Users
.Include(u => u.Orders)
.ThenInclude(o => o.Items)
.ToList();这在数据量小时没问题,但一旦层级深、数据大,就会有:
列表页优先投影查询:
var result = db.Users
.Select(u => new
{
u.Id,
u.UserName,
OrderCount = u.Orders.Count
})
.ToList();不要无脑 Include 全家桶。
比如:
OrderItem 应该是实体UserRole 在权限系统中也最好是实体设计时优先考虑业务语义,再考虑 EF Core 怎么映射。
你要求的是“用最简单的特性来标注”,这完全合理,也最适合学习和多数业务场景。
但要明确边界:
最好的工程实践不是“只用一种”,而是:
简单的用特性,复杂的交给 Fluent API。
EF Core 的关系配置,表面上看是在写实体类,实际上你做的是两层映射:
本文重点用最简单、最常用的特性讲了三种核心关系:
推荐共享主键方式,语义最清晰:
最常见,也最实用:
真实项目优先显式中间实体:
同时你也应该记住一个核心原则:
导航属性只是面向对象的便利,真正定义关系的是数据库中的主键与外键约束。
如果你把这个底层逻辑理解透了,EF Core 的各种关系配置就不会再只是“背语法”,而是真正理解为什么这么设计。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。