5.2 EF Core 建模与迁移实战 建模三件套:实体类描述结构(表、列、关系),DbContext 聚合成一个会话单元(身份映射加变更跟踪),迁移把模型改动序列化成数据库脚本。三件套让"改库表"变成"改代码加生成迁移",结构管理从此有了版本历史。 上一节说服自己用 ORM 之后,本节把模型立起来。观察哨的观察对象是三样东西:实体怎么被约定识别、上下文在请求生命周期里怎么活、迁移命令链怎么走。走完本节,第 3、4 章页面里的仓储占位就能换成真货。 实体建模:约定、注解与流式配置 EF Core 认实体主要靠约定:Id 或类名加 Id 的属性是主键,引用导航属性推断外键,集合属性推断一对多。
建模三件套:实体类描述结构(表、列、关系),DbContext 聚合成一个会话单元(身份映射加变更跟踪),迁移把模型改动序列化成数据库脚本。三件套让"改库表"变成"改代码加生成迁移",结构管理从此有了版本历史。
上一节说服自己用 ORM 之后,本节把模型立起来。观察哨的观察对象是三样东西:实体怎么被约定识别、上下文在请求生命周期里怎么活、迁移命令链怎么走。走完本节,第 3、4 章页面里的仓储占位就能换成真货。
EF Core 认实体主要靠约定:Id 或类名加 Id 的属性是主键,引用导航属性推断外键,集合属性推断一对多。约定之上,简单规则用数据注解,复杂规则用流式配置:
// 订单实体:约定为表 Orders,注解补充规则 public class Order { public int Id { get; set; } // 约定:主键,自增 public int CustomerId { get; set; } // 外键(与导航属性同名) public Customer Customer { get; set; } = null!; // 引用导航 [Column(TypeName = "decimal(10,2)")] public decimal Total { get; set; } // 注解:精度,避免存储舍入 [Timestamp] public byte[] RowVersion { get; set; } = []; // 乐观并发标记(第 6 章用到) public List<OrderItem> Items { get; set; } = new(); // 集合导航:一对多 } // 流式配置:复杂规则集中在一处(替代部分注解能力,可读性更好) public class ShopContext : DbContext { public DbSet<Order> Orders => Set<Order>(); public DbSet<Customer> Customers => Set<Customer>(); public DbSet<Product> Products => Set<Product>(); protected override void OnModelCreating(ModelBuilder b) { b.Entity<Product>(e => { e.Property(p => p.Name).HasMaxLength(40).IsRequired(); e.HasIndex(p => p.Name); // 搜索列建索引,5.3 节会感谢它 }); } }
DbSet 是上下文的"窗口清单"——出现在这里的类型才算模型的一部分。三个约定要点值得敲黑板:主键类型决定生成策略(int 自增、Guid 客户端生成);导航属性与外键属性并存时关系最明确;能被约定推断的别写注解,能被注解写清的别上流式——配置量与项目复杂度同步增长就好。
上下文的正确用法来自 2.1 节的生命周期知识:注册为 Scoped,一个请求一个实例。它的两个内部机制决定了行为:身份映射让同一请求内同主键实体只有一个实例;变更跟踪让 SaveChanges 时只对"改过的属性"发更新列。
// 组装:注册上下文并给连接串(配置系统出连接串,2.3 节机制) builder.Services.AddSqlite<ShopContext>( builder.Configuration.GetConnectionString("Shop")); // 使用:控制器或页面模型里注入即可 public class ProductsController : Controller { private readonly ShopContext _db; public ProductsController(ShopContext db) => _db = db; public async Task<IActionResult> Index() { var items = await _db.Products.ToListAsync(); return View(items); } }
背景:为什么强调"一个请求一个"?我处理过一例线上事故:同事把 DbContext 注册成 Singleton"省对象"。现象:高峰期偶发"上一用户的订单出现在下一用户页面",且无法复现。定位:单例上下文里身份映射全进程共享,两个请求查同一主键拿到同一实例,数据互相串改。结果:改回 Scoped 后消失。解读:上下文不是线程安全的,它的并发保护就是"每请求隔离"这条 Scoped 边界——第 2 章的安全线在数据层再次应验。变式:后台任务要用上下文,就用 2.1 节的作用域工厂每次新开作用域,同一个套路。
模型改了,数据库结构怎么跟上?迁移是答案:每个迁移是"模型某次改动"的脚本化快照,按顺序应用。完整流程走一遍:
# 0. 安装命令行工具(一次性) dotnet tool install --global dotnet-ef # 1. 新增迁移:把当前模型与上个快照的差异生成代码 dotnet ef migrations add InitOrders # 输出关键行: # Build started... # Build succeeded. # Done. Build the project for warnings. # 2. 检视生成的迁移(Up 是升级脚本,Down 是回滚) # 打开新生成的迁移类,确认建表语句与索引符合预期 # 3. 应用到数据库 dotnet ef database update # 输出关键行: # Applying migration '20260825090000_InitOrders'. # Done. # 4. 生成生产 SQL 脚本(生产库不给直接连,交给 DBA) dotnet ef migrations script --idempotent -o up.sql # 幂等脚本:可重复执行,已应用的迁移自动跳过
迁移纪律三条,都是事故换来的:迁移一旦提交进主干,不改不删——修正靠新迁移叠加,改历史会让队友的库状态错乱;每次生成的迁移必须人工检视——约定推断偶尔会删列重建表,数据列上这种"重建"等于丢数据;生产环境永远用脚本走评审,命令行直连生产库只存在于传说里。
⚠️ 常见坑:两个分支各自加了迁移,合并后快照冲突。解法是合并后重新生成一个合并迁移;更根本的预防是"迁移文件尽快合入主干,别在长命分支上攒迁移"。
💡 关键直觉:把迁移当"数据库的结构版本库"对待——提交、评审、按序应用,纪律同代码完全一致。模型是源代码,库结构是构建产物,产物不该手改。