完整 CRUD
前两章已经能创建和查询 Todo,这一章补上更新与删除:先读取实体,再修改或标记删除,最后调用 SaveChangesAsync() 保存。
CRUD 是 Create、Read、Update、Delete 的缩写,也就是创建、读取、更新、删除。校验、路由分组和错误处理沿用前面的做法。为集中讲解写入,本章列表返回全部 Todo,不保留筛选分页;分类预置 Work(1)和 Life(2),暂不提供增删接口。
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Categories.AnyAsync())
{
db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
await db.SaveChangesAsync();
}
}
var todos = app.MapGroup("/todos").WithTags("Todos");
todos.MapGet("/", async (TodoDbContext db) =>
await db.Todos.AsNoTracking().OrderBy(t => t.Id)
.Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync());
todos.MapGet("/{id:int}", async Task<Results<Ok<TodoResponse>, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is null ? TypedResults.NotFound()
: TypedResults.Ok(new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
});
todos.MapPost("/", async Task<Results<Created<TodoResponse>, ProblemHttpResult>> (CreateTodo input, TodoDbContext db) =>
{
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}",
new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
}).ProducesProblem(400);
todos.MapPut("/{id:int}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (int id, ReplaceTodo input, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
todo.Title = input.Title;
todo.Done = input.Done;
todo.CategoryId = input.CategoryId;
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).ProducesProblem(400);
todos.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
app.Run();using System.ComponentModel.DataAnnotations;
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Done { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Todo> Todos { get; set; } = [];
}
public record CreateTodo(
[Required, StringLength(100)] string Title,
[Range(1, int.MaxValue)] int CategoryId);
public record ReplaceTodo(
[Required, StringLength(100)] string Title,
bool Done,
[Range(1, int.MaxValue)] int CategoryId);
public record TodoResponse(int Id, string Title, bool Done, int CategoryId);using Microsoft.EntityFrameworkCore;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}运行与验证
停止上一章服务,在仓库根目录执行:
cd samples/17-crud
dotnet run本章使用 todos-17.db,首次运行时 Todo 表为空。下面按顺序操作,响应只摘录关键响应头;数据库已有数据时,编号会不同。
创建任务:
curl -i -X POST http://localhost:5080/todos -H "Content-Type: application/json" -d '{"title":"Write report","categoryId":1}'HTTP/1.1 201 Created
Location: /todos/1
Content-Type: application/json; charset=utf-8
{"id":1,"title":"Write report","done":false,"categoryId":1}把它改为已完成,并移到 Life 分类:
curl -i -X PUT http://localhost:5080/todos/1 -H "Content-Type: application/json" -d '{"title":"Write report","done":true,"categoryId":2}'HTTP/1.1 204 No Content204 没有响应体。重新读取,才能看到保存后的内容:
curl http://localhost:5080/todos/1{"id":1,"title":"Write report","done":true,"categoryId":2}删除并再次查询:
curl -i -X DELETE http://localhost:5080/todos/1
curl -i http://localhost:5080/todos/1前一个响应是 204;后一个是 404,响应体如下,traceId 的值每次不同:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"request-trace-id"}请求中允许修改哪些字段
CreateTodo 只允许输入 Title、CategoryId;ReplaceTodo 还允许修改 Done。Id 始终来自路由或数据库,不从请求体覆盖。
为什么不用 Todo 直接接收请求?如果将来实体增加内部字段,直接接收整个实体可能让客户端也能修改它。单独定义请求类型,就能明确限制可修改的字段。
输入上的 [Required]、[StringLength]、[Range] 沿用第 06 章的机制。输入 record 保持 public,让 .NET 10 校验生成器处理它。
PUT 在这里表示替换客户端可编辑的完整状态,请发送 title、done、categoryId 三个字段。它不是“只修改 JSON 中出现的字段”:例如省略 done,反序列化后的值是 false,就会把当前完成状态替换成 false。
先读取,再修改,再保存
PUT 的步骤是:
FindAsync(id)找到实体;不存在就返回 404。- 查询目标分类是否存在;不存在返回带说明的 400。
- 修改跟踪中的实体属性。
SaveChangesAsync()检测变化并写入数据库。
这里不需要再调用 Update(todo),因为 FindAsync 返回的实体已经由当前上下文跟踪。列表上的 AsNoTracking() 则用于只读场景;如果取回不跟踪的对象后只修改属性,直接保存不会自动写回它。基本保存操作
删除同理:Remove(todo) 标记实体待删除,SaveChangesAsync() 才真正删除数据库中的记录。删除后再查同一编号,得到 404。
编号合法,不代表分类存在
[Range(1, int.MaxValue)] 只能检查编号是正数,不能证明分类 99 确实存在。分类存在性需要查询数据库,所以它放在处理程序里:
curl -i -X POST http://localhost:5080/todos -H "Content-Type: application/json" -d '{"title":"Invalid","categoryId":99}'响应为 400,正文如下:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Category not found","status":400,"traceId":"request-trace-id"}先查询是为了返回明确的“分类不存在”,数据库的外键约束仍会阻止写入无效编号。本例不能删除分类;以后若加上删除接口,还要处理“检查时存在,保存前被删掉”的情况。
.ProducesProblem(400) 将这类 400 响应补充到 OpenAPI 文档中,原因见第 09 章。
重复请求与同时修改
重复 PUT 相同内容,最终状态相同;DELETE 多次后,资源仍是不存在的。这叫幂等性(idempotency),不要求每次状态码都相同,所以第一次 DELETE 的 204 和后一次的 404 并不矛盾。POST 则可能每次创建新资源。
本章还没有检查并发更新:如果两个人读到同一条 Todo 后各自修改,后保存的值可能覆盖前一次修改。下一章先解决访问权限,限制谁可以调用这些接口。
FastAPI 对照
这类似在 FastAPI 处理函数中查出 SQLAlchemy 实体、修改属性,然后提交 Session。DTO 与数据库实体分开,也对应 Pydantic 输入/输出模型与 ORM 模型各自负责一件事。
提示
想对照 INSERT、UPDATE、DELETE,可以查阅 EF Core / LINQ ↔ PostgreSQL 速查,附可运行的对照示例。
总结
- CRUD 组合创建、读取、更新、删除;不同操作使用相应的 HTTP 方法和状态码。
- 请求 DTO 限定可修改字段,实体对应数据库记录,响应 DTO 决定返回字段。
- 跟踪查询后的实体可以直接修改,
SaveChangesAsync()检测并保存变化。 Remove只标记删除,保存后才生效;字段校验不能替代数据库存在性检查。- 本例 PUT 替换完整可编辑状态,尚未处理并发更新冲突。
