☰
EFCore+MySQL实战:从DbContext到JWT的ASP.NET Web API完整落地指南
2026/9/29 19:39:14 网站建设 项目流程

简介:以EF Core和MySQL为核心的ASP.NET Web API后端项目源码,面向有C#基础的后端开发者,解决从零搭建API服务时的项目结构与数据库配置问题,展示如何借助对象关系映射工具完成数据库操作与接口开发。整个压缩包共22个文件,大小约213KB,其中C#源文件有9个,覆盖程序入口、控制器、数据库上下文与模型;JSON配置文件有4个,用于应用和环境配置;此外还有项目文件、解决方案、迁移脚本、说明文档、开源许可及版本控制文件,组织清晰,便于查阅。目前已有364人学习下载。源码中带有一份完整的初始迁移记录和参数表结构,控制器包含常规的数值接口与参数管理接口,启动类也配置了Swagger支持,便于调试;读者可以按目录顺序了解项目搭建、模型映射、迁移生成和接口编写的过程,尤其适合作为课程设计或Web API入门实战的参考。这类设计源码有助于快速掌握规范的分层方式,也可作为团队孵化新接口项目的起点。

1. EFCore + MySQL 的 ASP.NET Web API:源码里藏着一套趟平过的路

一个习惯用 SQL Server 的 .NET 开发者,第一次把 ASP.NET Web API 跑在 MySQL 上是什么体验?连接串不认、迁移报错、MySQL 没有 dbo、字段类型比你想象的更野、时间还会莫名其妙少 8 小时。这套源码就是把这条路趟平之后的结果:项目骨架、DbContext 设计、实体映射、仓储分层、Swagger 文档到 JWT 鉴权,全按真实项目能落地的状态铺好在里面。你拿到手不需要从零搭结构,改改连接串和数据表就能把 API 跑起来。适合两类人:刚接触 EFCore + MySQL 的入门者,以及要快速搭一个中后台 API 给前端联调的熟手。

2. 项目骨架与DbContext设计:为什么EFCore配MySQL是当前最省心的组合

用 EFCore 连接 MySQL,其实有两条技术路线:一条是 MySQL 官方出的 Connector/NET,另一条是社区维护的 Pomelo.EntityFrameworkCore.MySql。源码里选的是 Pomelo,理由是它在 MySQL 版本差异上处理得干净,用MySqlServerVersion一个参数就能把 5.7、8.0 的兼容逻辑分离,迁移和类型映射不容易半路翻车。官方驱动本身不差,但如果你用的是低版本 MySQL,Pomelo 对datetime(6)、json字段的适配明显更积极。这个选择直接决定了后面所有代码的写法。

2.1 依赖注入与连接串配置:先用一个能跑通的最小骨架

Program.cs 里最核心的就三件事:注册控制器、注册 DbContext、绑定连接串。这里我把它拆成最小可运行的样子,先让项目能启动,再谈业务。

var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddDbContext<AppDbContext>(options => { var conn = builder.Configuration.GetConnectionString("DefaultConnection"); options.UseMySql(conn, new MySqlServerVersion(new Version(8, 0, 36))); }); var app = builder.Build(); app.MapControllers(); app.Run();

对应 appsettings.json 里的连接串:

{ "ConnectionStrings": { "DefaultConnection": "Server=127.0.0.1;Port=3306;Database=shopdb;User=root;Password=your_password;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;" } }

MySqlServerVersion必须跟你实际部署的 MySQL 版本对齐。你本地跑 5.7,写死 8.0 可能导致某些 SQL 生成出来在 5.7 上不认识。CharSet=utf8mb4解决中文和 Emoji 乱码,这一项不加,接口返回中文会有欠账。SslMode=None配合AllowPublicKeyRetrieval=True是开发环境的常见组合,避免 MySQL 8 的 SSL 握手和公钥检索报错——本地调试没必要上加密链路,到了生产再按云厂商要求把这两项改回去。

2.2 实体映射与约定配置:IEntityTypeConfiguration把表结构管起来

源码里的实体映射没有用一大堆特性挨个往模型上贴,而是每个实体配一个实现IEntityTypeConfiguration<T>的类。这样做的好处是模型类保持干净,表结构相关的东西全部收敛到一个文件里,后面要加索引、改字段长度,只动配置类。

public class OrderConfiguration : IEntityTypeConfiguration<Order> { public void Configure(EntityTypeBuilder<Order> builder) { builder.ToTable("orders"); builder.HasKey(o => o.Id); builder.Property(o => o.OrderNo) .HasMaxLength(32) .HasColumnName("order_no"); builder.Property(o => o.Amount) .HasPrecision(18, 2) .HasColumnType("decimal(18,2)"); builder.Property(o => o.Status) .HasColumnType("tinyint") .HasDefaultValue(0); builder.Property(o => o.CreatedAt) .HasColumnType("datetime(6)"); builder.HasIndex(o => o.OrderNo).IsUnique(); } }

HasColumnName("order_no")是把 C# 的驼峰属性名转成数据库里的蛇形列名,团队要是统一了这套命名习惯,后面看 SQL 日志会舒服很多。decimal类型必须显式指定精度,否则 MySQL 默认decimal(10,0),金额的小数会被直接砍掉,这在订单场景里是事故级别的坑。datetime(6)保留了微秒精度,如果你的业务只需要秒级时间,改成datetime(0)会更省空间。

写完每个配置类之后,在AppDbContext的OnModelCreating里加一行modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);让框架自动扫描程序集里所有配置类,后续再新增实体配置,不需要回这里改注册。

2.3 代码优先迁移:从模型到数据库表的完整流程

源码是把 EFCore 的 Migrations 当作建表工具用的。模型和配置写好之后,两条命令就能把表结构落到 MySQL:

dotnet ef migrations add InitOrderTables dotnet ef database update

migrations add只生成迁移文件,改的是代码;database update才真正去连数据库执行建表语句。第一次跑update如果不带--context且项目里存在多个 DbContext,大概率会报歧义错误,命令改成dotnet ef database update --context AppDbContext指定上下文即可。

如果你还不想引入迁移机制,EFCore 提供了EnsureCreated()直接把库和表建出来。但它不记录任何迁移历史,后面改模型不会自动同步表结构,一旦项目进入真实迭代阶段,务必回到 Migrations 流程——源码也是这么处理的。

3. Repository与Service层拆分:避免控制器里写业务逻辑的三个理由

为什么控制器不能直接操作 DbContext?第一个理由是可测试性,控制器要 mock 数据源,接口就要抽象出来;第二个理由是 DbContext 本身不是线程安全对象,多请求共享实例会出问题,正确生命周期是按请求级别注入;第三个理由最实际——业务规则散落在控制器 Action 里,后期改一个订单金额计算逻辑要翻三四个文件。源码把对数据源的访问收敛到仓储层,把业务操作收敛到 Service 层,控制器只做参数绑定和状态码返回。

3.1 泛型仓储与工作单元:Controller不直接碰DbContext

泛型仓储是这类项目最常用的落法,把 Common CRUD 抽到IRepository<T>,避免每个实体写一套重复的增删改查。

public interface IRepository<T> where T : class { Task<T?> GetByIdAsync(int id); Task<IReadOnlyList<T>> ListAsync(); Task AddAsync(T entity); Task UpdateAsync(T entity); Task DeleteAsync(T entity); } public class Repository<T> : IRepository<T> where T : class { private readonly AppDbContext _db; public Repository(AppDbContext db) => _db = db; public async Task<T?> GetByIdAsync(int id) => await _db.Set<T>().FindAsync(id); public async Task<IReadOnlyList<T>> ListAsync() => await _db.Set<T>().AsNoTracking().ToListAsync(); public async Task AddAsync(T entity) => await _db.Set<T>().AddAsync(entity); public Task UpdateAsync(T entity) { _db.Set<T>().Update(entity); return Task.CompletedTask; } public Task DeleteAsync(T entity) { _db.Set<T>().Remove(entity); return Task.CompletedTask; } }

GetByIdAsync走的是FindAsync,它先查当前上下文跟踪过的实体,再查数据库,适合按主键取单条;ListAsync用了AsNoTracking(),告诉 EFCore 别为每条记录建跟踪快照,纯只读查询快不少,也避免接下来Update时出现“已有相同键被跟踪”的报错。整个仓储没提SaveChanges,因为保存动作统一交给调用方——也就是下面的 Service 层来控制,这才是工作单元的意思。

3.2 Service层的依赖注入回路:IService与IRepository怎么接上

Service 层的职责是把多个仓储操作编排成完整业务过程,同时控制事务边界。注册部分在 Program.cs 里加:

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); builder.Services.AddScoped<IOrderService, OrderService>();

Service 本身并不复杂,但要看懂它为什么同时注入了多个仓储和 AppDbContext:

public class OrderService : IOrderService { private readonly IRepository<Order> _orders; private readonly IRepository<OrderItem> _orderItems; private readonly AppDbContext _db; public OrderService( IRepository<Order> orders, IRepository<OrderItem> orderItems, AppDbContext db) { _orders = orders; _orderItems = orderItems; _db = db; } public async Task<Order> CreateOrderAsync(CreateOrderRequest request) { var order = new Order { OrderNo = GenerateOrderNo(), Amount = request.Amount, Status = 0 }; await _orders.AddAsync(order); using var tx = await _db.Database.BeginTransactionAsync(); try { await _db.SaveChangesAsync(); foreach (var item in request.Items) { await _orderItems.AddAsync(new OrderItem { OrderId = order.Id, Sku = item.Sku, Quantity = item.Quantity }); } await _db.SaveChangesAsync(); await tx.CommitAsync(); return order; } catch { await tx.RollbackAsync(); throw; } } }

这里最关键的是AddScoped生命周期:同一个 HTTP 请求里,Controller、Service、Repository、DbContext 拿到的是同一个实例。BeginTransactionAsync开启的数据库事务才能覆盖主表和明细分表的两次SaveChangesAsync,中间任意一次失败,RollbackAsync把前面写入全部撤掉。如果你把仓储注册成Singleton或Transient,这个事务链必然出问题,属于新手最容易踩的设计陷阱。

3.3 批量写入与事务控制:别在循环里SaveChanges

批量插入数据是很多接口性能翻车的重灾区。错误写法是循环里逐个AddAsync,每循环一次就SaveChangesAsync一次,5000 条数据能跑出几十秒,中途失败前面的已入库还不好回滚。正确写法:

public async Task<List<Order>> BulkCreateOrdersAsync(List<Order> orders) { await _db.Orders.AddRangeAsync(orders); await _db.SaveChangesAsync(); return orders; }

AddRangeAsync一次性把整批实体加入跟踪,SaveChangesAsync只在最后触发一次,EFCore 会把所有插入操作合并成批量 SQL,MySQL 端整体提交。单批几千条毫无压力;如果你要一次插几万条,建议拆成每批 2000 条分批提交,并评估事务粒度,避免单事务过大拖垮 MySQL 的 redo 日志。

4. EFCore + MySQL 避坑记录:五条亲测踩过的坑

这套源码之所以能直接跑,是因为这些坑被提前填过。下面的问题我在真实项目里全部遇到过,按「现象 → 原因 → 解决」一条条说清楚。

4.1 迁移时报找不到 __EFMigrationsHistory 表

现象:执行dotnet ef database update时抛错,提示找不到__EFMigrationsHistory或无法创建该表。

原因:最常见的是项目里同时引用了 SQL Server 和 MySQL 两个数据库提供程序,dotnet-ef工具不知道该选哪个,或者连接串里残留了 SQL Server 专属参数(比如Trusted_Connection=SSPI),Pomelo 驱动不会解析这些参数,建迁移历史表时自然走了错误路径。

解决:先跑dotnet ef dbcontext info确认实际生效的提供程序是不是 Pomelo。如果是 SQL Server 的,在AddDbContext里显式写死UseMySql,并清掉连接串里所有 SQL Server 专属字段。

4.2 DateTime 字段读出来少了 8 小时

现象:Navicat 里看created_at是 10:00,接口 JSON 返回的却是 02:00,同一个数据库两个时间。

原因:MySQL 服务器时区是SYSTEM,系统本身用的 UTC,驱动按数据库时区返回时间,而应用跑在东八区,直接展示就偏离了 8 小时。

解决:在连接串里追加ConnectionInitSql=SET time_zone = '+08:00';,每次建立连接先把会话时区固定住。如果连的是云数据库,先检查实例的时区参数是否已经设为东八区,避免治标不治本。

4.3 string 字段映射成 longtext 导致加索引失败

现象:表里的order_no、customer_name全是longtext类型,给它们加普通索引时 MySQL 报错“键值超过最大长度”。

原因:EFCore 映射string属性的默认类型是longtext,text 类型不能加普通索引,即便能加,索引长度也会超过 InnoDB 的限制。

解决:所有string属性在配置类里显式标注HasMaxLength,例如订单号给HasMaxLength(32),名称给HasMaxLength(64),需要走索引的列更要严格控制长度。这是源码里每张映射表都有的细节,不是顺手写的,是踩过一次后的手法。

4.4 循环里逐条 SaveChanges 导致性能爆炸

现象:往明细表插 5000 条数据,接口耗时 40 秒,MySQL 侧 CPU 被打满。

原因:每一条数据都触发一次完整的SaveChangesAsync,EFCore 要重复执行状态快照、变更检测、SQL 拼接,开销全部被放大。

解决:改用AddRangeAsync一次加入,再在最后调用一次SaveChangesAsync。删除同理,查出集合后走RemoveRange,不要循环删。

4.5 dotnet ef migrations add 报错 Unable to create a 'DbContext'

现象:dotnet ef migrations add InitOrderTables还没开始生成迁移文件,就直接抛Unable to create a 'DbContext'。

原因:设计时工具不会运行 Program.cs 的Main方法,如果 DbContext 构造函数的连接串依赖运行时配置,或者构造函数签名带参,EF 无从下手创建上下文实例。

解决:添加一个IDesignTimeDbContextFactory<AppDbContext>实现类,把连接串写在其中,作为迁移工具的兜底入口:

public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext> { public AppDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder<AppDbContext>() .UseMySql( "Server=127.0.0.1;Port=3306;Database=shopdb;User=root;Password=your_password;CharSet=utf8mb4;SslMode=None;", new MySqlServerVersion(new Version(8, 0, 36))) .Options; return new AppDbContext(options); } }

这段代码只服务于dotnet ef命令行工具,项目运行时不会执行它,不影响原有依赖注入链路。

5. Swagger与JWT鉴权:把API从本地调试推向真实调用

源码里除了数据访问部分,接口层的两件事也很完整——Swagger 自动文档和 JWT 鉴权。前者解决联调效率,后者解决接口安全,缺一个,这套骨架都算不上完整。

5.1 Swagger自动生成API文档:每个接口都有可点击的调试入口

Swagger 的作用不只是生成 JSON 文档,它给每个 Action 提供了可点击的调试面板,前端或测试可以直接在页面上填参数、发请求、看返回。源码里对 Swagger 的配置做了一步很值得抄的操作——加载 XML 注释文件:

builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo { Title = "Shop API", Version = "v1" }); var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); options.IncludeXmlComments(xmlPath); });

IncludeXmlComments会读取控制器和实体上写的///注释,渲染成接口说明。不加这行,Swagger 页面上每个接口只有路径和参数,没有语义描述,联调时还得反查代码。要启用 XML 文档生成,记得在 .csproj 里打开<GenerateDocumentationFile>true</GenerateDocumentationFile>,否则项目发布后找不到对应的 .xml 文件。

5.2 JWT鉴权接入:Authorization: Bearer xxx

JWT 鉴权在 Program.cs 里分三段:注册认证方案、配置 Token 校验参数、注册授权中间件。顺序错了接口就会一直 401。

var key = Encoding.ASCII.GetBytes(builder.Configuration["Jwt:Secret"]!); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.RequireHttpsMetadata = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), ValidateIssuer = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"], ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(5) }; }); app.UseAuthentication(); app.UseAuthorization();

Token 生成侧用一个简单的方法就能闭环:

public string GenerateToken(int userId) { var key = Encoding.ASCII.GetBytes(_config["Jwt:Secret"]!); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddHours(2), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); }

UseAuthentication必须写在UseAuthorization前面,中间件顺序反了,Token 根本没有机会被解析,接口直接打回 401。ClockSkew默认有五分钟宽松时间,考虑到分布式环境下客户端和服务端可能存在时钟偏移,保留它有好处,但别改得过大,否则 Token 过期时间形同虚设。接入 JWT 之后你会经常遇到 401 和 403:401 是没带 Token 或 Token 过期,403 是带了有效 Token 但没有对应角色/权限,排查接口鉴权问题时先区分这两个状态码,能省一半时间。

5.3 统一返回体与全局异常处理

真实接口很少直接返回裸实体,前端希望所有响应都长一个样,调用方好做统一拦截。源码里有一个简单的ApiResponse包装对象:

public class ApiResponse { public bool Success { get; set; } public string Message { get; set; } = string.Empty; public object? Data { get; set; } }

控制器里正常返回时包一层ApiResponse.Success(data),业务规则校验失败时返回ApiResponse.Fail(message)。异常处理不要靠每个 Action 写 try-catch,而是注册一个全局异常处理中间件,捕获未预期异常后按统一格式返回,同时记录日志。配合上一节 JWT 的中间件顺序,这套接口层的骨架才算完整:鉴权、参数绑定、业务执行、异常兜底,各管一段。

6. 进阶:启动自动迁移与SQL日志验证,让部署更稳

源码里有一个容易被忽略但很实用的小技巧:应用启动时自动执行数据库迁移,把建表和升级表结构的动作提前到发布阶段完成,而不是等接口第一次请求时才暴露问题。

var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); db.Database.Migrate(); } app.Run();

Migrate()和EnsureCreated()有本质区别:前者执行所有尚未应用的迁移文件,并把执行记录写进__EFMigrationsHistory,后续每次模型变更只需要新增迁移再发布;后者只建一次库和表,不记录历史,模型一改就失灵。这套骨架里用的是迁移方案,所以启动时执行Migrate()是安全的。要注意这段逻辑放在Build()之后、Run()之前,且必须在有依赖注入作用域的环境里执行,直接new AppDbContext()拿不到运行时配置。

另一个我每次都会开的验证技巧是把 EFCore 生成的 SQL 打到控制台:

builder.Services.AddDbContext<AppDbContext>(options => { var conn = builder.Configuration.GetConnectionString("DefaultConnection"); options.UseMySql(conn, new MySqlServerVersion(new Version(8, 0, 36))) .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging(); });

LogTo会在每次查询、插入、更新时把最终执行的 SQL 语句打出来,对应表名、字段名、参数一眼就能对上;EnableSensitiveDataLogging会把 SQL 参数里的具体值也展示出来,方便查数据问题,但生产环境绝不能开,这是明文泄露级别的隐患,一般只放在开发环境的配置里。

我这套流程被救过一次:某次发布前以为只是改一个字段长度,结果同事在迁移文件里带了一个删表操作,启动自动迁移当时就报错,流量还没进就拦住了,换作手动执行脚本,那一夜大概要在机房里熬过去了。从那以后,我每次发布前都会强制走一遍启动迁移加接口 SQL 日志验证,确认表结构和查询语句都没问题再放流量,这个习惯值回票价。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询