ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Visual Studio中EF Core完整实战:Code First迁移与CRUD操作指南

Visual Studio中EF Core完整实战:Code First迁移与CRUD操作指南 最近做数据访问层的重构正好把 Visual Studio 里跑 EF 框架Entity Framework的整套流程从头到尾捋了一遍。从环境准备到 Code First 迁移再到实际踩过的几个坑这篇就把完整过程写出来给正准备上手 EF 或者被 EF 折腾得头疼的朋友一个可以直接照着做的参考。我用的是 Visual Studio 2022 .NET 8 EF Core 8这套组合是目前最常见的搭配。如果你用的是 VS 2019 或者老一点的 .NET 6、EF Core 6操作流程基本一致就是工具窗口的样式略有差别。下面所有步骤都基于这个环境说明展开。1. 项目准备与EF框架版本选型1.1 为什么优先选EF Core而不是EF6刚开始接触 EF 的人容易懵因为市面上存在两套差异很大的框架EF6 和 EF Core。EF6 是 .NET Framework 时代的产物最早可以追溯到 2008 年跟着 .NET Framework 4.x 混了很多年在很多老项目里依然活得好好的。EF Core 是微软 2016 年开始重写的跨平台版本从 .NET Core 时代一路迭代到现在的 .NET 8/9基本上已经成了新项目的默认选择。我的建议很简单如果是新项目尤其跑在 .NET Core 3.1 以上版本直接用 EF Core。如果是维护老项目项目本身跑在 .NET Framework 4.5 上那 EF6 也没必要强行迁移稳定为王。EF Core 这几年的发展速度很快从 EF Core 5 开始功能上已经和 EF6 对齐得差不多到了 EF Core 6 更是把一些老大难问题比如批处理、查询拆分、编译模型等做得相当成熟我这篇文章里涉及的所有实践都基于 EF Core 8。版本选型的另一个关键点是数据库。EF Core 通过数据库 Provider 来适配不同的数据库产品SQL Server 用官方包MySQL 用 Pomelo 或 Oracle 官方的 MySql.EntityFrameworkCorePostgreSQL 用 Npgsql.EntityFrameworkCore.PostgreSQLSQLite 也有官方支持。选型之前一定要先确认数据库类型再去 NuGet 找对应的 Provider别装错包。1.2 Visual Studio中创建项目的完整流程在 Visual Studio 2022 中新建项目选 ASP.NET Core Web API 或者控制台应用都可以核心差异在于依赖注入的支持程度。Web 项目天然支持 DI控制台和 WinForms 需要自己手动搭一下服务容器。这里我以 Web API 项目为例因为这个场景最典型。打开 Visual Studio 2022选择创建新项目搜索 ASP.NET Core Web API选择 C# 模板点击下一步。项目名称建议用有意义的命名比如OrderService而不是默认的WebApplication1。框架选择.NET 8.0长期支持认证类型选无Configure for HTTPS 勾不勾无所谓开发阶段用 HTTP 也行。创建完成后解决方案里大概有这些结构OrderService/ ├── Controllers/ ├── Models/ ├── Program.cs ├── appsettings.json └── OrderService.csproj1.3 NuGet包的安装与版本锁定实践EF Core 不是 Visual Studio 内置的一部分它是一组以 NuGet 包形式发布的类库。所以我需要先在项目中安装对应的包。打开工具 - NuGet 包管理器 - 管理解决方案的 NuGet 程序包在浏览选项卡下搜索并安装以下几个核心包PackageReference IncludeMicrosoft.EntityFrameworkCore.SqlServer Version8.0.8 /只需要安装 SqlServer 这个包就可以了它会自动把核心包Microsoft.EntityFrameworkCore和依赖项带进来。如果你用其他数据库就替换成对应的 Provider 包。另外建议额外安装一个开发期的工具包PackageReference IncludeMicrosoft.EntityFrameworkCore.Tools Version8.0.8 /这个包提供了Add-Migration、Update-Database等命令行工具是后面做数据库迁移的必需品。注意版本号一定要和主包一致混用不同版本经常会出现类型加载异常。提示安装完包之后打开视图 - 其他窗口 - 包管理器控制台确认默认项目选的是当前实体所在的项目。这个细节很多人忽略结果迁移命令死活找不到 DbContext。2. 模型设计、DbContext与数据库连接配置2.1 从实体类搭建领域模型EF 框架的核心思想是对象关系映射ORM简单说就是让我直接用 C# 的类去操作数据库表。这个类就叫实体类Entity一张表对应一个类一行数据对应一个对象。我以一个订单系统的场景来举例涉及两张经典的表订单表Orders和订单明细表OrderItems。订单和明细是一对多的关系。先创建实体类public class Order { public int Id { get; set; } public string OrderNo { get; set; } public DateTime CreatedAt { get; set; } public decimal TotalAmount { get; set; } public ListOrderItem Items { get; set; } new(); } public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public string ProductName { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } public Order Order { get; set; } }这里有几个核心约定属性Id会被自动识别为主键导航属性Items和Order会被识别为表之间的关联关系外键属性OrderId会跟导航属性Order自动匹配成一对多关系。这就是 EF 的约定优于配置原则绝大多数情况下可以不用加任何特性标注。2.2 DbContext的定义与生命周期管理有了实体类之后需要有一个DbContext类来统一管理。这个类相当于数据库会话的抽象负责查询、增删改、状态跟踪、事务管理等一系列操作。using Microsoft.EntityFrameworkCore; public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetOrder Orders { get; set; } public DbSetOrderItem OrderItems { get; set; } }这里的要点是构造函数接收DbContextOptionsAppDbContext这样在 ASP.NET Core 里就能用依赖注入非常优雅地把数据库配置传递进来。DbSetT属性就代表了数据库中的一张表EF 默认会按属性名生成表名也就是Orders和OrderItems。特别说一下 DbContext 的生命周期。在 Web 应用里DbContext必须按请求注册也就是每次 HTTP 请求创建一个新的实例请求结束就释放。这一点在Program.cs里配置注入的时候会体现出来千万别自己手动new一个 DbContext 当单例用那是极容易出并发问题的。2.3 连接字符串的配置与读取连接字符串是 EF 连接数据库的最基本信息。在 ASP.NET Core 里连接字符串一般放在appsettings.json里{ ConnectionStrings: { DefaultConnection: Server(localdb)\\MSSQLLocalDB;DatabaseOrderDb;Trusted_ConnectionTrue;MultipleActiveResultSetstrue;TrustServerCertificateTrue } }然后在Program.cs中注册服务。这一步是最关键的注册方式决定了整个应用怎么拿到数据库连接using Microsoft.EntityFrameworkCore; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection))); var app builder.Build();AddDbContextAppDbContext是注册 DbContext 到 DI 容器的标准方法。默认生命周期是Scoped也就符合我在上一节提到的一次请求一个实例原则。UseSqlServer是指定使用 SQL Server Provider如果是 MySQL 就是UseMySqlPostgreSQL 就是UseNpgsql。连接字符串里的MultipleActiveResultSetstrue值得多说一句。这个配置允许多个 DataReader 同时并行工作避免在遍历结果集的同时又执行查询时出现 There is already an open DataReader associated with this Command 的报错。虽然不是每次都会碰到但提前配置上省心。如果是控制台应用或者 WinForms 项目没有appsettings.json和依赖注入这套东西我一般直接在Program.cs或者App.config文件里配置。比如控制台程序可以这样写var optionsBuilder new DbContextOptionsBuilderAppDbContext(); optionsBuilder.UseSqlServer(Server(localdb)\\MSSQLLocalDB;DatabaseOrderDb;Trusted_ConnectionTrue;); using var context new AppDbContext(optionsBuilder.Options); // 业务逻辑说白了就是手动构造DbContextOptions再传给 DbContext原理和 DI 注入完全一样只是少了容器帮忙管理生命周期。2.4 用Fluent API和数据注解来精细控制映射EF 的约定能解决 80% 的映射问题但总有一些场景需要手动干预。比如数据库表名跟实体类名对不上、字段长度需要限制、某个属性不希望映射到数据库、索引的唯一性约束等。这时候有两种手段可以用数据注解Data Annotations和 Fluent API。数据注解是直接在实体类属性上加特性写法简单直观using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; [Table(t_order)] public class Order { [Key] public int Id { get; set; } [Required] [MaxLength(50)] public string OrderNo { get; set; } [Column(created_time)] public DateTime CreatedAt { get; set; } }Fluent API 则是把配置放在一个独立的地方通过重写DbContext.OnModelCreating方法实现protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityOrder(entity { entity.ToTable(t_order); entity.HasKey(o o.Id); entity.Property(o o.OrderNo) .IsRequired() .HasMaxLength(50); entity.Property(o o.CreatedAt) .HasColumnName(created_time); }); }我个人的习惯是简单约束用数据注解复杂的关联关系、索引、级联删除等用 Fluent API。Fluent API 的优势在于配置都集中在OnModelCreating一个地方项目大了一眼能看到所有规则有配置文件隔离感和统一感。数据注解的优势是快速直观属性旁边就能看到约束条件但会把关注点的代码混在一起。3. 数据库迁移从模型到数据库表的落地3.1 Code First模式与迁移的工作原理EF Core 提供了三种开发模式Database First数据库优先、Model First模型优先、Code First代码优先。目前最主流的是 Code First这也是我推荐大家使用的模式。Code First 的精髓是代码即数据库结构我先写好 C# 实体类和 DbContext然后让 EF 根据这些代码生成数据库表结构。所有表结构的变化增加列、修改类型、增加索引等都通过代码来表达这一点真的很有源码即文档的快感。但这里有个问题数据库表结构不能凭空变需要考虑版本管理。第一次新建表容易后续改了实体类怎么同步更新这就轮到迁移Migration机制出场了。迁移的本质是生成一系列 C# 文件每个文件代表一次数据库结构变更的快照。EF 对比当前模型和上一次迁移的模型差异计算出需要执行的增删改操作生成对应的代码和 SQL 脚本。这样当你执行Update-Database时EF 会把所有未应用的迁移按顺序执行把数据库更新到最新状态。更重要的是这些迁移文件可以提交到代码仓库团队成员拉取代码后执行一下命令就能同步表结构。3.2 第一次执行Add-Migration和Update-Database在包管理器控制台中执行第一条迁移命令Add-Migration InitialCreate这条命令会在项目里生成一个Migrations文件夹里面包含三个关键文件{时间戳}_InitialCreate.cs迁移的主要操作定义了Up方法执行变更和Down方法回滚变更{时间戳}_InitialCreate.Designer.cs包含了当前模型快照的元数据AppDbContextModelSnapshot.cs整个项目的模型快照后续每次迁移都会基于这个快照做差异对比执行完之后还需要把迁移应用到数据库Update-Database如果一切顺利打开 SSMSSQL Server Management Studio就能看到数据库OrderDb被创建里面有两张表Orders和OrderItems主键、外键、索引全部自动生成。EF 还会创建一张__EFMigrationsHistory表专门记录哪些迁移已经应用过这张表极其重要是迁移系统的账本。3.3 支持多环境的迁移脚本生成在实际生产环境中我一般不会直接执行Update-Database去改生产库而是把迁移脚本交给 DBA 审核后再执行。这时候需要用到Script-Migration命令Script-Migration -From InitialCreate -To Latest -Output migration_script.sql这条命令会把从InitialCreate到最新版本的 SQL 变更脚本导出到一个.sql文件里。DBA 可以在测试环境先跑一遍确认没问题再在生产库执行整个过程可审计、可回滚比直接执行Update-Database要稳妥得多。另外还有一个小技巧Update-Database -Verbose可以在执行时输出每条 SQL 语句调试的时候开一下非常有帮助。3.4 迁移过程中容易踩的版本管理坑迁移用久了会碰到两类比较高频率的问题第一类是迁移文件冲突。团队多人各自加了迁移就会出现两个人同时基于同一个快照生成迁移文件的情况。解决方法是先Update-Database或执行Remove-Migration让本地和目标库回到一致状态然后合并别人的迁移代码再重新生成。这里没有银弹核心原则是迁移文件必须遵循先拉取、后执行、再生成的顺序。第二类是删除迁移后数据库已经存在变更的情况。如果执行了Add-Migration但还没执行Update-Database可以直接Remove-Migration但如果已经更新了数据库再想删迁移就会报 The migration was applied to the database 的错。因为数据已经不可逆地变了这时候我通常的做法是执行Update-Database -Migration LastGoodMigration回滚到上一个迁移或者直接手动改库结构再同步迁移快照。4. 核心增删改查操作的使用过程4.1 基于LINQ的查询操作与延迟加载机制准备好了模型和数据库接下来就是日常最常用的 CRUD增删改查。EF Core 使用 LINQLanguage Integrated Query作为查询语言写起来非常接近原生 SQL 的思维但类型安全。查询订单按创建时间倒序var orders await context.Orders .Where(o o.CreatedAt startDate) .OrderByDescending(o o.CreatedAt) .ToListAsync();这里要特别注意一个关键机制——延迟执行Deferred Execution。Where、OrderBy这些方法只是构造了表达式树并没有立即执行查询。只有到了ToListAsync、FirstOrDefaultAsync、CountAsync这类触发执行的方法时LINQ 才会被翻译成 SQL 发到数据库执行。这就引出 EF 里一个著名的性能话题延迟加载Lazy Loading。如果我查询订单后访问它的Items导航属性EF 会再次向数据库发送查询来加载明细数据。默认情况下 EF Core 没有启用延迟加载导航属性只有在没有加载的情况下通过显式加载或预加载来填充。// 预加载Eager Loading一条 SQL 用 JOIN 带出明细 var orders await context.Orders .Include(o o.Items) .ToListAsync(); // 显式加载Explicit Loading查询主表后手动加载 var order await context.Orders.FirstAsync(); await context.Entry(order).Collection(o o.Items).LoadAsync();预加载用一次 JOIN 把数据取回来效率高但可能会产生重复数据笛卡尔积我通常在查询数量不多、关系不超过两层的场景下直接用。延迟加载写起来省事但会有 N1 查询的风险如果一个集合有 100 条数据就会产生 101 次数据库请求数据库连接活活被打死。建议开发初期直接关闭懒加载用预加载和显式加载替代。4.2 增加的两种姿势与SaveChanges细节插入数据是 EF 最让人省心的操作。常见的做法是构造实体对象Add到 DbContext然后调用SaveChangesAsyncvar order new Order { OrderNo ORD20250701-001, CreatedAt DateTime.Now, TotalAmount 99.90m }; order.Items.Add(new OrderItem { ProductName 内存条16GB, Quantity 1, UnitPrice 99.90m }); context.Orders.Add(order); await context.SaveChangesAsync();这里有个特别好的体验只要Order对象通过导航属性挂上了OrderItem即使我只调用了context.Orders.Add(order)EF 的变更追踪器也会自动把OrderItem标记为新增状态一起插入到数据库。不用手动逐个 Add 子表实体对开发者非常友好。SaveChangesAsync默认会开启一个隐式事务如果一次保存涉及多张表的写入比如同时插入订单和订单明细只要任何一条失败整个事务回滚不会出现订单创建了但明细丢了这种半成功状态。这是 ORM 框架带给我的核心价值之一我自己用原生 ADO.NET 写时要手包事务用 EF 就省了这一步。4.3 修改数据的两种状态管理与并发控制修改数据有两种常见方式。第一种是先从数据库查询出实体修改属性后调用SaveChangesAsync。这个方式最直观查询出来的实体处于Unchanged状态修改属性后状态变为ModifiedEF 在保存时只生成UPDATE语句更新被修改的列。var order await context.Orders.FirstAsync(o o.Id 1); order.TotalAmount 199.90m; await context.SaveChangesAsync();第二种是使用Update方法适用于已经拿到实体但来自不同上下文比如前端传过来的的情况order.TotalAmount 199.90m; context.Orders.Update(order); await context.SaveChangesAsync();Update方法有个特点它会把整个实体标记为Modified保存时所有列都会被更新包括那些其实没有修改的字段。如果你只想更新某个字段建议用第一种方式或者手动设置Entry.Property状态。此外如果数据库表里有并发控制字段通常是一列rowversionEF 可以配置为乐观并发控制保存时报并发冲突时抛出DbUpdateConcurrencyException我可以捕获这个异常做重试或提示用户。4.4 删除与物理删除、软删除的实践删除操作看起来简单var order await context.Orders.FirstAsync(o o.Id 1); context.Orders.Remove(order); await context.SaveChangesAsync();注意一个隐含行为如果Order有子表OrderItems且外键设置的是级联删除CascadeEF 会生成先删子表再删主表的 SQL。如果外键没有配置级联那必须先删除明细或手动清空导航属性集合否则 SQL 执行会因为外键约束失败而抛出异常。生产环境的真实项目里我其实不太推荐物理删除硬删除而是习惯用软删除。也就是在实体上加一个IsDeleted字段查询时统一过滤删除操作变成一次 UPDATEpublic class Order { public bool IsDeleted { get; set; } } // 操作习惯 order.IsDeleted true; await context.SaveChangesAsync();再配合全局查询过滤器从应用层面保证 SELECT 时永远看不到已删除的数据protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityOrder().HasQueryFilter(o !o.IsDeleted); }这样一个简单的配置就在不提升任何查询代码的情况下实现了软删除的全部逻辑。代价则是每次查询都会在 SQL 后面自动追加WHERE IsDeleted 0字段上必须建立索引否则数据量大了查询性能会受损。4.5 事务的显式控制与性能调优建议虽然SaveChangesAsync自带隐式事务但某些场景下需要手动控制事务边界。比如扣库存操作先检查库存够不够再扣减库存同时更新订单状态这就在逻辑上组成一个单元。这种情况下我使用await using var transaction await context.Database.BeginTransactionAsync(); try { // 扣库存 // 更新订单状态 await context.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }此外在做只读查询的时候用AsNoTracking()是一个小但有效的性能改进。常规查询时 EF 会对返回的每个实体做状态跟踪以便后续能自动检测变化并生成 UPDATE。但如果我只是把数据展示出来不需要修改这个跟踪就是纯浪费。加上AsNoTracking()可以让查询结果直接返回实体不挂到跟踪管理器上减少内存占用执行也更快var orders await context.Orders .AsNoTracking() .ToListAsync();另一个容易被忽略的点是分页。不要ToList()之后再用 LINQ 的Skip和Take做内存分页而是直接把分页逻辑放在 EF 的查询表达式中var page await context.Orders .OrderByDescending(o o.CreatedAt) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync();这样生成的 SQL 就是数据库级的OFFSET FETCH分页数据量大的时候差别是数量级的。5. 常见问题与排查技巧实录5.1 处理连接字符串和数据库Provider不匹配我碰到过的最频繁的报错是这样的System.InvalidOperationException: The connection string DefaultConnection cannot be found.主要原因通常是appsettings.json里的连接字符串键名和GetConnectionString的参数不一致或者配置文件没有正确复制到输出目录。检查appsettings.json的属性确保复制到输出目录是如果较新则复制。另一个报错是 Provider 不匹配System.InvalidOperationException: No database provider has been configured for this DbContext.这说明AddDbContext里没有调用UseSqlServer或对应的数据库方法DbContext 不知道去连什么数据库。排查思路就是回到Program.cs确认options.UseSqlServer(...)已经配置并且对应的 Provider 包已经安装。5.2 迁移相关的高频报错与修复策略迁移命令执行时报 No DbContext was found in assembly 是最常见的。原因通常是包管理器控制台当前的默认项目选错了或者 DbContext 所在的程序集和启动项目不一致。解决方法是确认默认项目指向包含 DbContext 的项目并且在Add-Migration时可以显式指定Add-Migration InitialCreate -Project OrderService.Data -StartupProject OrderService.Api这里的-Project指明包含迁移文件的项目-StartupProject指明包含 DI 配置的启动项目两者可以不同。数据库已存在但表结构不一致时执行Update-Database会报已有对象名冲突的错误。这是因为__EFMigrationsHistory表不完整EF 以为从未建过表而实际上表已经存在。如果是开发环境直接删库重来是最省事的Drop-Database Update-Database生产环境就不建议这么干了老老实实写脚本修数据或者把业务数据先导出再重建。5.3 性能问题的排查思路查询慢、内存增长、数据库连接打满这三个问题各有常见的背后原因日志阶段级别可以这样设置在Program.cs里添加builder.Logging.AddFilter(Microsoft.EntityFrameworkCore.Database.Command, LogLevel.Information);这样控制台就会输出每条生成的 SQL 语句一眼就能看到包含SELECT * FROM ...的 N1 查询或者因为漏了.Include导致多次往返的情况。连接耗尽型问题的排查思路一般是查数据库连接被保持没释放。确认 DbContext 已经注册为Scoped并且由容器管理避免手动new出大量实例却不释放。此外查询时如果不需要跟踪状态优先加AsNoTracking。5.4 不落库的幽灵查询与状态跟踪问题实际开发中还经常遇到一种让人摸不着头脑的情况我用同一个 DbContext 实例先查询了一个实体然后修改它的某个属性再查询同一条数据结果发现查询回来的数据不是数据库里的最新值而是被我改过的值。这是 EF Core 的状态跟踪机制在起作用。同一个上下文里同主键的实体只会在跟踪器里保留一个实例后续查询如果命中同一个主键EF 会直接返回跟踪器里的实体而不会重新从数据库读取。理解这个机制之后遇到这类不符合直觉的查询结果就知道怎么处理了。要么用AsNoTracking查询只读数据要么在查询后用context.Entry(entity).ReloadAsync()强制刷新。5.5 Visual Studio环境相关的连带问题在 VS 里写 EF 项目环境本身也会出状况。有段时间我刚装好 VS 2022打开一个老项目编译报错 无法找到 Visual Studio 2010 的生成工具平台工具集 v100后来确认是因为项目配置引用了旧版平台工具集VS 2022 默认没有安装。解决办法是右键项目 - 属性 - 常规 - 平台工具集改成 Visual Studio 2022 (v143)或者在 Visual Studio Installer 里勾选对应的旧版组件。这类问题常见于从老仓库拉下来的代码跟 EF 本身无关但会挡在前面让人寸步难行。热词里那句 unable to find suitable visual studio toolc 也是同一大类问题多半是 VS 的 C 桌面开发组件没有装全EF 项目如果依赖本地 C 库同样会受影响。打开 Visual Studio Installer把 .NET 桌面开发 和 使用 C 的桌面开发 工作负载补齐即可。6. 个人实操总结整套 EF 框架在 Visual Studio 里的使用过程本质上可以浓缩成一条主线建立实体模型 - 配置 DbContext - 注册服务与连接字符串 - 用迁移生成数据库 - 用 LINQ 操作数据。这条主线打通之后绝大多数日常开发都能顺畅进行。我自己的感受是用 EF 最大的收益不是写代码更少而是让改需求这件事变得可控。数据结构要变更了改一个实体类通过迁移广播出去团队里每个人都能同步更新这远比手工维护一堆 SQL 脚本靠谱得多。最让我省心的还是事务处理尤其在订单、支付、库存这种跨表强一致性的场景不用再惦记着每次操作都开一个 SqlTransactionSaveChangesAsync在最普通的情况下已经帮你把事情办妥了。最后分享一个用了很久的小习惯每次开发新功能前先把实体类、DbContext、迁移文件这铁三角的变更整理清楚再动业务代码顺序错了就会在合并分支时被迁移冲突反复折磨。数据库结构永远是项目的基石EF 给了我们一把好用的铲子但挖地基的顺序还是得自己把握好。
返回列表