ASP.NET Core MVC校园活动报名系统:并发防超卖与工程化实现

发布时间:2026/8/26 13:20:26
ASP.NET Core MVC校园活动报名系统:并发防超卖与工程化实现 校园活动管理系统几乎是 ASP.NET Core MVC 学习路上绕不开的“标配项目”。无论是课程设计、毕业设计还是公司内部的活动管理小需求它的业务场景都足够典型有数据的增删改查、有用户权限、有状态流转、有前后台页面。很多人以为这只是一个“简单的 CRUD”但真正动手做才发现难的不是列表页和表单页而是报名这个核心动作背后的业务约束。我的判断是一个校园活动报名管理系统做得是否“像工程”不看它写了多少页面而看它能不能把三个问题处理干净——名额不能被抢超、同一个人不能重复报名、活动生命周期不能被错误状态污染。如果你正准备用 ASP.NET Core MVC 做这个项目这篇文章会帮你把从建项目、建数据模型到报名并发控制、管理后台导出的完整链路走通。读完之后你至少能收获四件事第一一套可以直接运行的关键代码覆盖活动、报名、管理后台三个模块第二报名场景下防重复、防超卖的具体实现思路这是很多教程不会展开讲的第三一个可复用的排查清单第四写课程设计和简历项目时可以拿出来讲的工程细节。1. 这篇文章真正要解决的问题很多人第一次做后台管理系统流程是这样的建一个数据库建几个表然后用脚手架生成页面管理员能发活动、学生能报名。第一天很顺利第二天开始出现问题同一个学生连续点了两次报名数据库里出现两条记录。后台把活动人数上限设置成 50结果 51 个人报名成功。活动已经结束页面还能继续报名。这些问题有一个共同点它们都不是页面层能解决的而是业务层没有把规则约束好。如果只是做教学演示这些问题可以忽略但如果要写成课设、毕设或者作为项目经验写进简历这些边界情况恰恰是面试官最关心的问题。所以整篇文章围绕一条主线展开数据模型如何避免重复报名报名逻辑如何原子更新名额活动状态如何统一管理管理员后台如何隔离权限。这条主线就是“业务约束的工程化实现”。2. 系统需求与功能拆解从真实场景来看校园活动报名系统至少包含两类角色、两类业务流程。先看角色。学生角色登录后浏览活动、查看详情、报名/取消报名、查看已报名记录管理员角色登录后维护活动信息、查看报名名单、统计参与人数、上下线活动。权限设计不需要一开始做得很复杂但学生和管理员的边界必须清晰。再看核心对象。业务围绕两个实体展开活动Activity和报名记录Registration。活动有名称、时间、地点、容量、已报名人数、状态报名记录则有学生信息、报名时间、状态。这里要特别注意不要只把“已报名人数”当成一个查询结果它必须是一个数据库字段并且在报名事务中原子更新。后面会详细说明这个字段为什么不能用内存计算替代。活动状态按业务流转可以拆成四种未开始报名、报名中、已满员、已结束。报名记录状态拆成两种正常报名、已取消。如果学校场景需要加入班主任审核可以再增加一个“审核中”状态但最小实现先不做复杂审批流程。模块功能点角色权限活动浏览列表、详情、状态展示登录用户报名管理报名、取消、我的报名学生活动管理新增、编辑、上下线管理员名单管理查看报名名单、导出 CSV管理员这个表格定义了本文所有功能开发的最小范围。做课程设计或者毕业设计时先把这些功能跑通再根据自己的场景去扩充审核、签到、消息通知等功能。3. 核心技术选型与运行环境技术栈的选择原则是不求最新但求最稳。以下组合足够支撑一个结构清晰、可演示、可扩展的 ASP.NET Core MVC 项目。框架ASP.NET Core MVC。它提供完整的 Model-View-Controller 分层适合这类业务逻辑清晰的后台管理系统。ORMEF Core。使用 Code First 方式建表省去手写建表 SQL也能保证实体与数据库结构同步。数据库开发环境用 SQLite生产环境建议换成 SQL Server 或 MySQL。SQLite 零配置、开箱即用对课程设计非常友好后面会讲到并发场景下 SQL Server 的锁机制更可靠。前端Bootstrap 5 jQuery通过 Layout 页面统一引入。认证ASP.NET Core Cookie 认证。相比 IdentityCookie 认证更轻量也更适合在教程中展示登录、角色授权的基本原理。环境准备方面需要安装 .NET SDK建议 8.0 或更高具体以实际安装为准、Visual Studio 2022 或 VS Code以及 EF Core 工具。安装完成后打开命令行检查版本dotnet --version dotnet ef --version如果dotnet ef命令不存在说明没有安装全局工具执行下面命令安装dotnet tool install --global dotnet-ef安装全局工具后可能需要重启终端才能让dotnet ef命令生效。4. 数据模型设计与数据库迁移数据模型是整个系统最该花时间设计的地方。很多问题如果在建表阶段就能避免后面不必写大量防御代码。4.1 活动实体先在Models目录下创建活动实体类// 文件路径Models/Activity.cs using System.ComponentModel.DataAnnotations; namespace CampusActivity.Models { public class Activity { public int Id { get; set; } [Required(ErrorMessage 活动名称不能为空)] [StringLength(100)] public string Title { get; set; } string.Empty; public string Description { get; set; } string.Empty; [Display(Name 开始时间)] public DateTime StartTime { get; set; } [Display(Name 结束时间)] public DateTime EndTime { get; set; } [Display(Name 活动地点)] public string Location { get; set; } string.Empty; [Display(Name 人数上限)] public int MaxParticipants { get; set; } // 已报名人数必须持久化到数据库 public int RegisteredCount { get; set; } // 0未开始报名 1报名中 2已满员 3已结束 public int Status { get; set; } public DateTime CreatedAt { get; set; } DateTime.Now; public ListRegistration Registrations { get; set; } new(); } }这里有一个关键设计RegisteredCount不是通过Registrations.Count()实时计算的而是在报名成功时同步更新。原因是后续报名逻辑需要一条原子 UPDATE 语句同时完成“判断名额未满”和“名额加一”如果每次都先查询列表再统计数量在高并发下就会出问题。4.2 报名记录实体报名记录要记录“谁报了哪个活动”并且必须设置唯一索引防止同一学生重复报名同一活动// 文件路径Models/Registration.cs using System.ComponentModel.DataAnnotations; namespace CampusActivity.Models { public class Registration { public int Id { get; set; } public int ActivityId { get; set; } public Activity? Activity { get; set; } [Display(Name 学号)] public string StudentId { get; set; } string.Empty; [Display(Name 姓名)] public string StudentName { get; set; } string.Empty; [Display(Name 联系电话)] public string Phone { get; set; } string.Empty; public DateTime RegisterAt { get; set; } public DateTime? CancelAt { get; set; } // 1正常 0已取消 public int Status { get; set; } } }在配置唯一索引时索引字段应该包含(ActivityId, StudentId)同时要处理“已取消的记录不参与唯一判定”的问题。最稳妥的做法是取消时把 StudentId 改写为带后缀的字符串或者使用“软删除加唯一过滤索引”。SQLite 和 SQL Server 都支持过滤索引但为了保持代码可读性本文先在业务层检查重复再用唯一索引兜底。4.3 数据库上下文与迁移创建AppDbContext类并配置唯一索引// 文件路径Data/AppDbContext.cs using CampusActivity.Models; using Microsoft.EntityFrameworkCore; namespace CampusActivity.Data { public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetActivity Activities SetActivity(); public DbSetRegistration Registrations SetRegistration(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // 唯一索引同一学生不能重复报名同一活动 modelBuilder.EntityRegistration() .HasIndex(r new { r.ActivityId, r.StudentId }) .IsUnique(); } } }然后在Program.cs中注册 DbContext。以 SQLite 为例// 文件路径Program.cs using CampusActivity.Data; using Microsoft.EntityFrameworkCore; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddDbContextAppDbContext(options options.UseSqlite(builder.Configuration.GetConnectionString(DefaultConnection))); var app builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler(/Home/Error); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute( name: default, pattern: {controllerHome}/{actionIndex}/{id?}); app.Run();在appsettings.json中配置连接字符串{ ConnectionStrings: { DefaultConnection: Data Sourceactivity.db }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } }, AllowedHosts: * }接下来执行迁移命令生成数据库dotnet ef migrations add Init dotnet ef database update执行成功后目录下会出现activity.db文件。如果迁移失败先检查dotnet ef工具版本和 .NET SDK 版本是否匹配再查看完整错误日志。这一步是后面所有功能的基础值得花时间确认它没问题。5. 活动列表与详情页实现活动页面是学生端的入口核心价值在于“状态展示准确”。一个活动是报名中、已满员还是已结束必须一眼就能看到。先创建活动控制器// 文件路径Controllers/ActivityController.cs using CampusActivity.Data; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace CampusActivity.Controllers { public class ActivityController : Controller { private readonly AppDbContext _context; public ActivityController(AppDbContext context) { _context context; } public async TaskIActionResult Index() { var activities await _context.Activities .OrderByDescending(a a.StartTime) .ToListAsync(); return View(activities); } public async TaskIActionResult Detail(int id) { var activity await _context.Activities .Include(a a.Registrations) .FirstOrDefaultAsync(a a.Id id); if (activity null) { return NotFound(); } return View(activity); } } }列表页视图需要根据活动状态显示不同按钮。可以封装一个简单的状态判断方法// 文件路径Models/ActivityStatusHelper.cs namespace CampusActivity.Models { public static class ActivityStatusHelper { public static string GetStatusText(Activity activity) { if (activity.Status 3 || activity.EndTime DateTime.Now) { return 已结束; } if (activity.Status 1 activity.RegisteredCount activity.MaxParticipants) { return 已满员; } if (activity.Status 1) { return 报名中; } return activity.Status 0 ? 未开始 : 已结束; } } }视图中的关键代码是循环展示活动和详情入口这里不再完整展示页面 CSS只给出核心判断逻辑model IEnumerableCampusActivity.Models.Activity foreach (var item in Model) { var statusText CampusActivity.Models.ActivityStatusHelper.GetStatusText(item); var canRegister item.Status 1 item.RegisteredCount item.MaxParticipants; div classcard mb-3 div classcard-body h5 classcard-titleitem.Title/h5 p classcard-text 时间item.StartTime.ToString(yyyy-MM-dd HH:mm) - item.EndTime.ToString(HH:mm)br/ 地点item.Locationbr/ 剩余名额(item.MaxParticipants - item.RegisteredCount) / item.MaxParticipants /p span classbadge (canRegister ? bg-success : bg-secondary)statusText/span a asp-actionDetail asp-route-iditem.Id classbtn btn-primary btn-sm查看详情/a /div /div }这里容易踩的坑是只判断Status 1就允许报名却忘了检查EndTime。如果活动创建时管理员没有及时改状态就会出现“活动时间已经过了页面还能报名”的尴尬。所以状态判断必须结合时间字段不能只依赖一个枚举字段。6. 报名与取消的核心业务逻辑这是整篇文章最重要的一章。报名功能看起来只是“给表插入一条记录”但实际涉及三个硬约束名额不能超卖、同一学生不能重复报名、活动状态必须是报名中。这三个约束遇到并发请求时会产生完全不同的结果。6.1 为什么不能只做查询再判断很多教程会这样写报名逻辑var activity await _context.Activities.FindAsync(activityId); if (activity null) return (false, 活动不存在); if (activity.Status ! 1) return (false, 活动当前不可报名); if (activity.RegisteredCount activity.MaxParticipants) return (false, 名额已满); // 检查是否重复报名 var exists await _context.Registrations.AnyAsync(r r.ActivityId activityId r.StudentId studentId); if (exists) return (false, 你已经报过名了); activity.RegisteredCount; await _context.SaveChangesAsync();这段代码单独运行完全没问题但并发场景下会超卖。原因是RegisteredCount先被读进内存程序判断“名额未满”然后把内存中的值加一再保存回数据库。假设两个请求同时读到RegisteredCount 49两个进程都认为自己拿下了最后一个名额最终结果就是 51 人报名成功。问题根源在于“判断”和“更新”不是原子的。解决思路也很简单把判断与更新合并为一条原子 SQL。6.2 使用原子 UPDATE 防超卖服务层方法的核心代码如下// 文件路径Services/RegistrationService.cs using CampusActivity.Data; using CampusActivity.Models; using Microsoft.EntityFrameworkCore; namespace CampusActivity.Services { public class RegistrationService { private readonly AppDbContext _context; public RegistrationService(AppDbContext context) { _context context; } public async Task(bool Success, string Message) RegisterAsync( int activityId, string studentId, string studentName, string phone) { await using var transaction await _context.Database.BeginTransactionAsync(); try { // 条件 UPDATE名额未满且状态为报名中才会执行成功 var rows await _context.Database.ExecuteSqlRawAsync( UPDATE Activities SET RegisteredCount RegisteredCount 1 WHERE Id {0} AND RegisteredCount MaxParticipants AND Status 1, activityId); if (rows 0) { return (false, 活动不存在、已结束或名额已满); } // 业务层先检查重复报名提升友好性 var duplicate await _context.Registrations .AnyAsync(r r.ActivityId activityId r.StudentId studentId r.Status 1); if (duplicate) { // 如果重复报名需要回滚已经占用的名额 await _context.Database.ExecuteSqlRawAsync( UPDATE Activities SET RegisteredCount RegisteredCount - 1 WHERE Id {0}, activityId); return (false, 你已经报名过该活动不能重复报名); } _context.Registrations.Add(new Registration { ActivityId activityId, StudentId studentId, StudentName studentName, Phone phone, RegisterAt DateTime.Now, Status 1 }); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return (true, 报名成功); } catch { await transaction.RollbackAsync(); throw; } } } }这里有几个关键点需要说明。第一UPDATE Activities SET RegisteredCount RegisteredCount 1 WHERE ...是一条原子语句数据库会为匹配的行加锁如果两个请求同时进入第二个请求会等待第一个提交后再执行。当第一个请求把名额增加到上限后第二个请求的RegisteredCount MaxParticipants条件不满足受到影响的行数为 0从而拒绝报名。第二唯一索引作为最终防线。即使业务层的duplicate检查因为极端并发出现漏判向Registrations表插入重复记录时数据库唯一索引仍然会抛异常事务回滚后名额不会丢失。第三业务层的duplicate检查不是多余的。它能让用户在正常场景下得到“你已经报名过”的友好提示而不是在提交时遇到一个看不懂的数据库错误。这是工程化实现与裸写 SQL 之间的重要区别。6.3 在控制器中调用服务控制器负责读取用户身份、做参数绑定然后调用服务层方法// 文件路径Controllers/ActivityController.cs using CampusActivity.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; public class ActivityController : Controller { private readonly RegistrationService _registrationService; public ActivityController(RegistrationService registrationService) { _registrationService registrationService; } [HttpPost] [Authorize] [ValidateAntiForgeryToken] public async TaskIActionResult Register(int id) { var studentId User.Identity?.Name ?? string.Empty; var studentName User.FindFirst(Name)?.Value ?? string.Empty; var phone User.FindFirst(Phone)?.Value ?? string.Empty; var result await _registrationService.RegisterAsync(id, studentId, studentName, phone); TempData[Message] result.Message; return RedirectToAction(nameof(Detail), new { id }); } }报名按钮放在详情页中并且必须带上防伪令牌form asp-actionRegister asp-controllerActivity methodpost input typehidden nameid valueModel.Id / button typesubmit classbtn btn-success立即报名/button /form如果视图中忘记添加Html.AntiForgeryToken()提交时会收到 400 错误。这是 ASP.NET Core 默认的防伪保护机制不能因为麻烦而关闭。6.4 取消报名并释放名额取消报名与报名是对称操作核心动作是把报名记录状态改为已取消同时把活动已报名人数减一。同样需要放在事务中public async Task(bool Success, string Message) CancelAsync(int activityId, string studentId) { await using var transaction await _context.Database.BeginTransactionAsync(); try { var registration await _context.Registrations .FirstOrDefaultAsync(r r.ActivityId activityId r.StudentId studentId r.Status 1); if (registration null) { return (false, 没有找到有效报名记录); } registration.Status 0; registration.CancelAt DateTime.Now; await _context.Database.ExecuteSqlRawAsync( UPDATE Activities SET RegisteredCount RegisteredCount - 1 WHERE Id {0} AND RegisteredCount 0, activityId); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return (true, 取消成功); } catch { await transaction.RollbackAsync(); throw; } }唯一要注意的是UPDATE语句中增加了RegisteredCount 0条件防止数据异常时名额被减成负数。这属于防御性编程实际场景中不会出现但加上成本很低。7. 管理员后台与数据导出管理员后台的职责很清晰管理活动信息、查看报名名单、导出数据。本系统使用 Cookie 认证加角色区分权限。7.1 登录与角色授权在Program.cs中注册认证服务using Microsoft.AspNetCore.Authentication.Cookies; builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options { options.LoginPath /Account/Login; options.AccessDeniedPath /Account/Denied; });控制器只需要添加特性即可限制权限// 文件路径Controllers/AdminController.cs using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; [Authorize(Roles Admin)] public class AdminController : Controller { // 只有 Admin 角色可以访问 }在登录逻辑中管理员登录成功后把角色写入 Cookie。学生登录时角色为Student管理员登录时角色为Admin。7.2 活动管理的核心代码管理员新增活动的代码与普通表单提交类似关键是状态设置。管理员提交活动时Status可以先设为0未开始报名到报名开始时再手动改为1。这样能避免误操作导致活动一发布就开放报名。7.3 导出报名名单为 CSV导出 CSV 是管理后台的常见需求。以下代码将报名名单导出为 CSV 文件下载// 文件路径Controllers/AdminController.cs using System.Text; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; [Authorize(Roles Admin)] public async TaskIActionResult ExportCsv(int activityId) { var activity await _context.Activities.FindAsync(activityId); if (activity null) { return NotFound(); } var registrations await _context.Registrations .Where(r r.ActivityId activityId r.Status 1) .OrderBy(r r.RegisterAt) .ToListAsync(); var sb new StringBuilder(); sb.AppendLine(序号,学号,姓名,电话,报名时间); int index 1; foreach (var r in registrations) { sb.AppendLine(${index},{r.StudentId},{r.StudentName},{r.Phone},{r.RegisterAt:yyyy-MM-dd HH:mm:ss}); index; } // 添加 UTF-8 BOM避免 Excel 打开中文乱码 var preamble Encoding.UTF8.GetPreamble(); var body Encoding.UTF8.GetBytes(sb.ToString()); var fileBytes preamble.Concat(body).ToArray(); return File(fileBytes, text/csv; charsetutf-8, $活动报名名单_{activity.Title}.csv); }导出 CSV 这个方法虽然简单但有两个细节值得注意一是中文文件名需要浏览器兼容建议用 URL 编码后的文件名二是必须添加 UTF-8 BOM否则 Excel 直接打开 CSV 时中文会乱码。如果项目中使用 EPPlus 或 NPOI 导出真正的 xlsx 文件效果会更好但 CSV 作为最小实现已经足够。8. 运行验证与常见问题排查整个项目开发完成后通过下面命令启动dotnet run浏览器打开控制台输出的地址通常是https://localhost:5001或http://localhost:5000。建议按以下顺序验证核心功能注册或登录学生账号创建测试活动并设置人数上限为 1。使用一个学生账号连续报名两次第二次应提示“你已经报名过该活动”。再创建一个上限为 1 的活动使用两个不同学生账号同时报名确认只有一个学生报名成功。取消报名后确认名额释放另一个学生可以报名成功。管理员登录后创建一个活动修改状态、查看报名名单、导出 CSV。如果过程中出现问题优先查看控制台日志。下表整理了本项目最常见的六类问题问题现象可能原因排查方式解决方案执行迁移失败SDK 版本不匹配或未安装 ef 工具查看完整错误日志安装与 SDK 匹配的dotnet-ef全局工具页面返回 403未登录或角色不匹配查看用户 Cookie 中的角色声明确认登录逻辑中写了角色 Claim并重新登录表单提交返回 400缺少防伪令牌检查视图表单在 form 中添加Html.AntiForgeryToken()报名后名额超卖未使用原子 UPDATE在服务层打断点观察并发改为条件 UPDATE 唯一索引同一学生重复报名缺少唯一索引查询Registrations表记录增加(ActivityId, StudentId)唯一索引CSV 用 Excel 打开乱码缺少 UTF-8 BOM用记事本打开文件查看编码在文件输出前写入 BOM对于并发问题如果本地开发环境不容易模拟可以先用 Postman 或 JMeter 对报名接口做压测观察最终报名的成功数量是否超过活动上限。这一步能直观验证防超卖逻辑是否真的生效。9. 最佳实践与工程建议项目跑通只是第一步真正能写进球历、能在答辩中讲清楚的内容往往是一些工程化细节。这里整理几条建议供你在开发过程中有意识地落实。第一报名核心逻辑必须放在 Service 层 Controller 只负责读取用户身份、做参数绑定、调用服务、返回视图。这样做的最大好处是如果以后要扩展到 Web API 接口不需要重写业务代码。项目里的RegistrationService就是一个可以被 Controller 或 API Controller 同时复用的类。第二不要用字符串拼接 SQL。本文给出的ExecuteSqlRawAsync使用了{0}占位符这是参数化查询不会引入 SQL 注入风险。如果使用$UPDATE ... {activityId}在用户输入可控的场景下就会出大问题。第三管理员后台的每项操作都要记录日志至少记录“谁在什么时间做了什么操作”。这里可以使用ILoggerAdminController输出到日志也可以在数据库里建一个操作日志表。对于校园活动管理系统日志表的效果更直观答辩时也更有说服力。第四所有表单必须启用防伪令牌。ASP.NET Core 默认对 POST 请求做防伪校验但这只在表单中包含令牌时才生效。如果视图用 AJAX 提交需要在请求头中携带令牌。这一步关系到最基本的站点安全不能省略。第五生产环境不要继续用 SQLite。虽然 SQLite 对课程设计完全够用但它写入时是整库锁并发报名场景下性能上限很低。推荐生产环境使用 SQL Server 或 MySQL连接字符串的改动很小但并发能力完全不同。第六给热门查询加索引。活动列表页通常按照开始时间倒序展示可以给Activities表的(Status, StartTime)建复合索引提升列表查询速度。数据量小的时候看不出差别但作为工程习惯值得养成。第七如果活动容量非常大比如全校级活动几千个名额可以把热点计数放在 Redis 中报名成功后异步写入数据库。但这属于进阶方案本文的最小实现用原子 UPDATE 已经能解决大多数问题。不要在课设阶段就引入 Redis避免过度设计。10. 总结与后续学习方向这个项目做成什么程度算“完成度高”不是页面多不多、动画炫不炫而是核心业务规则是否稳固。名额不会超卖、重复报名会被拦截、活动状态不会混乱、权限边界清晰这些都是比页面数量更值钱的工程能力。如果你想把项目进一步升级可以按顺序做三件事。第一把数据库从 SQLite 换成 SQL Server重新跑通所有功能这个过程中你会理解不同数据库在事务和并发上的差异。第二给系统增加 Excel 导出和报名名单的分页查询贴近真实后台管理的使用习惯。第三尝试把报名接口改造成 RESTful API用 Postman 测试接口在并发场景下的表现。以上练习做完后这个项目的深度已经超过大多数课设和简历项目。后续学习方向上值得花时间研究的是 ASP.NET Core Identity、JWT 认证、EF Core 性能优化以及 Repository 模式在项目中的应用。这些内容都能在现有系统上自然延伸不会出现“学完不知道放哪里”的问题。最后提醒一句任何生产环境的上线操作都要先在测试环境验证涉及数据库结构的变更先做备份权限配置遵循最小权限原则。这个习惯越早养成后面做真实项目时越省心。