ASP.NET MVC Model Binding


  1. Without Model Binding
    1. Value Provider 順序
  2. Interesting Topics
  3. 應用
    1. GridView Edit
    2. Dual Entity Edit
      1. TryUpdateModel
      2. Referential integrity constraint violation
  4. 參考資料

筆記 ASP.NET MVC 5 使用 Model Binding 處理使用者與應用系統之間的資料傳送與處理。

logo

Without Model Binding

// QueryString id
Request["id"]
Request.QueryString["id"]

// Form id
Request.Form["id"]

// Route id
Request.RequestContext.RouteData.Values["id"] as string)

// Files
HttpPostedFileBase file = Request.Files[0]

// Application/json
string jsonData;
using (var reader = new StreamReader(Request.InputStream, Encoding.UTF8))
{
    jsonData = reader.ReadToEnd();
}

var person = JsonConvert.DeserializeObject<Person>(jsonData);

Value Provider 順序

Form Fields > Application/json > Route > Query String > Posted Files

Interesting Topics

  • 簡單型別 / 複雜型別 / 集合

  • Bind Prefix

應用

GridView Edit

Controller

public ActionResult GridviewEdit()
{
    return View(db.Regions.ToList());
}

[HttpPost]
public ActionResult GridviewEdit(IList<Region> regions)
{
    foreach (var region in regions)
    {
        db.Regions.AddOrUpdate(region);
    }
    db.SaveChanges();
    return View(db.Regions.ToList());
}

View

@model IList<NorthwindShop2.Web.Models.Region>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()

<div class="form-horizontal">
    @Html.ValidationSummary(true, "", new { @class = "text-danger" })

    @using (Html.BeginForm("GridViewEdit", "ModelBinding", FormMethod.Post))
    {
        <table class="table table-bordered">
            <thead>
                <tr>
                    <th>Regiod Id</th>
                    <th>Description</th>
                </tr>
            </thead>
            <tbody>
                @for (int i = 0; i < Model.Count; i++)
                {
                    <tr>
                        <td>
                            <div class="form-group">
                                @Html.HiddenFor(m => m[i].RegionID)
                                <input type="text" value="@Model[i].RegionID" class="form-control" disabled />
                            </div>
                        </td>
                        <td>
                            <div class="form-group">
                                @Html.TextBoxFor(m => m[i].RegionDescription, new { @class = "form-control" })
                            </div>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    }
</div>
}

Dual Entity Edit

編輯的 View
成功的綁定
成功的綁定

Model.cs

public partial class Categories
{
    public Categories()
    {
        this.Products = new HashSet<Products>();
    }

    public int CategoryID { get; set; }
    public string CategoryName { get; set; }
    public string Description { get; set; }

    public virtual ICollection<Products> Products { get; set; }
}
public partial class Products
{
    public int ProductID { get; set; }
    public string ProductName { get; set; }
    public int CategoryID { get; set; }
    public Nullable<decimal> UnitPrice { get; set; }
    public Nullable<short> UnitsInStock { get; set; }

    public virtual Categories Categories { get; set; }
}

兩個類別的 EDMX 關聯如圖:

View.cshtml

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    
<div class="form-horizontal">
    <h4>Products</h4>
    <hr />
    @Html.ValidationSummary(true, "", new { @class = "text-danger" })
    @Html.HiddenFor(model => model.ProductID)
    @Html.HiddenFor(model => model.Categories.CategoryID)

    <div class="form-group">
        @Html.LabelFor(model => model.ProductName, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.ProductName, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.ProductName, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.CategoryID, "CategoryID", htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.DropDownList("CategoryID", null, htmlAttributes: new { @class = "form-control" })
            @Html.ValidationMessageFor(model => model.CategoryID, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.UnitPrice, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.UnitPrice, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.UnitPrice, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.UnitsInStock, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.UnitsInStock, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.UnitsInStock, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.Categories.CategoryName, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.Categories.CategoryName, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.Categories.CategoryName, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.Categories.Description, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.Categories.Description, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.Categories.Description, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group mt-3">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" value="Save" class="btn btn-outline-primary me-3" />
            @Html.ActionLink("Back to List", "Index", null, new {@class= "btn btn-outline-secondary" })
        </div>
    </div>
</div>
}

除了 Scaffolding 提供的欄位外,額外新增了 HiddenFor(model => model.Categories.CategoryID), model.Categories.CategoryName, model.Categories.Description 來達到複數 Entities 的綁定成功。

Controller.cs

// GET: Products/Edit/5
public ActionResult Edit(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Products products = db.Products.Find(id);
    if (products == null)
    {
        return HttpNotFound();
    }
    ViewBag.CategoryID = new SelectList(
        db.Categories, "CategoryID", "CategoryName", 
        products.CategoryID);
    return View(products);
}

// POST: Products/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit([Bind] Products products)
{
    if (ModelState.IsValid)
    {
        db.Entry(products).State = EntityState.Modified;
        db.Entry(products.Categories).State = EntityState.Modified;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    ViewBag.CategoryID = new SelectList(db.Categories, "CategoryID", "CategoryName", products.CategoryID);
    return View(products);
}

需要加上額外的 db.Entry 來綁定除了主 Entity 外的其他 Entity,並設定為 EntityState.Modifies 來達成儲存置資料庫。

注意在 Post 的 Action 的 Parameter 綁定,有多種寫法達到多個 Entities 的更新,除了範例的方式外,也可以使用以下方式來綁定。

[Bind(Include = ("ProductID, ProductName, CategoryId, UnitPrice, UnitsInStock, Categories"))]

需要注意的是上述兩種綁定方式,因為沒有提供的值會清空資料表原來資料列的值,所枚舉的所有欄位名稱也有可能受到 OverPosting 的攻擊,如果要避免可以用 TryUpdateModel 搭配 Entity 的 Id,先取得 Entity 再透過 TryUpdateModel 去更新 Entity 並只允許特定的欄位。

TryUpdateModel

Model Binding 是被動式的綁定,藉由比對 Controller Action 的參數,透過 DefaultModelBinder 來進行綁定。而也可以進行主動式的綁定,使用 UpdateModel 以及 TryUpdateModel 的方式來達成。

public ActionResult Edit(int Id, FormCollection FormValue)
{
    Product product = db.Product.FirstOrDefault(p => p.Id == Id);
    if (TryUpdateModel(product, FormValue.AllKeys) && ModelState.IsValid)
    {
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(product);
}

Referential integrity constraint violation

A referential integrity constraint violation occurred: The property value(s) of ‘Categories.CategoryID’ on one end of a relationship do not match the property value(s) of ‘Products.CategoryID’ on the other end.

需要注意目前的設定方式,如果變更 Product 所屬的 CategoryID 就會發生錯誤。

權衡的緩解方式如下,判斷如果 CategoryID 發生改變,則只改變 Product 本身,不去調整關聯到 Category 內容,否則才依照原本的邏輯進行複數 Entity 調整。

if (ModelState.IsValid)
{
    if (products.CategoryID != products.Categories.CategoryID)
    {
        products.Categories = db.Categories.FirstOrDefault(i => i.CategoryID == products.CategoryID);
        db.Entry(products).State = EntityState.Modified;
    }
    else
    {
        db.Entry(products).State = EntityState.Modified;
        db.Entry(products.Categories).State = EntityState.Modified;
    }

    db.SaveChanges();
    return RedirectToAction("Index");
}

參考資料

ASP.NET MVC - The Features and Foibles of ASP.NET MVC Model Binding

[ASP.NET MVC 小牛之路]15 - Model Binding