简介:面向 ASP.NET Web API 开发者的 EF Core 与 MySQL 整合示例工程,以 .NET Core 为基础,演示了从数据模型定义、EF Core 迁移到 Controller 暴露 RESTful 接口的完整链路,适合正在学习 ORM 与 Web API 实战组合的初中级开发者。压缩包共 22 个文件,大小约 213KB,包含 9 个 C# 源文件、4 个 JSON 配置、解决方案文件、项目描述文件、版本控制与开源协议文件等;C# 源码覆盖控制器、模型与数据访问层,JSON 配置则区分了通用与开发环境,目录划分清晰,便于快速定位各层代码。项目内置一次数据库迁移与模型快照,可直接观察 EF Core 在 MySQL 下建表与变更流程;配置与控制器分离,也方便对照学习依赖注入、路由设定和配置管理这类 Web API 开发要点。已有 364 人学习浏览,若想以最小规模源码为蓝本搭建或重构类似服务,这份工程是一个不错的参照起点。
1. 基于EFCore和Mysql的ASP.NET.Web.API项目设计源码:这套后端骨架解决的核心问题
基于EFCore和Mysql的ASP.NET.Web.API项目设计源码这个标题,几乎是把.NET后端最常见的三个词凑齐了:EFCore负责数据访问、MySQL负责存储、ASP.NET Web API负责暴露接口。它面向的是一个很具体的诉求——不是照着零散教程拼一个能跑通的Demo,而是拿到一份分层清晰、能改能扩、换环境不崩的工程源码。这类项目在开发者的硬盘和课程设计里反复出现,但真正能直接改造成生产用的,赢在骨架而不是代码量。这套设计适合从零开新后端的人、从EF6迁移到EFCore的维护者,也适合拿来做毕业设计底子、再往里填业务的学生。
2. 项目分层与依赖注入:先把四层骨架搭对
拿到这类源码,第一件事不是看Controller写了多少接口,而是先看解决方案里分了几层。很多能直接改造成生产用的项目,赢在分层而不在代码量。分层不清的项目,往往把DbContext直接new在Controller里,功能一多必然翻车。今天这篇笔记,我会从骨架讲到落库,再讲到生产前的坑,全程按可复现的方式来写。
2.1 从Controller里拆出四层:Domain、Infrastructure、Application、WebApi
我一般会把后端项目拆成四个独立工程,结构如下:
src/ ├── Domain/ # 实体、枚举、领域接口,不引用任何框架 ├── Infrastructure/ # DbContext、EFCore配置、仓储实现、迁移脚本 ├── Application/ # 业务Service、DTO、AutoMapper Profile └── WebApi/ # Controllers、Middleware、Program.cs依赖方向是单向的:WebApi 引用 Application,Application 引用 Infrastructure 的抽象接口与 Domain,Infrastructure 引用 Domain。Domain 是最底层的裸类库,连 EFCore 的包都不该装。这样做的好处是,将来把 MySQL 换成 PostgreSQL,Infrastructure 层单独替换,Controller 和 Service 的代码一行都不用动。
很多人觉得四层项目小题大做,但 EF Core 的 DbContext 本质上是「工作单元 + 连接管理器」的组合,如果每个 Controller 各自 new 一个 DbContext,事务就没法在多个 Service 之间共享,连接也会频繁开关。我曾见过一个项目把 DbContext 属性写进基类 Controller,请求一来就 new,并发一上来连接池直接被打满。分层真正的价值是让对象的生命周期可管理,而不是单纯为了代码好看。
2.2 用 AddDbContextPool 注册 DbContext 与 MySQL 连接字符串
在 Program.cs 里注册 DbContext 是整个后端源码的入口配置,也是这类项目里最容易被抄错的几行代码:
// Program.cs builder.Services.AddDbContextPool<AppDbContext>(options => { options.UseMySql( builder.Configuration.GetConnectionString("Default"), new MySqlServerVersion(new Version(8, 0, 36)), mysql => mysql.EnableRetryOnFailure(3) ); });这里有两个关键选择。第一个是AddDbContextPool而不是AddDbContext,前者把 DbContext 实例放入池中复用,高并发下能减少连接和元数据初始化的开销,是生产环境里的常见做法。第二个是MySqlServerVersion必须显式传入,EFCore 要靠它来决定生成哪一版 MySQL 的 SQL 方言,比如分页语法和函数支持,8.0 与 5.7 之间是有差异的。
连接字符串放在 appsettings.json 里,标准写法如下:
{ "ConnectionStrings": { "Default": "server=localhost;port=3306;database=testdb;user=root;password=你的密码;CharSet=utf8mb4;SslMode=None;AllowZeroDateTime=true;" } }CharSet=utf8mb4是中文不乱码的前提,MySQL 8.0 默认字符集虽然是 utf8mb4,但连接层不指定,某些驱动仍可能回退到 latin1。SslMode=None是为了绕开 MySQL 8.0 默认的加密握手在本地开发时的证书校验问题,上生产如果走内网且没有配 SSL 证书,这个参数也要保留。AllowZeroDateTime=true则是为读取 MySQL 里历史遗留的0000-00-00日期准备的,没有它查询到这种值会直接抛异常。
连接串写好后,建议先用 Navicat for MySQL 或命令行客户端验证一次,确认账号、密码和数据库名都对得上,再去启动 API。装 MySQL 的教程遍地都是,但真正容易出问题的往往不是安装过程,而是这些连接参数。还有一点要注意:密码不要硬编码在 appsettings.json 里提交到 Git,开发环境用 user-secrets,生产用环境变量或密钥管理服务,这属于这类源码工程里最基本的卫生习惯。
3. EFCore连接MySQL的落地路径:选型、实体映射与迁移脚本
分层搭好之后,最核心的技术点就是让 EFCore 和 MySQL 真正跑起来。很多人在这一步卡住,是因为选了不合适的 Provider,或者把实体映射和迁移脚本的顺序搞反。这一章我会把从选型到生成数据库的全过程拆开讲,每一步都有能直接抄的命令和配置。
3.1 为什么选 Pomelo.EntityFrameworkCore.MySql 而不是官方包
EFCore 官方并没有为 MySQL 提供开箱即用的 Provider,需要在两个第三方包里选:Oracle 官方维护的MySql.EntityFrameworkCore,以及社区维护的Pomelo.EntityFrameworkCore.MySql。两者都用过之后,我基本固定用 Pomelo,原因有三条。第一,Pomelo 的版本与 EFCore 主版本保持同步,项目是 net8.0 就装 8.x,net6.0 就装 6.x,版本对应不看文档也不会错;第二,Pomelo 对MySqlServerVersion的支持更完整,切换 MySQL 5.7 和 8.0 时不用改实体代码;第三,它在 GitHub 上的 issue 响应和兼容性测试明显更活跃,遇到问题更容易搜到解决方案。
版本选择不是玄学,直接按目标框架来。在类库项目里执行:
dotnet add package Pomelo.EntityFrameworkCore.MySql不指定版本时,NuGet 会解析出与当前目标框架匹配的最新稳定版。如果你用的是 .NET 8,会拿到 8.x,正好对应 EFCore 8。需要特别注意的是,千万不要在同一个项目里混装MySql.EntityFrameworkCore和Pomelo.EntityFrameworkCore.MySql,两个包的扩展方法同名,编译期不报错,运行时的行为却不可预测。
3.2 Fluent API 实体映射:把表名、精度和默认值一次配齐
实体映射有 Data Annotation 和 Fluent API 两种写法。我倾向用 Fluent API,因为实体类可以保持纯净,不引用任何 EFCore 的特性,Domain 层的“裸”属性才能守住。下面是一个典型的产品表配置:
// Infrastructure/Data/Configurations/ProductConfiguration.cs public class ProductConfiguration : IEntityTypeConfiguration<Product> { public void Configure(EntityTypeBuilder<Product> builder) { builder.ToTable("products"); builder.HasKey(p => p.Id); builder.Property(p => p.Name) .HasMaxLength(128) .IsRequired(); builder.Property(p => p.Price) .HasPrecision(18, 2); builder.Property(p => p.Status) .HasConversion<string>() .HasMaxLength(20); builder.Property(p => p.CreatedAt) .HasDefaultValueSql("CURRENT_TIMESTAMP"); builder.Property(p => p.IsDeleted) .HasDefaultValue(false); } }这段配置里藏着几个容易踩坑的细节。ToTable("products")强制表名小写,是为了 Linux 上与 MySQL 的大小写策略保持一致,后面避坑章节会专门讲。HasPrecision(18, 2)解决 decimal 类型在 MySQL 里被映射成 decimal(10,0) 导致小数丢失的问题。HasConversion<string>()把枚举存成字符串,可读性比默认的 int 好得多,调试时一眼能看懂状态含义。HasDefaultValue(false)对应 MySQL 里默认值为 0 的字段语义。
配置写完后,需要在 DbContext 里统一加载:
// Infrastructure/Data/AppDbContext.cs protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly( typeof(AppDbContext).Assembly); }ApplyConfigurationsFromAssembly会自动扫描程序集里所有实现了IEntityTypeConfiguration<T>的类,不用一个一个手动注册。新增实体时只需新建实体和对应的 Configuration 类,DbContext 不需要频繁改动。这个习惯能让你在实体数量增长到几十个时,OnModelCreating 依然干净整洁。
3.3 Code First 迁移:从生成脚本到更新数据库
实体配置完成后,用迁移把表结构同步到 MySQL。迁移是这类源码工程里最值得保留的部分,因为数据库结构的历史变更全部记录在 Migrations 目录里,团队协作时可以 review,本地环境可以一键重建。
# 在解决方案根目录执行 dotnet ef migrations add Init \ --project src/Infrastructure \ --startup-project src/WebApi \ --output-dir Migrations dotnet ef database update \ --project src/Infrastructure \ --startup-project src/WebApi这两个命令的参数必须解释清楚:--project指向包含 DbContext 的 Infrastructure 项目,--startup-project指向 WebApi 项目,因为工具要从启动项目的配置里读连接字符串;--output-dir指定迁移文件输出的目录,不指定时默认以 DbContext 所在项目的根目录生成。把 DbContext 放在类库里,就必须带这三个参数,很多人第一次执行迁移时翻车,就是因为漏了--project。
迁移命令还有几个高频用法,整理成表:
| 命令 | 作用 | 使用场景 |
|---|---|---|
dotnet ef migrations add Init | 生成迁移脚本 | 实体结构有新改动时 |
dotnet ef database update | 更新数据库到最新迁移 | 开发环境同步表结构 |
dotnet ef migrations remove | 撤销最近一次未提交的迁移 | 刚生成的迁移有问题 |
dotnet ef migrations script | 导出 SQL 脚本 | 生产环境由 DBA 评审后执行 |
生产发布时,我不建议在服务器上直接跑database update,而应该在本地用dotnet ef migrations script --idempotent导出一份可重复执行的 SQL,交给 DBA 评审后再执行。这样数据库变更可追溯,回滚也方便,是“设计源码”里真正值钱的部分。开发环境则随意,database update一条命令重建库,效率最高。
4. Web API层的统一设计:DTO映射、全局异常与异步查询
数据库访问层打通后,接下来是 Controller 和业务层怎么协作。很多从教程里走出来的项目,Controller 直接返回实体,异常处理散落在每个 Action 里,一时跑得通,上线后却很难维护。这一章的内容,就是把 Web API 层做成一个统一的、可预期的出口。
4.1 DTO 与 AutoMapper:为什么不能直接返回实体
直接把 EFCore 实体作为接口返回值,是新手项目最明显的设计问题。实体上往往带着导航属性,JSON 序列化时容易循环引用;数据库字段结构被完整暴露给前端,等于把底牌亮了出去;后续要裁剪或聚合字段时,只能改实体,牵一发动全身。所以我会在 Application 层建一层 DTO,用 AutoMapper 做转换。
// Application/Mapping/ProductProfile.cs public class ProductProfile : Profile { public ProductProfile() { CreateMap<Product, ProductDto>(); CreateMap<CreateProductRequest, Product>(); } }注册到容器里并调用:
// Program.cs builder.Services.AddAutoMapper(typeof(ProductProfile).Assembly);// WebApi/Controllers/ProductsController.cs [HttpGet("{id:int}")] public async Task<ActionResult<ProductDto>> Get(int id) { var product = await _productService.GetByIdAsync(id); return Ok(_mapper.Map<ProductDto>(product)); }AutoMapper 适合实体和 DTO 字段高度相似的场景,配置简单,团队接手成本低。但如果映射逻辑复杂,比如需要连表聚合多个实体,我一般会在 Service 里手写投影表达式,而不是强行用 AutoMapper 的Map,这样性能更好,也更容易静态检查。
4.2 全局异常中间件:让所有错误返回同一种格式
异常处理最忌讳的做法是在每个 Action 里单独 try-catch,因为异常类型五花八门,日志格式参差不齐,前端解析也头疼。常见的做法是写一个全局异常中间件,把未处理异常统一拦下来。
// WebApi/Middleware/ExceptionHandlingMiddleware.cs public class ExceptionHandlingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<ExceptionHandlingMiddleware> _logger; public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception for {Path}", context.Request.Path); context.Response.StatusCode = 500; context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(new { Code = 500, Message = "服务器内部错误,请稍后重试" }); } } }在 Program.cs 里注册中间件,注意要放在 UseRouting 之前:
// Program.cs app.UseMiddleware<ExceptionHandlingMiddleware>();这个中间件是统一出口,业务代码里就不再需要到处 try-catch。对于业务层面的校验错误,我通常会在 Service 里抛自定义的BusinessException,中间件里判断异常类型,若是业务异常返回 400,未知异常返回 500,日志记录在中间件这一层完成。这样做的好处是,前端接到的错误响应永远只有一个结构,联调和排错的成本大幅下降。
4.3 异步查询:让 EFCore 的每一次 IO 都走 async
ASP.NET Core 的线程池是稀缺资源,同步阻塞请求会在高并发时拖垮吞吐量。EFCore 的查询方法都有对应的 Async 版本,写代码时要用到底。下面是一个典型的查询写法:
public async Task<List<ProductDto>> GetOnSaleAsync() { var list = await _context.Products .AsNoTracking() .Where(p => p.Status == ProductStatus.OnSale) .OrderBy(p => p.Price) .Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price }) .ToListAsync(); return list; }这里有两个细节值得说。AsNoTracking()是只读查询的标配,EFCore 不需要跟踪实体状态,省下大量变更检测开销。Select直接投影到 DTO,EFCore 会生成只查这三列的 SQL,不会把整行数据拉回内存再裁剪。反过来,如果写成ToList()再在内存里 Where 和 OrderBy,就是把整张表搬到内存后才筛选,数据量一上来必然翻车。
还有一个藏在分层里的坑:不要把IQueryable从 Service 层返回到 Controller。IQueryable 是延迟执行的,如果 Controller 拿到后在循环里才真正执行查询,DbContext 可能已经被 dispose 了,运行时才抛出 ObjectDisposedException。这类问题在团队协作里尤其隐蔽,因为本地测不出,压力测试时才会暴露。Service 层负责执行终结操作,返回 List 或 DTO,让 IQueryable 不逃逸出 Application 层,这一条应该作为代码评审的红线。
5. EFCore+MySQL避坑与排查:五个常见老大难
把前面几章的内容都跑通,你已经能交付一个可以运行的后端项目了。但从“能运行”到“能上线”,中间隔着至少五个固定会遇到的坑。这些坑我全都踩过,写成血泪经验分享在这里。
5.1 坑1:连接 MySQL 8.0 时握手失败或认证不识别
现象:项目启动正常,但第一个查询就抛异常,报Authentication method 'caching_sha2_password' failed或Unable to connect to any of the specified MySQL hosts。
原因:MySQL 8.0 默认使用caching_sha2_password认证插件,旧版驱动或旧版 Pomelo 不认识这个认证方式,握手阶段就失败了。本地能连往往是因为用了 Navicat 这类内置了新认证支持的客户端,而应用走的是另一套驱动。
解决:先升级 Pomelo 到与 EFCore 大版本一致的最新版,再检查连接串是否包含SslMode=None。如果还不能解决,再考虑把该账号改为mysql_native_password认证,但这种方式是在回避问题而不是解决问题,生产环境不建议长期用旧认证。
5.2 坑2:迁移时提示 unable to create a DbContext
现象:执行dotnet ef migrations add Init时,报错类似Unable to create a 'AppDbContext' type或No parameterless constructor defined。
原因:DbContext 放在类库项目里,而dotnet ef默认从启动项目读取服务注册。如果连接字符串不在启动项目的 appsettings.json 中,或者 DbContext 构造函数带参数但实例化失败,工具就无法创建上下文。
解决:确保 DbContext 有一个接收DbContextOptions<T>的构造函数,这是 EFCore 的标准写法:
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }同时命令里必须带全--project和--startup-project两个参数,--project指向包含 DbContext 的项目,--startup-project指向启动 API 项目。排查顺序是先看命令参数,再看构造函数,最后确认启动项目确实能编译通过。
5.3 坑3:Linux 部署后报表不存在,Windows 下却一切正常
现象:本地开发机跑得好好的,部署到 Linux 服务器后,查询时报Table 'testdb.products' doesn't exist,但在 Navicat 里能看到表。
原因:MySQL 的lower_case_table_names参数在 Windows 默认值为 1(表名不区分大小写),Linux 默认值为 0(区分大小写)。开发时手写 SQL 或 EFCore 生成的表名大小写不一致,Windows 下被忽略了,Linux 下直接报错。
解决:最稳妥的做法是在 Fluent API 配置中统一ToTable("products")小写表名,并保证所有手动执行的 SQL 都用相同的小写表名。注意lower_case_table_names参数必须在 MySQL 实例初始化前修改,中途改会导致已有的数据文件无法识别,不要在生产库上尝试。
5.4 坑4:并发写入时死锁,事务处理没有兜底
现象:并发请求偶发报错Deadlock found when trying to get lock; try restarting transaction,压测时尤其频繁。
原因:InnoDB 检测到两个事务以不同顺序锁定了同一批行,就会牺牲其中一个事务。EFCore 把一次SaveChanges包在隐式事务里,但如果业务里显式开了事务,且多张表的更新顺序不一致,死锁概率就上来了。
解决:事务内多张表的操作顺序要固定,不要这次先更新订单再更新库存,下次先更新库存再更新订单。同时给连接串开启EnableRetryOnFailure,死锁发生的瞬间 EFCore 会自动重试一次,很多偶发死锁就这样被透明化解了。只有跨多个 DbContext 或跨仓储操作时才需要显式包事务,单次 SaveChanges 本身就是事务,没必要画蛇添足。
5.5 坑5:DateTime 零值与时区偏移
现象:读取某行数据时报DateTime value '0000-00-00 00:00:00' could not be converted,或者查出来的时间比实际时间差 8 个小时。
原因:MySQL 允许存储零值日期,但 C# 的 DateTime 是值类型,无法表示这种非法日期;时区问题则是应用服务器与数据库服务器的时区设置不一致导致的,写入时存的是本地时间,读出时被当成另一种时区处理。
解决:连接串里加AllowZeroDateTime=true,允许驱动把零值读成默认时间,在应用层再做转换或过滤。时间一律以 UTC 格式写入数据库,展示层再按用户时区转换,不要依赖 MySQL 服务器的CURRENT_TIMESTAMP默认值,因为你控制不了生产服务器的时区配置。
6. 把源码推到生产前:JWT认证与性能自检清单
如果你已经能把接口跑通,下一步就是加认证和做一次性能自检。这两件事决定这套源码是停留在 Demo 阶段,还是能真正被团队接手。
认证最省事的方式是 JWT Bearer。在 Program.cs 里注册AddAuthentication().AddJwtBearer(),配置签发与校验的密钥和过期时间,然后给需要保护的 Controller 或 Action 加[Authorize]特性。注意 JWT 的密钥要放在配置中心或环境变量,不要明文提交到源码。与之配套的是 Swagger,AddSwaggerGen里开启 Bearer 认证输入框,联调时先登录拿 Token,再带着 Token 调业务接口,这套流程是后端源码交付的标准组合。
性能自检我一般按这个清单走:第一,所有只读查询加了AsNoTracking();第二,接口返回的是 DTO 而不是实体;第三,查询里没有IQueryable逃逸到 Controller;第四,连接串确认使用连接池且开启重试;第五,迁移脚本经过 review 而不是在服务器上临时database update。这五条都满足,这套源码就具备了交付的底线质量。
我现在搭新项目已经形成固定顺序:先建迁移脚本,再写业务代码;凡是返回实体的接口一律换成 DTO;凡是上生产的项目,先把中间件和认证接好再谈功能。这套习惯就是从这一类项目源码里沉淀出来的,也让我在后续换数据库、拆微服务时少走了很多弯路。希望这份设计能帮到你,也希望你在复现时能避开我当年踩过的那些坑。
本文还有配套的精品资源,点击获取