На переднем крае

Улучшение Web Forms с помощью шаблона MVP

Дино Эспозито (Dino Esposito)

Dino EspositoСоздание шаблона Model-View-Controller (MVC) — важная веха в разработке ПО. Он показал, что проектирование приложений с учетом разделения обязанностей улучшает как процесс разработки, так и его результаты. В нем также содержится воспроизводимый подход для использования этого шаблона на практике.

Однако MVC не идеален, поэтому за прошедшие годы появилось несколько его разновидностей.

Поскольку он был создан в 80-х, одна из проявившихся проблем заключается в том, что MVC не приспособлен к разработке для Web напрямую. Адаптация MVC к Web заняла еще несколько лет и привела к созданию более специфических MVC-шаблонов, например Model2. (Model2 — это разновидность MVC, реализованная компанией Castle MonoRail и в ASP.NET MVC.)

В более общем контексте шаблон Model-View-Presenter (MVP) является результатом эволюционного развития MVC, который корректно разделяет представление и модель, размещая между ними контроллер, выступающий в роли посредника. На рис. 1 показано поведение приложения, спроектированного на основе шаблона MVP.

Figure 1 Using the MVP Pattern

Рисунок 1 Использование шаблона MVP

В этой статье я впервые представлю возможную (и относительно стандартную) реализацию шаблона MVP для ASP.NET Web Forms, а затем опишу область применения этого шаблона, его преимущества для групповой разработки, а также сравню его с ASP.NET MVC и Model-View-ViewModel (MVVM) в том виде, в каком они реализованы в Windows Presentation Foundation (WPF) и Silverlight.

Краткое введение в MVP

MVP является производным от исходного шаблона MVC и разработан компанией Taligent (теперь она стала частью IBM) в 90-е годы. Хорошее введение в MVP и идеи, заложенные в него, можно найти в техническом документе, доступном по ссылке wildcrest.com/Potel/Portfolio/mvp.pdf.

Авторы MVP аккуратно отделили модель (данные, с которыми вы работаете в представлении) от пары «представление-контроллер». Они также переименовали контроллер в презентатор, чтобы подчеркнуть его роль в этом шаблоне — это посредник между пользователем и приложением. Презентатор является компонентом, который «представляет» UI пользователю и принимает от него команды. Презентатор содержит большую часть презентационной логики и знает, как работать с представлением и остальной частью системы, в том числе с уровнями внутренних сервисов и данных.

Ключевое новшество в MVP — тот факт, что детали представления абстрагируются до интерфейса (или базового класса). Презентатор взаимодействует с абстракцией представления, что превращает сам презентатор в класс с высоким уровнем повторного использования и тестирования. А это делает возможным два интересных варианта применения.

Во-первых, презентационная логика становится независимой от применяемой технологии UI. Соответственно один и тот же контроллер можно было бы повторно использовать в презентационных уровнях Windows и Web. Презентатор кодируется на основе интерфейса и может взаимодействовать с любым объектом, который предоставляется этим интерфейсом, — будь то объект Windows Forms, ASP.NET-объект Page или WPF-объект Window.

Во-вторых, один и тот же презентатор мог бы работать с разными представлениями одного приложения. Это важно для применения ПО как сервиса — Software as a Service (SaaS), — где приложение размещается на веб-сервере и предоставляется клиентам как сервис, причем каждому клиенту требуется собственный настроенный UI.

Само собой разумеется, что обе возможности не обязательно применимы во всех ситуациях. Тут все зависит от прикладной и навигационной логики, которую вы собираетесь задействовать в своих клиентских интерфейсах для Windows и Web. Однако, когда логика одинакова, вы можете повторно использовать ее через модель MVP.

MVP в действии

При реализации шаблона MVP первый шаг — определение абстракции для каждого необходимого представления. Каждая страница в приложении ASP.NET и каждая форма в Windows-приложении (или WPF/Silverlight) будет иметь свой интерфейс для взаимодействия с остальной частью презентационного уровня. Такой интерфейс идентифицирует модель данных, поддерживаемую представлением. У каждого логически эквивалентного представления будет один и тот же интерфейс независимо от конкретной платформы.

Абстракция представления включает модель (которая распознается представлением и с которой оно способно работать) и может расширять ее некоторыми специфическими методами и событиями, удобными при взаимодействии между презентатором и представлением. На рис. 2 показана возможная абстракция для представления, визуализированного на рис. 3 и используемого простым приложением — списком задач.

Рис. 2. Пример ошибки проверки

public interface IMemoFormView {
  String Title { get; set; }
  String Summary { get; set; }
  String Location { get; set; }
  String Tags { get; set; }
  DateTime BeginWithin { get; set; }
  DateTime DueBy { get; set; }
  String Message { get; set; }

  Int32 GetSelectedPriorityValue();
  void FillPriorityList(Int32 selectedIndex);
  Boolean Confirm(String message, String title);
  void SetErrorMessage(String controlName);
}

На рис. 3 также видно, как члены интерфейса связаны с визуальными элементами на форме.

Figure 3 Binding Members of the Interface to Visual Elements

Рисунок 3 Привязывание членов интерфейса к визуальным элементам

Главный момент состоит в том, что любое взаимодействие между презентатором и UI должно осуществляться через контракт представления. Щелчок любой кнопки, выбор любого элемента, любой ввод должен пересылаться презентатору и обрабатываться именно там. Если презентатору нужно запросить какие-то данные в представлении или передать их в представление, в интерфейсе должен присутствовать соответствующий метод.

Реализация контракта представления

Интерфейс, «представляющий представление», должен быть реализован классом, который отражает само представление. Как уже упоминалось, класс представления — это страница в ASP.NET, форма в Windows Forms, объект Window в WPF и пользовательский элемент управления в Silverlight. Пример для Windows Forms показан на рис. 4.

Рисунок 4 Возможная реализация класса представления

public partial class MemoForm : Form, IMemoFormView {
  public string Title {
    get { return memoForm_Text.Text; }
    set { memoForm_Text.Text = value; }
    ...
  }

  public DateTime DueBy {
    get { return memoForm_DueBy.Value; }
    set { memoForm_DueBy.Value = value; }
  }

  public int GetSelectedPriorityValue() {
    var priority = 
      memoForm_Priority.SelectedItem as PriorityItem;
    if (priority == null)
      return PriorityItem.Default;
    return priority.Value;
  }

  public void FillPriorityList(int selectedIndex) {
    memoForm_Priority.DataSource = 
      PriorityItem.GetStandardList();
    memoForm_Priority.ValueMember = "Value";
    memoForm_Priority.DisplayMember = "Text";
    memoForm_Priority.SelectedIndex = selectedIndex;
  }

  public void SetErrorMessage(string controlName) {
    var control = this.GetControlFromId(controlName);
    if (control == null)
      throw new NullReferenceException(
        "Unexpected null reference for a form control."); 

    memoForm_ErrorManager.SetError(control, 
      ErrorMessages.RequiredField);
  }

  ...
}

Как видите, свойства реализуются как оболочки для некоторых свойств визуальных элементов. Например, свойство Title является оболочкой свойства Text элемента управления TextBox. Аналогично свойство DueBy обертывает свойство Value элемента управления DatePicker. Что важнее, интерфейс избавляет класс презентатора от деталей UI для данной платформы. Тот же класс презентатора, созданный для взаимодействия с интерфейсом IMemoFormView, может работать с любым объектом, реализующим этот интерфейс, благополучно игнорируя любые детали интерфейса программирования нижележащих элементов управления.

Как вы работали бы с UI-элементами, требующими набора данных, например с раскрывающимся списком? Надо ли использовать связывание с данными (как на рис. 4) или достаточно более простого подхода, при котором представление остается пассивным и лишается любой презентационной логики?

Выбор исключительно за вами. В ответ на вопросы такого рода шаблон MVP был разделен на два шаблона — Supervising Controller и Passive View, разница между которыми заключается главным образом в количестве кода в представлении. Использование связывания с данными для заполнения UI (рис. 4) требует добавления в представление некоей презентационной логики и делает ее своего рода координирующим контроллером (supervising controller).

Чем больше логики в представлении, тем больше внимания следует уделять тестированию. А тестирование части UI — задача, которую не так-то просто автоматизировать. Выбор координирующего контроллера или более «тонкого» представления — решение чисто субъективное.

Класс презентатора

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

Рисунок 5 Создание MVP-формы

public partial class Form1 : 
  Form, ICustomerDetailsView {

  private MemoFormPresenter presenter;

  public Form1() {
    // Framework initialization stuff
    InitializeComponent();
    // Instantiate the presenter
    presenter = new MemoFormPresenter(this);
    // Attach event handlers
    ...
  }

  private void Form1_Load(
    object sender, EventArgs e) {

    presenter.Initialize();
  }
  ...
}

Класс презентатора обычно принимает ссылку на представление через свой конструктор. Представление хранит ссылку на презентатор, а презентатор — ссылку на представление. Однако представление известно презентатору только через контракт. Презентатор изолирует любой объект представления, получаемый им по контрактному интерфейсу представления. Костяк класса презентатора приведен на рис. 6.

Рисунок 6 Пример класса презентатора

public class MemoFormPresenter {
  private readonly IMemoFormView view;

  public MemoFormPresenter(IMemoFormView theView) {
    view = theView;
    context = AppContext.Navigator.Argument 
      as MemoFormContext;
    if (_context == null)
      return;
  }
 
  public void Initialize() {
    InitializeInternal();
  }

  private void InitializeInternal() {
    int priorityIndex = _context.Memo.Priority;
    if (priorityIndex >= 1 && priorityIndex <= 5)
      priorityIndex--;
    else
      priorityIndex = 2;

    if (_context.Memo.BeginDate.HasValue)
      _view.BeginWithin = _context.Memo.BeginDate.Value;
    if (_context.Memo.EndDate.HasValue)
      _view.DueBy = _context.Memo.EndDate.Value;
      _view.FillPriorityList(priorityIndex);
      _view.Title = _context.Memo.Title;
      _view.Summary = _context.Memo.Summary;
      _view.Tags = _context.Memo.Tags;
      _view.MemoLocation = _context.Memo.Location;
  }
  ...
}

Конструктор принимает и кеширует ссылку на представление, а затем инициализирует представление, используя открытый интерфейс контракта. Объект контекста, задействованный в коде на рис. 6, — это любые входные данные, которые презентатор должен принять от вызвавшего кода для инициализации представления. Эта информация нужна не во всех случаях, но требуется, когда вы используете форму для редактирования каких-либо данных или когда у вас имеются диалоговые окна, отображающие некие сведения.

Инициализация представления заключается в простом присваивании значений членам класса с тем исключением, что на этот раз любое присваивание отражается в UI.

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

private void memoForm_OK_Click(
  object sender, EventArgs e) {
  presenter.Ok();
}

Метод презентатора использует ссылку на представление для доступа к входным значениям и обновления UI.

Навигация в MVP

Презентатор также отвечает за навигацию в рамках приложения. В частности, презентатор включает или отключает подпредставления (sub-views) и переход с помощью команд к следующему представлению.

Подпредставление — это, по сути, подмножество представления. Обычно это панель, которую можно раскрыть или свернуть в зависимости от контекста или, возможно, дочернее окно — модальное или немодальное. Презентатор управляет видимостью подпредставлений через члены (в основном булевы) интерфейса представления.

А как передать управление другому представлению (и презентатору)? Вы создаете статический класс, представляющий контроллер приложения, т. е. центральную консоль, в которой хранится вся логика для определения следующего представления. Схема контроллера приложения приведена на рис. 7.

Figure 7 The Application Controller

Рисунок 7 Контроллер приложения

Класс контроллера приложения представляет оболочку, вызываемую презентаторами для любых переходов. В этом классе должен присутствовать метод NavigateTo, реализующий рабочий процесс, который определяет следующее представление или просто переключает на указанное представление. Рабочий процесс навигации может быть сложным как настоящий рабочий процесс или тривиальным как последовательность операторов IF. Логику этого рабочего процесса можно статически кодировать в контроллере приложения или импортировать из внешнего подключаемого компонента (рис. 8).

Рис. 8 Реализация Контроллера приложения

public static class ApplicationController {
  private static INavigationWorkflow instance;
  private static object navigationArgument;

  public static void Register(
    INavigationWorkflow service) {
    if (service == null)
      throw new ArgumentNullException();
    instance = service;
  }

  public static void NavigateTo(string view) {
    if (instance == null)
      throw new InvalidOperationException();
    instance.NavigateTo(view);      
  }
 
  public static void NavigateTo(
    string view, object argument) { 
    if (instance == null)
      throw new InvalidOperationException();
    navigationArgument = argument;
    NavigateTo(view);
 }

 public static object Argument {
   get { return navigationArgument; }
 }
}

Логика навигации в компоненте рабочего процесса будет использовать для переключения на другое представление специфичное для конкретной платформы решение. В случае Windows Forms будут вызываться методы для открытия и отображения форм; в ASP.NET будет применяться метод Redirect объекта Response.

MVP и ASP.NET MVC

ASP.NET MVC основан на разновидности шаблона MVC, имеющей некоторое сходство с MVP. Контроллер в MVC является посредником между представлением и внутренней частью приложения (back end). Этот контроллер не хранит ссылку на представление, но заполняет объект модели и передает его в представление, используя сервисы промежуточного компонента — ядра представления (view engine).

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

Как насчет Web Forms? Web Forms хорошо пригодна для хостинга реализации MVP. Однако следует четко понимать, что вы лишь добавляете уровни в контекст события обратной передачи (postback event). Вы не можете включить ничего, что происходит до события обратной передачи или после. Полная реализация MVP, охватывающая весь жизненный цикл, невозможна в Web Forms, но даже добавление MVP только вокруг обратной передачи — дело хорошее, и оно значительно повысит уровень тестируемости
страниц Web Forms.

MVP и MVVM

А как обстоит дело с MVP и MVVM в контексте приложений WPF и Silverlight? MVVM — это вариация MVP, также известная как Presentation Model. Идея состоит в том, что модель представления помещается в класс презентатора и этот класс предоставляет открытые члены, доступные представлению для чтения и записи. Это осуществляется за счет двухстороннего связывания с данными. В итоге вы можете назвать MVVM специфической разновидностью MVP, особенно подходящей для многофункциональных UI и инфраструктур (наподобие WPF), которые поддерживают такое связывание с данными.

В MVVM представление связывается со свойствами класса презентатора (модель представления) через механизм привязки данных. Любые действия пользователя приводят к модификации значений этих свойств в презентаторе. Любые запросы от пользователя (команды в WPF) обрабатываются соответствующими методами класса презентатора. Любые результаты, вычисленные методом презентатора, сохраняются в модели представления и становятся доступны представлению через механизм привязки данных. В WPF и Silverlight ничто не мешает вам использовать собственноручную реализацию шаблона MVP. Но такие инструменты, как Blend, все же существенно облегчают использование MVVM через связывание с данными.

Заключение

MVP — шаблон, позволяющий управлять множеством представлений, но, разумеется, за это приходиться платить повышенной сложностью кода приложения. Как вы можете представить, эти издержки менее заметны в крупных приложениях. Таким образом, MVP не предназначен для любого приложения. Основанный на контракте, который отражает представление, MVP позволяет проектировщикам и разработчикам работать параллельно, а это хорошо в любом случае. MVP обеспечивает автономность класса презентатора и его изоляцию от представления. В Web Forms MVP фактически является единственным разумным способом, который дает возможность тестировать хотя бы код, выполняющий обратную передачу.

Дино Эспозито (Dino Esposito)  — автор книги «Programming ASP.NET MVC» (Microsoft Press, 2010) и соавтор «Microsoft .NET: Architecting Applications for the Enterprise» (Microsoft Press, 2008). Проживает в Италии и часто выступает на отраслевых мероприятиях по всему миру. С ним можно связаться через его блог weblogs.asp.net/despos.

Выражаю благодарность за рецензирование статьи экспертам:  Дону Смиту (Don Smith) и Джошу Смиту (Josh Smith).