从零搭一个 ASP.NET Core 后端
记录我用 ASP.NET Core(.NET 11)给「大学选课系统」搭后端的完整流程。写这个主要是想锻炼下高并发的能力(当然还有复习复习古法编程啦,啊哈哈)
一、分层架构总览
后端一共四层
Models 实体 → AppDbContext 映射 → DTO/Results 契约 → Service 业务 → Controller 接口 └──────── 迁移 + 建库 + 种子数据(依赖前两层,可提前做)────────┘全程依赖注入注册(AddDbContext / AddScoped)| 层 | 职责 | 总结 |
|---|---|---|
| Models | 领域实体 | 数据库表在c#中的映射 |
| AppDbContext | 映射配置 | 表、关系、唯一索引、级联删除 |
| DTO + Results | 契约 | 后端返回给前端的数据,业务结果 |
| Service | 业务逻辑 | 干活的 |
| Controller | HTTP 接口 | 参数校验 + 调 Service + 映射状态码 |
代码一定要解耦我说。不然到时候改都不好改,虽然是小项目,但结构必须清晰,养成良好开发习惯
二、详细代码实现
1. Models:实体就是数据库的形状
public class Course{ public int Id { get; set; } public string Code { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public int Credit { get; set; }
public int Capacity { get; set; } // 容量 public int SelectedCount { get; set; } // 已选人数(物化计数器)}约定:主键统一 int Id 自增;字符串用 string.Empty 初始化避免 nullable 警告;导航属性用 null! 或 new() 兜底。这都是约定俗成了
2. AppDbContext:把实体映射成表
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options){ public DbSet<Course> Courses => Set<Course>(); // ... 其余 DbSet
protected override void OnModelCreating(ModelBuilder b) { // 唯一索引 b.Entity<Enrollment>().HasIndex(e => new { e.StudentId, e.CourseId }).IsUnique(); b.Entity<Student>().HasIndex(s => s.StudentNo).IsUnique();
// 冗余外键:Restrict,防级联删除 b.Entity<Student>().HasOne(s => s.College).WithMany() .HasForeignKey(s => s.CollegeId).OnDelete(DeleteBehavior.Restrict); }}坑:EF Core 里「非空外键」默认是
CASCADE级联删除,只有你显式.OnDelete(DeleteBehavior.Restrict)的才是NO ACTION。看生成的建表 SQL 就能确认这一点。
3. DTO:为什么不直接返回实体?
实体带导航属性(Course.Enrollments 等),直接传你的json就爆炸了捏(
public sealed record CourseDto( int Id, string Code, string Name, int Credit, int Capacity, int SelectedCount);4. Results:c#15真是太好用了
每个业务操作定义一个具体的 union 类型,case 用 record 声明。比如选课:
public sealed record CourseNotFound;public sealed record CourseFull; // 已满——并发核心public sealed record AlreadySelected; // 重复选课public sealed record EnrollmentCreated(Enrollment Enrollment); // 成功承载实体
public union EnrollmentResult( CourseNotFound, AlreadySelected, CourseFull, StudentNotFound, EnrollmentCreated);好处:业务结果类型安全,不可能漏处理某个错误分支。以前的话要用_进行空分支处理,这样的话可能会有莫名奇妙的错误。这样一改的话,前端也记得写好通知提示呢(上次的选课系统是前端没写好错误提示,导致我一个bucket桶没配好,结果返回的错误莫名其妙,让我抓不着头脑)
5. Service:业务逻辑,返回 union
public async Task<EnrollmentResult> SelectEnrollmentAsync(string studentNo, int courseId){ var student = await db.Students.SingleOrDefaultAsync(s => s.StudentNo == studentNo); if (student == null) return new StudentNotFound();
var course = await db.Courses.FindAsync(courseId); if (course == null) return new CourseNotFound();
if (course.SelectedCount >= course.Capacity) return new CourseFull();
var existing = await db.Enrollments .SingleOrDefaultAsync(e => e.StudentId == student.Id && e.CourseId == courseId); if (existing != null) return new AlreadySelected();
var enrollment = new Enrollment { StudentId = student.Id, CourseId = courseId, EnrolledAt = DateTime.UtcNow }; db.Enrollments.Add(enrollment); course.SelectedCount++; await db.SaveChangesAsync(); return new EnrollmentCreated(enrollment);}每个 return new Xxx() 都对应 union 里的一个 case
6. Controller:前后端交互层
[ApiController][Route("api/[controller]")]public sealed class StudentsController(CourseService service) : ControllerBase{ [HttpPost("{studentNo}/enrollments/{courseId}")] public async Task<IActionResult> Enroll(string studentNo, int courseId) { var result = await service.SelectEnrollmentAsync(studentNo, courseId); return result switch { EnrollmentCreated created => Ok(new { created.Enrollment.Id }), StudentNotFound => NotFound(new { error = "学生不存在" }), CourseNotFound => NotFound(new { error = "课程不存在" }), CourseFull => Conflict(new { error = "课程已满" }), AlreadySelected => Conflict(new { error = "已选过该课程" }), }; }}漏掉一个就报编译错误
7. 数据库:迁移 + 种子数据
dotnet ef migrations add InitialCreate # 生成迁移dotnet ef database update # 应用迁移、建表种子数据用 DbInitializer,在启动时跑一次且幂等:
using (var scope = app.Services.CreateScope()){ var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); db.Database.Migrate(); // 启动自动应用迁移 DbInitializer.Seed(db); // 幂等种子}三、踩坑清单(孩子们我是傻逼)
-
string和int划等号来了:s.StudentNo == studentNo,一个 string 一个 int,报CS0019。强类型语言没有隐式转换(我错了我再也不写js了),类型不一样就爆了 -
record 位置参数对不上:
new StudentProfile(...)少了Id参数,后面整体错位,报CS1729。位置参数构造,个数和顺序必须和声明一一对应。 -
union switch 写
_兜底:孩子们我忘了我用了新特性( -
Enrollments.Count代替SelectedCount:导航属性没Include时恒为 0;而且「已选人数」本就是为了避免COUNT(*)才物化出来的字段,又是一个忘记自己约定的例子( -
路由双重前缀:
[Route("api/[controller]")]里[controller]已经展开成类名了,action 里再写一遍"courses"就变成/api/Courses/courses。 -
漏 DI 注册:写了 Service 却没
AddScoped,不好孩子们,原来Program.cs才是项目主入口,我忘了 -
dotnet ef 版本不匹配:依赖问题(
四、解决依赖问题的焚诀
# 一次性准备dotnet tool install --global dotnet-ef --version <和包版本一致>
# 日常循环(改模型后)dotnet ef migrations add <名字>dotnet ef database update # 或启动时 db.Database.Migrate()
# 撤销 / 查看dotnet ef migrations removedotnet ef migrations list记住:Migrations/ 目录要提交进 git,别删别手改。
总结
契约先行,命名规范,写的时候出错总比跑起来各种问题强。这篇文主要是给可能学习后端开发的小登看看,借鉴一下流程和工程思想。具体代码的话我写的挺一般的(另外还有就是提醒自己吧,这些最基本的东西,能写出来才内化于心
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





