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

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

Рассмотрим интерфейс модели 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. Внешний вид окна показан на рисунке:

Теперь давайте взглянем, на код 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.
Ссылки для загрузки:
- Исходный код демонстрационного приложения DemoMVP можно загрузить с github