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 入門教學