跳到內容
關於我 數位花園

[筆記] ASP.NET MVC5 - Ch.6 Controller

前一章介紹了 Routing,它最重要的工作有兩個:

  • 找到要執行的 Controller
  • 找到那個 Controller 裡的要執行的 Action

一般我們會說是 Controller 與 Model 互動,但更精確來說是 Controller 裡的 Action 與 Model 互動

但有些情況下,Action 又不一定會直接跟 Model 互動

因此,無論有與 Model 互動與否,最終 Controller 都會將結果回傳給 View

  • 檔案放置與 Controllers 資料匣底下
  • 習慣上,檔案名稱結尾都會加上 Controller 字樣
  • 以關注點分離的概念,將職責分類並交由各個 Controller 去執行
  • Controller 底下的各個 Action,理想狀況下,盡量都只專注做一件事,向單一指責原則的理想趨近

基本架構:

public class EmptyController: Controller
{
// action
public ActionResult Index()
{
return View();
}
}

EmptyController 繼承自 System.Web.Mvc.Controller 抽象類別

這個 Index action 要回傳的 View,會對應到 View 資料匣的檔案,檔名會是 Index.cshtml

EmptyController 位於 Controllers/EmptyController.cs

因此,這個 Index.cshtml 就會放在 Views/Empty/Index.cshtml

預設的 View 會長這樣:

@{ ViewBag.Title = "Index"; }
<h2>Index</h2>

View 繼承 System.Web.Mvc.ViewPage 類別

ViewPage 類別提供一些屬性及方法,在 View 裡面使用,例如:ViewDataViewBagTempData⋯⋯ 等

View 傳遞資料至 Controller 的主要媒介是 <form> 表單,在 ASP.NET MVC 中的技術是 Model Binding

Controller 傳資料給 View 的方式,像是透過 model 傳給 RazorViewEngine 並交給 View Engine 去渲染成 HTML

另外,View 也可以透過 ViewDataViewBagTempData 這幾種屬性取得渲染時需要的資料

ViewData 屬性屬於 ViewDataDictionary 型別,而 ViewDataDictionary 型別的定義如下:

public class ViewDataDictionary: IDictionary<string, object>{}

從定義可以看出,它就是一個 dictionary 的 key-value 結構:key 的型別為 string,且 value 可以儲存任何型別的資料(定義為很寬鬆的 object

另外,ViewData 與 ViewBag 都有一個特性,就是無法跨 Action 方法存取,只限於此次的 HTTP request

在 Action 裡面儲存一個 Name 欄位的資料:

// in Action
public ActionResult DemoViewData()
{
ViewData["Name"] = "Bruce";
return View();
}

然後在 View 這裡接收並顯示:

// in View @{ ViewBag.Title = "DemoViewData"; }
<h2>DemoViewData</h2>
@ViewData["Name"]

ViewBag 屬於 dynamic 型別

一般的型別是靜態型別,在編譯期就已經確定,執行期不能再做修改

dynamic 型別也是靜態型別(不要因為叫做 dynamic 而混淆),但不同的是,它是動態化的靜態型別

意思就是,dynamic 型別的物件會略過編譯期的型別檢查,在執行期進行型別檢查

雖然型別不同,但 ViewBag 的特性跟 ViewData 一模一樣,而寫法上還是有一點差異

來看一下書中的範例:

// in Action
public ActionResult DemoViewBag()
{
ViewData["Name"] = "Bruce"; // ViewData 的寫法
ViewBag.Name = "Bruce";
return View();
}
// in View @{ ViewBag.Title = "DemoViewBag"; }
<h2>DemoViewBag</h2>
@ViewBag.Name

由上述範例可以看出,比起 ViewData,ViewBag 的寫法又更簡潔了一點

利用 Model 這個屬性,在 View 的寫法會更為簡單

// in Action
public ActionResult DemoViewDataModel()
{
var product = db.Products.ToList();
ViewData.Model = product;
return View();
}

此時,我們可以直接在 View 中,用 Model 這個字取得資料:

// in View
<ul>
@foreach(var item in Model) {
<li>@item.ProductName</li>
}
</ul>

此時的 Model 雖然可以拿到 Action 那裡 product 的資料,但它還是弱型別的

要使 Model 具有型別檢查的能力,就要使用 @model 這個關鍵字

@model 可以定義這個 model 的型別,通常寫在 View 的最上面:

// in View @model INumerable<Product>
// <-- 定義 model 的型別
<ul>
@foreach(var item in Model) {
<li>@item.ProductName</li>
}
</ul></Product
>

PS. 這裡 Product 型別不用寫完整的 namespace,是因為已經在 /View/Web.Config 檔案裡加入其 namespace

{/* ### TempData 屬性

ViewData 與 ViewBag 都是只存活在同一個 Action 裡,無法跨兩個 Action 進行溝通,此時 TempData 屬性就派上用場 */}

有時候,View 所要呈現的資料不只限於單一 Model 的資料,然而在 View 裡面,@model 只允許定義一個型別,無法同時定義兩個 model

此時,我們熟悉的 ViewModel 就派上用場了

書中關於 ViewModel 的一段描述:

ViewModel 是一個專門給 View 使用的 Model 物件資料,透過 ViewModel 將所需的多個物件透過一層 Class 進行屬性封裝,然後就可以透過傳遞 ViewModel 物件至 View 來達到強型別開發的目的

  • 習慣放置在 Models/ViewModels 目錄下(但公司專案是跟 Models 同在根目錄下)
  • 命名習慣上,會以 ViewModel 結尾
  • 依照 View 畫面所需定義欄位

有了畫面專屬的 ViewModel 之後,我們就可以將 model 定義成 @model CustomViewModel 了!

Client 端與 Server 端如何溝通,這時就要透過 Model Binding

常用的 QueryString 就是來自於 Request 物件中,可以從網址上面 ?name1=value1&name2=value2&... 中取得對應的值

我們來看一下書中的範例:

// in Action
public ActionResult DemoQueryString()
{
ViewBag.id = int.Parse(Request.QueryString["id"]);
return View();
}

若使用表單 <form> 的話,就可以用 Request.Form["name"] 取得資料

然而,若 QueryString 太過冗長,則會造成 URL 的難以閱讀

// in Action
public ActionResult BasicModelBinding(string name)
{
ViewBag.Name = name;
return View();
}
// in View
<div>
@using(Html.BeginForm()) {
<p>
姓名:<input type="text" name="name" />
<input type="submit" value="送出" />
</p>
}
</div>

在這裡,Razor 的 Html.BeginForm() 會轉換成 html 的 form 表單格式:

<form action="/VtoC/BasicModelBinding" method="post">
<p>
ID: <input name="name" type="text" />
<input type="submit" value="送出" />
</p>
</form>
// in Action
public ActionResult DemoFormCollection(FormCollection form)
{
ViewBag.Name = form["name"];
ViewBag.Age = form["age"];
return View();
}
// in View
<div>
@using(Html.BeginForm()) {
<p>
姓名:<input type="text" name="name" /><br />
年紀:<input type="text" name="age" />
<input type="submit" value="送出" />
</p>
}
</div>
<p>
Your Name: @ViewBag.Name <br />
Your Age: @ViewBag.Age
</p>

{/### ModelBinder 擴充/}

每個 controller 的 Action 最終要回傳一個實作 ActionResult 抽象類別的型別,例如:return View(),實際上是回傳一個 ViewResult 型別

ASP.NET MVC 5 實作了 9 種繼承自 ActionResult 的型別:

繼承自 ActionResult 型別Controller 類別方法描述
ContentResult
- FileContentResult
- FileStreamResult
- FilePathResult
Content()回傳文字內容
FileResultFile()輸出檔案內容
HttpNotFoundResultHttpNotFound()回應 HTTP 狀態碼
JavaScriptResultJavaScript()輸出 JavaScript 內容
JsonResultJson()輸出 JSON 內容
ViewResultView()輸出 HTML 內容
PartialViewResultPartialView()輸出部分 HTML 內容
RedirectResultRedirect()
RedirectPermanent()
進行 URL 重新導向
RedirectToRouteResultRedirectToAction()
RedirectToActionPermanent()
RedirectToRoute()
RedirectToRoutePermanent()
使用路由系統,進行 URL 重新導向
  • EmptyResult 類別的 ExecuteResult 方法沒有實作任何程式碼
  • 為了遵循 OOP 的 Null Object Pattern(Null 物件模式),應該要回傳一個空物件,而非 null

Content 方法共有三個多載型別:

// 1.
Content(string content)
// 2.
Content(string content, string contentType)
// 3.
Content(string content, string contentType, Encoding contentEncoding)

其中,各參數代表的意義:

  • content:要 return 的內容,我們可以回傳純文字、HTML、excel 檔或是 CSV 檔 ⋯⋯ 等各種格式的 content
  • contentType:內容類型(MIME type),Mime type 的種類,像是常見的 text/html、text/csv ⋯⋯ 等
  • contentEncoding:編碼方式(可參考 System.Text.Encoding 類別),編碼方式,例如常見的 UTF8、Big5 之類的

JavaScriptResult 本質上跟 ContentResult 一樣,但是它多設置了 ContentType

它用於動態產生 JavaScript 在 View 執行的情境,例如:當 JavaScript 的內容需要 Model 提供的時候

書中的範例如下:

// in Action
public ActionResult OnlineGame()
{
return View();
}
public ActionResult NextTime()
{
StringBuilder sb = new StringBuilder();
sb.AppendFormat("var nextTime = '{0}'; \r\n", DateTime.UtcNow);
return JavaScript(sb.ToString());
}
{/* in View */}
<head>
<script src='@Url.Action("NextTime", "MvcType")'></script>
</head>
<body>
<div>
<script>
alert("伺服器時間: " + nextTime);
</script>
</div>
</body>

在 View 裡,@Url.Action 裡的參數分別為 ActionName、ControllerName

JSON 格式是目前前端接收 Web API 資料最常見的格式

ASP.NET MVC 提供將物件、Model 的資料轉換成 JSON 格式輸出,或是讀取 JSON 格式的資料

  • JsonResult 使用 JavaScriptSerializer 類別進行序列化工作
  • ContentType 為 application/json
  • 為避免 JSON Hijacking 攻擊,預設不允許接受 HTTP GET request
public ActionResult DemoJson()
{
var person = new
{
Name = "Bruce",
Age = 18,
Birthday = new DateTime(2099, 9, 9)
};
return Json(person);
}

回傳的 JSON 資料如下:

{ "Name": "Bruce", "Age": 18, "Birthday": "/Date(4092566400000)/" }

HttpStatusCodeResult 有兩個子類別:HttpNotFound 與 HttpUnauthorizedResult

  • HttpNotFoundResult 用於「找不到」的情況
  • HttpUnauthorizedResult 用於「無存取權」的情況

System.Net.HttpStatusCode 這個 enum 提供很多種狀態,可以參考微軟官網

HttpNotFoundResult 會提供 404 狀態碼,HttpUnauthorizedResult 則會提供 401 狀態碼

書中對於 status code 的一段敘述:

當 client 端進行操作符合某一種狀態時, 我們應該把應用程式設計為回應一個狀態而不是回應錯誤拋出例外

重新導向(或稱為轉址)分為兩種:

  • 301 轉址:永久轉址(Permanent Redirect)
  • 302 轉址:暫時轉址(Temporary Redirect)

兩者的差異只有在 SEO 上,301 轉址會將舊網址的權重移轉至新網址上,302 則否

而 RedirectResult 與 RedirectToRouteResult 類別都提供 301 或 302 轉址

差異在於 RedirectResult 採用 URL 的指定方式進行轉址,而 RedirectToRouteResult 則是指定路由(Routing)來取得最後轉址的 URL

RedirectResult 有兩種封裝的方法:

  • Redirect
  • RedirectPermanent

書中範例:

public ActionResult DemoRedirect(string param)
{
if(!String.IsNullOrEmpty(param))
{
string baseUrl = "http://mvcbook.net/";
Uri url = new Uri(baseUrl + param);
return Redirect(url.ToString()); // 302 redirect
// return RedirectPermanent(url.ToString()); // 301 redirect
}
else
{
return Content("error");
}
}

有四種方法:

  • RedirectToAction
  • RedirectToActionPermanent
  • RedirectToRoute
  • RedirectToRoutePermanent

RedirectResult 使用的是轉址至一個存在的 URL,通常是網站之外的 URL

而 RedirectToRouteResult 則使用內部的轉址,導至其他的 Action、或是其他 Controller 的 Action

我們當然也可以用 RedirectResult 轉址到其他的 Action

然而,利用字串去做轉址,當 routing 名稱改變的時候,

這個轉址也就自然會壞掉,而 IDE 也不會告訴你這裡需要修改,

因為對它而言只是一個字串而已,這也是為何內部轉址會用 RedirectToRouteResult 的原因

FileResult 代表一個可下載的檔案,它有三個子類別:

  • FileContentResult:來源為 Byte[] 陣列
  • FilePathResult:來源為實體檔案路徑
  • FileStreamResult:來源為 Stream 類別

可下載的檔案要提供三個資訊,前兩個為必要資訊:

  1. 要下載的檔案內容:由剛才說的三個子類別提供
  2. Content Type:它是一種網路媒體類型(Internet Media Type),進行下載時,必須提供檔案類型的資訊,全球已註冊的 Media Type 可於 IANA(Internet Assigned Numbers Authority)機構查詢
  3. 指定下載檔案的名稱:此為選擇性資訊。FileResult 在指定下載檔案名稱時,採用的編碼是 RFC 2231;而目前瀏覽器均已支援 RFC 2231(UTF-8),所以中文檔名也不是問題

另外,關於 RFC 2231 編碼,作者這裡有一個 memo:

Internet Explorer 8(含)以前不支援 RFC 2231 標準, 也就是說,如果使用非英文與數字指定檔案名稱,將會以亂碼來呈現。 (微軟已於 2014/4/8 正式公告 WindowsXP 支援終止, 希望各位開發者未來的路會好走些。)

這段話看起來怎麼有點似曾相識?

近幾年,IE 仍然是前端開發界一個很大的痛點

而在今年(2022 年),微軟宣布終止支援 IE11,全面改用 Chromium 核心的 Edge,想必前端開發人員都很振奮

微軟總是每隔一段時間,很貼心地送給世人一個大禮物 🙃

前面的範例程式不斷出現的,在 Action 裡面 return View();

就是所謂的 ViewResult

View Engine 將 View 轉譯成 HTML 之後,再丟給瀏覽器去渲染

而 View Engine 可以分為兩種:WebFormViewEngine 及 RazorViewEngine

WebFormViewEngine 是最原始 ASP.NET MVC 的 View Engine,而後來引進的 RazorViewEngine 寫法上更威簡潔,兩者的語法如下:

ASPX 語法

<% forEach(var p in model) {%>
<li><%=p.Name%></li>
<%}>

Razor 語法

@{
forEach(var p in model) {
<li>@p.Name</li>
}
}

View Engine 轉譯大致分為三個動作:

  1. 取得對應的 View 檔案內容(檔案存取)
  2. 填入關聯的 Model 資料
  3. 轉譯取得 HTML,寫入資料流(資料流輸出)

從前一章 Routing 的章節我們知道,Routing 會用 URL 導向至對應的 Controller、對應的 Action

然而,進入 Action 之前之後,其實另外有好幾層處理

Action Filter 的職責就是事件監聽器,分別有五個種類:

  • Authentication Filter:驗證過濾器
  • Authorization Filter:授權過濾器
  • Action Filter:動作過濾器
  • Result Filter:結果過濾器
  • Exception Filter:例外過濾器

借用一下 dotnettricks 網站的圖來解釋這幾個 Action Filter 與 Action 的先後順序:

asp.net mvc pipeline

上面提到的每種 Action Filter 都能有三種運作層級:Action、Controller、Global

  • Action 層級:設置在某個 Action 上,此 Action Filter 就僅限這個 Action 之前或之後執行
  • Controller 層級:將 Action Filter 設置到 Controller,那麼 Controller 裡面所有的 Action 都會受影響
  • Global 層級:透過註冊,將 Action Filter 註冊到 GlobalFilterCollection 類別中,就會影響整個 APP

此外,若 Action 或 Controller 層級設置了多個相同的 Filter,則預設由上而下執行,也可以使用 Order 屬性調整順序,例如:

[AF1(order = 3)]
[AF2(order = 2)]
[AF3(order = 1)]
public ActionResult ActionOrder(){}

資訊安全上,AAA 是一種標準做法,它分別代表「Authentication(驗證)」、「Authorization(授權)」、「Accounting(帳戶)」

  • Authentication:確認「你」的唯一身分
  • Authorization:你能做什麼或你不能做什麼

IActionFilter interface 提供兩個方法,分別在 Action 執行的前後觸發執行:

public interface IActionFilter
{
void OnActionExecuting(ActionExecutingContext filterContext);
void OnActionExecuted(actionExecutedContext filterContext);
}

可以設定 Action 的 timeout 時間(毫秒,ms):

public class TestAsyncController: AsyncController
{
[AsyncTimeout(10000)]
public void DownloadAsync(string url)
{
// ...
}
public ActionResult DownloadCompleted(string content)
{
return Content(content);
}
}

也可以讓 Action 沒有 timeout 時間,也就是無限等待:

[NoAsyncTimeout]
public void DownloadAsync(string url) {
// ...
}

IResultFilter interface 提供兩個方法,分別在進行 View 處理之前之後觸發:

public interface IResultFilter
{
void OnResultExecuting(ResultExecutingContext filterContext);
void OnResultExecuted(ResultExecutedContext filterContext);
}

我們在上面的關於 Action Filter 的流程圖中可以看到, Result Filter 在整個 Result Execution 當中的開始與結束都會遇到, 其順序為:

  • OnResultExecuting()
  • InvokeActionResult()
  • OnResultExecuted()

而 Result Filter 最典型的應用就是 OutputCache 機制, 也就是將執行結果快取起來,節省節省預算資源、加快回應速度

[OutputCache(Duration = 10)]
public ActionResult GetCacheTime()
{
ViewBag.Time = DateTime.now;
return View();
}

duration 代表快取的間隔時間,在這段時間內,除了第一次的請求外,都是從快取取得資料

除了 duration 之外,還有其他參數可以設定,在此就不多作介紹

整個 Action Filters 拋出的例外,都將由 Exception Filter 進一步來處理,簡單的範例如下:

[HandleError(View = "Error", ExceptionType = typeof(Exception))]
public ActionResult Index()
{
throw new Exception("測試 Error 頁面");
return View();
}

預設的 Error View Page 會在 /Views/Shared/Error.cshtml 下:

@model System.Web.Mvc.HandleErrorINfo
@{
ViewBag.Title = "錯誤";
}
<h1 class="text-danger">錯誤。</h1>
<h2 class="text-danger">處理您的要求時發生錯誤。</h2>

當然,如果是以 SPA 的架構下,就不需要這個由 server 提供的 error page 了

我們當然也可以自行撰寫一些客製化的 Action Filter,原生的 Action Filters 都實作 ASP.NET MVC 提供的 interface

因此,若要客製化自己的 Action Filter,也要從這些 interface 中挑選需要的來實作

ASP.NET MVC 的 interface 有:IAuthenticationIAuthorizationIActionFilterIResultFilterIExceptionFilter

{/TODO/}

{/- [ ] Serialization /} {/ - [ ] JSON Hijacking/}