Особенности реализации MVP для Windows Forms

в 7:56, , рубрики: .net, mvp, pattern, windows forms, WinForms, ооп, паттерны проектирования, метки: , , , ,

Доброго времени суток!
Model-View-Presenter — довольно известный шаблон проектирования. С первого взгляда все выглядит просто: есть Модель (Model), которая содержит всю бизнес-логику экрана; Вид/Представление (View), который знает, как отобразить те или иные данные; Представитель (Presenter), который является связующий звеном — реагирует на действия пользователя во View, изменяя Model, и наоборот.
Сложность начинается, когда количество форм в проекте становится более одной.
В данной статье рассматривается:
— немножко теории;
— общие проблемы реализации MVP (а именно Passive View) под Windows Forms;
— особенности реализации переходов между формами и передача параметров, модальные окна;
— использование IoC-контейнера и шаблона Dependency Injection — DI (а именно Сonstructor Injection);
— некоторые особенности тестирования MVP приложения (с использованием NUnit и NSubstitute);
— все это будет происходить на примере мини-проекта и постарается быть наглядным.
В статье затрагивается:
— применение шаблона Адаптер (Adapter);
— простенькая реализация шаблона Контроллер приложения (Application Controller).
Для кого эта статья?
Главным образом для начинающих разработчиков на Windows Forms, которые слышали, но не пробовали, или пробовали, но не получилось. Хотя уверен, что некоторые приемы применимы и для WPF, и даже для веб-разработки.

Постановка задачи

Придумаем простую задачу — реализовать 3 экрана:
1) экран авторизации;
2) главный экран;
3) модальный экран изменения имени пользователя.
Должно получиться что-то вроде этого:

Особенности реализации MVP для Windows Forms

Немного теории

MVP, как и его родитель, MVC (Model-View-Controller) придуман для удобства разделения бизнес-логики от способа ее отображения.

Особенности реализации MVP для Windows Forms

На просторах интернета можно встретить целое множество реализаций MVP. По способу доставки данных в представление их можно разделить на 3 категории:
— Passive View: View содержит минимальную логику отображения примитивных данных (строки, числа), остальным занимается Presenter;
— Presentation Model: во View могут передаваться не только примитивные данные, но и бизнес-объекты;
— Supervising Controller: View знает о наличии модели и сам забирает из нее данные.

Далее будет рассматриваться модификация Passive View. Опишем основные черты:
— интерфейс Представления (IView), который предоставляет некий контракт для отображения данных;
— Представление — конкретная реализация IView, которая умеет отображать саму себя в конкретном интерфейсе (будь то Windows Forms, WPF или даже консоль) и ничего не знает о том, кто ей управляет. В нашем случае это формы;
— Модель — предоставляет некоторую бизнес-логику (примеры: доступ к базе данных, репозитории, сервисы). Может быть представлена в виде класса или опять же, интерфейса и реализации;
— Представитель содержит ссылку на Представление через интерфейс (IView), управляет им, подписывается на его события, производит простую валидацию (проверку) введенных данных; также содержит ссылку на модель или на ее интерфейс, передавая в нее данные из View и запрашивая обновления.

Типичная реализация Представителя

public class Presenter
{
    private readonly IView _view;
    private readonly IService _service;

    public Presenter(IView view, IService service)
    {
        _view = view;
        _service = service;

        _view.UserIdChanged += () => UpdateUserInfo();
    }

    private void UpdateUserInfo()
    {
        var user = _service.GetUser(_view.UserId);
        _view.Username = user.Username;
        _view.Age = user.Age;
    }
}

Какие плюсы нам дает малая связанность классов (использование интерфейсов, событий)?
1. Позволяет относительно свободно менять логику любого компонента, не ломая остального.
2. Большие возможности при unit-тестировании. Поклонники TDD должны быть в восторге.
Начнем!

Как организовать проекты?

Условимся, что решение будет состоять из 4х проектов:
— DomainModel — содержит сервисы и всевозможные репозитории, одним словом — модель;
— Presentation — содержит логику приложения, не зависящую от визуального представления, т.е. все Представители, интерфейсы Представлений и остальные базовые классы;
— UI — Windows Forms приложение, содержит только лишь формы (реализацию интерфейсов Представлений) и логику запуска;
— Tests — unit-тесты.

Что писать в Main()?

Стандартная реализация запуска Windows Forms приложения выглядит так:

private static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new MainForm()); // непосредственный запуск формы (представления)
}

Но мы условились, что Представители будут управлять Представлениями, следовательно хотелось бы, чтобы код выглядел как-то так:

private static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);

    var presenter = new LoginPresenter(new LoginForm(), new LoginService()); // Dependency Injection
    presenter.Run();
}

Попробуем реализовать первый экран:

Базовые интерфейсы

// общие методы для всех представлений
public interface IView
{
    void Show();
    void Close();
}
// контракт, по которому представитель будет взаимодействовать с формой
public interface ILoginView : IView
{
    string Username { get; }
    string Password { get; }
    event Action Login;      // событие "пользователь пытается авторизоваться"
    void ShowError(string errorMessage);
}
public interface IPresenter
{
    void Run();
}
// глупейший сервис авторизации
public interface ILoginService
{
    bool Login(User user); // true - успешная авторизация, иначе false
}

Представление

public class LoginPresenter : IPresenter
{
    private readonly ILoginView _view;
    private readonly ILoginService _service;

    public LoginPresenter(ILoginView view, ILoginService service)
    {
        _view = view;
        _service = service;
            
        _view.Login += () => Login(_view.Username, _view.Password);
    }

    public void Run()
    {
        _view.Show();
    }

    private void Login(string username, string password)
    {
        if (username == null)
            throw new ArgumentNullException("username");
        if (password == null)
            throw new ArgumentNullException("password");

        var user = new User {Name = username, Password = password};
        if (!_service.Login(user))
        {
            _view.ShowError("Invalid username or password");
        }
        else
        {
            // успешная авторизация, запуск главного экрана (?)
        }
    }
}

Создать форму и реализовать в ней интерфейс ILoginView не составит труда, как и написать реализацию ILoginService. Следует только отметить одну особенность:

public partial class LoginForm : Form, ILoginView
{
    // ...
    public new void Show()
    {
        Application.Run(this);
    }
}

Это заклинание позволит нашему приложению запуститься, отобразить форму, а по закрытии формы корректно завершить приложение. Но к этому мы еще вернемся.

А тесты будут?

С момента написания представителя (LoginPresenter), появляется возможность сразу же его от-unit-тестировать, не реализуя ни формы, ни сервисы.
Для написания тестов я использовал библиотеки NUnit и NSubstitute (библиотека создания классов-заглушек по их интерфейсам, mock).

Тесты для LoginPresenter

[TestFixture]
public class LoginPresenterTests
{
    private ILoginView _view;

    [SetUp]
    public void SetUp()
    {
        _view = Substitute.For<ILoginView>();          // заглушка для представления
        var service = Substitute.For<ILoginService>(); // заглушка для сервиса
        service.Login(Arg.Any<User>())                 // авторизуется только пользователь admin/password
            .Returns(info => info.Arg<User>().Name == "admin" && info.Arg<User>().Password == "password");
        var presenter = new LoginPresenter(_view, service);
        presenter.Run();
    }

    [Test]
    public void InvalidUser()
    {
        _view.Username.Returns("Vladimir");
        _view.Password.Returns("VladimirPass");
        _view.Login += Raise.Event<Action>();
        _view.Received().ShowError(Arg.Any<string>()); // этот метод должен вызваться с текстом ошибки
    }

    [Test]
    public void ValidUser()
    {
        _view.Username.Returns("admin");
        _view.Password.Returns("password");
        _view.Login += Raise.Event<Action>();
        _view.DidNotReceive().ShowError(Arg.Any<string>()); // а в этом случае все ОК
    }
}

Тесты довольно глупые, как пока и само приложение. Но так или иначе, они успешно пройдены.

Кто и как запустит второй экран с параметром?

Как вы могли заметить, я не написал никакого кода при успешной авторизации. Как же мне запустить второй экран? Первое на ум приходит это:

// LoginPresenter: успешная авторизация
var mainPresenter = new MainPresenter(new MainForm());
mainPresenter.Run(user);

Но мы условились, что представители ничего не знают о представлениях кроме их интерфейсов. Что же делать?
На помощь приходит паттерн Application Controller (реализован упрощенно), внутри которого содержится IoC-контейнер, знающий, как по интерфейсу получить объект реализации.
Контроллер передается каждому Представителю параметром конструктора (снова DI) и реализует примерно следующие методы:

public interface IApplicationController
{
    IApplicationController RegisterView<TView, TImplementation>()
        where TImplementation : class, TView
        where TView : IView;
    IApplicationController RegisterService<TService, TImplementation>()
        where TImplementation : class, TService;
    void Run<TPresenter>()
        where TPresenter : class, IPresenter;
}

После небольшого рефакторинга запуск приложения стал выглядеть так:

private static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);

    // все зависимости теперь регистрируются в одном месте:
    var controller = new ApplicationController(new LightInjectAdapder())
        .RegisterView<ILoginView, LoginForm>()
        .RegisterService<ILoginService, StupidLoginService>()
        .RegisterView<IMainView, MainForm>();

    controller.Run<LoginPresenter>();
}

Пару слов о new ApplicationController(new LightInjectAdapder()). В качестве IoC-контейнера я использовал библиотеку LightInject, но не напрямую, а через адаптер (паттерн Adapter), чтобы в случае, если понадобится сменить контейнер на другой, я смог написать другой адаптер и не менять логику контроллера. Все используемые методы есть в большинстве IoC-библиотек, сложностей возникнуть не должно.
Реализуем дополнительный интерфейс IPresenter<TArg>, отличающийся только тем, что метод Run принимает параметр. Затем унаследуемся от него аналогично первому экрану.
Теперь, не без гордости, запускаем второй экран, передавая туда авторизованного пользователя:

Controller.Run<MainPresener, User>(user);
View.Close();

Нельзя просто так взять и закрыть форму...

Один из подводных камней связан со строчкой View.Close(), после которой закрывалась первая форма, а вместе с ней и приложение. Дело в том, что Application.Run(Form) запускает стандартный цикл обработки сообщений Windows и рассматривает переданную форму как главную форму приложения. Это выражается в том, что приложение вешает ExitThread на событие Form.Closed, что и вызывает закрытие приложения после закрытия формы.
Обойти данную проблему можно несколькими способами, один из них — использовать другой вариант метода: Application.Run(ApplicationContext), затем вовремя подменяя свойство ApplicationContext.MainForm. Передача контекста формам реализована с помощью Контроллера приложения, в котором регистрируется объект (instance) ApplicationContext и затем подставляется в конструктор формы (опять DI) во время запуска Представителя. Методы отображения первых двух экранов теперь выглядят так:

// LoginForm
public new void Show()
{
    _context.MainForm = this;
    Application.Run(_context);
}

// MainForm
public new void Show()
{
    _context.MainForm = this;
    base.Show();
}

Модальное окно

Реализация модального окна не вызывает затруднений. По кнопке «Сменить имя» выполняется Controller.Run<ChangeUsernamePresenter, User>(user). Единственное отличие этой формы от остальных — она не главная, поэтому форме для показа не требуется ApplicationContext:

public new void Show()
{
    ShowDialog();
}

Если необходимо открыть обычное окно, метод вообще не требуется определять, так как он уже реализован в классе Form.

Ну и накрутили… Как теперь ЭТО использовать?

Теперь, когда каркас готов, добавление новой формы сводится к следующим шагам:

  1. Пишем интерфейс Представления, интерфейс Модели (если требуется).
  2. Реализуем Представителя, попутно решив, будем ли мы в него передавать какие-то данные или модель.
  3. [Опционально] Пишем тесты для Представителя, убеждаемся, что все нормально.
  4. [Опционально] Реализуем Модель и тесты для нее.
  5. Накидываем формочки и реализуем интерфейс Представления.

Смена IoC-контейнера на ваш любимый происходит путем реализации простого интерфейса IContainer классом-адаптером.

Забрать демонстрационный проект можно c Github (для сборки необходимо выкачать Nuget-зависимости).

Напоследок хочу сказать, что все, что здесь было описано — лишь один из множества способов реализации MVP, который, как мне кажется, обладает неплохой гибкостью и тестируемостью.

Бонус. Хочу еще круче, больше, сложнее!

На этом я остановлюсь, а пытливые умы могут идти дальше:
— улучшение Контроллера приложения (у меня написан немного упрощенный вариант);
— реализация агрегирования событий для обмена информацией между формами (шаблон Event Aggregator);
— дальнейшее изучение шаблона по ссылкам ниже

Источники, ссылки

Первая статья, которую я прочел про MVP
Мартин Фаулер про Passive View
Мартин Фаулер про Application Controller
Application Controller и Event Aggregator
Еще про Application Controller
Большая серия статей про MVP и все, что с ним связано
Почему используются события вместо прямых вызовов методов Presenter

Автор: Dem0n13

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js