简介:围绕EF CodeFirst(代码优先)的实战入门实例,面向希望快速掌握Entity Framework自动建库机制的.NET开发者,尤其适合需要在SQLServer与MySQL两种数据库间切换的初学者。实例完整演示了从编写数据类到自动生成数据库表的全过程,并分别配置了两种数据库的连接方式,便于对比理解差异。压缩包共88个文件,以.cs源码、.dll程序集、.config配置、.xml文档为主,同时包含.sln解决方案和.csproj工程文件,包体仅13.61MB,轻量易下载。项目内Program.cs与App.config等关键文件直接展现调用与配置入口,结构清晰,可直接打开调试运行。已有704人学习浏览,适合作为Code First入门或课程设计的参考资料。内容预览显示其内置MySQL相关包如MySql.Data、EntityFramework等,并附有packages配置与NuGet依赖信息,可帮助读者理清环境搭建与包版本对应关系。通过本实例可直观掌握两种数据库下EF CodeFirst的配置写法,减少踩坑,提升数据访问开发效率。 做后端开发的朋友,应该都经历过这种场景:系统已经上线跑了好几个月,客户突然说“我们这边用的不是 SQLServer,是 MySQL,能迁过去吗”。如果项目用的是“先建表、再写代码”的数据库优先模式,整个数据层基本要推翻重来。但如果你一开始就用 EF Code First(代码优先),这个需求会变得没那么吓人。
这篇文章分享我最近完成的一个实例:用同一套 EF Core 实体模型,分别跑通 SQLServer 和 MySQL,涉及实体设计、DbContext 配置、Provider 切换、迁移生成和双库并行时的踩坑记录。适合理清 Code First 原理的后端新手,也适合正在做“一套代码适配多数据库”的 .NET 开发者。
1. 先搞清楚:EF代码优先到底解决了什么问题
1.1 从数据库优先到代码优先的思维转变
传统的开发模式是数据库优先:DBA 建表、写索引、定外键,然后我们再根据表结构去写实体类、写仓储。这个模式没什么硬伤,唯一的麻烦是——数据库结构成了项目里的“老大哥”,每次改动都要先走一遍建表脚本,然后同步实体类,稍不留神就漏掉一个字段。
代码优先把这种关系倒了过来。实体类成了“唯一事实来源”(Single Source of Truth),迁移脚本(Migration)负责把代码里的模型变化翻译成 DDL,再应用到数据库。改字段就是改代码,加表就是加 DbSet,执行一条命令就能自动生成对应的 ALTER TABLE 或 CREATE TABLE。
用我做过的一个成绩管理系统来打比方:以前是“先有档案柜,再按格子塞文件”,现在是“先设计表格模板,档案柜按模板自动定制”。后者明显更适合需求经常变化的项目,而且代码评审时能直接看模型。更关键的是,迁移文件是可追溯的,每次数据库变更都有对应的 Git 记录,比散落在团队聊天记录里的 SQL 脚本靠谱太多了。
1.2 为什么我非要把SQLServer和MySQL塞进同一个项目
很多团队的情况是:开发环境用 SQLServer,上了生产环境客户要求换成 MySQL;或者集团公司内部一个项目要交付给不同客户,各家的数据库标准不统一。所以从架构层面让“一套实体模型适配多种数据库”并不是炫技,而是实打实的降本增效需求。
EF Core 在这方面给了我们很大的空间。它的底层抽象做得不错,同一个 LINQ 查询会被翻译成不同数据库的方言,理论上你只要切换UseSqlServer和UseMySql,再各自生成迁移文件,就能让一套领域模型跑在两种数据库上。当然,“理论上”三个字背后藏着不少细节,这也是本篇文章重点展开的部分。
2. 环境准备与依赖选型
2.1 开发环境和数据库安装建议
做这个实例之前,先把开发环境备齐。我用的技术栈是 .NET 8 + EF Core 8,IDE 用 Visual Studio 2022,日常用 Rider 的也完全可以。数据库方面,SQLServer 建议装一张开发版或 Express 版,安装教程网上很多,核心是记住实例名和登录方式;MySQL 我强烈推荐用 Docker 装,比在本机装一堆服务干净利落:
docker run -d --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=SchoolDb \ mysql:8.0跑完这条命令,MySQL 8.0 就在本机的 3306 端口等着你了,用 Navicat 或 MySQL Workbench 连上去即可。SQLServer 如果非要用 Docker 也不是不行,只是内存占用大,开发机配置一般的话还是建议原生安装。这里务必要养成一个习惯:把数据库版本记清楚。后面配置ServerVersion时,MySQL 5.7 和 8.0 的处理方式有差异,别拿着 8.0 的配置去连 5.7 的库。
2.2 NuGet包怎么选
EF Core 连接到不同数据库靠的是 Provider 包,这一步选不好后面全是坑。SQLServer 端没有悬念,用微软官方的包:
Microsoft.EntityFrameworkCore.SqlServer
MySQL 端就要多说两句了。市面上有两个主流的 Provider:
| 包名 | 维护方 | 我的评价 |
|---|---|---|
MySql.EntityFrameworkCore | Oracle 官方 | 更新滞后,坑比较多,不推荐 |
Pomelo.EntityFrameworkCore.MySql | 社区 Pomelo 团队 | 更新及时,和 EF Core 版本对齐,推荐 |
我自己一直用 Pomelo,它和 EF Core 的版本要严格对应:EF Core 8 对应Pomelo.EntityFrameworkCore.MySql8.x,EF Core 9 对应 9.x。版本对不上大概率会在运行时爆出奇怪的映射错误。
最后别漏了命令行工具包,如果还没安装过,先装:
dotnet tool install --global dotnet-ef这个工具是生成迁移、更新数据库的必备武器,没有它,写代码优先项目等于少了一条腿。
3. 从零搭建EF Code First项目
3.1 实体类与DbContext的设计要点
我以学生成绩表为例,建两个实体:Student和Score,这几乎是教学级的标准模型,但足够把主要知识点都带出来。
public class Student { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string? StudentNo { get; set; } public DateTime EnrollmentDate { get; set; } public bool IsActive { get; set; } = true; public List<Score> Scores { get; set; } = new(); } public class Score { public int Id { get; set; } public int StudentId { get; set; } public Student? Student { get; set; } public string CourseName { get; set; } = string.Empty; public decimal ScoreValue { get; set; } public DateTime ExamDate { get; set; } }实体类很简单,重点在DbContext里怎么配置约束。写代码优先项目最忌讳的是“把实体丢给 EF,其余全交给默认约定”。默认约定能应付简单场景,但只要字段类型稍微特殊一点,SQLServer 和 MySQL 生成的 DDL 就会有出入。所以我建议都用 Fluent API 显式声明一遍关键字段:
public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Student> Students => Set<Student>(); public DbSet<Score> Scores => Set<Score>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Student>(e => { e.Property(p => p.Name).HasMaxLength(50); e.Property(p => p.StudentNo).HasMaxLength(20); e.HasIndex(p => p.StudentNo).IsUnique(); }); modelBuilder.Entity<Score>(e => { e.Property(p => p.CourseName).HasMaxLength(100); e.Property(p => p.ScoreValue).HasPrecision(5, 2); e.HasIndex(p => new { p.StudentId, p.CourseName }).IsUnique(); }); } }注意两处:一是HasMaxLength,字符串如果不指定长度,SQLServer 默认映射成nvarchar(max),MySQL 默认映射成longtext。这两种类型在索引和排序上都有麻烦,所以业务字段一律给显式长度。二是HasPrecision(5, 2)指定decimal的精度,否则两种数据库默认的 decimal 映射不一致,后面会展开讲。
3.2 多Provider切换的核心配置
要让项目在两种数据库之间切换,核心思路是:把数据库类型和连接字符串都放进配置文件里,启动时按配置注入不同的 Provider。
{ "DbType": "SqlServer", "ConnectionStrings": { "SqlServer": "Server=localhost;Database=SchoolDb;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;", "MySql": "Server=localhost;Port=3306;Database=SchoolDb;User=root;Password=root123;Charset=utf8mb4;SslMode=None;" } }然后Program.cs里这样注册:
var builder = WebApplication.CreateBuilder(args); var dbType = builder.Configuration.GetValue<string>("DbType"); builder.Services.AddDbContext<AppDbContext>(options => { if (dbType == "SqlServer") { options.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer")); } else if (dbType == "MySql") { options.UseMySql( builder.Configuration.GetConnectionString("MySql"), ServerVersion.AutoDetect(builder.Configuration.GetConnectionString("MySql")), mysql => mysql.EnableRetryOnFailure()); } });这里两个点值得注意。第一,ServerVersion.AutoDetect是 Pomelo 提供的方便方法,会自动去查一下服务器版本,代价是启动时多一次网络开销;如果你明确知道自己连的是 MySQL 8.0,也可以直接写死new MySqlServerVersion(new Version(8, 0, 36)),省掉自动探测那一步。第二,EnableRetryOnFailure是 Pomelo 的执行策略,默认处理临时性连接故障,建议加上,否则服务器重启的瞬间应用可能直接抛异常。
4. 实操:让同一套实体跑通两种数据库
4.1 生成并应用SQLServer迁移
项目能跑起来、能连上 SQLServer 之后,第一步是生成迁移。很多人喜欢一个迁移目录走天下,但当你目标数据库不止一种时,强烈建议按 Provider 分目录,否则哪天混合应用迁移,可能把 SQLServer 的迁移脚本误打到 MySQL 上。
我的做法是这样:
dotnet ef migrations add InitSqlServer \ --context AppDbContext \ --output-dir Migrations/SqlServer执行完会在Migrations/SqlServer目录下生成带时间戳的迁移文件。打开看一眼,里面全是 SQLServer 方言的 DDL,比如主键用IDENTITY,字符串用nvarchar(50)。这一步能帮你快速发现模型设计的问题,如果某个字段生成了预期之外的类型,趁现在改模型,不要拖到上线。
然后应用迁移:
dotnet ef database update \ --context AppDbContext \ --connection "Server=localhost;Database=SchoolDb;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"成功后用 SSMS 刷新一下,能看到多了一张__EFMigrationsHistory表。这张表是 EF 自己的“版本履历表”,记录了当前数据库已经应用了哪些迁移,后续再执行database update时,EF 会跳过历史表里已有的迁移。很多人第一次看到这张表会误以为是系统表,千万不要删,删了之后 EF 会认为所有迁移都没应用过,执行 update 时会尝试重建表,到时候等着你的就是连环报错。
4.2 切换MySQL并生成第二个迁移
SQLServer 跑通之后,把配置文件里的DbType改成MySql,这时候直接启动应用大概率还能正常连库,但没有表。别急,先给 MySQL 生成一套专属于它的迁移:
dotnet ef migrations add InitMySql \ --context AppDbContext \ --output-dir Migrations/MySql注意我特意用了不同的迁移名称InitMySql,而不是沿用InitSqlServer。这是踩坑踩出来的经验:EF 的迁移历史表只认MigrationId,不区分 Provider,如果你给两个数据库生成同名迁移,切到 MySQL 时 EF 会误以为迁移已经应用过。用不同名称虽然看起来不那么整齐,但换来了跨库的稳定。
执行完打开 MySQL 的迁移文件,你会发现同样的实体类,生成的 DDL 已经完全变了:主键变成了int AUTO_INCREMENT,字符串变成了longtext或varchar(50),IsActive映射成了tinyint(1)。这就是 Provider 切换的意义——你写的 LINQ 和实体类不变,底层 SQL 方言和字段类型全由 Provider 接管。
接下来应用迁移,命令和 SQLServer 版本几乎一样,只换连接字符串:
dotnet ef database update \ --context AppDbContext \ --connection "Server=localhost;Port=3306;Database=SchoolDb;User=root;Password=root123;Charset=utf8mb4;SslMode=None;"完成之后用 Navicat 或 MySQL Workbench 连上去看一下,表结构、索引、历史表都在,和 SQLServer 侧一一对应。
4.3 写入种子数据验证跨库一致性
表建好了,先别急着写业务接口,手动插几行数据验证一下最踏实。我习惯在OnModelCreating里用HasData配种子数据,这样每次迁移部署后都会自动做一次数据初始化:
modelBuilder.Entity<Student>().HasData( new Student { Id = 1, Name = "张三", StudentNo = "S001", EnrollmentDate = new DateTime(2024, 9, 1), IsActive = true }, new Student { Id = 2, Name = "李四", StudentNo = "S002", EnrollmentDate = new DateTime(2024, 9, 1), IsActive = true } );有一点需要提醒:种子数据的HasData在两种数据库下有细微差异。SQLServer 的大多数类型都能直接映射,而 MySQL 对DateTime、decimal的处理更敏感,所以种子数据的精度要写清楚,比如decimal最好写成50.00m而不是50m,否则迁移时可能出现类型转换告警。
5. 双数据库并行最容易踩的坑
5.1 类型映射差异:decimal、DateTime、bool
这是跨库项目中“看起来能跑、跑起来就出妖”的重灾区。同一套实体类,在两个数据库里生成的物理类型完全不同。举几个实际例子:
| 实体属性 | SQLServer 默认映射 | MySQL 默认映射(Pomelo) |
|---|---|---|
decimal ScoreValue | decimal(18,2) | decimal(65,30) |
DateTime ExamDate | datetime2 | datetime(6) |
bool IsActive | bit | tinyint(1) |
如果你不显式配置HasPrecision,同一个ScoreValue在 SQLServer 里是decimal(18,2),在 MySQL 里却变成decimal(65,30)。sqlserver 里算成绩保留两位小数没问题,MySQL 里你会看到一长串诡异的小数尾巴,就是因为精度映射不一致。所以模型里凡是涉及金额、分数、比率这种对精度敏感的字段,老老实实写HasPrecision(18, 2)或者HasPrecision(5, 2),别再依赖默认约定。
DateTime也有坑。SQLServer 的datetime2精度是 100 纳秒,MySQL 的datetime(6)精度是微秒,相差一个数量级。如果你的业务逻辑里有“拿时间戳做唯一标识”这种危险操作,在两个库上表现会不同。更保险的做法是:日期时间字段统一在应用层用DateTime.UtcNow赋值,不要用数据库的GETDATE()或NOW(),这样至少能做到跨库行为一致。
bool映射的差异则体现在查询和存储两方面。SQLServer 的bit只有 0 和 1,MySQL 的tinyint(1)其实是个整数类型,如果用原生 SQL 注入,可能会写入 2、3 这样的值,等 EF 读出来的时候IsActive就变成true了。这也解释了为什么我强烈建议所有写操作都走 EF,不要混用裸 SQL,除非你有充分的理由并做了严格校验。
5.2 索引命名与迁移历史表问题
跨库项目里,索引和约束的命名默认策略也不同。SQLServer 默认的索引名格式是IX_表名_字段名,MySQL 里也差不多,但长度上限和字符集规则不一样。当你的索引名超过 MySQL 的限制,或者包含特殊字符时,迁移阶段就会直接报错。
这里有一个非常实际的经验:在OnModelCreating里给索引显式命名,尤其是联合索引。这样既能保证两种数据库的索引名一致,方便后续排查慢 SQL,也能避免默认命名在换库时出现兼容性问题。
迁移历史表__EFMigrationsHistory在两种数据库里的表现也不完全一样。SQLServer 里它是普通表;MySQL 里它默认是MyISAM或InnoDB,取决于数据库版本和配置。如果并行连接两个库,我建议定期检查这张表里的MigrationId是否和应用代码中的迁移类一一对应。最简单的方法是打开dotnet ef migrations list --context AppDbContext,它会列出应用到当前连接数据库的迁移列表,和代码里的迁移对比一下,一目了然。
5.3 连接字符串与SSL踩坑
连接字符串是最容易被低估的坑。SQLServer 侧,本地开发经常遇到“证书链是由不受信任的颁发机构颁发的”,这是因为默认开启了加密连接。如果你在本地开发环境不需要加密,还是在连接字符串末尾加上TrustServerCertificate=True省心。
MySQL 侧,连接字符串里两个参数要特别注意。一是Charset=utf8mb4,不设置的话默认字符集可能不是 utf8mb4,中文写入后会出现乱码,包括表情符号直接丢失。二是SslMode=None,本地开发时如果不开这个,Pomelo 可能因为 SSL 握手超时连不上数据库;生产环境则建议改成SslMode=Required,别一刀切。
我见过很多人在“本机能连 SQLServer、MySQL 却连不上”的问题上卡半天,最后排查半天是Port没写对。MySQL 默认端口是 3306,但只要改过 Docker 映射就未必是这个值。连接字符串里显式写Port=3306是最稳妥的,千万别省。
5.4 原生SQL与批量操作注意事项
EF Code First 下推荐用 LINQ,但总有一些场景绕不开原生 SQL,比如复杂报表、特殊性能优化。这时你要清楚:原生 SQL 是绕过模型的,一旦数据库结构变化,SQL 可能不会跟着同步更新。
我的建议是:跨库项目里,原生 SQL 尽量用在“只读”场景,写操作一律走 EF。如果非得写原生 SQL,避免两个库的方言差异。比如 sqlserver 里字符串转数字常用CAST('123' AS INT),MySQL 里也能用CAST但行为略有不同;SQLServer 遇到空字符串转数字会抛异常,MySQL 会更宽容地返回 0。这种微妙的差异,跨库时最容易咬人。
批量操作方面,EF Core 8 引入了ExecuteUpdate和ExecuteDelete,可以直接执行批量删除和更新,不再需要先查询出来再逐条改。这两个 API 在 SQLServer 和 MySQL 上都有对应的原生翻译,性能差异不大。但要注意,批量操作不会触发SaveChanges的ChangeTracker逻辑,也不会自动更新导航属性,如果你后续还要用这些实体做别的操作,建议先让ChangeTracker.Clear()清空跟踪状态。
6. 常见问题排查速查表
顺手整理一份速查表,都是我在这个实例里实际碰到过的问题。遇到对应现象时可以按图索骥。
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
MySQL 连不上,报Unable to connect to any of the specified MySQL hosts | MySQL 没启动 / Docker 端口没映射 / 连接串 Port 错误 | 用docker ps确认容器状态,检查端口映射 |
| SQLServer 报证书错误 | 未启用 TrustServerCertificate | 连接串加TrustServerCertificate=True |
| Pomelo 报版本不匹配 | Provider 包版本和 EF Core 版本不对应 | Pomelo 包版本和Microsoft.EntityFrameworkCore大版本保持一致 |
| 迁移时报“数据丢失警告” | 模型改动和已有迁移不一致 | 执行dotnet ef migrations list核对迁移列表,必要时回滚 |
插入中文变成? | MySQL 字符集不对 | 连接串加Charset=utf8mb4,建库时指定 utf8mb4 |
| 日期时间精度不一致 | 未显式配置精度 | 对DateTime不默认,用DateTime.UtcNow统一赋值 |
| decimal 小数尾巴异常 | 默认精度映射不同 | 用HasPrecision(18, 2)显式声明 |
删除__EFMigrationsHistory后更新失败 | 历史表数据丢失 | 不要手动删,必要时手动构造迁移记录或用脚本重建 |
这个实例最大的收获不是“我会用 EF Code First 了”,而是理解了 EF Core 的迁移机制和 Provider 抽象之间的关系。迁移不只是数据库结构的快照,它绑定了具体的数据库方言。切换数据库时,实体类可以不变,但迁移必须重新生成。这也解释了为什么“一套代码适配多数据库”听起来很美,实际操作中要为每个数据库维护独立的迁移历史。
如果你正准备在公司项目里做类似的多数据库兼容,我的建议是尽早把数据库类型、连接字符串、迁移目录、迁移命名这些规则定好。定得越早,后面切换越轻松。等到数据库表已经三四十张再回头补迁移和梳理 Provider 差异,那才叫一个痛苦。
本文还有配套的精品资源,点击获取