20.02.2017 Архитектура и асинхронность в NewStage2VK

Еще раз о причинах написания программы NewStage2VK

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

Об узкой специализации программы NewStage2VK
Об узкой специализации программы NewStage2VK

Собственно, это и была одна из причин создания NewStage2VK, т.к. шаблонные решения для рассылки не очень хорошо подходили. К тому же я сам против откровенного спама и агрессивного маркетинга. Так что идеология программы не преследовала цель расширения аудитории, а ее назначение - по возможности упростить оповещение своих подписчиков и друзей о новых событиях, а также отправка напоминаний о спектакле, на который записался зритель. Ну а данная статья, возможно, будет интересна разработчикам, которые интересуются использованием конструкции async/await на практике, в реальном (хоть и небольшом) приложении, а также архитектурным паттерном MVP. Хотя программа написана на платформе WPF (а сам код - на C#), я не буду заострять внимание на том, что такое разметка XAML и других специфических моментах технологии WPF. Исходники программы NewStage2VK опубликованы на github'е.

Архитектура приложения NewStage2VK

NewStage2VK написана с использованием паттерна MVP, о котором я рассказал в своей предыдущей статье. Приложение состоит из 3-х проектов: DataAccess, DomainModel и NewStage2VK.

  • DataAccess - содержит код доступа к сохраненным данным приложения, а именно - к БД с использованием ORM Entity Framework.
  • DomainModel - это модель приложения. Содержит код доступа к данным из ВК, а также предоставляет ссылку на объект DataAccess.
  • NewStage2VK - содержит представления и презенторы, которые взаимодействуют с моделью и обновляют представления.

Диаграмма наиболее значимых классов приложения представлена на рисунке ниже:

Диаграмма классов NewStage2VK
Диаграмма классов NewStage2VK

Начнем распутывать клубок с доменной модели. На диаграмме представлен интерфейс IVKDomainModel. Именно этот интерфейс реализует используемая модель в приложении VkDomainModel и на этот интерфейс содержат ссылку презенторы. Такая реализация позволяет гибко изменять модель, не затрагивая использующие ее классы. IVKDomainModel содержит ссылки на два других интерфейса - это IDataAccess и ICryptoProvider. Класс, реализующий первый интерфейс, обеспечивает доступ к БД и сохраняет состояние модели. А класс, который реализует второй интерфейс - управляет шифрованием и дешифровкой токена для ВК-API. Таким образом, можно очень просто изменить алгоритм шифрования просто предоставив другой класс. Здесь у нас в деле поведенческий шаблон проектирования - стратегия.

Далее пройдемся по интерфейсам представлений.

  • IView - обычно является базовым интерфейсом для других интерфейсов представления. Он определяет единственный метод RunOnUI, который служит для выполнения указанного метода в потоке пользовательского интерфейса.
  • IStatusView определяет ряд методов, которые характерны для представления, содержащих строку статуса, индикатор прогресса выполнения задачи, а также методы для информирования пользователя приложения об ошибках и предупреждениях.
  • ISenderView<T> - шаблонный интерфейс, который уже "заточен" под представления с табличным набором данных, возможностями загрузки данных из ВК, рассылки сообщений и сохранения состояния. Здесь параметр T определяет класс сущности для рассылки, т.е. это по сути данные, которые отображаются в строке таблицы (грида).
  • IInviteView - интерфейс представления, который определяет методы и события для управления рассылкой уведомлений среди друзей и подписчиков. Данный интерфейс наследуется от IStatusView и ISenderView<ProfileMessage>
  • IRemindView - интерфейс представления, который определяет методы и события для управления рассылкой напоминаний о записи на спектакль. Данный интерфейс наследуется от IStatusView и ISenderView<EventMessage>.
  • IMainView содержит методы, обновляющие информацию о текущем операторе программы, а также событие выхода из системы.
  • ILoginView не показан на диаграмме. Предоставляет методы и события для взаимодействия с представлением авторизации в сети ВК.

Класс окна MainWindow реализует 3 интерфейса: IMainView, IRemindView, IInviteView. Область этого окна разбита на вкладки (управление уведомлениями и управление напоминаниями), а также содержит панель состояния и элементы по отображению информации о текущем операторе программы (плюс кнопку "Выход") . Благодаря тому, что элементы управления этих частей в достаточной мере независимы я решил выделить 3 презентера для взаимодействия с каждой из частей окна. Каждый из презенторов получает ссылку на нужный интерфейс: IMainView или IInviteView или IRemindView. Всего в программе существует 5 классов презенторов. Один из них - абстрактный.

  • MessagePresenterBase - абстрактный класс для презенторов, которые взаимодействуют с представлениями по управлению рассылкой сообщений. Он определяет общую логику по обработке событий от представления и взаимодействие с моделью, а производные от него классы в переопределяемых методах реализуют уже свое специфическое поведение. Механизм похож на паттерн проектирования шаблонный метод, который позволяет дочерним классам переопределять определенные шаги алгоритма.
  • InvitePresenter взаимодействует с моделью и интерфейсом IInviteView, наследуется от MessagePresenterBase и переопределяет некоторые методы базового класса.
  • RemindPresenter взаимодействует с моделью и интерфейсом IRemindView, наследуется от MessagePresenterBase и переопределяет некоторые методы базового класса.
  • MainPresenter - Презентор для представления информации о текущем операторе и обработка события выхода из системы.

Как было отмечено ранее, MessagePresenterBase содержит ряд методов, которые переопределяются в наследуемых классах. Это методы:

/// <summary>
/// Вернуть текст, который отображается в статусной строке при загрузке данных
/// </summary>
/// <returns>Текст для отображение в статусе</returns>
protected abstract string GetLoadingText();

/// <summary>
/// Вернуть текст, который отображается в статусной строке после окончания загрузки данных
/// </summary>
/// <param name="list">Спсиок объектов данных</param>
/// <returns>Текст статусной строки</returns>
protected abstract string GetLoadDoneText(IList<T> list);

/// <summary>
/// Запуск асинхронной загрузки данных
/// </summary>
/// <param name="e">Аргументы события загрузки данных</param>
/// <returns>Задача, результат которой содержит список объектов данных</returns>
protected abstract Task<IList<T>> RunLoad(StartLoadBaseEventArgs e);

/// <summary>
/// Метод обработки перед началом сохранения даных
/// </summary>
/// <param name="e">Аргументы события сохранения состояния</param>
/// <returns>False - отменить дальнейшее сохранение</returns>
protected abstract bool PreInitSave(SaveBaseEventArgs e);

/// <summary>
/// Асинхронный метод выполнения сохранения данных
/// </summary>
/// <param name="items">Список объектов напоминаний</param>
/// <param name="e">Аргументы события сохранения состояния</param>
/// <returns>Задача, которая содержит результат выполнения. 
/// True - если сохранение выполнено полностью, False - если была выполнена отмена</returns>
protected abstract Task<bool> RunSave(IList<T> items, SaveBaseEventArgs e);

/// <summary>
/// Получить репозиторий для доступа к состоянию рассылки
/// </summary>
/// <returns>Объект репозитория</returns>
protected abstract IRepository<T> GetRepository();

/// <summary>
/// Получить идентификатор сущности в ВК
/// </summary>
/// <param name="item">Объект данных рассылки</param>
/// <returns>Идентификатор ВК</returns>
protected abstract int GetVkId(T item);

/// <summary>
/// Создать объект данных рассылки
/// </summary>
/// <param name="vkEntity">Объект ВК</param>
/// <returns>Объект данных рассылки</returns>
protected abstract T CreateItem(VK vkEntity);

/// <summary>
/// Обновить объект данных рассылки
/// </summary>
/// <param name="vkEntity">Объект ВК</param>
/// <param name="item">Объект данных рассылки</param>
protected virtual void UpdateItem(VK vkEntity, T item) { }

Сам базовый класс MessagePresenterBase занимается обработкой 3-х основных событий - загрузка данных, рассылка сообщений и сохранение состояния в БД. Как пример, код загрузки данных из ВК показан ниже:

/// <summary>
/// Обработчик события начала загрузки данных
/// </summary>
/// <param name="sender">Объект-отправитель события</param>
/// <param name="e">Аргументы события загрузки данных</param>
private async void senderView_StartLoad(object sender, StartLoadBaseEventArgs e)
{
    InitLoad();
    try
    {
        IList<T> list = await RunLoad(e);
        if (list == null)
        {
            ActionCanceled(ViewCancelActions.Load);
        }
        else
        {
            DoneLoad(list);
        }
    }
    catch(Exception ex)
    {
        FailAction(ex, ViewCancelActions.Load);
    }
}

Здесь обработчик вызывает абстрактный метод RunLoad, который переопределяется в наследниках и выполняет фактическую загрузку данных, т.е. пользователей - в InvitePresenter и комментариев (бронирование мест зрителями на спектакль) - в RemindPresenter. Методы ActionCanceled, DoneLoad и FailAction обновляют соответствующим образом элементы управления и отображают нужные данные. Примерно таким же образом обстоят дела и с другими обработчиками событий представления.

Еще один момент, которому стоит уделить внимание в связке представление / презентор - это механизм обновления данных в таблице (гриде). Здесь обновление происходит через механизм привязки данных (биндинг). Т.е. изменение данных в объекте модели автоматически приводит к обновлению этих данных в гриде, и наоборот, если привязка двусторонняя, то при изменении информации в ячейке грида данные обновляются и в модели. Так что здесь мы имеем дело с вкраплением MVVM паттерна и, в итоге у нас получается что-то типа MVPVM паттерна. Механизм привязки в WPF для одной из колонок грида (выбор пользователей для рассылки) показан ниже. В данном случае имеет место быть привязка в обе стороны.

<DataGridTemplateColumn CanUserResize="False" IsReadOnly="True">
    <DataGridTemplateColumn.Header>
        <CheckBox Checked="GridInvMsgCbHeader_Checked" Unchecked="GridInvMsgCbHeader_Unchecked" Margin="3" HorizontalAlignment="Center" />
    </DataGridTemplateColumn.Header>
    <DataGridTemplateColumn.CellTemplate>
        <DataTemplate>
            <CheckBox IsChecked="{Binding IsSend, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" 
                      Margin="3" HorizontalAlignment="Center" VerticalAlignment="Center" />
        </DataTemplate>
    </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

Пожалуй, стоит сказать еще несколько слов касательно проекта DataAccess, который содержит код доступа к таблицам БД. В данном проекте применен паттерн репозиторий, что создает дополнительной слой абстракции от БД или ORM технологии. Репозиторий Repository<T> представляет собой обобщенный класс, реализующий интерфейс IRepository<T>, который предоставляет группу асинхронных методов для основных манипуляций данными в асинхронном виде. Определение интерфейса IRepository<T> представлено ниже:

public interface IRepository<T> : IDisposable where T : class
{
    Task<IList<T>> GetItemsAsync();
    Task<T> GetByIdAsync(int id);
    void Create(T item);
    void Update(T item);
    Task DeleteAsync(int id);
    Task SaveAsync();
}

При необходимости можно расширить данный интерфейс и интерфейс дополнительными методами для более гибких манипуляций с данными. Так в программе от IRepository<T> унаследованы следующие интерфейсы: IMessageRepository, IProfileMessageRepository, ISettingsRepository и IVkRepository<T>. Также в проекте существует интерфейс доступа к данным IDataAccess, который возвращает ссылки на объекты, реализующие вышеперечисленные интерфейсы репозиториев. Диаграмма классов данного проекта хорошо проясняет ситуацию.

Диаграмма классов проекта DataAccess
Диаграмма классов проекта DataAccess

Асинхронные операции в NewStage2VK

В программе доступ к данным и запросы к VK-API выполнены в виде асинхронных методов. Благодаря использованию ключевых слов async/awit асинхронный код можно писать в синхронном стиле. Все асинхронные методы в проекте DomainModel содержат вызов ConfigureAwait(false), который указывает системе, что возобновление задачи возможно любым потоком из пула потоков. Такая практика является рекомендуемой при создании своих библиотечных методов. Более подробно об этом можно прочитать в статье "Взаимная блокировка (deadlock) в async/await".

В качестве примера рассмотрим приватную функцию MakeWebRequestAsync, которая отвечает за выполнение всех запросов к VK-API.

/// <summary>
/// Выполнить асинхронно веб-запрос
/// </summary>
/// <param name="url">Url запроса</param>
/// <returns>Задачу, результат которой содержит данные ответа сервера</returns>
private async Task<dynamic> MakeWebRequestAsync(string url)
{
    if (tsDelay.HasValue)
    {
        TimeSpan ts = DateTime.Now - lastRequest;
        if (ts <= tsDelay.Value)
        {
            await Task.Delay(tsDelay.Value - ts);
        }
        lastRequest = DateTime.Now;
    }

    dynamic obj = null;
    string requestUrl = $"{config.ApiHost}{url}&v={apiVersion}&access_token={identity.AccessToken}";

    try
    {
        using(HttpClient client = new HttpClient())
        {
            client.Timeout = TimeSpan.FromMilliseconds(config.RequestTimeout);
            using(var response = await client.GetAsync(new Uri(requestUrl)).ConfigureAwait(false))
            {
                string result = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
                obj = JObject.Parse(result);
            }
        }
        return obj;
    }
    catch(Exception ex)
    {
        throw new VkRequestException("Ошибка при запросе к серверу VK", ex);
    }
}

Первая часть управляет задержкой между запросами, которая задается в конфигурационном файле приложения. Поскольку сервер, обслуживающий запросы VK-API, позволяет выполнять не более 3-х запросов в секунду для одной учетной записи, то по умолчанию в конфигурационном файле указано значение задержки в 350 мс. Далее, формируется строка полного url с параметрами запроса, переданными в качестве аргумента, к которым добавляется номер версии API и токен авторизации ВК. В блоке try с помощью экземпляра HttpClient выполняется асинхронный запрос к серверу ВК, после успешного завершения которого мы считываем ответ в JSON-формате в строковую переменную result. Затем, с помощью вызова статического метода JObject.Parse (определенного в сторонней библиотеке Newtonsoft.Json) происходит создание из JSON-строки объекта типа dynamic, который и возвращается функцией. Все исключения, которые могу произойти во время запроса HttpClient к серверу, обрабатываются в секции catch, которая в свою очередь выбрасывает свое собственное, "библиотечное" исключение.

Интересным моментом в плане асинхронности является функция сохранения объектов в БД, которая определена в классе MessagePresenterBase. Вот ее код:

/// <summary>
/// Сохранить объекты в БД
/// </summary>
/// <param name="repository">Репозиторий, выполняющий операцию сохранения</param>
/// <param name="itemsList">Спсиок объектов для сохранения</param>
/// <param name="updateAvatars">Флаг, указывающий обновлять ли аватарки</param>
/// <returns>Задача, результат которой содержит True, если сохранение прошло успешно. False - если была отмена</returns>
protected async Task<bool> Save(IRepository<T> repository, IList<T> itemsList, bool updateAvatars)
{       
    Task<bool> saveTask = Task.Run(async () =>
    {
        int i = 0;
        int count = 0;
        List<Task<byte[]>> tasks = new List<Task<byte[]>>();
 	    foreach(T item in itemsList)
        {
            count++;
            if (item.Id == 0)
            {
                repository.Create(item);
            }
            if (updateAvatars)
            {
                Task<byte[]> task = model.GetImageAsync(item.Profile.AvatarUrl_50);
                tasks.Add(task);
                Task t2 = task.ContinueWith(t => 
                { 
                    item.Profile.Avatar_50 = t.Result;
                    RunOnUI(() => 
                    {
                        statusView.SetProgressValue(++i);
                    });
                });
            }             
            if (count % SAVE_ITEMS_COUNT == 0)
            {
                if (updateAvatars)
                {
                    await Task.WhenAll(tasks);
                    tasks.Clear();
                }
                else
                {
                    RunOnUI(() => 
                    {
                        statusView.SetProgressValue(count);
                    });
                    i = count;
                }
                await repository.SaveAsync();
                if (cancelFlag)
                {
                    return false;
                }
            }
        }
        if (count % SAVE_ITEMS_COUNT != 0)
        {
            await Task.WhenAll(tasks);
            await repository.SaveAsync();
            RunOnUI(() => 
            {
                statusView.SetProgressValue(count);
            });
        }
        return true;
    });
    return await saveTask;
}

Здесь создается новая задача, с помощью статического метода Task.Run, которому передается лямбда выражение, представляющее собой асинхронную функцию, внутри которой происходит итерация по элементам списка и их сохранение в БД. Если параметр updateAvatars установлен в True, то происходит загрузка аватарок и их обновление. Несмотря на то, что загрузка изображений выполняется асинхронно, т.е. в другом потоке, частый вызов метода model.GetImageAsync(item.Profile.AvatarUrl_50) приводил к небольшому подтомаживанию графического интерфейса, вероятно из-за того, что метод DownloadDataTaskAsync (внутри метода GetImageAsync) выполняет создание задачи, которая является относительно затратной операцией. Чтобы убрать этот неприятный эффект от легкого подтормаживания я обернул процесс сохранения в новую задачу.

Еще один интересный момент связан с вызовом метода task.ContinueWith, который выполняется после окончания загрузки изображения. Методу task.ContinueWith передается лямбда выражение, внутри которого происходит обновление свойства item.Profile.Avatar_50 массивом байтов загруженного изображения, после чего с помощью вызова метода RunOnUI, происходит обновление индикатора прогресса операции в уже в потоке пользовательского интерфейса. Сохранение в БД происходит через каждые SAVE_ITEMS_COUNT итераций. Дополнительно, если происходит обновление аватарок мы ждем окончания их загрузки строкой await Task.WhenAll(tasks) и только после этого сохраняем объекты в БД. Здесь же мы проверям, был ли установлен флаг отмены. И после окончания всех итераций сохраняем оставшиеся объекты в БД. Данный блок не будет выполняться в случае, если количество объектов кратно значению SAVE_ITEMS_COUNT.

Подключение к своей программе VK-API

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

Файлы для загрузки: