源碼解析:路由、控制器與計(jì)分全流程)
簡(jiǎn)介基于ASP.NET MVC的簡(jiǎn)易在線考試系統(tǒng)是一份面向計(jì)算機(jī)相關(guān)專業(yè)學(xué)生、教師及企業(yè)開發(fā)者的學(xué)習(xí)與實(shí)戰(zhàn)資源適合用作課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)或項(xiàng)目初期立項(xiàng)演示。項(xiàng)目代碼經(jīng)過運(yùn)行驗(yàn)證功能完整可靠。壓縮包共155個(gè)文件、約532KB主要包含68個(gè)cshtml視圖模板、45個(gè)cs控制器與業(yè)務(wù)邏輯、13個(gè)js交互腳本以及配置文件、SQL數(shù)據(jù)庫腳本和字體圖標(biāo)等覆蓋了MVC分層架構(gòu)中視圖、控制器、模型與配置的典型組成。通過Visual Studio可直接打開解決方案運(yùn)行便于對(duì)照學(xué)習(xí)用戶登錄、考試管理、題目管理等核心模塊的編碼方式。當(dāng)前已有186人瀏覽學(xué)習(xí)具備一定的參考價(jià)值。下載后既可系統(tǒng)梳理ASP.NET MVC開發(fā)流程也能在此基礎(chǔ)上擴(kuò)展功能滿足畢設(shè)、課設(shè)或?qū)嶋H項(xiàng)目二次開發(fā)需求。1. 一個(gè)基于 ASP.NET MVC 的簡(jiǎn)易在線考試系統(tǒng)先拆給你看打開壓縮包的第一眼沒有一堆 .aspx 頁面文件而是 Global.asax、Web.config、UserController.cs、ExamController.cs、ExamViewModel.cs 這些文件把整套考試系統(tǒng)撐了起來。這就是 ASP.NET MVC 與 Web Forms 最直觀的差別——請(qǐng)求先進(jìn)路由再進(jìn)控制器最后才輪到視圖。這個(gè)簡(jiǎn)易系統(tǒng)麻雀雖小但用戶登錄、試題加載、答案提交、自動(dòng)計(jì)分的完整閉環(huán)都在里面適合剛學(xué)完 C# 基礎(chǔ)、想搞懂 MVC 設(shè)計(jì)模式真實(shí)跑法的開發(fā)者也適合做課程設(shè)計(jì)時(shí)快速判斷“這套代碼能不能改成自己的畢設(shè)”。下載之后如果沒有 README建議按 Global.asax → Web.config → UserController → ExamController → ExamViewModel 的順序拆代碼正好對(duì)應(yīng)一次請(qǐng)求從入口到綁定的完整鏈路。2. 路由與配置鏈路Global.asax、Web.config、packages.config 協(xié)同工作的底層邏輯2.1 為什么啟動(dòng)順序先看 Global.asaxASP.NET MVC 不是靠物理 URL 去找頁面而是靠 RouteTable 里注冊(cè)的路由規(guī)則來匹配 controller/action。應(yīng)用啟動(dòng)時(shí)Global.asax 里的 Application_Start 只執(zhí)行一次負(fù)責(zé)注冊(cè)路由、初始化數(shù)據(jù)庫、綁定依賴注入容器等。這個(gè)項(xiàng)目里的 Global.asax 雖然只有短短幾行但整個(gè)系統(tǒng)的入口就在這里。下面這段代碼就是最常見的默認(rèn)路由注冊(cè)// Global.asax 關(guān)鍵代碼 protected void Application_Start() { AreaRegistration.RegisterAllAreas(); RouteTable.Routes.MapRoute( name: Default, url: {controller}/{action}/{id}, defaults: new { controller User, action Login, id UrlParameter.Optional } ); }這里的{controller}/{action}/{id}是占位符。默認(rèn)請(qǐng)求/時(shí)由于 defaults 指定了 controllerUser、actionLogin所以會(huì)直接進(jìn)入 UserController 的 Login 方法。如果訪問/Exam/Index則 id 為空。MapRoute 的 name 參數(shù)只是路由名不參與匹配但后續(xù)用 Url.RouteUrl 生成鏈接時(shí)會(huì)用到。改動(dòng) defaults 里的 controller 或 action就能調(diào)整整個(gè)應(yīng)用的首屏入口這也是排查 404 時(shí)最先該看的地方。順帶說一句如果你是先接觸 Spring MVC 再回來看這個(gè)項(xiàng)目會(huì)發(fā)現(xiàn)兩者的路由心智模型基本一致Spring 的 RequestMapping 在 ASP.NET MVC 里對(duì)應(yīng)著 RouteTable 加 Action 方法上的路由特性。傳統(tǒng) ASP.NET MVC 4/5 采用集中式路由而 ASP.NET Core 更推薦用 Attribute Routing。簡(jiǎn)單項(xiàng)目用集中式路由足夠了但若你打算以后遷移到 Core可以把 controller 和 action 上的路由特性提前用起來。2.2 Web.config連接字符串、認(rèn)證與模塊注冊(cè)Web.config 承擔(dān)運(yùn)行時(shí)的上下文配置。簡(jiǎn)易在線考試系統(tǒng)的 Web.config 通常至少包含三塊connectionStrings 存放數(shù)據(jù)庫連接system.web 下的 compilation 控制調(diào)試狀態(tài)和框架版本authentication 決定未登錄用戶跳轉(zhuǎn)到哪個(gè)頁面。下面是一個(gè)典型片段connectionStrings add nameOnlineExamDb connectionStringData Source.;Initial CatalogOnlineExam;Integrated SecurityTrue providerNameSystem.Data.SqlClient / /connectionStrings system.web compilation debugtrue targetFramework4.7.2 / authentication modeForms forms loginUrl~/User/Login timeout60 / /authentication /system.web這里 connectionString 里的Data Source.指的是本機(jī) SQL Server如果你用的是 SQL Express要改成.\SQLEXPRESS數(shù)據(jù)庫名 OnlineExam 需要先在 SQL Server 里創(chuàng)建否則頁面在訪問用戶表時(shí)會(huì)直接拋異常。Forms 認(rèn)證設(shè)置成登錄頁統(tǒng)一跳轉(zhuǎn)到/User/Logintimeout 60 表示不操作 60 分鐘后登錄態(tài)失效。需要注意debugtrue只在開發(fā)階段使用。它會(huì)讓編譯器生成更多調(diào)試符號(hào)并且頁面異常時(shí)會(huì)顯示詳細(xì)堆棧。線上環(huán)境必須置為 false否則訪問者能看到你的源碼路徑和內(nèi)部變量名也會(huì)拖慢并發(fā)響應(yīng)。Web.config 里還有一個(gè)容易忽略的節(jié)點(diǎn)是httpRuntime targetFramework4.7.2 /它和編譯節(jié)點(diǎn)必須匹配實(shí)際安裝的 .NET Framework 版本版本不一致會(huì)導(dǎo)致“無法加載程序集”的錯(cuò)誤。2.3 packages.config依賴的版本鎖packages.config 記錄的是 NuGet 安裝的每個(gè)包的精確版本。MVC 項(xiàng)目的 packages.config 里至少能看到 Microsoft.AspNet.Mvc 和 Microsoft.AspNet.Web.Optimization。當(dāng)你在另一臺(tái)機(jī)器上還原時(shí)NuGet 會(huì)按這個(gè)文件里的版本號(hào)去下載而不是隨便拉最新版否則容易引發(fā)編譯器兼容問題。下表對(duì)比了三個(gè)配置文件在不同階段的作用| 配置文件 | 生命周期階段 | 主要職責(zé) | 調(diào)試時(shí)最常改什么 | | Global.asax | 啟動(dòng)時(shí) | 路由注冊(cè)、全局事件處理 | MapRoute 參數(shù) | | Web.config | 運(yùn)行時(shí) | 數(shù)據(jù)庫連接、認(rèn)證、模塊 | connectionString、debug | | packages.config | 還原依賴時(shí) | 鎖定 NuGet 包版本 | 包版本號(hào) |如果你在項(xiàng)目里找不到數(shù)據(jù)庫文件先別急著翻 Models 目錄八成是連接字符串里的 Initial Catalog 和實(shí)際庫名不一致。簡(jiǎn)易系統(tǒng)很多直接使用 MDF 文件此時(shí)連接寫法是AttachDbFilename|DataDirectory|\Exam.mdf這種寫法在部署到服務(wù)器時(shí)容易出現(xiàn)權(quán)限問題建議改成標(biāo)準(zhǔn) SQL Server 連接。還原包時(shí)若發(fā)現(xiàn)某個(gè)包版本不存在打開 packages.config把版本號(hào)改成本地 NuGet 緩存里已有的版本再重新編譯即可。3. UserController 到用戶體系登錄注冊(cè)與 [Authorize] 過濾器的實(shí)現(xiàn)細(xì)節(jié)3.1 一個(gè)典型 UserController 的 Action 劃分UserController 在在線考試系統(tǒng)里管的不只是考試賬號(hào)還包括注冊(cè)、登錄、登出和“當(dāng)前用戶是否已登錄”的校驗(yàn)邏輯。從文件命名也能看出來這個(gè)控制器把用戶相關(guān)的所有 HTTP 入口集中到了一起而不是拆成 AuthController、AccountController 兩個(gè)類簡(jiǎn)單項(xiàng)目這樣做反而更好找。下面是一個(gè)基于 FormCollection 的登錄 Action[HttpPost] [ValidateAntiForgeryToken] public ActionResult Login(FormCollection form) { string userName form[UserName]; string password form[Password]; if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password)) { ModelState.AddModelError(, 用戶名和密碼不能為空); return View(); } // 簡(jiǎn)易項(xiàng)目常見做法查內(nèi)存集合或數(shù)據(jù)庫 // 這里以固定賬號(hào)示意 if (userName admin password 123456) { FormsAuthentication.SetAuthCookie(userName, false); return RedirectToAction(Index, Exam); } ModelState.AddModelError(, 用戶名或密碼錯(cuò)誤); return View(); }這里的 FormCollection 把表單里所有 name 屬性收進(jìn)來靈活但容易拼錯(cuò)字符串。如果你手頭這個(gè)資源用的是類似寫法改用戶名時(shí)一定要同步檢查視圖里的 input name 是否完全一致。正規(guī)一點(diǎn)的做法是定義一個(gè) LoginViewModel用強(qiáng)類型綁定但簡(jiǎn)易系統(tǒng)里為了少寫一個(gè)類用 FormCollection 也很普遍。SetAuthCookie的作用是往客戶端寫一個(gè)加密票據(jù)之后 [Authorize] 過濾器通過讀取這個(gè)票據(jù)來判斷用戶是否通過驗(yàn)證。它是基于 FormsAuthenticationModule 的受 Web.config 里 authentication modeForms 控制。3.2 [Authorize] 與登錄態(tài)校驗(yàn)在需要登錄才能訪問的方法或控制器上只要加一行[Authorize]未登錄用戶就會(huì)被自動(dòng)重定向到登錄頁。比如登出方法[Authorize] public ActionResult LogOff() { FormsAuthentication.SignOut(); return RedirectToAction(Login, User); }如果沒有 [Authorize]即使登錄失敗用戶也能通過直接輸入/User/LogOff來訪問這個(gè)方法。加了過濾器后未登錄請(qǐng)求會(huì)被攔截并重定向到 Web.config 里配置的 loginUrl。放在控制器類名上表示類內(nèi)所有 Action 都生效放在方法上只對(duì)這單個(gè) Action 生效。你還可以用[Authorize(Roles admin)]做角色隔離但簡(jiǎn)易系統(tǒng)通常用不到。使用 FormsAuthentication 時(shí)要注意票據(jù)默認(rèn)會(huì)被寫入 Cookie如果站點(diǎn)部署在 HTTPS 下建議給SetAuthCookie傳入createPersistentCookie參數(shù)后同時(shí)在 Web.config 里配置forms requireSSLtrue /防止票據(jù)被明文截獲。課程設(shè)計(jì)不強(qiáng)制要求但面試被問到安全加固時(shí)這算是加分項(xiàng)。3.3 注冊(cè)邏輯密碼不能明文存簡(jiǎn)單考試系統(tǒng)不一定要上 Identity但至少不要明文存密碼。常見做法是取 MD5 或 SHA256 哈希后存庫。下面這個(gè)函數(shù)可以直接放在 UserController 里私有方法private string GetMd5Hash(string input) { using (var md5 System.Security.Cryptography.MD5.Create()) { var bytes md5.ComputeHash(Encoding.UTF8.GetBytes(input)); var sb new StringBuilder(); foreach (var b in bytes) sb.Append(b.ToString(x2)); return sb.ToString(); } }調(diào)用時(shí)GetMd5Hash(password)得到 32 位十六進(jìn)制字符串。需要注意MD5 已被認(rèn)為不適合做高強(qiáng)度安全存儲(chǔ)但作為課程設(shè)計(jì)或入門演示性能足夠如果是正式上線的考試系統(tǒng)建議換用 BCrypt 或 ASP.NET Identity 自帶的 PasswordHasher。注冊(cè)時(shí)還要處理重名問題先查一下數(shù)據(jù)庫里是否已存在相同的 UserName存在就返回ModelState.AddModelError(UserName, 該用戶名已被占用)而不是依賴數(shù)據(jù)庫拋唯一約束異常。下表列出了 UserController 里最常用的 Action 設(shè)計(jì)| Action 名 | HTTP 動(dòng)詞 | 功能 | 返回 | | Login | GET/POST | 登錄 | View / RedirectToAction | | Register | GET/POST | 注冊(cè)新用戶 | View / RedirectToAction | | LogOff | POST | 登出 | RedirectToAction |實(shí)際項(xiàng)目中LogOff 最好聲明為[HttpPost]避免用戶被惡意鏈接強(qiáng)制登出。整個(gè)模塊保護(hù)起來的方法也很簡(jiǎn)單在 ExamController 類名上方加一行[Authorize]就能讓考試模塊只對(duì)登錄用戶開放。這也是 MVC 過濾器最直接的應(yīng)用解釋了為什么資源里會(huì)有 UserController 又會(huì)有 ExamController——一個(gè)管身份一個(gè)管業(yè)務(wù)。4. ExamController 與 ExamViewModel考試答題、狀態(tài)保持和計(jì)分的 MVC 玩法4.1 ViewModel 到底解決什么問題在線考試系統(tǒng)的頁面要顯示的不僅僅是數(shù)據(jù)庫里的一張表而是“題目集合 用戶答案 得分”的組合數(shù)據(jù)結(jié)構(gòu)。這種跨實(shí)體、按視圖裁剪的數(shù)據(jù)正好是 ViewModel 的用武之地。ExamViewModel.cs 就是把考試頁所需的全部字段打包讓 Controller 和 View 之間傳遞的是一個(gè)可測(cè)試的強(qiáng)類型對(duì)象而不是 ViewBag 里的散裝數(shù)據(jù)。下面是一個(gè)典型的定義public class ExamViewModel { public string ExamName { get; set; } // 試卷名 public ListQuestion Questions { get; set; } // 題目列表 public Liststring UserAnswers { get; set; } // 用戶提交的答案 public int Score { get; set; } // 總分 public bool IsSubmitted { get; set; } // 是否已交卷 } public class Question { public int Id { get; set; } public string Title { get; set; } // 題干 public string OptionA { get; set; } public string OptionB { get; set; } public string OptionC { get; set; } public string OptionD { get; set; } public string CorrectAnswer { get; set; } // 正確答案 }UserAnswers的索引和題目順序一一對(duì)應(yīng)UserAnswers[0]表示第一題的答案。這樣的設(shè)計(jì)讓前端表單可以非常規(guī)整地提交一個(gè)數(shù)組。你可能注意到Question里沒有放 IsChecked 之類的視圖狀態(tài)字段因?yàn)橛脩羰欠襁x中應(yīng)通過 UserAnswers 在 Action 里計(jì)算而不該污染題目本身。4.2 用模型綁定接收表單數(shù)組當(dāng)用戶點(diǎn)擊交卷按鈕表單會(huì) POST 到 ExamController 的 Submit 方法。因?yàn)楸韱慰丶?name 是UserAnswers[i]ASP.NET MVC 的模型綁定器會(huì)自動(dòng)把它們組裝成Liststring。下面的 Action 展示了完整處理[HttpPost] public ActionResult Submit(ExamViewModel submitted) { var vm new ExamViewModel(); vm.ExamName ASP.NET MVC 基礎(chǔ)測(cè)試; vm.Questions LoadQuestions(); // 重新從庫里取題目 vm.UserAnswers submitted.UserAnswers; vm.Score ComputeScore(vm.Questions, vm.UserAnswers); vm.IsSubmitted true; return View(Result, vm); }為什么需要重新加載題目因?yàn)楸韱尾粫?huì)把正確答案暴露給用戶必須在服務(wù)端根據(jù)題目 ID 重新查一次避免用戶改 HTML 作弊。如果用戶直接偽造 POST 請(qǐng)求只提交自己想要的答案服務(wù)端因重新加載題目也能保證正確答案不會(huì)被惡意篡改。對(duì)應(yīng)的 Razor 視圖片段model ExamViewModel using (Html.BeginForm(Submit, Exam, FormMethod.Post)) { for (int i 0; i Model.Questions.Count; i) { p(i 1). Model.Questions[i].Title/p labelHtml.RadioButton($UserAnswers[{i}], A) Model.Questions[i].OptionA/label labelHtml.RadioButton($UserAnswers[{i}], B) Model.Questions[i].OptionB/label labelHtml.RadioButton($UserAnswers[{i}], C) Model.Questions[i].OptionC/label labelHtml.RadioButton($UserAnswers[{i}], D) Model.Questions[i].OptionD/label } button typesubmit交卷/button }注意Html.RadioButton的第一個(gè)參數(shù)是 name第二個(gè)參數(shù)是 value。由于 name 相同同一組單選按鈕只能選一個(gè)不同的i對(duì)應(yīng)不同組。這里使用了$UserAnswers[{i}]字符串插值編譯后等價(jià)于普通字符串拼接。計(jì)分邏輯可以寫成獨(dú)立的私有方法private int ComputeScore(ListQuestion questions, Liststring userAnswers) { int score 0; for (int i 0; i questions.Count; i) { if (string.Equals(questions[i].CorrectAnswer, userAnswers?[i], StringComparison.OrdinalIgnoreCase)) { score 10; // 每題10分 } } return score; }userAnswers?[i]中的?是空條件運(yùn)算符防止用戶漏答導(dǎo)致索引越界。如果題目數(shù)量超過 100建議把每題分值改成從設(shè)計(jì)題目時(shí)傳入或根據(jù)題目難度設(shè)定權(quán)重避免所有題平均分配導(dǎo)致區(qū)分度不足。4.3 Session 與答題進(jìn)度保持HTTP 是無狀態(tài)的用戶做到一半刷新頁面所有答案就丟了。簡(jiǎn)易考試系統(tǒng)里最常見的方案是把 ExamViewModel 塞進(jìn) Session。在加載試題時(shí)存一下[HttpGet] public ActionResult Index() { var vm new ExamViewModel(); vm.ExamName ASP.NET MVC 基礎(chǔ)測(cè)試; vm.Questions LoadQuestions(); Session[ExamProgress] vm; // 保存試卷快照 return View(vm); }提交時(shí)再讀var saved Session[ExamProgress] as ExamViewModel; if (saved null) return RedirectToAction(Index);這里要注意Session 默認(rèn)存在 IIS 進(jìn)程內(nèi)存中應(yīng)用池回收或服務(wù)器重啟都會(huì)丟失。如果考試人數(shù)較多把 Session 持久化到 SQL Server 或 Redis 是更穩(wěn)的做法但簡(jiǎn)易課設(shè)完全夠用。另外Session 的 Key 別用魔法字符串滿天飛建議定義成常量防止多控制器之間拼寫不一致。下表列出了表單綁定與 ViewModel 屬性的對(duì)照關(guān)系| HTML name | ViewModel 屬性 | 說明 | | UserAnswers[0] | UserAnswers[0] | 第一題答案 | | UserAnswers[1] | UserAnswers[1] | 第二題答案 | | ExamName | ExamName | 隱藏字段或只讀展示 |如果想要支持多選題可以把UserAnswers從Liststring改成ListListstring或者用逗號(hào)分隔字符串存到一個(gè)字段里。前端改成 checkbox用UserAnswers[ i ][]作為 name模型綁定器也能正確收集到集合但解析時(shí)要額外做字符串拆分。5. 把項(xiàng)目跑起來applicationhost.config、IIS Express 與 Release 配置的驗(yàn)證技巧5.1 定位 applicationhost.config 并啟動(dòng)站點(diǎn)在 Visual Studio 中按 F5 時(shí)IIS Express 會(huì)讀取項(xiàng)目里隱藏的 .vs 目錄生成的 applicationhost.config。如果目錄結(jié)構(gòu)變化導(dǎo)致站點(diǎn)無法啟動(dòng)可以手動(dòng)指定配置文件運(yùn)行。下面這條命令在無 Visual Studio 的環(huán)境下也適用C:\Program Files\IIS Express\iisexpress.exe /config:D:\OnlineExam\.vs\OnlineExam\config\applicationhost.config啟動(dòng)后控制臺(tái)會(huì)列出當(dāng)前站點(diǎn)名與物理路徑看到 Successfully registered 說明站點(diǎn)已經(jīng)加載。項(xiàng)目若用到 SQL Server 本地庫注意 IIS Express 工作進(jìn)程身份是當(dāng)前用戶默認(rèn)有權(quán)限訪問 LocalDB如果連的是 SQL Server 的 Windows 認(rèn)證實(shí)例一般也沒問題。5.2 端口沖突與 binding 修改同一臺(tái)機(jī)器上開過多個(gè)網(wǎng)站時(shí)很容易碰到端口被占用。IIS Express 報(bào)錯(cuò)信息里會(huì)給出被占用的端口號(hào)。此時(shí)可以直接改 applicationhost.config 里的 site 節(jié)點(diǎn)bindings binding protocolhttp bindingInformation*:8080:localhost / /bindings把 8080 換成 5200 之類的空閑端口。保存后重新執(zhí)行 /config 命令訪問http://localhost:5200/User/Login即可。5.3 Release 配置如何清理調(diào)試信息Web.Release.config 和 Web.Debug.config 是 MSBuild 發(fā)布時(shí)的轉(zhuǎn)換腳本。在不使用發(fā)布向?qū)А⒅苯訌?fù)制代碼到服務(wù)器時(shí)最需要關(guān)注的是編譯節(jié)點(diǎn)里的 debug 屬性。Debug 模式下編譯體積大、執(zhí)行路徑中有額外日志長(zhǎng)期運(yùn)行最好關(guān)掉。可以在 Web.Release.config 里寫system.web compilation xdt:TransformRemoveAttributes(debug) / /system.web這樣發(fā)布后 Web.config 中的debugtrue會(huì)被自動(dòng)移除而本地 Debug 運(yùn)行不受影響。5.4 用 curl 和瀏覽器做快速驗(yàn)證啟動(dòng)站點(diǎn)后先訪問默認(rèn)路由看是否跳轉(zhuǎn)到登錄頁curl -I http://localhost:5200/User/Login正常返回 200 后再繼續(xù)訪問考試首頁。如果某個(gè) Action 返回 404先看 Global.asax 里的路由注冊(cè)再看控制器類名是否以 Controller 結(jié)尾如果 500馬上打開 Web.config 設(shè)置system.web customErrors modeOff / /system.web然后重啟 IIS Express讓異常信息直接渲染在頁面上。根據(jù)堆棧里的文件名和行號(hào)能快速定位是數(shù)據(jù)庫連接失敗還是空引用。修復(fù)后記得把 customErrors 改成 RemoteOnly避免線上泄露內(nèi)部信息。本文還有配套的精品資源點(diǎn)擊獲取