在WebForm下使用ModelBinding(打造ASHX可用的通用類別)

在上一篇「在WebForm下使用ModelBinding(PostBack遭遇之問題)」文章中,我們試圖解決使用MVC提供的System.Web.ModelBinding來處理更之前利用Reflection土炮自幹的物件賦值行為(進行物件Reflection反射後,將值Value寫入到物件屬性中),未料在一開始的PostBack就被擊沉,如果你更繼續看下去更會有下巴掉下來的反應,那就是ModelBindingExecutionContext屬性只有WebForm專屬的Page才有!而我們都知道Page是屬於System.Web.UI命名空間的類別,這意味著我高效能的.ASHX(泛型處理常式)根本無法支援啦!

打造ASPX、ASHX均可通用的ModelBinding類別

既然微軟沒有提供,那我們就自己想辦法抄一份類別出來。在這邊我的處理方式是往.NET Framework原始碼的方向走,也就是我自己去抄一份原始碼到我自己的類別,再將其修改成我想要操作的方法。後來找到這個ModelBindingExecutionContext方法簡直是太棒啦!原來他也是透過new HttpContextWrapper(Context)建構子來取得HttpContext,那就代表ASHX可以支援了。

在最小的修改狀況下,我撰寫了一個類別讓這一切變得可能。

改寫System.Web.UI內Page Class的ModelBindingExecutionContext類別

public class WebFormModelBinding
{
  //ModelBinding:HTTP內文綁定
  private System.Web.ModelBinding.ModelBindingExecutionContext _oModelBindingExecutionContext;
  //ModelBinding:模組狀態描述
  private System.Web.ModelBinding.ModelStateDictionary         _oModelState;

  /// <summary>
  /// (公有)ModelBinding:HTTP內文綁定
  /// </summary>
  public System.Web.ModelBinding.ModelBindingExecutionContext ModelBindingExecutionContext
  {
    get
    {
      if (_oModelBindingExecutionContext == null)
      {
        _oModelBindingExecutionContext = new System.Web.ModelBinding.ModelBindingExecutionContext(new System.Web.HttpContextWrapper(System.Web.HttpContext.Current), this.ModelState);
        //This is used to query the ViewState in ViewStateValueProvider later.
        //_oModelBindingExecutionContext.PublishService<System.Web.UI.StateBag>(ViewState);
        //This is used to query RouteData in RouteDataValueProvider later.(回寫RouteData以備不時之需)
        _oModelBindingExecutionContext.PublishService<System.Web.Routing.RouteData>(System.Web.HttpContext.Current.Request.RequestContext.RouteData);
      }
      return _oModelBindingExecutionContext;
    }
  }

  /// <summary>
  /// (公有)ModelBinding:模組狀態描述
  /// </summary>
  public System.Web.ModelBinding.ModelStateDictionary ModelState
  {
    get
    {
      if (_oModelState == null)
      { _oModelState = new System.Web.ModelBinding.ModelStateDictionary(); }
      return _oModelState;
    }
  }

  /// <summary>
  /// (公有)ModelBinding:嘗試更新模組內部資料
  /// </summary>
  /// <typeparam name="TModel">要綁定的ORM類別</typeparam>
  /// <param name="oModel">要綁定的ORM類別</param>
  /// <param name="oValueProvider">數值提供者</param>
  /// <returns>true:轉換成功;false:轉換失敗</returns>
  public bool TryUpdateModel<TModel>(TModel oModel, System.Web.ModelBinding.IValueProvider oValueProvider) where TModel : class
  { //參數null錯誤檢查
    if (oModel == null)
    { throw new System.Exception($"oModel不可為空值。"); }
    if (oValueProvider == null)
    { throw new System.Exception("oValueProvider不可為空值。"); }
    //宣告綁定所需類別
    System.Web.ModelBinding.IModelBinder oBinder = System.Web.ModelBinding.ModelBinders.Binders.DefaultBinder;
    System.Web.ModelBinding.ModelBindingContext oBindContext = new System.Web.ModelBinding.ModelBindingContext()
    {
      ModelBinderProviders = System.Web.ModelBinding.ModelBinderProviders.Providers,
      ModelMetadata        = System.Web.ModelBinding.ModelMetadataProviders.Current.GetMetadataForType(() => oModel, typeof(TModel)),
      ModelState           = ModelState,
      ValueProvider        = oValueProvider
    };
    //進行綁定
    if (oBinder.BindModel(ModelBindingExecutionContext, oBindContext))
    { return ModelState.IsValid; }
    //綁定錯誤
    return false;
  }
}

經過類別新增後,我們可以開始操作了。

var oData = new SomeORM();
var oMB = new WebFormModelBinding();
var bIsSuccess = oMB.TryUpdateModel(oData, new System.Web.ModelBinding.FormValueProvider(oMB.ModelBindingExecutionContext));
if(bIsSuccess)
{
  //驗證通過...
}

這邊還是要提醒一下,ModelBinding的驗證通過,不代表你的ORM物件屬性一切都完滿,因為前端有可能有屬性根本沒有傳遞進入,這時候沒有觸發綁定當然也不會引發問題,一般來說需要注意的有下列項次:

  1. HTTP前端根本沒有傳入對應的屬性名稱項次,不會引發錯誤。
  2. 就算觸發驗證失敗(被記錄在ModelState了),ORM依然會被賦予該轉型失敗的值,除非這個值根本不符型別。舉例來說傳入了「abcd1234」,cName被依據system.componentmodel.dataannotations設定屬性[System.ComponentModel.DataAnnotations.MaxLength(5, ErrorMessage = "字串不可超過5個字。")],盡管回應了綁定失敗也記錄了相關的錯誤資訊,但ORM中的cName依然會被設定成「abcd1234」。
  3. 承上述,同樣的道理面對一個列舉(Enum)類別,HTTP傳入一個不存在的「99」數值值,盡管有設定[System.ComponentModel.DataAnnotations.EnumDataType(typeof(yourEnum))],ORM裡面該列舉屬性依然會被值派99。
  4. ModelBinding在無法轉型的時候才會真正阻擋對ORM寫值的動作,舉例來說「abcd1234」值預期寫入ORM的某int屬性,這時候會觸發轉型失敗,錯誤訊息會放在ModelState的ModelError.Exception,而非驗證時期慣用的Error.ErrorMessage,這個機制我個人有點無言。

接下來我們會意識到實務上不可能還在那邊宣告instance完在操作,因此往偷懶的私有靜態方法發展。在這邊我們依據資料來源是FormData或是QueryString來進行不同的ValueProvider載入資料以利分析:

/// <summary>
/// (私有靜態)ModelBinding:擷取傳入資訊並取得綁定後的物件與相關資訊
/// </summary>
/// <typeparam name="TModel">ORM類別物件</typeparam>
/// <param name="oModel">ORM類別物件</param>
/// <returns>(是否錯誤;綁定後的ORM類別物件;錯誤資訊字典列表;綁定時期之模組資料物件)</returns>
private static
(
  bool                                                  bIsError,   //是否錯誤
  TModel                                                oData,      //綁定後的ORM類別物件
  System.Collections.Generic.Dictionary<string, string> oErrorList, //錯誤資訊字典列表(方便應用時期取用)
  System.Web.ModelBinding.ModelStateDictionary          oModelState //綁定時期之模組資料物件(可用來求取延伸資訊)
) ModelBinding<TModel>(TModel oModel, string cMode = "Form") where TModel : class
{
  var oReq = new WebFormModelBinding();
  //依據不同的來源給定不同的模組綁定方法
  bool bIsSuccess = false;
  switch (cMode)
  { //GET: QueryString
    case string x when x.Equals("QueryString", System.StringComparison.InvariantCultureIgnoreCase):
      bIsSuccess = oReq.TryUpdateModel(oModel, new System.Web.ModelBinding.QueryStringValueProvider(oReq.ModelBindingExecutionContext));
      break;
    //Post: Form
    default:
      bIsSuccess = oReq.TryUpdateModel(oModel, new System.Web.ModelBinding.FormValueProvider(oReq.ModelBindingExecutionContext));
      break;
  }
  System.Collections.Generic.Dictionary<string, string> oErrorList = new System.Collections.Generic.Dictionary<string, string>();
  foreach (var cKey in oReq.ModelState.Keys)
  { //將有出錯的綁定鍵值列舉並求取錯誤訊息
    if (oReq.ModelState[cKey].Errors.Count > 0)
    { 
      oErrorList.Add(
        cKey,
        $@"{string.Join("|", oReq.ModelState[cKey].Errors.Select(x => $"{x.ErrorMessage}{x.Exception?.Message}"))}[{oReq.ModelState[cKey].Value.AttemptedValue}]"
      );
    }     
  }
  return (!bIsSuccess, oModel, oErrorList, oReq.ModelState);
}

最後我們再開出兩個公有靜態方法,讓未來方法的呼叫可以更直觀可用:

// (公有靜態)ModelBinding:Form傳入資訊並取得綁定後的物件與相關資訊
public static
(
  bool                                                  bIsError,
  TModel                                                oData,
  System.Collections.Generic.Dictionary<string, string> oErrorList,
  System.Web.ModelBinding.ModelStateDictionary          oModelState
) ModelBindingForm<TModel>(TModel oModel) where TModel : class
{
  return WebFormModelBinding.ModelBinding(oModel, "Form");
}

// (公有靜態)ModelBinding:QyeryString傳入資訊並取得綁定後的物件與相關資訊
public static
(
  bool                                                  bIsError,
  TModel                                                oData,
  System.Collections.Generic.Dictionary<string, string> oErrorList,
  System.Web.ModelBinding.ModelStateDictionary          oModelState
) ModelBindingQyeryString<TModel>(TModel oModel) where TModel : class
{
  return WebFormModelBinding.ModelBinding(oModel, "QueryString");
}

驗證時間,使用在QueryString上:

var oResultQ   = WebFormModelBinding.ModelBindingQyeryString(new yourORM());
bool IsSuccess = !oResultQ.bIsError;
var oData      = oResultQ.oData;
string cError  = string.Empty;
if (oResultQ.bIsError)
{ cError = string.Join("|", oResultQ.oErrorList.Select(x => $"{x.Key}:{x.Value}")); }

驗證時間,使用在Form上:

var oResultF   = WebFormModelBinding.ModelBindingForm(new yourORM());
bool IsSuccess = !oResultF.bIsError;
var oData      = oResultF.oData;
string cError  = string.Empty;
if (oResultF.bIsError)
{ cError = string.Join("|", oResultF.oErrorList.Select(x => $"{x.Key}:{x.Value}")); }

總結

透過ASP.NET ModelBinding讓我們可以以更高速穩定的方式,快速的將程式碼中很枯燥的部分跳過,但其實在綁定後的ORM資料整理工作其實也沒有節省到多少功夫(但話說回來,同樣的條件下直接去解析Request.Form也是要做一樣的驗證工程),建議這種東西可以適量的用在內部程式交換數據上,前後端彼此信任的關係下其實可以省掉很多防範方面的程式碼。

相關連結

在WebForm下使用ModelBinding(PostBack遭遇之問題)

進行物件Reflection反射後,將值Value寫入到物件屬性中

ASP.NET WebForm MVC System.Web.ModelBinding ModelBinding BindModel