10.02.2017 Шаблон проектирования MVP. Описание и пример программы

Введение в паттерн MVP

Model View Presenter (MVP) - это архитектурный паттерн и его назначение - отделение пользовательского интерфейса от данных приложения и методов их обработки (бизнес-логики). Это достигается путем введения дополнительного объекта - презентора. Диаграмма взаимодействия всех трех частей показана ниже:

Диаграмма шаблона проектирования MVP
Диаграмма шаблона проектирования MVP

Об этом паттерне написано, наверно, более 9000 статей, но достаточно редко встречаются описания, подкрепленные практическими примерами. Поэтому вначале я дам определения каждой составляющей этого архитектурного паттерна, потом рассмотрим совсем простое приложение для прояснения всех деталей, а затем (в следующей статье) уже рассмотрим реализацию MVP в программе NewStage2VK. Итак:

  • Модель (Model) - это набор данных и методов их обработки, т.е. это бизнес-логика нашего приложения. Модель ничего не знает о существовании презентора и, тем более, представления. Она полностью независима.
  • Представление (View) отвечает за визуализацию данных, которые получены от модели. Как, правило, модель только отображает данные, но в некоторых случаях возможно включение и простых алгоритмов расчета. Например, представление самостоятельно может подсчитать общее количество записей в таблице или итоговую сумму.
  • Презентор (Presenter) - связующее звено между моделью и представлением. Он ответственен за обработку событий, возникающих в представлении, которые обычно инициированы пользователем, и в зависимости от типа события изменять состояние модели путем вызова ее публичных методов, после чего обновлять представление в соответствии с состоянием модели. Один презентор отвечает за взаимодействие с одним представлением.

Это достаточно отстраненные определения, и чтобы лучше в них разобраться рассмотрим совсем простое приложение DemoMVP, которое будет производить эмуляцию перевода денежных средств с одного счета на другой. При написании я использовал технологию WPF, которая сейчас стала более популярна, чем WinForms, однако, благодаря четкому разделению обязанностей приложение очень легко портируется и под WinForms. С другой стороны, в WPF обычно рулит шаблон проектирования MVVM (Model-View-ViewModel), но, возможно, это тема одной из будущих статей. На рисунке ниже представлена диаграмма основных классов:

Диаграмма классов приложения DemoMVP
Диаграмма классов приложения DemoMVP

Рассмотрим интерфейс модели IBankModel. Я выделил специальный интерфейс для модели, чтобы не привязывать ссылающиеся на нее классы (наш презентор) к конкретной реализации. В данном примере модель - это класс SimpleBankModel, который реализует интерфейс IBankModel. Создание прослойки в виде интерфейса позволит в дальнейшем нашу простую реализацию заменить на модель, которая работает с БД и сохраняет свое состояние, без изменения использующих ее классов. Реализация SimpleBankModel выглядит следующим образом:

namespace DemoMVP.DomainModel
{
    public class SimpleBankModel : IBankModel
    {
        private IList<Account> accounts;

        public SimpleBankModel(IList<Account> accounts)
        {
            this.accounts = accounts;
        }

        /// <summary>
        /// Снять деньги со счета
        /// </summary>
        /// <param name="accountId">Идентификатор счета</param>
        /// <param name="sum">сумма</param>
        /// <param name="transaction">объект транзакции</param>
        /// <returns>True, если успешно. False - если нет такой суммы на балансе</returns>
        public bool Withdraw(string accountId, decimal sum, ITransaction transaction)
        {
            EnsureSum(sum);
            Account account = GetAccountById(accountId);
            if (account.Balance < sum)
            {
                return false;
            }

            (transaction as Transaction).SaveState(account);
            account.Balance -= sum;
            return true;
        }

        /// <summary>
        /// Внести деньги на счет
        /// </summary>
        /// <param name="accountId">Идентификатор счета</param>
        /// <param name="sum">сумма</param>
        /// <param name="transaction">объект транзакции</param>
        public void Deposit(string accountId, decimal sum, ITransaction transaction)
        {
            EnsureSum(sum);
            Account account = GetAccountById(accountId);
            (transaction as Transaction).SaveState(account);
            account.Balance += sum;
        }

        /// <summary>
        /// Получить баланс счета
        /// </summary>
        /// <param name="accountId">Идентификатор счета</param>
        /// <returns>Сумма денег на счету</returns>
        public decimal GetBalance(string accountId)
        {
            Account account = GetAccountById(accountId);
            return account.Balance;
        }

        /// <summary>
        /// Создать объект транзакции
        /// </summary>
        /// <returns></returns>
        public ITransaction CreateTransaction()
        {
            return new Transaction();
        }

        private void EnsureSum(decimal sum)
        {
            if (sum <= 0)
            {
                throw new ApplicationException("Sum must be greater than 0");
            } 
        }

        private Account GetAccountById(string accountId)
        {
            Account account = accounts.Where(x => x.Id == accountId).FirstOrDefault();
            if (account == null)
            {
                throw new ApplicationException("Account not found");
            }
            return account;
        }
    }
}

Т.е. реализация достаточно тривиальна. Стоит обратить внимание на объект transaction. В данном случае он ничего не делает, но призван показать, что при совершении операций система сохраняет свое состояние, чтобы в случае каких-либо порблем смогла восстановить свое исходное состояние. Методов Withdraw (снять) и Deposit (зачислить) и объекта транзакции достаточно для того, чтобы обеспечить надежную систему по переводу денежных средств.

Представлением в нашим случае является класс MainWindow, который реализует интерфейс ITransferView. Этот интерфейс скрывает подробности реализации представления и обеспечивает только несколько методов и событий для взаимодействия с представлением. Код данного интерфейса показан ниже:

namespace DemoMVP.Abstract
{
    public interface ITransferView
    {
        /// <summary>
        /// Обновить баланс счета-источника
        /// </summary>
        /// <param name="balance">Сумма на балансе</param>
        void UpdateSrcBalance(decimal balance);

        /// <summary>
        /// Обновить баланс счетаназначения
        /// </summary>
        /// <param name="balance">Сумма на балансе</param>
        void UpdateDestBalance(decimal balance);

        /// <summary>
        /// Отобразить предупреждение
        /// </summary>
        /// <param name="text">Текст сообщения</param>
        void ShowWarning(string text);

        /// <summary>
        /// Отобразить ошибку
        /// </summary>
        /// <param name="text">Текст ошибки</param>
        void ShowError(string text);

        /// <summary>
        /// Событие, срабатывающее при изменении идентификатора счета-источника
        /// </summary>
        event EventHandler<AccountChangedEventArgs> SrcAccountChanged;

        /// <summary>
        /// Событие, срабатывающее при изменении идентификатора счета-назначения
        /// </summary>
        event EventHandler<AccountChangedEventArgs> DestAccountChanged;

        /// <summary>
        /// Событие, срабатывающее при запросе перевода денег
        /// </summary>
        event EventHandler<TransferMoneyEventArgs> TransferMoney;
    }
}

Обратите внимание, что ITransferView не содержит никаких ссылок на элементы управления, характерные для WPF. Это важный момент! Поэтому класс, который будет содержать ссылку на данный интерфейс, не сможет получить доступ к элементам управления. Он сможет обращаться к представлению только через четко обозначенный нами интерфейс. Таким образом и достигается независимость представления от других частей программы. Поэтому, чтобы сменить интерефейс нам достаточно будет переписать только класс MainWindow. Внешний вид окна показан на рисунке:

Окно приложения DemoMVP
Окно приложения DemoMVP

Теперь давайте взглянем, на код MainWindow и посмотрим, каким образом достигается реализация интерфейса ITransferView.

namespace DemoMVP
{
    /// <summary>
    /// Логика взаимодействия для MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window, ITransferView
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private string srcAccountId = string.Empty;
        private string destAccountId = string.Empty;

        public void UpdateSrcBalance(decimal balance)
        {
            lblSrcBalance.Content = balance.ToString();
        }

        public void UpdateDestBalance(decimal balance)
        {
            lblDestBalance.Content = balance.ToString();
        }

        public void ShowWarning(string text)
        {
            MessageBox.Show(text, "Предупреждение", MessageBoxButton.OK, MessageBoxImage.Warning);
        }

        public void ShowError(string text)
        {
            MessageBox.Show(text, "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
        }

        public event EventHandler<TransferMoneyEventArgs> TransferMoney;

        protected virtual void OnTransferMoney(TransferMoneyEventArgs e)
        {
            if (TransferMoney != null)
            {
                TransferMoney(this, e);
            }
        }

        public event EventHandler<AccountChangedEventArgs> SrcAccountChanged;

        protected virtual void OnSrcAccountChanged(AccountChangedEventArgs e)
        {
            if (SrcAccountChanged != null)
            {
                SrcAccountChanged(this, e);
            }
        }

        public event EventHandler<AccountChangedEventArgs>DestAccountChanged;

        protected virtual void OnDestAccountChanged(AccountChangedEventArgs e)
        {
            if (DestAccountChanged != null)
            {
                DestAccountChanged(this, e);
            }
        }

        private void txtSrcAccount_LostFocus(object sender, RoutedEventArgs e)
        {
            if (!string.IsNullOrEmpty(txtSrcAccount.Text) && txtSrcAccount.Text != srcAccountId)
            {
                OnSrcAccountChanged(new AccountChangedEventArgs(txtSrcAccount.Text));
                srcAccountId = txtSrcAccount.Text;
            }
        }

        private void txtDestAccount_LostFocus(object sender, RoutedEventArgs e)
        {
            if (!string.IsNullOrEmpty(txtDestAccount.Text) && txtDestAccount.Text != destAccountId)
            {
                OnDestAccountChanged(new AccountChangedEventArgs(txtDestAccount.Text));
                destAccountId = txtDestAccount.Text;
            }
        }

        private void btnTransfer_Click(object sender, RoutedEventArgs e)
        {
            decimal sum;
            if (decimal.TryParse(txtSum.Text, out sum))
            {
                OnTransferMoney(new TransferMoneyEventArgs(txtSrcAccount.Text, txtDestAccount.Text, sum));
            }
            else
            {
                ShowWarning("Ошибка ввода суммы");
            }
        }
    }
}

Здесь мы видим, каким образом реализация методов по обновлению баланса взаимодействует с существующими элементами управления WPF, а также, каким образом достигается отображение предупреждений и ошибок. Благодаря тому, что данный класс реализует интерфейс ITransferView, мы можем всего лишь в одном месте изменить реализацию методов ShowWarning и ShowError таким образом, чтобы отображать сообщения не окошками, а выводить их, например в Label где-то внизу окна. Что касается срабатывания событий, то здесь мы, по сути, просто пробрасываем события от элементов управления WPF в подходящие события ITransferView, предварительно поместив всю необходимую информацию из элементов управления формы в экземпляр соответствующего класса. В обработчиках событий txtSrcAccount_LostFocus и txtDestAccount_LostFocus мы реализовали логику проверки изменения номера счета. И события срабатывают только в том случае, если пользователь указал новый счет. Обработчик btnTransfer_Click дополнительно выполняет преобразование введенную строку в число и в случае, если обнаружена ошибка, то информирует об этом.

Осталось рассмотреть последний элемент - класс презентора, который служит связующим звеном между представлением и моделью. Презентор подписан на события интерфейса представления ITransferView, в соответствии с полученными данными изменяет состояние модели через вызовы метода интерфейса IBankModel, а затем обновляет представление. Ниже показан код презентора.

namespace DemoMVP.Presenter
{
    public class TransferPresenter
    {
        private IBankModel model;
        private ITransferView view;

        public TransferPresenter(IBankModel model, ITransferView view)
        {
            this.model = model;
            this.view = view;

            this.view.SrcAccountChanged += view_SrcAccountChanged;
            this.view.DestAccountChanged += view_DestAccountChanged;
            this.view.TransferMoney += view_TransferMoney;
        }

        private void view_SrcAccountChanged(object sender, AccountChangedEventArgs e)
        {
            try
            {
                decimal balance = model.GetBalance(e.AccountId);
                view.UpdateSrcBalance(balance);
            }
            catch(ApplicationException ex)
            {
                view.ShowError(ex.Message);
            }
        }

        private void view_DestAccountChanged(object sender, AccountChangedEventArgs e)
        {
            try
            {
                decimal balance = model.GetBalance(e.AccountId);
                view.UpdateDestBalance(balance);
            }
            catch (ApplicationException ex)
            {
                view.ShowError(ex.Message);
            }
        }

        private void view_TransferMoney(object sender, TransferMoneyEventArgs e)
        {
            ITransaction tran = model.CreateTransaction();
            try
            {
                tran.Begin();
                bool success = model.Withdraw(e.SrcAccountId, e.Sum, tran);
                if (success)
                {
                    model.Deposit(e.DestAccountId, e.Sum, tran);
                    tran.Commit();
                    view.UpdateSrcBalance(model.GetBalance(e.SrcAccountId));
                    view.UpdateDestBalance(model.GetBalance(e.DestAccountId));
                }
                else
                {
                    tran.Rollback();
                    view.ShowWarning("Недостаточно денег для перевода");
                }
            }
            catch(ApplicationException ex)
            {
                tran.Rollback();
                view.ShowError(ex.Message);
            }
        }
    }
}

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

Мы рассмотрели все составляющие паттерна MVP. Теперь осталось нам создать экземпляры всех наших классов и запустить приложение. По умолчанию приложение WPF создает частичный класс приложения App и помещает точку входа - метод Main в автоматически генерируемый файл App.g.cs. Чтобы самому реализовать метод Main необходимо открыть свойства файла App.xaml и параметр "Действие при построении" изменить с "ApplicationDefinition" на "Page". Далее, нужно открыть содержимое файла App.xaml и удалить атрибут StartupUri="MainWindow.xaml". Теперь добавим в содержимое файла App.xaml.cs следующие строки:

namespace DemoMVP
{
    /// <summary>
    /// Логика взаимодействия для App.xaml
    /// </summary>
    public partial class App : Application
    {
        App()
        {
            InitializeComponent();
        }

        [STAThread]
        static void Main()
        {
            IBankModel model = new SimpleBankModel(new List<Account>() 
            {
                new Account() { Id = "1", Balance = 5000 },
                new Account() { Id = "2", Balance = 2000 },
                new Account() { Id = "3", Balance = 500 },
                new Account() { Id = "4", Balance = 5500 }
            });
            MainWindow view = new MainWindow();

            TransferPresenter presenter = new TransferPresenter(model, view);

            App app = new App();
            app.Run(view);
        }
    }
}

Здесь мы создаем уже конкретный экземпляр модели и наполняем ее некоторыми объектами счетов. Затем создаем экземпляр представления и презентора, которому в конструктор и передаем нашу модель и представление. Далее идет стандартный код WPF по созданию экземпляра класса приложения и вызов метода Run, в который передается созданный ранее экземпляр окна (нашего представления).

Теперь наше приложение готово к компиляции и запуску. В следующей статье я планирую рассмотреть уже архитектуру приложения NewStage2VK, где также используется паттерн MVP.

Ссылки для загрузки: