İçeriğe atla

ASP.NET Core 6/Model Binding

Vikikitap, özgür kütüphane

Daha önceki dersimizde FormController sınıfının SubmitForm() action metodu şöyleydi:

[HttpPost]
public IActionResult SubmitForm() {
    foreach (string key in Request.Form.Keys.Where(k => !k.StartsWith("_"))) {
        TempData[key] = string.Join(", ", Request.Form[key]);
    }
    return RedirectToAction(nameof(Results));
}

Koddan da göreceğiniz üzere SubmitForm action'ı form verilerine ulaşmak için ana sınıftan devralınan Request özelliği üzerinden Form özelliğini çağırıyor. Elbetteki bu şekilde direkt form verilerini işleyebilirsiniz. Benzer şekilde rota değişkenlerine ve query string değerlerine de daha önce gördüğümüz şekilde direkt erişebiliriz. Ancak bu yol uğraştırıcı, hataya açık, bakımı zor, okunabilirliği düşük kod üretmemize neden olacaktır. Daha şık bir yol model binding'i kullanmaktır. Model binding, gelen request'i inceler, request'teki bilgilerden C# değişkenini oluşturur ve action metotta veya Razor sayfası handler'ında söz konusu değişkene direkt erişmemize imkan sağlar. Söz konusu request verisine ulaşmak için action'da ve Razor sayfası handler'ında aynı isimli bir parametre tanımlarız.

Model binding, action ve Razor sayfası handler'ında belirli bir isimli bir parametre gördüğü zaman aynı isimli parametreyi sırasıyla request'in şu kısımlarında arar:

  1. Form verisi
  2. Request'in gövdesi (sadece ApiController attribute'u ile işaretlenmiş controller'larda)
  3. Rota değişkenleri
  4. Query string

Basit veri tiplerini bağlama

[düzenle]

Model binding sürecinde basit tipler ve karmaşık tipler bağlanabilir. Bu bölümde basit tiplerin nasıl bağlanacağı anlatılacaktır. Model binding sürecinde parse edilebilecek basit tipler nümerik tipler (int, float, vb.), bool, tarihler ve string olabilir. Basit veri tiplerinin bağlanması sürecinde request'ten tek bir değer alınır, bu değer C#'taki ilgili temel tipe dönüştürülür. Şimdi yukarıdaki SubmitForm() metodunu şöyle değiştirelim:

[HttpPost]
public IActionResult SubmitForm(string name, decimal price) {
    TempData["name param"] = name;
    TempData["price param"] = price.ToString();
    return RedirectToAction(nameof(Results));
}

Artık foreach döngüsüyle form kontrollerinin üzerinde dönmeye gerek kalmadı. İstediğimiz belirli bir isimli form kontrolünün değerine direkt erişebiliyoruz. Bu form kontrollerinin değerleri metodumuza direkt name ve price isimli değişkenler olarak geliyor, tür dönüşümü model binding süreci tarafından otomatik yapılıyor.

Razor sayfalarında basit veri tiplerini bağlama

[düzenle]

Razor sayfalarında da basit veri tiplerinin bağlanması benzer şekilde yapılır. Ancak dikkat etmemiz gerekir ki Razor sayfalarının görünüm kısımları model olarak PageModel nesnesi alır. Razor sayfasının görünüm kısmındaki form kontrolleri asıl modele aşağıdaki şekilde bağlanır:

<input asp-for="Product.Name" /><br />
<input asp-for="Product.Price" />

Kodu bu şekilde bıraktığımızda form kontrollerinin isimleri "Product.Name" ve "Product.Price" olacaktır ve mevcut haliyle bu form kontrollerinin değerlerine Razor sayfasının handler metodundan erişemezsiniz. Çünkü C#'ta nokta işaretinin özel anlamı vardır. Bu sorundan kurtulmak için Razor sayfasının görünüm kısmındaki form kontrollerini şöyle değiştirmeliyiz:

<input asp-for="Product.Name" name="name" /><br />
<input asp-for="Product.Price" name="price" />

Artık form kontrollerinin değerlerine name ve price değişkenleri üzerinden Razor sayfasının handler metodunda erişebileceğiz. Şimdi aynı Razor sayfasının kod kısmındaki form verilerini işleyecek handler metodu şöyle değiştirelim.

public IActionResult OnPost(string name, decimal price) {
    TempData["name param"] = name;
    TempData["price param"] = price.ToString();
    return RedirectToPage("FormResults");
}

Varsayılan bağlama değerlerinin kullanımı

[düzenle]

Model binding, bir action veya handler metodun istediği isimde bir değeri request'te bulamazsa hata döndürmez, action veya handler'ı ilgili parametre tipinin varsayılan değeriyle çalıştırır. Varsayılan değerler sayısal tipler için 0, bool için false, referans tipleri için null'dur. Çoğunlukla model binding'in bu varsayılan davranışı istediğimiz bir şey değildir ve istenmeyen sonuçlara yol açar. Bu sorunun üç şekilde üstesinden gelebiliriz.

Action veya handler'ın opsiyonel parametre alması

[düzenle]

Örnek:

public async Task<IActionResult> Index(long id = 1) { //...

Bu action model binding mekanizmasından id isimli bir değer alamadığında id parametresi için 1 değerini kullanmaktadır.

Rota belirtiminde varsayılan değerli segment kullanılması

[düzenle]

Eğer model binding ilgili değeri bir rota değişkeninden alacaksa rota belirtiminde varsayılan değerli segment kullanılması mantıklı olabilir. Bu sayede aynı rota segmentine birden fazla action veya handler erişecekse her action veya handler'da aynı varsayılan değeri belirtme zahmetinden kurtuluruz. Örnek (Program.cs dosyası):

app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id=1}");

veya bir Razor sayfasının başında

@page "{id=1}"

Metodun gövdesinde varsayılan değerle uğraşma

[düzenle]

Üçüncü bir yol olarak metot gövdesinde, varsayılan değerli bir parametre aldığında yapacağı şeyi kendisi belirtebilir. Örnek:

public async Task<IActionResult> Index(long id) {
    return View("Form", await context.Products.FirstOrDefaultAsync(p => p.ProductId == id));
}

Bu action id için 0 değerini aldığı durumla kendisi baş etmektedir. LINQ'teki FirstOrDefaultAsync() metodu eğer istenilen öge veritabanında bulunamadıysa geriye Product tipinin varsayılan değeri olan null'u döndürür, dolayısıyla view'a null değer gider. Form tag helper'ları modelin değerinin null olması durumunda form kontrollerine bir şey yazmaz.

Bazen gerçekten model binding mekanizması request'ten 0 değerini alabilir. Örneğin kullanıcı bir rota değişkenine 0 yazabilir veya form kontrolüne 0 değerini girebilir. Böyle bir durumda kullanıcının 0 verdiği durumla değer vermediği durumu ayırt etmek için nullable tipler kullanılabilir. Örnek:

public async Task<IActionResult> Index(long? id) {
    return View("Form", await context.Products.FirstOrDefaultAsync(p => id == null || p.ProductId == id));
}

Bu örneğimizde model binding mekanizması id için bir değer bulamazsa null değerini id değişkenine yazacaktır. Bu sayede kullanıcının id için bilinçli bir şekilde 0 verdiği durumla değer vermediği durum ayırt edilebilecektir. LINQ sorgusu da güncellenmiştir. LINQ sorgusuna göre id değeri verilmemişse veritabanındaki ilk kayıt, verilmişse belirtilen id'li kayıt veritabanından çekilecektir. Eğer belirtilen id'li kayıt veritabanında yoksa null değer view'a gönderilecektir.

Karmaşık tipleri bağlama

[düzenle]

Model binding basit tipler için kullanılabilir. Ancak model binding'in asıl faydası karmaşık tiplerin bağlanmasında ortaya çıkmaktadır. Karmaşık tipin bağlanması sürecinde request'teki birden fazla veriden tek bir karmaşık tip nesnesi üretilir. Request'teki her bir veri karmaşık tipin aynı isimli public bir özelliğine eşlenir. Örnek:

[HttpPost]
public IActionResult SubmitForm(Product product) {
    TempData["product"] = System.Text.Json.JsonSerializer.Serialize(product);
    return RedirectToAction(nameof(Results));
}

Örneğimizde SubmitForm action'ına verilen Product nesnesi model binding süreci tarafından üretilmektedir. Gelen request'teki Name ve Price isimli form verileri Product nesnesinin Name ve Price özelliklerinin değerleri olmaktadır. Model binding sürecinin çalışması için request'in ilgili tipin her bir public özelliğine veri sağlamasına gerek yoktur. Request'te verisi olmayan özellikler varsayılan değerine atanır.

Özelliğe bağlama

[düzenle]

Kuşkusuz Razor sayfalarındaki handler metotlar da parametre yoluyla karmaşık tip nesnesi alabilir. Örnek:

public IActionResult OnPost(Product product) {
    TempData["product"] = System.Text.Json.JsonSerializer.Serialize(product);
    return RedirectToPage("FormResults");
}

Kuşkusuz bu kod çalışacaktır. Ancak Razor sayfalarında çoğunlukla asıl modeli temsil eden ve PageModel sınıfında bir özellik olarak tanımlanan modeli kullanırız. Yukarıdaki örneğimizde PageModel sınıfında zaten üzerinde çalıştığımız Product tipinde bir özellik vardır. Bu kodu şöyle değiştirsek daha güzel olacaktır:

// ...
[BindProperty]
public Product Product { get; set; }
// ...
public IActionResult OnPost()
{
    TempData["product"] = System.Text.Json.JsonSerializer.Serialize(Product);
    return RedirectToPage("FormResults");
}
// ...

Bu örneğimizde OnPost() handler metodu request'teki veriye aynı sınıftaki Product özelliği aracılığıyla erişmektedir. Bir Razor sayfasına her gelen request'te yeni bir model sınıfı nesnesi oluşturulur. Bu model sınıfı nesnesi oluşturulurken model sınıfının BindProperty attribute'u ile işaretlenmiş özelliğine request'teki veriler aktarılır. Daha sonra handler metot tarafından model binding süreci tarafından oluşturulmuş bu özelliğe erişilebilir. Başka bir örnek:

// ...
[BindProperty]
public string Name { get; set; }
[BindProperty]
public decimal Price { get; set; }
// ...
public IActionResult OnPost()
{
    TempData["name"] = Name;
    TempData["price"] = Price.ToString();
    return RedirectToPage("FormResults");
}
// ...

Gördüğünüz gibi BindProperty attribute'u ile birden fazla özelliği işaretleyebiliyoruz, bütün bu özelliklerin değerleri request'ten sağlanacaktır. Özelliğe bağlama tekniği controller'larda da kullanılabilir. Örnek:

public class HomeController : Controller
{
    [BindProperty]
    public string Name { get; set; }
    [BindProperty]
    public decimal Price { get; set; }
    public IActionResult Index()
    {
        return View();
    }
    [HttpPost]
    public IActionResult VeriAl()
    {
        return View("Index", Name + " " + Price.ToString());
    }
}

Burada Index action'ı form kontrollerini gösterecek olan action'dır. Index action'ının render'ladığı Index view'ı model olarak string kabul etmekte ve form kontrollerini içermektedir. İlk seferinde form kontrolleri render'lanacağı zaman Index view'ına veri gitmemektedir, böyle bir durumda view'da model nesnesinin gösterileceği yer boş kalacaktır. Index view'ında form kontrolleri doldurulup submit butonuna basıldığında <form> etiketinin asp-action seçeneği ile belirtilen VeriAl action'ı form verilerini Name ve Price özellikleri aracılığıyla alacak ve aynı Index view'ına model nesnesi olarak gönderecektir. Bu durumda Index view'ında model nesnesinin içeriğinin yazılacağı yer dolu olarak gelecektir.

Varsayılan durumda özellik bazlı model bağlama yalnızca POST request'lerinde kulanılabilir. Örneğin yukarıdaki GET request'iyle erişilen Index action'ı, gelen request'te Name ve Price isimli veriler olsa bile model binding ile özelliklere değer ataması yapılmadığı için bu verilere özellikler üzerinden erişemeyecektir. Bu, controller'daki her action veya Razor sayfasındaki her handler metot çağrısının model binding gibi maliyetli olabilecek bir süreç başlatmaması bakımından faydalıdır. Ancak bazen yine de GET request'lerinde de özellik bazlı model binding'i etkinleştirmek isteyebiliriz. Bunun için tek yapmamız gereken özelliğe uygulanan BindProperty attribute'unun SupportsGet argümanını true yapmaktır. Örnek:

[BindProperty(SupportsGet = true)]
public string Name { get; set; }
[BindProperty(SupportsGet = true)]
public decimal Price { get; set; }
public IActionResult Index()
{
    return View(Name + " " + Price.ToString());
}

Artık Index action'ı da özelliklere bağlanan request verilerine erişebildiği için aldığı söz konusu verileri kendi view'ına gönderebilmektedir. Index action'ına veriler query string halinde gönderilebilir.

Bir controller veya Razor sayfası model sınıfındaki bütün public özelliklerin model binding sürecine dahil edilmesini istiyorsak direkt controller sınıfı veya Razor sayfası model sınıfı BindProperties attribute'u ile işaretlenebilir. Bu sayede ilgili controller veya Razor model sınıfında bütün public özellikleri BindProperty attribute'u ile işaretleme zahmetinden kurtulmuş oluruz.

Eğer BindProperties attribute'u ile işaretlenmiş bir controller veya Razor model sınıfındaki bir özelliği model binding sürecinden çıkarmak istiyorsak ilgili özellik BindNever attribute'u ile işaretlenir. BindNever attribute'unun bir kullanımı da karmaşık tipe bağlama esnasında karmaşık tipin belirli bir özelliğinin model binding sürecine dahil edilmemesidir. Örnek:

public class Product
{
    [BindNever]
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

Bu Product modelinin bir controller'da şöyle kullanıldığını varsayalım:

[HttpPost]
public IActionResult VeriAl(Product p)
{
    return View(p.Id.ToString() + " " + p.Name + " " + p.Price.ToString());
}

Örneğimizde request'de Id isimli bir veri olsa bile bu veri Product nesnesinin üretiminde kullanılmayacaktır.

Alt tiplerin bağlanması

[düzenle]

Kuşkusuz bir karmaşık tipin belirli bir özelliğinin tipi de karmaşık olabilir. Şimdi aşağıdaki gibi Product model sınıfına yeni bir özellik ekleyelim:

public class Product
{
    [BindNever]
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public Category Category { get; set; }
}

Şimdi Category isimli yeni bir model sınıfı oluşturalım ve içeriği şöyle olsun:

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Şimdi daha önceki formu render'layan view'ı şöyle değiştirelim:

@model Product
<form asp-action="submitform" method="post">
    <label asp-for="Name"></label>
    <input asp-for="Name" /><br />
    <label asp-for="Price"></label>
    <input asp-for="Price" /><br />
    <label asp-for="Category.Name">Category Name</label>
    <input asp-for="Category.Name" /><br />
    <input type="submit">Submit</button>
</form>

Home controller'ındaki form view'ını render'layacak Index action'ını ve form verilerini işleyecek SubmitForm action'ını şöyle değiştirelim:

public IActionResult Index()
{
    Product p=new Product { Id=1, Name="Top", Price=100, Category=new Category { Id=2,Name="Spor"} };
    return View(p);
}
[HttpPost]
public IActionResult SubmitForm(Product p)
{
    string s = System.Text.Json.JsonSerializer.Serialize(p);
    return View(s);
}

Şimdi SubmitForm action'ının render'layacağı view'ı şöyle oluşturalım:

@model string
<p>@Model</p>

Programı çalıştırıp Index view'ındaki forma bir şeyler girip Submit butonuna basarsanız aşağıdakine benzer şekilde bir JSON çıktısı alırsınız:

{"Id":0,"Name":"Top","Price":50,"Category":{"Id":0,"Name":"Spor"}}

Kuşkusuz sizin forma girdiğiniz veriler değişebilir. Burada önemli olan Product nesnesinin Category özelliğinin Name özelliğinin model binding süreci tarafından otomatik doldurulmasıdır. Formları oluştururken tag helper kullanmak arkaplandaki olayı gizleyebilir. Tag helper'ların tek yaptığı modelin bir özelliğinin bir özelliğini bir form kontrolüne bağlarken form kontrolüne UstOzellik.AltOzellik şeklinde bir isim vermesidir. Tag helper kullanmadan aynı formatta ismi siz de vererek model binding sürecinin alt tipler için de çalışmasını sağlayabilirsiniz. Örnek:

@model Product
<form asp-action="submitform" method="post">
    <input type="text" name="Name" /><br />
    <input type="text" name="Price" /><br />
    <input type="text" name="Category.Name" /><br />
    <input type="submit">Submit</button>
</form>

Kodda ayrıca basitlik olması açısından <label> etiketleri kaldırılmıştır. Bu kod işlevsel olarak tag helper'larla oluşturulan versiyonla aynıdır. Elbetteki formları tag helper'larla oluşturmak daha kullanışlıdır, bu örnek sadece model binding sürecinin alt tipleri de kapsamasını sağlamanın ne kadar basit olduğunu göstermek amaçlı oluşturulmuştur. Alt tip zinciri daha da artırılabilir. Örneğin Category tipinin Urunler tipinde Urunler isimli bir özelliği, bu özelliğin de UrunSayisi isimli int tipinde başka bir özelliği olabilir. Bu durumda formda UrunSayisi özelliğine değer girmek için form kontrolünü şöyle oluşturabiliriz:

<input type="text" name="Category.Urunler.UrunSayisi" /><br />