mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
982 字
3 分钟
从零搭一个 ASP.NET Core 后端之笔记
2026-09-19

从零搭一个 ASP.NET Core 后端#

记录我用 ASP.NET Core(.NET 11)给「大学选课系统」搭后端的完整流程。写这个主要是想锻炼下高并发的能力(当然还有复习复习古法编程啦,啊哈哈)

一、分层架构总览#

后端一共四层

Models 实体 → AppDbContext 映射 → DTO/Results 契约 → Service 业务 → Controller 接口
└──────── 迁移 + 建库 + 种子数据(依赖前两层,可提前做)────────┘
全程依赖注入注册(AddDbContext / AddScoped)
层职责总结
Models领域实体数据库表在c#中的映射
AppDbContext映射配置表、关系、唯一索引、级联删除
DTO + Results契约后端返回给前端的数据,业务结果
Service业务逻辑干活的
ControllerHTTP 接口参数校验 + 调 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); // 幂等种子
}

三、踩坑清单(孩子们我是傻逼)#

  1. string 和 int 划等号来了:s.StudentNo == studentNo,一个 string 一个 int,报 CS0019。强类型语言没有隐式转换(我错了我再也不写js了),类型不一样就爆了

  2. record 位置参数对不上:new StudentProfile(...) 少了 Id 参数,后面整体错位,报 CS1729。位置参数构造,个数和顺序必须和声明一一对应。

  3. union switch 写 _ 兜底:孩子们我忘了我用了新特性(

  4. Enrollments.Count 代替 SelectedCount:导航属性没 Include 时恒为 0;而且「已选人数」本就是为了避免 COUNT(*) 才物化出来的字段,又是一个忘记自己约定的例子(

  5. 路由双重前缀:[Route("api/[controller]")] 里 [controller] 已经展开成类名了,action 里再写一遍 "courses" 就变成 /api/Courses/courses。

  6. 漏 DI 注册:写了 Service 却没 AddScoped,不好孩子们,原来Program.cs才是项目主入口,我忘了

  7. dotnet ef 版本不匹配:依赖问题(


四、解决依赖问题的焚诀#

# 一次性准备
dotnet tool install --global dotnet-ef --version <和包版本一致>
# 日常循环(改模型后)
dotnet ef migrations add <名字>
dotnet ef database update # 或启动时 db.Database.Migrate()
# 撤销 / 查看
dotnet ef migrations remove
dotnet ef migrations list

记住:Migrations/ 目录要提交进 git,别删别手改。


总结#

契约先行,命名规范,写的时候出错总比跑起来各种问题强。这篇文主要是给可能学习后端开发的小登看看,借鉴一下流程和工程思想。具体代码的话我写的挺一般的(另外还有就是提醒自己吧,这些最基本的东西,能写出来才内化于心

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从零搭一个 ASP.NET Core 后端之笔记
https://sannohana.top/posts/post_8/
作者
鈴天衣
发布于
2026-09-19
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录