ASP.NET MVC Extending Framework 藉由實作擴充讓開發更為優雅

2023-02-05

筆記 ASP.NET MVC 如何藉由擴充開發框架所提供的 Interface 以及既有 Class,來達到優雅簡潔的需求達成以及易於維護的架構。

logo

說明

本次的筆記來自 Alex Wolf 在 Improving Your MVC Applications with 10 Extension Points 課程中的教學內容。

GitHub

Views

HTML Helpers

在 Keeping Your Razor Code Clean with HTML Helpers 的課程模組中,介紹兩種 Helper 方式來簡化重複的內容,包含使用 inline Razor Helpers 以及 HTML Helper Using Extensino Methods 的方式來 Reuse 需要在 View 當中重複使用的程式碼以及邏輯。

@helper DisplayList(List<string> data)
{
  <ol>
    @foreach (var item in data)
    {
      <li>@item</li>
    }
  </ol>
}

使用 inline Razor Helper 的好處是容易建立,可以快速用於消除重複的 HTML 語法,但缺點是重複使用的可能較低,受限於在相同的 cshtml 當中使用。

可以藉由將 Razor Helper 獨立儲存在 cshtml 檔案當中,並且儲存於 App_Code 資料夾,這樣在其他 cshtml 當中,也可以重複使用,提升重複使用的可能性。例如儲存在 App_Code/MyHelper.cshtml,可以透過 @MyHelper.DisplayList(...) 的方式使用。

Infrastructure/HtmlHelperExtensions.cs

public static class HtmlHelperExtensions
{
  public static MvcHtmlString DisplayList(this HtmlHelper html, List<string> data)
  {
      var ulBuilder = new TagBuilder("ul");
      foreach (var item in data)
      {
          var liBuilder = new TagBuilder("li");
          liBuilder.SetInnerText(item);
          ulBuilder.InnerHtml += liBuilder.ToString();
      }
      return new MvcHtmlString(ulBuilder.ToString());
  }
}

可以在 Views/Web.config 加入 HelperExtensions 的命名空間,如此在使用擴充的 Helper 時就不用重複輸入命名空間。

而除了 Helper 的方式外,也可以透過 Partial View 以及 ChildAction 的方式來重複使用 View 的內容與邏輯,而取決於如果是簡單的 Html 元素搭配資料重現,適合使用 Helper;如果是複雜的區塊 (Sections) 包含較多的 Html 組合以及資料,則適合使用 Partial View 以及 ChildAction 的方式。

Custom View Engine

在 Enabling Theme Support with a Custom View Engine 的課程模組中,示範如何客製化 View 的主題引擎,能夠在 Web.config 調整主題的選擇,讓應用程式對應使用不同風格的 cshtml 進行呈現。

原理是利用客製 View Engine,由此客製的 View Engine 決定如何尋找與回應對應的 cshtml,而如果沒有找到相符的 cshtml,則會使用第二順序的 View Engine 進行尋找。

ThemeEngine.cs

public class ThemeViewEngine : RazorViewEngine
{
    public ThemeViewEngine(string activeThemeName)
    {

        ViewLocationFormats = new[]
        {
            "~/Views/Themes/" + activeThemeName + "/{1}/{0}.cshtml",
            "~/Views/Themes/" + activeThemeName + "/Shared/{0}.cshtml"
        };

        PartialViewLocationFormats = new[]
        {
            "~/Views/Themes/" + activeThemeName + "/{1}/{0}.cshtml",
            "~/Views/Themes/" + activeThemeName + "/Shared/{0}.cshtml"
        };

        AreaViewLocationFormats = new[]
        {
            "~Areas/{2}/Views/Themes/" + activeThemeName + "/{1}/{0}.cshtml",
            "~Areas/{2}/Views/Themes/" + activeThemeName + "/Shared/{0}.cshtml"
        };

        AreaPartialViewLocationFormats = new[]
        {
            "~Areas/{2}/Views/Themes/" + activeThemeName + "/{1}/{0}.cshtml",
            "~Areas/{2}/Views/Themes/" + activeThemeName + "/Shared/{0}.cshtml"
        };
    }
}

完成 Custom View Engine 的設計後,接著必須在 Global.asax 進行註冊,並且將 Custom View Engine 的順序設定為最優先 (index 0):

Global.asax

if (!string.IsNullOrEmpty(ConfigurationManager.AppSettings["ActiveTheme"]))
{
    var activeTheme = ConfigurationManager.AppSettings["ActiveTheme"];
    ViewEngines.Engines.Insert(0, new ThemeViewEngine(activeTheme));
};

而在 Web.config 中調整 ActiveTheme 即可以讓 Controller 在回應 Action 所需要使用的 View 時,優先至 Custom View Engine 所定義的路徑進行 cshtml 檔案尋找,如沒有才會回到以往的尋找路徑 🙂

<configuration>
  <appSettings>
    <add key="ActiveTheme" value="Vertical"/>
  </appSettings>
</configuration>

Custom Action Results

在 Improving Application Responses with Custom Action Results 的課程模組中,說明如何客製化 Action Results,例如回應 XML 或者是 CSV 檔。

要客製化 Action Results,可以藉由繼承 ActionResult 類別,並且覆寫 ExecuteResult 方法來達成。

Infrastructure/XMLResult.cs

public class XMLResult : ActionResult
{
    private object _data;

    public XMLResult(object data)
    {
        _data = data;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        XmlSerializer serializer = new XmlSerializer(_data.GetType());
        var response = context.HttpContext.Response;
        response.ContentType = "text/xml";
        serializer.Serialize(response.Output, _data);
    }
}

接著就可以在 Controller 當中使用:

public ActionResult XMLService()
{
    ...
    return new XMLResult(vmApplicants);
}

而許多開發情境下,繼承 ASP.NET MVC 既有的 ActionResult 省去重複的底層實作會讓開發與維護更為輕鬆,例如回應 CSV 檔案,可以利用既有的 FileResult 去覆寫 ExecuteResult 方法:

Infrastructure/CSVResult.cs

public class CSVResult : FileResult
{
    private IEnumerable _data;

    public CSVResult(IEnumerable data, string fileName) : base("text/csv")
    {
        _data = data;
        FileDownloadName = fileName;
    }

    protected override void WriteFile(HttpResponseBase response)
    {
        var builder = new StringBuilder();
        var stringWriter = new StringWriter(builder);

        foreach (var item in _data)
        {
            var properties = item.GetType().GetProperties();
            foreach (var prop in properties)
            {
                stringWriter.Write(GetValue(item, prop.Name));
                stringWriter.Write(", ");
            }
            stringWriter.WriteLine();
        }

        response.Write(builder);
    }

    public static string GetValue(object item, string propName)
    {
        return item.GetType().GetProperty(propName).GetValue(item, null).ToString() ?? "";
    }
}

Model

Extending Validation

在 Extending Validation to Improve Data Integrity 的課程模組中,說明除 ASP.NET MVC 預設的 Validation Attributes 外,也可以利用在 Model 加入時做 IValidatableObject 的方式,來達到資料 Model 的驗證,Validation Attributes 的優點是驗證的邏輯關注點分離、易於重複使用;IValidatableObject 則是易於設計跨屬性的資料驗證邏輯。

BirthdateValidator.cs 範例說明如何客製化 Validation Attributes。

Infrastructure\BirthdateValidator.cs

public class BirthdateValidator : ValidationAttribute
{
    public BirthdateValidator()
    {
        ErrorMessage =
          "Please enter a valid birth date.";
    }

    public override bool IsValid(object value)
    {
        DateTime enteredDate;
        if (DateTime.TryParse(value.ToString(), out enteredDate))
        {
          return enteredDate <= DateTime.Now.AddYears(-18);
        }
        else
        {
            return false;
        }
    }
}

EmploymentSummary.cs 則是以 ViewModel 結合 IValidatableObject 的方式進行資料驗證,

ViewModels\EmploymentSummary.cs

public class EmploymentSummary : IValidatableObject
{
    public EmploymentSummary()
    {
        PrimaryEmployer = new EmploymentVM();
        PreviousEmployer = new EmploymentVM();
    }
    public EmploymentVM PrimaryEmployer { get; set; }
    public EmploymentVM PreviousEmployer { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var results = new List<ValidationResult>();

        if (PrimaryEmployer.StartDate > DateTime.Now.AddYears(-3))
        {
            if (PreviousEmployer.EmploymentType != "Unemployed")
            {
                if (string.IsNullOrEmpty(PreviousEmployer.Employer))
                {
                    results.Add(new ValidationResult("Previous employer is required."));
                }

                if (string.IsNullOrEmpty(PreviousEmployer.Position))
                {
                    results.Add(new ValidationResult("Previous position is required."));
                }
            }
        }

        return results;
    }
}

Custom Model Binders

在 Extending Data Binding with Custom Model Binders 的課程模組中,介紹如何客製 Model Binder,並以處理 Client 端 XML 格式的 InputStream 為例。

ASP.NET MVC 的 Action Invoker 在 Controller 處理傳入的參數時,會依序使用 Model Binder 檢查是否可以處理對應的參數,如果不行就換下一個 Model Binder 直到 Default Model Binder 都無法處理則報錯為止;除了可以將直接客製化的 Model Binder 加入順序以外,也可以藉由客製化 Model Binder Provider 的方式來達到特定時機使用特定的 Model Binder。

示範如何客製 Model Binder ,搭配分別建立類別實作 IModelBinder 以及 IModelBinderProvider 介面。

public interface IModelBinder
{
  object BindModel(ControllerContext controllerContext, ModelBindingContenxt bindingContext);
}
public interface IModelBinderProvider
{
  IModelBinder object GetBinder(Type model);
}

Infrastructure/XMLModelBinder.cs

public class XmlModelBinder : IModelBinder
{
    public object BindModel(
        ControllerContext controllerContext,
        ModelBindingContext bindingContext)
    {
        try
        {
            var modelType = bindingContext.ModelType;
            var serializer = new XmlSerializer(modelType);

            var inputStream = controllerContext.HttpContext.Request.InputStream;

            return serializer.Deserialize(inputStream);
        }
        catch(Exception ex) {
            bindingContext.ModelState.AddModelError("", "The item could not be serialized");
            return null;
        }

    }
}

Infrastructure/XMLModelBinderProvider.cs

public class XmlModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(Type modelType)
    {
        var contentType = HttpContext.Current.Request.ContentType;

        if (string.Compare(contentType, @"text/xml",
            StringComparison.OrdinalIgnoreCase) != 0)
        {
            return null;
        }

        return new XmlModelBinder();
    }
}

接著到 Global.asax 進行 Model Binder Provider 的註冊:

Global.asax

protected void Application_Start()
{
    ModelBinderProviders.BinderProviders.Add(new XmlModelBinderProvider());
}

相關參考:保哥的 ASP.NET MVC 開發心得分享 (25):ModelBinder 與 ValueProvider 的用途

Custom Value Providers

在 Improving Data Availability with Custom Value Providers 的課程模組中,示範如何藉由 Value Provider 將 Http Request Headers 的資料讀入 Model 之中,方便在 Controller 使用。

Custom Value Provider 是除了重新實作 IModelBinder 或者是覆寫既有 ModelBinder BindModel, BindProperty 以外,同樣可以達成資料 Binding 的擴充方式。

public interface IValueProvider
{
    bool ContainsPrefix(string prefix);
    ValueProviderResult GetValue(string key);
}

Infrastructure/HttpValueProvider.cs

public class HttpValueProvider : IValueProvider
{
    private readonly NameValueCollection _headers;
    private readonly string[] _headerKeys;

    public HttpValueProvider(NameValueCollection httpHeaders)
    {
        _headers = httpHeaders;
        _headerKeys = _headers.AllKeys;
    }

    public bool ContainsPrefix(string prefix)
    {
        return _headerKeys.Any(h => h.Replace("-", "")
                          .Equals(prefix, StringComparison.OrdinalIgnoreCase));
    }

    public ValueProviderResult GetValue(string key)
    {
        var header = _headerKeys.FirstOrDefault(
            h => h.Replace("-", "").Equals(key, StringComparison.OrdinalIgnoreCase));

        if (!string.IsNullOrEmpty(_headers[header]))
        {
            return new ValueProviderResult(
                _headers[header], 
                _headers[header], 
                CultureInfo.CurrentCulture);
        }

        return null;
    }
}

public class HttpValueProviderFactory : ValueProviderFactory
{
    public override IValueProvider GetValueProvider(ControllerContext controllerContext)
    {
        return new HttpValueProvider(controllerContext.HttpContext.Request.Headers);
    }
}

接著著在 Global.asax 進行 Value Provider Factory 的註冊。

Global.asax

protected void Application_Start()
{
    ValueProviderFactories.Factories.Insert(0, new HttpValueProviderFactory());
}

從 ASP.NET MVC 的原始碼,我們可以觀察各種預設的 Value Provider 順序,從而 Model Binder 在繫結模型時,取得資料的優先順序。

ValueProviderFactories.cs

public static class ValueProviderFactories
{
    private static readonly ValueProviderFactoryCollection _factories = new ValueProviderFactoryCollection()
    {
        new ChildActionValueProviderFactory(),
        new FormValueProviderFactory(),
        new JsonValueProviderFactory(),
        new RouteDataValueProviderFactory(),
        new QueryStringValueProviderFactory(),
        new HttpFileCollectionValueProviderFactory(),
        new JQueryFormValueProviderFactory()
    };

    public static ValueProviderFactoryCollection Factories
    {
        get { return _factories; }
    }
}

Value Provider
提供網站資料的提供程式,它可以從不同的來源(例如表單、請求查詢字符串、請求頭)中獲取資料;從請求中擷取參數值,並將其填入 ValueProviderResult 物件中。
Model Binder Provider
選擇適當的 Model Binder 的提供程式,根據模型類型和資料來源選據對應的 Model Binder;
Model Binder
綁定模型對象的系統,它使用 Value Provider 中的資料來填充模型對象;將 ValueProviderResult 值與 MyInputModel 參數物件繫結。

Controller

Action Filters

在 Organizing Application Flow with Action Filters 的課程模組中

Authentication Filtes

在 Customizing Security Using Authentication Filters 的課程模組中

Exception Filters

在 Improving Error Handling with Custom Exception Filters 的課程模組中

Action Method Selectors

在 Influencing Action Method Execution Using Custom Selectors 的課程模組中

相關連結

ASP.NET MVC 從無到有打造一個應用系統

Visual Studio 入門教學