Часть 23 – Связывание получаемых данных и Модели

Рассмотрим еще один вопрос, касающийся Модели. А именно, каким образом в её экземпляр попадают данные из запроса, полученного от формы на странице сайта. Кроме того, разберем решение одной интересной задачи, периодически встречающейся в реальных проектах.

1. Связывание через параметры запроса

Это самый простой вариант, но при этом необходимо быть с ним очень аккуратным. Принцип его прост: необходимо создать Действие с именами и типами параметров, копирующими все или часть свойств Модели. При этом в метод View() необходимо передать пустую ссылку вместо экземпляра Модели.

Для примера добавим в Контроллер Payments Действие AddFromParams:

// GET: /Payments/AddFromParams
public ActionResult AddFromParams(
    string userLogin,
    double amount,
    string info)
{
    return this.View("Add");
} 

В этом методе нет явного создания экземпляра Модели. Кроме того, даже не все её свойства перечислены в качестве параметров. Давайте запустим веб-приложение и введем запрос вида:

http://localhost:[порт]/Payments/AddFromParams?UserLogin=axel&Amount=500&Info=some%20text

В открывшемся Представлении Payments/Add в соответствующих полях можно увидеть значения из указанного выше запроса. Откуда они там появились? Дело в том, что при получении пустой ссылки вместо Модели, метод View() самостоятельно создаст её экземпляр. После чего заполнит её данными, если у у Действия присутствуют параметры, аналогичных именам Модели.

Необходимо принять во внимание, что передача пустой ссылки в View() приведет к выбросу исключения. Оно будет перехвачено внутри ядра ASP.NET MVC, но тем не менее это лишние затраты процессорного времени при выполнении веб-приложения.

Посмотрим что необходимо сделать, чтобы улучшить данный вариант. Создадим Действие, которое будет добавлять запись в базу данных. Для начала создадим сам экземпляр Модели. Затем проверим его на наличие ошибок и выберем нужное действие в ответ:

// GET: /Payments/AddFromParams
public ActionResult AddFromParams(
    string userLogin,
    double amount,
    string info)
{
    var newPayment = new PaymentModel() {
        UserLogin = userLogin,
        Amount = amount,
        Info = info
    };

    this.TryValidateModel(newPayment);

    if (!this.ModelState.IsValid) {
        return this.View("Add", newPayment);
    }

    var paymentsRepository = new PaymentRepository();
    paymentsRepository.Add(newPayment);

    this.TempData["Message"] = Resources.Controllers.PaymentsRes.PaymentAddedMessage;

    return this.RedirectToAction("Index");
} 

Внимательно посмотрим на приведенный код. Легко заметить, что это аналог уже метода Add(), который использует другой вид связывания.

2. Связывание через объект

Более удобным способом является связывание параметров запроса и Модели через объект. По сути, он является развитием предыдущего варианта и не раз уже использовался в демонстрационном веб-приложении. Например, в Действии Add Контроллера Payments.

// POST: /Payments/Add
[HttpPost]
public ActionResult Add(PaymentModel newPayment)
{
    if (!this.ModelState.IsValid) {
        return this.View(newPayment);
    }

    var paymentsRepository = new PaymentRepository();
    paymentsRepository.Add(newPayment);

    this.TempData["Message"] = Resources.Controllers.PaymentsRes.PaymentAddedMessage;

    return this.RedirectToAction("Index");
}

Данный код аналогичен приведенному ранее варианту, но короче его за счет использования возможностей ядра ASP.NET MVC. Оно самостоятельно передаст параметры из запроса в экземпляр PaymentModel и вызовет метод для проверки значений Модели.

Коме того, данный способ позволяет связывать данные с коллекциями объектов. Например вот так может выглядеть Действие, которое позволяет принять и сохранить список платежей:

// POST: /Payments/AddMultiple
[HttpPost]
public ActionResult AddMultiple(PaymentModel[] newPayments)
{
    if (!this.ModelState.IsValid) {
        return this.View(newPayments);
    }

    var paymentsRepository = new PaymentRepository();

    foreach (var payment in newPayments) {
        paymentsRepository.Add(payment);
    }
    
    this.TempData["Message"] = Resources.Controllers.PaymentsRes.PaymentAddedMessage;

    return this.RedirectToAction("Index");
}

При этом соответствующее Представление должно передать список платежей, даже если он будет состоять только из одного элемента.

3. Управление связыванием с объектом

Иногда нет необходимости запрашивать значения для всех свойств Модели. Для этого достаточно оставить только нужные поля ввода на станице веб-приложения. Как уже можно было убедиться, связывание данных с Моделью все равно будет выполнено.

Однако, подобные свойства могут стать целью для различных инъекций. Ведь если самостоятельно добавить недостающие параметры в запрос, то ядро ASP.NET MVC скопирует эти значения в созданный экземпляр Модели.

Чтобы избежать этого, ядро ASP.NET MVC предоставляет атрибут [Bind]. Он позволяет определять параметры, которые будет участвовать в процессе связывания. Остальные попросту игнорируются. При этом атрибут поддерживает следующие параметры:

  • Include – “белый список” свойств, участвующих в связывании;
  • Exclude – “черный список” свойств, исключенных из связывания.

Если необходимо указать несколько свойств, то в качестве разделителя используется запятая. Сам атрибут указывается перед параметром Действия, который является экземпляром Модели.

Рассмотрим несколько примеров. Разрешим передачу в метод Add() только имени входа пользователя:

// GET: /Payments/AddFromParams
public ActionResult AddFromParams(
    [Bind(Include="UserLogin")] PaymentModel newPayment)
{
    .........
} 

Теперь все параметры, кроме указанного UserLogin, будут игнорироваться. В этом легко можно убедиться, если посмотреть на результат выполнения вот такого запроса:

http://localhost:[порт]/Payments/AddFromParams?UserLogin=axel&Amount=500&Info=some%20text

Аналогично можно указать и “черный список”:

// GET: /Payments/AddFromParams
public ActionResult AddFromParams(
    [Bind(Exclude="Date, Source")] PaymentModel newPayment)
{
    .........
} 

В результате значения, переданные в качестве параметров Date и Source, будут проигнорированы.

Метод AddFromParams() был создан только для примера и не потребуется в дальнейшем. Можно удалить его из исходного кода демонстрационного веб-приложения.

Полный контроль связывания передаваемых значений и Модели

IModelBinder

Давайте рассмотрим ситуацию, которая часто, в той или иной форме, встречается при разработке веб-проектов. Предположим, что кроме принятия платежей напрямую необходимо реализовать поддержку платежной системы. В качестве партнера выступает некая фирма GlobalBankOne, которая будет получать платежи от клиентов. Данные о них они готовы передавать в реальном времени в виде вот такого запроса:

/somepage.html?user=[name]&money=[amount]&additionalinfo=[payment info]

Как хорошо видно, ни один из параметров запроса не совпадает с именами свойств Модели. С одной стороны, можно реализовать Действие, которое будет заниматься сортировкой. Но это уже перенос бизнес-логики в код Контроллера. Как быть в этой ситуации?

Для решения подобных задач в ASP.NET MVC предусмотрен интерфейс IModelBinder. С его помощью можно реализовать метод, который будет заниматься созданием экземпляра Модели из полученных данных. Т.е. по сути, создать свой механизм связывания. Сам интерфейс выглядит очень просто:

namespace System.Web.Mvc
{
    public interface IModelBinder
    {
        object BindModel(
            ControllerContext controllerContext, 
            ModelBindingContext bindingContext);
    }
}

Метод BindModel() получает в качестве параметров два значения:

  • controllerContext – текущий контекст Контроллера;
  • bindingContext – контекст связывания, который позволяет получить информацию о самой Модели.

В качестве возвращаемого значения возвращается созданный экземпляр Модели. Таким образом, возможности данного варианта гораздо шире, чем у Действия для сбора параметров запроса.

Необходимо отметить, что ядро ASP.NET MVC для связывания использует свою реализацию IModelBinder по умолчанию. Это класс DefaultModelBinder, работа которого и была описана в этой части.

Рассмотрим способы указания ASP.NET MVC использовать заданную реализацию IModelBinder:

Способ 1: Установка атрибута [ModelBinder] перед параметром метода

В данном случае используется атрибут [ModelBinder], чтобы указать какой класс реализует связывание, которое будет применено для выбранного параметра.

// POST: /Payments/GlobalBankOne
public ActionResult GlobalBankOne(
    [ModelBinder(typeof(GlobalBankOneBinder))] PaymentModel newPayment)
{
    ..........
}

Способ 2: Добавление типа в общий список

Такой способ задает реализацию связывания конкретного типа Модели во всех Действиях веб-приложения. Для этого в методе Application_Start() необходимо добавить такую строчку:

ModelBinders.Binders[typeof(PaymentModel)] = new GlobalBankOneBinder();

Способ 3: Указание атрибута [ModelBinder] перед объявлением класса

Данный вариант похож по сути на предыдущий. Но теперь указание добавляется в файл с кодом Модели. Таким образом, при её повтором использовании, например в другом проекте, он не будет утерян:

[ModelBinder(typeof(GlobalBankOneBinder))] 
public class PaymentModel
{
    .........
}

Пример реализации

Будем считать, что Модель PaymentModel позволяет указать источник платежа. Поэтому добавим элемент в перечень. В остальном оставим её без изменений.

namespace MVCDemo.Models
{
    using System;

    public class PaymentModel
    {
        public enum PaymentSource
        {
            Unknown = 0,
            Direct = 1,
            GlobalBankOne = 2
        }

        .........
    }
}

Для эмуляции запросов в корне сайта создадим HTML файл GlobalBankOne.html с формой для ввода данных. Условимся, что обрабатывать запросы будет Действие GlobalBankOne Контроллера Payments.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>IModelBinder Demo</title>
</head>
<body>
    <form action="/Payments/GlobalBankOne" method="get">
    User:
    <input id="user" name="user" type="text" /><br />
    Amount (RUR):
    <input id="money" name="money" type="text" /><br />
    Payment info
    <input id="additionalinfo" name="additionalinfo" type="text" /><br />
    <input id="Submit" type="submit" value="submit" />
    </form>
</body>
</html>

Теперь в папке Models создадим вложенную папку Binders. В ней разместим класс GlobalBankOneBinder:

namespace MVCDemo.Models.Binders
{
    using System;
    using System.Web.Mvc;
    using MVCDemo.Resources.Models;
    using MVCDemo.Resources.Shared;

    public class GlobalBankOneBinder : IModelBinder
    {
        #region IModelBinder Members

        public object BindModel(
            ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            var payment = new PaymentModel();

            payment.UserLogin = controllerContext.HttpContext.Request["user"];
            var userRepository = new UserRepository();
            if (!userRepository.IsLoginExists(payment.UserLogin)) {
                bindingContext.ModelState.AddModelError("UserLogin", ErrorsRes.InvalidUserLogin);
            }            

            payment.Date = DateTime.Now;

            try {
                payment.Amount
                    = Convert.ToDouble(controllerContext.HttpContext.Request["money"]);
            }
            catch {
                bindingContext.ModelState.AddModelError(
                    "Amount", PaymentRes.IncorrectAmountValue);
            }

            payment.Source = PaymentModel.PaymentSource.GlobalBankOne;

            payment.Info = controllerContext.HttpContext.Request["additionalinfo"];

            return payment;
        }

        #endregion
    }
}

Обратите внимание на следующие моменты:

  • Пример достаточно простой и в нем не задействуются метаданные.
  • Метод BindModel() не обязательно должен создавать экземпляры только одного конкретного класса. Он получает информацию о типе, к которому надо привести исходный запрос, через параметр bindingContext. Это позволяет создать одну реализацию, которая может создавать экземпляры разных классов Модели в зависимости от текущей задачи и бизнес-логики приложения.
  • В приведенном коде реализована своя проверка данных. Для уведомления об ошибках используется объект ModelState.
  • Контекст Контроллера позволяет получить любую информацию о запросе. Именно с его помощью в данном случае осуществляется чтение переданных данных.
  • Не обязательно все данные брать только из запроса. В данном случае дата и источник платежа заполняются исходя из потребностей и задач бизнес-логики.

Теперь создадим Действие в Контроллере. Оно не будет отличаться от метода Add() по перечню параметров. Однако, обратите внимание на указание атрибута [ModelBinder]. Он и определяет используемую для связывания реализацию IModelBinder:

namespace MVCDemo.Controllers
{
    using System;
    using System.Web.Mvc;
    using MVCDemo.Models;
    using MVCDemo.Models.Binders;

    public class PaymentsController : Controller
    {
        .........

        // GET: /Payments/GlobalBankOne
        public ActionResult GlobalBankOne(
            [ModelBinder(typeof(GlobalBankOneBinder))] PaymentModel newPayment)
        {
            if (!this.ModelState.IsValid) {
                return this.View("Add", newPayment);
            }

            var paymentsRepository = new PaymentRepository();
            paymentsRepository.Add(newPayment);

            this.TempData["Message"] = Resources.Controllers.PaymentsRes.PaymentAddedMessage;

            return this.RedirectToAction("Index");
        }
    }
}

Для демонстрационных целей в Действии реализована следующая логика: если полученный запрос является корректным, то добавим его в базу данных. В противном случае будет выведена форма для его редактирования. Конечно, в реальном веб-приложении вместо этого стоит использовать запись в журнал.

В очередной раз можно запустить веб-приложение и убедиться в работоспособности реализованного способа связи с Моделью.


Исходный код проекта (C#, Visual Studio 2010): MVCDemo-Part23.zip (517 Kb)