۱۳۹۱/۰۱/۱۷

ASP.NET MVC #10


آشنايي با روش‌هاي مختلف ارسال اطلاعات يك درخواست به كنترلر

تا اينجا با روش‌هاي مختلف ارسال اطلاعات از يك كنترلر به View متناظر آن آشنا شديم. اما حالت عكس آن چطور؟ مثلا در ASP.NET Web forms، دوبار بر روي يك دكمه كليك مي‌كرديم و در روال رويدادگردان كليك آن، همانند برنامه‌هاي ويندوزي، دسترسي به اطلاعات اشياء قرار گرفته بر روي فرم را داشتيم. در ASP.NET MVC كه كلا مفهوم Events را حذف كرده و وب را همانگونه كه هست ارائه مي‌دهد و به علاوه كنترلرهاي آن، ارجاع مستقيمي را به هيچكدام از اشياء بصري در خود ندارند (براي مثال كنترلر و متدي در آن نمي‌دانند كه الان بر روي View آن، يك گريد قرار دارد يا يك دكمه يا اصلا هيچي)، چگونه مي‌توان اطلاعاتي را از كاربر دريافت كرد؟
در اينجا حداقل سه روش براي دريافت اطلاعات از كاربر وجود دارد:
الف) استفاده از اشياء Context مانند HttpContext، Request، RouteData و غيره
ب) به كمك پارامترهاي اكشن متدها
ج) با استفاده از ويژگي جديدي به نام Data Model Binding

يك مثال كاربردي
قصد داريم يك صفحه لاگين ساده را طراحي كنيم تا بتوانيم هر سه حالت ذكر شده فوق را در عمل بررسي نمائيم. بحث HTML Helpers استاندارد ASP.NET MVC را هم كه در قسمت قبل شروع كرديم، لابلاي توضيحات قسمت جاري و قسمت‌هاي بعدي با مثال‌هاي كاربردي دنبال خواهند شد.
بنابراين يك پروژه جديد خالي ASP.NET MVC را شروع كرده و مدلي را به نام Account با محتواي زير به پوشه Models برنامه اضافه كنيد:

namespace MvcApplication6.Models
{
    public class Account
    {
        public string Name { get; set; }
        public string Password { get; set; }
    }
}

يك كنترلر جديد را هم به نام LoginController به پوشه كنترلرهاي برنامه اضافه كنيد. بر روي متد Index پيش فرض آن كليك راست نمائيد و يك View خالي را اضافه نمائيد.
در ادامه به فايل Global.asax.cs مراجعه كرده و نام كنترلر پيش‌فرض را به Login تغيير دهيد تا به محض شروع برنامه در VS.NET، صفحه لاگين ظاهر شود.
كدهاي كامل كنترلر لاگين را در ادامه ملاحظه مي‌كنيد:

using System.Web.Mvc;
using MvcApplication6.Models;

namespace MvcApplication6.Controllers
{
    public class LoginController : Controller
    {
        [HttpGet]
        public ActionResult Index()
        {
            return View(); //Shows the login page
        }

        [HttpPost]
        public ActionResult LoginResult()
        {
            string name = Request.Form["name"];
            string password = Request.Form["password"];

            if (name == "Vahid" && password == "123")
                ViewBag.Message = "Succeeded";
            else
                ViewBag.Message = "Failed";

            return View("Result");
        }

        [HttpPost]
        [ActionName("LoginResultWithParams")]
        public ActionResult LoginResult(string name, string password)
        {
            if (name == "Vahid" && password == "123")
                ViewBag.Message = "Succeeded";
            else
                ViewBag.Message = "Failed";

            return View("Result");
        }

        [HttpPost]
        public ActionResult Login(Account account)
        {
            if (account.Name == "Vahid" && account.Password == "123")
                ViewBag.Message = "Succeeded";
            else
                ViewBag.Message = "Failed";

            return View("Result");
        }
    }
}

همچنين Viewهاي متناظر با اين كنترلر هم به شرح زير هستند:
فايل index.cshtml به نحو زير تعريف خواهد شد:

@model MvcApplication6.Models.Account
@{
    ViewBag.Title = "Index";
}
<h2>
    Login</h2>
@using (Html.BeginForm(actionName: "LoginResult", controllerName: "Login"))
{
    <fieldset>
        <legend>Test LoginResult()</legend>
        <p>
            Name: @Html.TextBoxFor(m => m.Name)</p>
        <p>
            Password: @Html.PasswordFor(m => m.Password)</p>
        <input type="submit" value="Login" />
    </fieldset>
}
@using (Html.BeginForm(actionName: "LoginResultWithParams", controllerName: "Login"))
{
    <fieldset>
        <legend>Test LoginResult(string name, string password)</legend>
        <p>
            Name: @Html.TextBoxFor(m => m.Name)</p>
        <p>
            Password: @Html.PasswordFor(m => m.Password)</p>
        <input type="submit" value="Login" />
    </fieldset>
}
@using (Html.BeginForm(actionName: "Login", controllerName: "Login"))
{
    <fieldset>
        <legend>Test Login(Account acc)</legend>
        <p>
            Name: @Html.TextBoxFor(m => m.Name)</p>
        <p>
            Password: @Html.PasswordFor(m => m.Password)</p>
        <input type="submit" value="Login" />
    </fieldset>
}

و فايل result.cshtml هم محتواي زير را دارد:

@{
    ViewBag.Title = "Result";
}
<fieldset>
    <legend>Login Result</legend>
    <p>
        @ViewBag.Message</p>
</fieldset>

توضيحاتي در مورد View لاگين برنامه:
در View صفحه لاگين سه فرم را مشاهده مي‌كنيد. در برنامه‌هاي ASP.NET Web forms در هر صفحه، تنها يك فرم را مي‌توان تعريف كرد؛ اما در ASP.NET MVC اين محدوديت برداشته شده است.
تعريف يك فرم هم با متد كمكي Html.BeginForm انجام مي‌شود. در اينجا براي مثال مي‌شود يك فرم را به كنترلري خاص و متدي مشخص در آن نگاشت نمائيم.
از عبارت using هم براي درج خودكار تگ بسته شدن فرم، در حين dispose شيء MvcForm كمك گرفته شده است.
براي نمونه خروجي HTML اولين فرم تعريف شده به صورت زير است:

<form action="/Login/LoginResult" method="post">   
    <fieldset>
        <legend>Test LoginResult()</legend>
        <p>
            Name: <input id="Name" name="Name" type="text" value="" /></p>
        <p>
            Password: <input id="Password" name="Password" type="password" /></p>
        <input type="submit" value="Login" />
    </fieldset>
</form>

توسط متدهاي كمكي Html.TextBoxFor و Html.PasswordFor يك TextBox و يك PasswordBox به صفحه اضافه مي‌شوند، اما اين For آن‌ها و همچنين lambda expression ايي كه بكارگرفته شده براي چيست؟
متدهاي كمكي Html.TextBox و Html.Password از نگارش‌هاي اوليه ASP.NET MVC وجود داشتند. اين متدها نام خاصيت‌ها و پارامترهايي را كه قرار است به آن‌ها بايند شوند، به صورت رشته مي‌پذيرند. اما با توجه به اينكه در اينجا مي‌توان يك strongly typed view را تعريف كرد،‌ تيم ASP.NET MVC بهتر ديده است كه اين رشته‌ها را حذف كرده و از قابليتي به نام Static reflection استفاده كند (^ و ^).

با اين توضيحات، اطلاعات سه فرم تعريف شده در View لاگين برنامه، به سه متد متفاوت قرار گرفته در كنترلري به نام Login ارسال خواهند شد. همچنين با توجه به مشخص بودن نوع model كه در ابتداي فايل تعريف شده، خاصيت‌هايي را كه قرار است اطلاعات ارسالي به آن‌ها بايند شوند نيز به نحو strongly typed تعريف شده‌اند و تحت نظر كامپايلر خواهند بود.


توضيحاتي در مورد نحوه عملكرد كنترلر لاگين برنامه:

در اين كنترلر صرفنظر از محتواي متدهاي آن‌ها، دو نكته جديد را مي‌توان مشاهده كرد. استفاده از ويژگي‌هاي HttpPost، HttpGet و ActionName. در اينجا به كمك ويژگي‌هاي HttpGet و HttpPost در مورد نحوه دسترسي به اين متدها، محدوديت قائل شده‌ايم. به اين معنا كه تنها در حالت Post است كه متد LoginResult در دسترس خواهد بود و اگر شخصي نام اين متدها را مستقيما در مرورگر وارد كند (يا همان HttpGet پيش فرض كه نيازي هم به ذكر صريح آن نيست)، با پيغام «يافت نشد» مواجه مي‌گردد.
البته در نگارش‌هاي اوليه ASP.NET MVC از ويژگي ديگري به نام AcceptVerbs براي مشخص سازي نوع محدوديت فراخواني يك اكشن متد استفاده مي‌شد كه هنوز هم معتبر است. براي مثال:

[AcceptVerbs(HttpVerbs.Get)]

يك نكته امنيتي:
هميشه متدهاي Delete خود را به HttpPost محدود كنيد. به اين علت كه ممكن است در طي مثلا يك ايميل، آدرسي به شكل http://localhost/blog/delete/10 براي شما ارسال شود و همچنين سشن كار با قسمت مديريتي بلاگ شما نيز در همان حال فعال باشد. URL ايي به اين شكل، در حالت پيش فرض، محدوديت اجرايي HttpGet را دارد. بنابراين احتمال اجرا شدن آن بالا است. اما زمانيكه متد delete را به HttpPost محدود كرديد، ديگر اين نوع حملات جواب نخواهند داد و حتما نياز خواهد بود تا اطلاعاتي به سرور Post شود و نه يك Get ساده (مثلا كليك بر روي يك لينك معمولي)، كار حذف را انجام دهد.


توسط ActionName مي‌توان نام ديگري را صرفنظر از نام متد تعريف شده در كنترلر، به آن متد انتساب داد كه توسط فريم ورك در حين پردازش نهايي مورد استفاده قرار خواهد گرفت. براي مثال در اينجا به متد LoginResult دوم، نام LoginResultWithParams را انتساب داده‌ايم كه در فرم دوم تعريف شده در View لاگين برنامه مورد استفاده قرار گرفته است.
وجود اين ActionName هم در مثال فوق ضروري است. از آنجائيكه دو متد هم نام را معرفي كرده‌ايم و فريم ورك نمي‌داند كه كداميك را بايد پردازش كند. در اين حالت (بدون وجود ActionName معرفي شده)، برنامه با خطاي زير مواجه مي‌گردد:

The current request for action 'LoginResult' on controller type 'LoginController' is ambiguous between the following action methods:
System.Web.Mvc.ActionResult LoginResult() on type MvcApplication6.Controllers.LoginController
System.Web.Mvc.ActionResult LoginResult(System.String, System.String) on type MvcApplication6.Controllers.LoginController

براي اينكه بتوانيد نحوه نگاشت فرم‌ها به متدها را بهتر درك كنيد، بر روي چهار return View موجود در كنترلر لاگين برنامه، چهار breakpoint را تعريف كنيد. سپس برنامه را در حالت ديباگ اجرا نمائيد و تك تك فرم‌ها را يكبار با كليك بر روي دكمه لاگين، به سرور ارسال نمائيد.


بررسي سه روش دريافت اطلاعات از كاربر در ASP.NET MVC

الف) استفاده از اشياء Context

در ويژوال استوديو، در كنترلر لاگين برنامه، بر روي كلمه Controller كليك راست كرده و گزينه Go to definition را انتخاب كنيد. در اينجا بهتر مي‌توان به خواصي كه در يك كنترلر به آن‌ها دسترسي داريم، نگاهي انداخت:

public HttpContextBase HttpContext { get; }
public HttpRequestBase Request { get; }
public HttpResponseBase Response { get; }
public RouteData RouteData { get; }

در بين اين خواص و اشياء مهيا، Request و RouteData بيشتر مد نظر ما هستند. در مورد RouteData در قسمت ششم اين سري، توضيحاتي ارائه شد. اگر مجددا Go to definition مربوط به HttpRequestBase خاصيت Request را بررسي كنيم، موارد ذيل جالب توجه خواهند بود:

public virtual NameValueCollection QueryString { get; } // GET variables
public NameValueCollection Form { get; } // POST variables
public HttpCookieCollection Cookies { get; }
public NameValueCollection Headers { get; }
public string HttpMethod { get; }

توسط خاصيت Form شيء Request مي‌توان به مقادير ارسالي به سرور در يك كنترلر دسترسي يافت كه نمونه‌اي از آن‌را در اولين متد LoginResult مي‌توانيد مشاهده كنيد. اين روش در ASP.NET Web forms هم كار مي‌كند. جهت اطلاع اين روش با ASP كلاسيك دهه نود هم سازگار است!
البته اين روش آنچنان مرسوم نيست؛ چون NameValueCollection مورد استفاده، ايندكسي عددي يا رشته‌اي را مي‌پذيرد كه هر دو با پيشرفت‌هايي كه در زبان‌هاي دات نتي صورت گرفته‌اند، ديگر آنچنان مطلوب و روش مرجح به حساب نمي‌آيند. اما ... هنوز هم قابل استفاده است.
به علاوه اگر دقت كرده باشيد در اينجا HttpContextBase داريم بجاي HttpContext. تمام اين كلاس‌هاي پايه هم به جهت سهولت انجام آزمون‌هاي واحد در ASP.NET MVC ايجاد شده‌اند. كار كردن مستقيم با HttpContext مشكل بوده و نياز به شبيه سازي فرآيندهاي رخ داده در يك وب سرور را دارد. اما اين كلاس‌هاي پايه جديد، مشكلات ياد شده را به همراه ندارند.


ب) استفاده از پارامترهاي اكشن متدها

نكته‌اي در مورد نامگذاري پارامترهاي يك اكشن متد به صورت توكار اعمال مي‌شود كه بايد به آن دقت داشت:
اگر نام يك پارامتر، با نام كليد يكي از ركوردهاي موجود در مجموعه‌هاي زير يكي باشد، آنگاه به صورت خودكار اطلاعات دريافتي به اين پارامتر نگاشت خواهد شد (پارامتر هم نام، به صورت خودكار مقدار دهي مي‌شود). اين مجموعه‌ها شامل موارد زيرهستند:

Request.Form
Request.QueryString
Request.Files
RouteData.Values

براي نمونه در متدي كه با نام LoginResultWithParams مشخص شده، چون نام‌هاي دو پارامتر آن، با نام‌هاي بكارگرفته شده در Html.TextBoxFor و Html.PasswordFor يكي هستند، با مقادير ارسالي آن‌ها مقدار دهي شده و سپس در متد قابل استفاده خواهند بود. در پشت صحنه هم از همان ركوردهاي موجود در Request.Form (يا ساير موارد ذكر شده)، استفاده مي‌شود. در اينجا هر ركورد مثلا مجموعه Request.Form، كليدي مساوي نام ارسالي به سرور را داشته و مقدار آن هم، مقداري است كه كاربر وارد كرده است.
اگر همانندي يافت نشد، آن پارامتر با نال مقدار دهي مي‌گردد. بنابراين اگر براي مثال يك پارامتر از نوع int را معرفي كرده باشيد و چون نوع int، نال نمي‌پذيرد، يك استثناء بروز خواهد كرد. براي حل اين مشكل هم مي‌توان از Nullable types استفاده نمود (مثلا بجاي int id نوشت int? id تا مشكلي جهت انتساب مقدار نال وجود نداشته باشد).
همچنين بايد دقت داشت كه اين بررسي تطابق‌هاي بين نام عناصر HTML و نام پارامترهاي متدها، case insensitive است و به كوچكي و بزرگي حروف حساس نيست. براي مثال، پارامتر معرفي شده در متد LoginResult مساوي string name است، اما نام خاصيت تعريف شده در كلاس Account مساوي Name بود.


ج) استفاده از ويژگي جديدي به نام Data Model Binding

در ASP.NET MVC چون مي‌توان با يك Strongly typed view كار كرد، خود فريم ورك اين قابليت را دارد كه اطلاعات ارسالي يكي فرم را به صورت خودكار به يك وهله از يك شيء نگاشت كند. در اينجا model binder وارد عمل مي‌شود، مقادير ارسالي را استخراج كرده (اطلاعات دريافتي از Form يا كوئري استرينگ‌ها يا اطلاعات مسيريابي و غيره) و به خاصيت‌هاي يك شيء نگاشت مي‌كند. بديهي است در اينجا اين خواص بايد عمومي باشند و هم نام عناصر HTML ارسالي به سرور. همچنين model binder پيش فرض ASP.NET MVC را نيز مي‌توان كاملا تعويض كرد و محدود به استفاده از model binder توكار آن نيستيم.
وجود اين Model binder، كار با ORMها را بسيار لذت بخش مي‌كند؛ از آنجائيكه خود فريم ورك ASP.NET MVC مي‌تواند عناصر شيءايي را كه قرار است به بانك اطلاعاتي اضافه شود، يا در آن به روز شود، به صورت خودكار ايجاد كرده يا به روز رساني نمايد.
نحوه كار با model binder را در متد Login كنترلر فوق مي‌توانيد مشاهده كنيد. بر روي return View آن يك breakpoint قرار دهيد. فرم سوم را به سرور ارسال كنيد و سپس در VS.NET خواص شيء ساخته شده را در حين ديباگ برنامه، بررسي نمائيد.
بنابراين تفاوتي نمي‌كند كه از چندين پارامتر استفاده كنيد يا اينكه كلا يك شيء را به عنوان پارامتر معرفي نمائيد. فريم ورك سعي مي‌كند اندكي هوش به خرج داده و مقادير ارسالي به سرور را به پارامترهاي تعريفي، حتي به خواص اشياء اين پارامترهاي تعريف شده، نگاشت كند.

در ASP.NET MVC سه نوع Model binder وجود دارند:
1) Model binder پيش فرض كه توضيحات آن به همراه مثالي ارائه شد.
2) Form collection model binder كه در ادامه توضيحات آن‌را مشاهده خواهيد نمود.
3) HTTP posted file base model binder كه توضيحات آن به قسمت بعدي موكول مي‌شود.

يك نكته:
اولين متد LoginResult كنترلر را به نحو زير نيز مي‌توان بازنويسي كرد:
[HttpPost]
[ActionName("LoginResultWithFormCollection")]
public ActionResult LoginResult(FormCollection collection)
{
       string name = collection["name"];
       string password = collection["password"];

       if (name == "Vahid" && password == "123")
                ViewBag.Message = "Succeeded";
       else
                ViewBag.Message = "Failed";

       return View("Result");
}

در اينجا FormCollection به صورت خودكار بر اساس مقادير ارسالي به سرور توسط فريم ورك تشكيل مي‌شود (FormCollection هم يك نوع model binder ساده است) و اساسا يك NameValueCollection مي‌باشد.
بديهي است در اين حالت بايد نگاشت مقادير دريافتي، به متغيرهاي متناظر با آن‌ها، دستي انجام شود (مانند مثال فوق) يا اينكه مي‌توان از متد UpdateModel كلاس Controller هم استفاده كرد:

[HttpPost]
public ActionResult LoginResultUpdateFormCollection(FormCollection collection)
{
       var account = new Account();
       this.UpdateModel(account, collection.ToValueProvider());

       if (account.Name == "Vahid" && account.Password == "123")
                ViewBag.Message = "Succeeded";
       else
                ViewBag.Message = "Failed";

       return View("Result");
}

متد توكار UpdateModel، به صورت خودكار اطلاعات FormCollection دريافتي را به شيء مورد نظر، نگاشت مي‌كند.
همچنين بايد عنوان كرد كه متد UpdateModel، در پشت صحنه از اطلاعات Model binder پيش فرض و هر نوع Model binder سفارشي كه ايجاد كنيم استفاده مي‌كند. به اين ترتيب زمانيكه از اين متد استفاده مي‌كنيم، اصلا نيازي به استفاده از FormCollection نيست و متد بدون آرگومان زير هم به خوبي كار خواهد كرد:

[HttpPost]
public ActionResult LoginResultUpdateModel()
{
       var account = new Account();
        this.UpdateModel(account);

        if (account.Name == "Vahid" && account.Password == "123")
                ViewBag.Message = "Succeeded";
        else
                ViewBag.Message = "Failed";

        return View("Result");
}

استفاده از model binderها همينجا به پايان نمي‌رسد. نكات تكميلي آن‌ها در قسمت بعدي بررسي خواهند شد.