ASP.NET Core WebAPI与EF Core:构建可复用增删改查模板
2026/9/14 14:40:50 网站建设 项目流程

简介:面向ASP.NET Core初学者及需要快速搭建WebAPI数据接口的后端开发者,这是一份框架搭建入门级配套代码包,演示如何通过ADO方式连接MySQL数据库,完成增删改查、分页等常用接口,可作为搭建可运行WebAPI的最小项目骨架。资源共41个文件,压缩包仅132KB,包含12个C#源文件、1个SQL脚本、6个JSON配置文件以及项目工程文件、DLL等;控制器、模型、数据库操作类分层清晰,SQL脚本可导入建表,配置好连接串后即可运行查看效果,整体结构简洁,便于二次开发。已有1458人浏览学习,受到不少后端学习者关注。模板覆盖WebAPI路由设计、ADO数据访问封装、分页参数处理、配置文件读取等关键编写思路,目录入口与数据访问层拆分明了,适合作为项目初始模板、课程实训或毕业设计参考,也可按需扩展为更完整的数据服务层。

1. ASP.NET Core WebAPI 与数据增删改查模板:先解决骨架,再谈业务

几年前我第一次接手 .NET 组的时候,团队里每个新项目的起手式都不一样:有人用控制台改成 Web 服务,有人把仓储层抄了三百行,还有人直接在 Controller 里拼 SQL。后来我们把“ASP.NET Core 框架搭建 + WebAPI + 数据增删改查模板”定成内部公共项目的第一个环节,效果很直接:新后端能在一小时内跑通一条数据链路,前端拿到的不再是接口风格各异的半成品。这个标题的关键不在“建一个空项目”,而在于把项目结构、数据访问方式、返回格式和异常处理统一成一套可复用的模板,后续新增一张业务表时,只改实体、DTO 和路由就能交付。适合刚转 .NET 的工程师理解 WebAPI 的骨架,也适合团队负责人借此统一日常后端项目的落地规范。

2. 搭建 WebAPI 项目骨架:模板、最小 API 与 Program.cs 的关键配置

2.1 用 dotnet new 创建 WebAPI,并确认两个模板参数

新建项目的命令本身不值得花太多篇幅,但有两个参数决定了后续手感:--use-controllers--use-minimal-apis。在 .NET 8/9 的模板里,默认并不是控制器风格,直接dotnet new webapi出来的是 MapGet/MapPost 的最小 API 写法。如果你要做的是一套对数据表增删改查模板,我一般会明确指定控制器模板:

dotnet new webapi -n Demo.Api --use-controllers cd Demo.Api dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Design code .

-n Demo.Api指定项目名,--use-controllers生成带 Controllers 目录的经典结构。.Design包是为了后续在命令行执行迁移命令用的,只在开发期需要。如果你团队用的是 Visual Studio,勾选“控制器”选项即可,效果一样。需要注意:模板自带的WeatherForecastControllerSampleController只是演示代码,模板搭建阶段建议直接删掉,避免后面在 Swagger 列表里看到一堆无关路径。

2.2 最小 API 与控制器 API 的取舍:模板基座选哪个

标题里写的是“框架搭建”,这里的框架指的是承载 CRUD 的服务端骨架。ASP.NET Core 官方从 .NET 6 开始主推最小 API,它的优点是路由代码集中、启动文件短,但放到“数据增删改查模板”这个场景里有明显短板:控制器天然支持[ApiController]提供的自动 400 响应、模型校验、路由前缀,还可以配合过滤器统一处理日志和异常。最小 API 也能做这些事,但每个路由都要用扩展方法去挂载,模板化之后可读性会变差。

我的经验是:如果项目里接口数量少于 20 个且不打算做后台管理类系统,最小 API 更顺手;如果目标是“给数据表做一套标准操作模板,后续复制粘贴”,控制器 + EF Core 的组合是更常规的选择。下面的内容就以控制器方案为准,但这不妨碍你把 Controller 里的逻辑抽到服务层,Controller 只做参数映射和结果包装。

2.3 Program.cs 中必须注册的五个配置项

用模板创建的项目带一个很干净的 Program.cs,但离“能稳定支撑增删改查的 WebAPI”还差四样东西:数据库上下文注册、控制器服务、Swagger 接口文档和 JSON 序列化选项。一个典型的最小可运行版本如下:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers() .AddJsonOptions(options => { // 避免导航属性循环引用导致序列化抛异常 options.JsonSerializerOptions.ReferenceHandler = System.Text.Json .Serialization.ReferenceHandler.IgnoreCycles; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(builder.Configuration.GetConnectionString("Default"))); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.MapControllers(); app.Run();

AddControllers注册 MVC 控制器所需的服务,没有它,Controller 不会被发现;AddEndpointsApiExplorerAddSwaggerGen负责生成 OpenAPI 文档,调试 CRUD 接口时直接在 Swagger UI 里点按钮即可;AddDbContext的注册生命周期默认是 Scoped,也就是每个 HTTP 请求一个上下文实例,这是 EF Core 的标准用法,不要改成 Singleton。ReferenceHandler.IgnoreCycles的作用比较隐蔽但很实用:当你返回的实体包含导航属性,而导航属性又指回父实体时,默认的 JSON 序列化会抛出循环引用异常,开启忽略后虽然会截断链路,但接口不会挂掉。

3. 用 EF Core 对接数据源:DbContext、实体映射与迁移机制

3.1 按数据库选包与连接串参数

做数据增删改查模板,先要确定数据访问层。EF Core 是 ASP.NET Core 生态里的标准 ORM,它能把你写的 LINQ 查询翻译成 SQL,并支持代码优先迁移。数据库方面,本地演示用 SQLite 最省事,连appsettings.json都不用改权限;实际企业项目里 SQL Server 和 MySQL 更常见。选择包的方式很简单:

# SQLite 开发调试 dotnet add package Microsoft.EntityFrameworkCore.Sqlite # SQL Server 生产环境 dotnet add package Microsoft.EntityFrameworkCore.SqlServer # MySQL 8.x 社区常用 Pomelo 驱动 dotnet add package Pomelo.EntityFrameworkCore.MySql

Pomelo 连接串里的参数要比 SQLite 多几个,常见的配置项如下:

参数说明示例
Server数据库地址127.0.0.1 或 localhost
Port端口3306
Database库名school_dev
User / Password账号密码root / 你的密码
TreatTinyAsBooleantinyint(1) 是否映射成 boolfalse
AllowPublicKeyRetrievalMySQL 8 是否允许请求公钥true

一个完整的 MySQL 连接串看起来像这样:

Server=127.0.0.1;Port=3306;Database=school_dev;User=root;Password=your_pwd;TreatTinyAsBoolean=false;AllowPublicKeyRetrieval=true

注意TreatTinyAsBoolean默认是 true,如果某张表的tinyint(1)列实际存的是 0 和 1 以外的值,实体属性类型用 byte 会更安全。这个选项是很多新手查半天才发现的坑。

3.2 实体类设计的三个注意点:主键、软删除、并发令牌

增删改查模板终究要落到实体类上。设计表对应的实体时,除了“数据库有什么字段就写什么属性”之外,有三个约定我会提前放进模板:

第一个是主键类型尽量统一为long,比int多一倍的上限,也比字符串主键节省索引空间。第二个是软删除字段,后台管理系统里删除操作经常要留痕,IsDeleted字段配合全局过滤可以让查询自动屏蔽已删数据。第三个是并发令牌,用RowVersion能避免两个请求同时更新一条记录时互相覆盖。

public class Student { public long Id { get; set; } [MaxLength(50)] public string Name { get; set; } = default!; public int Age { get; set; } [MaxLength(100)] public string? Email { get; set; } public bool IsDeleted { get; set; } public byte[] RowVersion { get; set; } = default!; }

[MaxLength]会参与迁移生成,控制列长度;default!是给非空引用类型一个空值默认,避免编译器告警;byte[] RowVersion在 SQL Server 里被配置为 rowversion 后,每次更新数据库自动改值,EF Core 会在 UPDATE 语句的 WHERE 条件里带上旧值,以此判断有没有被别的事务改过。SQLite 不支持自动 rowversion,本地演示时可以暂时去掉这个属性,不影响其他部分。

3.3 DbContext 注册与迁移三连:从模型到数据库表

实体定义完,需要建一个继承DbContext的类:

public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Student> Students => Set<Student>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Student>(entity => { entity.ToTable("student_info"); entity.HasIndex(x => x.Name); entity.HasQueryFilter(x => !x.IsDeleted); if (Database.IsSqlServer()) { entity.Property(x => x.RowVersion) .IsRowVersion() .IsConcurrencyToken(); } }); } }

DbSet<Student>是暴露给查询用的入口,没写set;而用表达式体=> Set<Student>(),是为了在上下文里用受控方式获取集合。HasQueryFilter是关键,它会把这个表达式自动合并到所有针对Student的 LINQ 查询末尾,相当于给每一条 SQL 都加上WHERE IsDeleted = 0Database.IsSqlServer()的写法让同一个模型可以在不同数据库间切换,从 SQL Server 换到 SQLite 时不需要改实体代码。

迁移命令在项目根目录执行:

dotnet ef migrations add InitialCreate -o Data/Migrations dotnet ef database update

migrations add InitialCreate会在Data/Migrations目录生成迁移文件,database update把变更应用到当前连接串指向的数据库。注意区分EnsureCreated和迁移:EnsureCreated只适合原型演示,它不记录迁移历史,后续模型一改就麻;凡是给团队维护的项目,一律走迁移。迁移失败时先看最后的HResult和连接串,绝大多数情况不是 SQL 语法问题,而是数据库连接串没配上或Design包未安装。需要输出 SQL 脚本时,用:

dotnet ef migrations script -o upgrade.sql

这个脚本可以在 CI 或 DBA 手里执行,不用在本机装 SDK。

4. 在控制器里落地增删改查模板:异步方法、状态码与数据校验

4.1 REST 路由与增删改查动作的对应关系

控制器是增删改查模板最直观的体现。前面已经提到,[ApiController]特性会启用自动模型状态验证,配合 REST 风格的路由,一个资源通常有五个标准动作:GET 列表、GET 单条、POST 新增、PUT 整笔更新、DELETE 删除。路由设计为/api/student而不是/api/student/getlist,是为了让 URL 与 HTTP 方法共同表达语义。

HTTP 方法路由用途成功响应
GET/api/student查询列表200 OK
GET/api/student/{id}查询单条200 OK
POST/api/student新增数据201 Created
PUT/api/student/{id}修改数据200 OK
DELETE/api/student/{id}删除数据204 No Content

状态码不是随便定的。新增后返回201而不是200,是因为前者带 Location 头并明确指出资源已创建;删除成功返回204 No Content,避免返回一个空 body 还占用带宽;找不到资源时返回404,参数校验失败时返回400,这些都是前端和联调同学最容易依赖的规则。

4.2 一个可直接复制的 Student 控制器

把前面的实体与上下文组合起来,一个可用的增删改查控制器代码如下:

[ApiController] [Route("api/[controller]")] public class StudentController : ControllerBase { private readonly AppDbContext _db; public StudentController(AppDbContext db) { _db = db; } [HttpGet] public async Task<ActionResult<List<Student>>> GetList( string? keyword, int page = 1, int pageSize = 20, CancellationToken ct = default) { var query = _db.Students.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(keyword)) { query = query.Where(x => x.Name.Contains(keyword) || x.Email!.Contains(keyword)); } var total = await query.CountAsync(ct); var items = await query .OrderBy(x => x.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(ct); return Ok(new { total, items }); } [HttpGet("{id:long}")] public async Task<ActionResult<Student>> GetById(long id, CancellationToken ct) { var student = await _db.Students .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == id, ct); if (student is null) { return NotFound(); } return Ok(student); } [HttpPost] public async Task<ActionResult<Student>> Create( StudentDto input, CancellationToken ct) { var entity = new Student { Name = input.Name, Age = input.Age, Email = input.Email }; _db.Students.Add(entity); await _db.SaveChangesAsync(ct); return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity); } [HttpPut("{id:long}")] public async Task<ActionResult<Student>> Update( long id, StudentDto input, CancellationToken ct) { var entity = await _db.Students .FirstOrDefaultAsync(x => x.Id == id, ct); if (entity is null) { return NotFound(); } entity.Name = input.Name; entity.Age = input.Age; entity.Email = input.Email; await _db.SaveChangesAsync(ct); return Ok(entity); } [HttpDelete("{id:long}")] public async Task<IActionResult> Delete(long id, CancellationToken ct) { var entity = await _db.Students .FirstOrDefaultAsync(x => x.Id == id, ct); if (entity is null) { return NotFound(); } _db.Students.Remove(entity); await _db.SaveChangesAsync(ct); return NoContent(); } }

控制器直接注入AppDbContext是为了让模板更短,等你积累多个业务模块后,再把它抽到 Service 层也不迟。每个方法都传了CancellationToken ct,客户端断开连接时数据库查询会及时取消,避免线程池里堆积无效任务。AsNoTracking()用在哪两个地方值得注意:查询接口用它,因为只读数据不需要跟踪;更新接口不用它,因为必须先让 EF Core 跟踪实体,修改属性后SaveChangesAsync才能生成 UPDATE 语句。CreatedAtAction的返回值里包含了新实体的Id,前端不用再去查一次。

DTO 的作用是防止客户端传入模板里不允许的字段。举个典型例子:创建学生时,主键Id和软删除标记IsDeleted应该由服务端控制,如果把整个Student实体作为 POST 入参,懂接口的人完全可以传一个IsDeleted = true进来。规范做法是用只读 DTO:

public class StudentDto { [Required, MaxLength(50)] public string Name { get; set; } = default!; [Range(0, 150)] public int Age { get; set; } [EmailAddress, MaxLength(100)] public string? Email { get; set; } }

[Required]触发 400 校验,[Range]挡住负数年龄,[EmailAddress]做格式检查。使用 DTO 之后,实体类可以作为内部模型自由加字段,不会把实现细节暴露给接口消费者。

4.3 并发冲突与事务:增删改查模板不能漏的两个边界

数据库层面的并发问题在删除和更新接口里最容易出现。两个请求同时读到同一条记录,都改了名字,后提交的人会把先提交的人覆盖掉,这种问题用乐观并发处理:

try { await _db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var dbValues = await entry.GetDatabaseValuesAsync(ct); if (dbValues is null) { return NotFound(); } entry.OriginalValues.SetValues(dbValues); } await _db.SaveChangesAsync(ct); }

DbUpdateConcurrencyException表示数据库端行版本与实体进入跟踪状态时不一致。这里的处理策略是“以数据库当前值为准重新提交”,适合后台管理这类低频场景。如果业务要求“客户端强制覆盖”,可以把entry.CurrentValues.SetValues(dbValues)改成加载客户端的原始值,再合并需要保留的字段。这个异常处理可以抽到一个全局过滤器里,避免每个 Update 方法都写一遍。

当一次请求需要操作多张表时,比如新增学生同时初始化他的账号,模板里要显式使用事务:

await using var transaction = await _db.Database.BeginTransactionAsync(ct); try { _db.Students.Add(student); _db.Accounts.Add(account); await _db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); } catch { await transaction.RollbackAsync(ct); throw; }

BeginTransactionAsync之后,同一上下文内的操作会在一个数据库事务里执行,中途任何一次SaveChangesAsync失败,前面的写入都会被回滚。值得强调的是,EF Core 的SaveChanges本身是隐式事务,但一次请求里调用两次就会变成两个独立事务,中途异常时第一个操作不会撤销,所以多条写操作需要手动包一层事务。

4.4 给 CRUD 接口套一个统一返回结构

上面控制器的写法直接返回实体本身,特点是代码少、直观,但前端需要同时处理多种数据结构:成功时是数组,失败时是错误文本。另一个更规范的方案是统一返回包装类:

public record ApiResult<T>(int Code, string Message, T? Data) { public static ApiResult<T> Ok(T data) => new(0, "ok", data); public static ApiResult<T> Fail(string message, int code = 1) => new(code, message, default); }

控制器里的接口改成return Ok(ApiResult<Student>.Ok(student)),前端只需要约定Code == 0代表成功。配合全局异常过滤器,业务异常可以统一映射到ApiResult<T>.Fail的 JSON 结构,而不是返回一个 ASP.NET Core 默认的空错误对象。对于纯 REST 风格的项目,直接返回实体没有错,但一旦前端有几个团队并行开发,统一的失败结构能省掉大量沟通成本。

5. 让查询参数、分页与排序进模板:增删改查的补充能力

5.1 用 IQueryable 动态拼接查询条件,避免全表取出再过滤

标准的增删改查模板往往只做按主键操作,但实际项目里列表页几乎都会带搜索条件。实现动态查询的常见做法是先构造IQueryable,再根据参数决定是否追加Where条件。关键点是:Where要在调用ToListAsync之前执行,否则会把整张表加载到内存再过滤。

[HttpGet] public async Task<ActionResult<List<Student>>> GetList( string? name, int? minAge, int? maxAge, int page = 1, int pageSize = 20) { var query = _db.Students.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(name)) { query = query.Where(x => x.Name.Contains(name)); } if (minAge.HasValue) { query = query.Where(x => x.Age >= minAge.Value); } if (maxAge.HasValue) { query = query.Where(x => x.Age <= maxAge.Value); } var total = await query.CountAsync(); var items = await query .OrderBy(x => x.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return Ok(new { total, items }); }

IQueryable的本质是表达式树,Where方法叠加时只是在组合表达式,不会立即执行查询。CountAsync会生成SELECT COUNT(*)ToListAsync才真正把数据取回来。这里的pagepageSize对应列表页的分页参数,框架里没有默认值保护时,pageSize传 100000 会把所有数据一次性拽出来,属于接口性能隐患。

5.2 分页模型的参数上限与排序白名单

分页模板不能只写Skip/Take,还要处理边界。常见约定是:page从 1 开始,小于 1 时按 1 处理;pageSize上限 100,超出后截断。实现方式很多,用扩展方法最干净:

public static class QueryableExtensions { public static IQueryable<T> PageBy<T>( this IQueryable<T> query, int page, int pageSize, int maxPageSize = 100) { if (page < 1) page = 1; pageSize = Math.Clamp(pageSize, 1, maxPageSize); return query.Skip((page - 1) * pageSize).Take(pageSize); } }

Math.Clamp是 .NET 6 起的内置方法,用来把 pageSize 限制在 1 到 100 之间。控制器里调用query.PageBy(page, pageSize)就能统一控制。排序参数要防止 SQL 注入和性能问题。最直接的做法是不让客户端传字段名,服务端用字典白名单映射:

private static readonly Dictionary<string, Expression<Func<Student, object>>> OrderMap = new() { ["id"] = x => x.Id, ["age"] = x => x.Age, ["name"] = x => x.Name }; var orderExpr = OrderMap.TryGetValue(orderBy, out var expr) ? expr : OrderMap["id"]; query = orderByDesc ? query.OrderByDescending(orderExpr) : query.OrderBy(orderExpr);

白名单字典的好处是客户端只能从idagename里选一个排序字段,传其他值自动落到id,既不需要拼接字符串,又让排序行为完全可控。orderByDesc是一个 bool 参数,用于控制升序还是降序。如果业务需要支持多字段排序,可以把字典值改为List<SortDescriptor>结构,但单表单查询场景用一个字段排序足够。

5.3 模糊查询的转义与 NoTracking 的误用边界

模糊查询用Contains生成LIKE '%关键字%',在数据量超过几十万行时性能和正确性都要注意一个细节:用户输入里的%_是 SQL LIKE 的通配符。比如搜索100%时,实际结果会包含“1001”,这不符合大多数人的预期。EF Core 里处理方式是:

var escaped = keyword .Replace("\\", "\\\\") .Replace("%", "\\%") .Replace("_", "\\_"); query = query.Where(x => EF.Functions.Like(x.Name, $"%{escaped}%"));

这里用EF.Functions.Like替代Contains,因为Like支持指定转义符,而Contains的转义行为在高版本里虽然已修复,但语义不直观。代价是会导致这列索引失效,不过列表页关键词搜索本身走全表扫描也常见,真要加速需要加全文索引。

另外AsNoTracking()不是万能的:跟踪查询返回的实体一旦被修改,SaveChanges会把这些修改一并提交,所以写接口里不能用AsNoTracking;读接口是否使用跟踪模式取决于你有没有“读取后改一两个字段再保存”的需求,模板里默认加即可。

5.4 用 Swagger 和 HttpClient 快速验证查询参数

写完查询接口,验证参数最直接的方式是打开 Swagger UI,找到 GET 接口点 Try it out,把分页和搜索参数填进去看返回。命令行里用 curl 也能测:

curl "http://localhost:5000/api/student?name=%E5%BC%A0&page=1&pageSize=20"

%E5%BC%A0是“张”的 URL 编码,如果接口返回中文乱码,多半是响应编码设置问题,检查appsettings.json里有没有设置"ApplicationUrl"或反向代理层是否加了 charset。还有一个体积不大但很常用的做法:在 WinForms 老项目里调 WebAPI 接口时,用HttpClient写一段小工具,把 GET/POST 的地址、body 都做成可配置项。这个工具能长期复用,因为很多内部系统的联调环境就是客户端直连服务端,不需要专门装 Postman。

6. 模板化进阶:用泛型基类封装通用增删改查,再谈运行时模型

6.1 用基类把增删改查模板改成可复用代码

当你有多张结构相近的表时,每个控制器都写一遍增删改查会开始烦躁。这时候可以把“按主键查找、新增、更新、删除”抽象到泛型基类里。假设约定每张表的主键属性名为Id,基类可以写成:

[ApiController] public abstract class BaseCrudController<TEntity, TKey> : ControllerBase where TEntity : class { protected readonly AppDbContext Db; protected BaseCrudController(AppDbContext db) { Db = db; } private static Expression<Func<TEntity, bool>> BuildKeyEquals(TKey id) { var parameter = Expression.Parameter(typeof(TEntity), "x"); var property = Expression.Property(parameter, "Id"); var value = Expression.Constant(id, typeof(TKey)); var body = Expression.Equal(property, value); return Expression.Lambda<Func<TEntity, bool>>(body, parameter); } [HttpGet("{id}")] public virtual async Task<ActionResult<TEntity>> Get( TKey id, CancellationToken ct) { var item = await Db.Set<TEntity>() .AsNoTracking() .FirstOrDefaultAsync(BuildKeyEquals(id), ct); return item is null ? NotFound() : Ok(item); } [HttpPost] public virtual async Task<ActionResult<TEntity>> Create( TEntity entity, CancellationToken ct) { Db.Set<TEntity>().Add(entity); await Db.SaveChangesAsync(ct); var id = typeof(TEntity).GetProperty("Id")?.GetValue(entity); return CreatedAtAction(nameof(Get), new { id = id }, entity); } [HttpPut("{id}")] public virtual async Task<ActionResult<TEntity>> Update( TKey id, TEntity entity, CancellationToken ct) { var existing = await Db.Set<TEntity>() .FirstOrDefaultAsync(BuildKeyEquals(id), ct); if (existing is null) { return NotFound(); } Db.Entry(existing).CurrentValues.SetValues(entity); await Db.SaveChangesAsync(ct); return Ok(existing); } [HttpDelete("{id}")] public virtual async Task<IActionResult> Delete(TKey id, CancellationToken ct) { var item = await Db.Set<TEntity>() .FirstOrDefaultAsync(BuildKeyEquals(id), ct); if (item is null) { return NotFound(); } Db.Set<TEntity>().Remove(item); await Db.SaveChangesAsync(ct); return NoContent(); } }

这段代码用表达式树动态构造x.Id == id的 Lambda,这样泛型基类就不需要where TEntity : IHasId<TKey>接口也可以工作。使用方式是让业务控制器继承它:

[Route("api/[controller]")] public class StudentController : BaseCrudController<Student, long> { public StudentController(AppDbContext db) : base(db) { } }

继承之后,增删改查的基本能力自动具备,剩下的工作是在子类里补充查询条件、DTO 转换或缓存逻辑。注意Update方法里SetValues会把传入实体的所有属性覆盖到现有实体上,如果业务上有“部分字段不允许修改”的需求,这个方法要按字段逐个赋值,不能被框架上的便捷操作带偏。

6.2 运行时注册实体,让模板应对动态业务表

比泛型基类走得更远的一步,是在OnModelCreating里运行时注册实体类型。这套机制适合低代码平台、MES 系统里用户自定义表单的场景:实体类是编译期不知道的,只能靠元数据生成。实现思路是在DbContext里维护一个类型列表:

public class AppDbContext : DbContext { private readonly List<Type> _dynamicEntityTypes = new(); public void RegisterEntity(Type type) { _dynamicEntityTypes.Add(type); } protected override void OnModelCreating(ModelBuilder modelBuilder) { foreach (var type in _dynamicEntityTypes) { modelBuilder.Entity(type); } // 静态实体配置 } }

使用的时候,用Db.Set(type)拿到非泛型的 DbSet,再配合Property反射调用AddUpdateRemove,就能对任意一张运行时注册的表执行标准操作。把泛型基类和运行时注册组合起来,就是一套完整的“增删改查模板”:静态部分的实体表用基类继承,动态部分用元数据驱动。这两种方式各有成本,团队规模小、业务固定时,模板化到泛型基类足够了;业务经常加表、又不想改代码重发布时,才值得上运行时模型。实现时记住一点:动态注册的表也要走迁移流程,建议用migrations script生成增量 SQL,而不是依赖EnsureCreated

验证这套模板是否合格的标准很简单:开一个新的控制器子类,编译通过后直接跑 Swagger,增删改查四个方法零改动就能用。如果每个新模块还需要手工改基类代码,说明模板的抽象粒度还没到位,继续把变化的部分往上提。

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

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

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

立即咨询