Введение
В данной статье я хочу рассмотреть разработку клиентской библиотеки к условному api сервису. В качестве такого сервиса я буду использовать воображаемый Rest-api Хабрахабра.
Чтобы такая рутинная задача стала немного интереснее, мы усложним требования, добавив кэширование и приправим всё это библиотекой Reactive Extensions.
Всех заинтересовавшихся приглашаю под кат.
Представим, что у нас есть url формата nonexisting-api.habrahabr.ru/v1/karma/user_name, который возвращает следующий json:
{
"userName" : "requested user name",
"karma" : 123,
"lastModified" : "2014-09-01"
}
Обращение к такому сервису, десериализация ответа и отображение результатов пользователю — всё это достаточно тривиально. Пожалуй, наивная имплементация могла бы выглядеть подобным образом:
public sealed class NonReactiveHabraClient
{
private IHttpClient HttpClient { get; set; }
public NonReactiveHabraClient(IHttpClient httpClient)
{
HttpClient = httpClient;
}
public async Task<KarmaModel> GetKarmaForUser(string userName)
{
var karmaResponse = await HttpClient.Get(userName);
if (!karmaResponse.IsSuccessful)
{
throw karmaResponse.Exception;
}
return karmaResponse.Data;
}
}
Добавим кэширование
Работа с мобильным приложением серьёзно отличается от работы с десктопным или веб-приложением. Мобильное приложение используется «на бегу», одной рукой, зачастую в условиях плохой связи. Конечно же, пользователь ожидает максимально быстрого отображения интересующей его информации. Очевидным образом возникает необходимость кэшировать данные.
Ключевая особенность нашего приложения — редко обновляемые данные, невысокая критичность свежести и актуальности данных. То есть мы можем позволить себе показать информацию с предыдущего запуска. Подобным свойством обладают многие погодные приложения, twitter-клиенты и другие.
В приложениях подобным нашему достаточно распространена следующая логика:
- приложение должно быстро запуститься;
- показать закешированные данные;
- попытаться достать свежие данные с бэк-энда;
- в случае успеха, сохранить данные в кэш;
- отобразить новые данные пользователю или сообщить об ошибке.
Или то же самое, но в виде диаграммы (надеюсь, я не окончательно забыл, как рисовать диаграммы последовательности).
Существует несколько основных стратегий работы с локальным кэшем приложения. Я не буду рассматривать их все, в данной статье нас интересует подход (или паттерн) Cache-Aside.
Основная идея паттерна в том, что кэш является лишь пассивным хранилищем данных. Таким образом задача обновления данных ложится на плечи кода, который использует кэш. Для такого кэша можно определить достаточно простой интерфейс.
public interface ICache
{
bool HasCached(string userName);
KarmaModel GetCachedItem(string userName);
void Put(KarmaModel updatedKarma);
}
Кэш, реализующий данный интерфейс, в достаточной степени отвечает требованиям 2 и 4 из предыдущего списка. Пункты 2, 3 и 4 вместе являются некоторой версией подхода, называемого Refresh-Ahead Caching.
Хочу отметить, что вышеописанный подход не является классической реализацией обоих паттернов, а лишь комбинацией основных идей. Впрочем, для этого шаблоны и существуют.
Надеюсь, стандартная итеративная реализация данного подхода не вызовет затруднений у читателя, поэтому я сразу перейду к варианту, использующему Reactive Extensions. Кроме того, я рассчитываю на то, что читатель уже знаком с Rx, хотя бы на уровне общего представления. Если же вы не знакомы с Rx или хотите освежить в памяти, то советую ознакомиться со статьёй от SergeyT «Реактивные расширения» и асинхронные операции.
Реализация
И так, для начала создадим проект в Visual Studio, тип проекта укажем как Class Library. Нам понадобится NuGet-пакет Rx-Main:
Install-Package Rx-Main
Определим абстракцию над http-клиентом:
public interface IHttpClient
{
Task<KarmaResponse> Get(string userName);
}
public class KarmaResponse
{
public bool IsSuccessful { get; set; }
public KarmaModel Data { get; set; }
public Exception Exception { get; set; }
}
public class KarmaModel
{
public string UserName { get; set; }
public int Karma { get; set; }
public DateTime LastModified { get; set; }
}
Конкретная реализация выполнения http-запросов, парсинг и десериализация ответа, обработка ошибок нам не важна.
Определим интерфейс нашего api-клиента:
public interface IHabraClient
{
IObservable<KarmaModel> GetKarmaForUser(string userName);
}
Ключевой момент здесь: мы возвращаем IObservable<T>, то есть «поток» событий, на который можно подписаться.
И наконец, определим реализацию нашего HabraClient:
public sealed class ReactiveHabraClient : IHabraClient
{
private ICache Cache { get; set; }
private IHttpClient HttpClient { get; set; }
private IScheduler Scheduler { get; set; }
public ReactiveHabraClient(ICache cache, IHttpClient httpClient, IScheduler scheduler)
{
Cache = cache;
HttpClient = httpClient;
Scheduler = scheduler;
}
public IObservable<KarmaModel> GetKarmaForUser(string userName)
{
return Observable.Create<KarmaModel>(observer =>
Scheduler.Schedule(async () =>
{
KarmaModel karma = null;
if (Cache.HasCached(userName))
{
karma = Cache.GetCachedItem(userName);
observer.OnNext(karma);
}
var karmaResponse = await HttpClient.Get(userName);
if (!karmaResponse.IsSuccessful)
{
observer.OnError(karmaResponse.Exception);
return;
}
var updatedKarma = karmaResponse.Data;
Cache.Put(updatedKarma);
if (karma == null || updatedKarma.LastModified > karma.LastModified)
{
observer.OnNext(updatedKarma);
}
observer.OnCompleted();
}));
}
}
Код достаточно прямолинеен: мы создаём и возвращаем новый
Observable
объект, который сразу же возвращает закешированные данные (если они есть) и дальше спокойно асинхронно запрашивает обновлённые значения. В случае, если данные обновились (изменилось поле LastModified
) мы снова уведомляем подписчиков, сохраняем данные в кэш и заканчиваем последовательность.Таким образом, код Вью-модели, использующей наш ReactiveHabraClient, получится компактным и декларативным:
public class MainViewModel
{
private IHabraClient HabraClient { get; set; }
public MainViewModel(IHabraClient habraClient, string userName)
{
HabraClient = habraClient;
Initialize(userName);
}
private void Initialize(string userName)
{
IsLoading = true;
HabraClient.GetKarmaForUser(userName)
.Subscribe(onNext: HandleData,
onError: HandleException,
onCompleted: () => IsLoading = false);
}
private void HandleException(Exception exception)
{
ErrorMessage = exception.Message;
IsLoading = false;
}
private void HandleData(KarmaModel data)
{
Karma = data.Karma;
}
public bool IsLoading { get; set; }
public int? Karma { get; set; }
public string ErrorMessage { get; set; }
}
Конечно же, внимательный читатель уже заметил, что тут отсутствует имплементация INotifyPropertyChanged и диспатчеризация (
OnNext
, OnError
и OnCompleted
выполняются не в UI потоке). Представим себе, что эти задачи взял на себя ваш любимый MVVM-фреймворк.Пожалуй на этом статью можно было бы и закончить, но мы совсем не раскрыли вопрос тестирования. Действительно, писать юнит-тесты на асинхронный код зачастую не очень удобно. Что уж говорить про асинхронный код с использованием Rx?
Тестирование
Попробуем написать несколько юнит-тестов для нашего ReactiveHabraClient и MainViewModel.
Для этого создадим новый проект типа Class Library, добавим ссылку на основной проект и установим несколько NuGet-пакетов.
А именно: Rx-Main, Rx-Testing, Nunit и Moq.
Install-Package Rx-Main
Install-Package Rx-Testing
Install-Package NUnit
Install-Package Moq
Создадим класс
ReactiveHabraClientTest
, унаследованный от ReactiveTest.ReactiveTest
— это базовый класс, поставляемый с пакетом Rx-Testing. Он определяет несколько методов, которые пригодятся нам при написании тестов.Я не буду захламлять статью большими листингами, и приведу здесь лишь по одному тесту на каждый из классов. С остальными тестами можно будет ознакомиться на GitHub. Ссылка на репозиторий находится в конце статьи.
Протестируем следующий сценарий: При пустом кэше HabraClient должен скачать данные, положить их в кэш, вызвать
OnNext
и OnCompleted
.Для этого нам понадобятся Mock-и на
IHttpClient
, ICache
. Так же нам пригодится класс TestScheduler из пакета Rx-Test.TestScheduler
имплементирует интерфейс IScheduler и может быть подставлен вместо платформозависимой реализации планировщика. Класс позволяет нам буквально управлять временем и исполнять асинхронный код по шагам. Желающим настоятельно рекомендую отличную статью Testing Rx Queries using Virtual Time Scheduling .[SetUp]
public void SetUp()
{
Model = new KarmaModel {Karma = 10, LastModified = new DateTime(2014, 09, 10, 1, 1, 1, 0), UserName = USER_NAME};
Cache = new Mock<ICache>();
Scheduler = new TestScheduler();
HttpClient = new Mock<IHttpClient>();
}
И приступим к написанию самого теста.
Arrange
Настроим поведение Mock-ов: кэш будет пустой, данные загрузятся успешно.
Cache.Setup(c => c.HasCached(It.IsAny<string>())).Returns(false);
HttpClient.Setup(http => http.Get(USER_NAME)).ReturnsAsync(new KarmaResponse
{
Data = Model,
IsSuccessful = true
});
var client = new ReactiveHabraClient(Cache.Object, HttpClient.Object, Scheduler);
В тестируемом случае мы ожидаем последовательность из одного вызова OnNext и одного вызова OnCompleted.
Создадим такую последовательность:
var expected = Scheduler.CreateHotObservable(OnNext(2, Model), OnCompleted<KarmaModel>(2));
Тут потребуются пояснения. Метод
OnNext(2, Model)
это метод, определённый в ReactiveTest
.Сигнатура у него следующая:
public static Recorded<Notification<T>> OnNext<T>(long ticks, T value)
По сути, он создаёт запись о том, что был вызван метод OnNext с параметром Model. Магическое число 2 — это время в «тиках» для нашего
TestScheduler
. Не очень красивое решение, но вполне понятное. В «тик» номер ноль мы создаём TestScheduler
, в «тик» номер один мы подписываемся на события, а в «тик» номер два должна поступить последовательность сообщений.Act
var results = Scheduler.Start(() => client.GetKarmaForUser(USER_NAME), 0, 1, 10);
Тут мы запускаем
TestScheduler
, который создастя в нулевой тик, и подпишется на client.GetKarmaForUser(USER_NAME)
в первый «тик». Последний параметр — это «тик», на котором будет вызван Dispose, но в данном случае нам не важно это значение. И наконец, последний шаг.
Assert
ReactiveAssert.AreElementsEqual(expected.Messages, results.Messages);
Cache.Verify(cache => cache.Put(Model), Times.Once);
Убеждаемся, что последовательность сообщений, которую мы получили совпадает с предполагаемой последовательностью. А также проверим, что обновлённая модель сохранена в кэш.
В идеале такой тест стоило бы разбить на два, но я хотел продемонстрировать, что привычные нам техники продолжают работать и в rx-мире.
Тест для
MainViewModel
будет немного отличаться. Создадим Mock для
IHabraClient
и объявим KarmaStream
типа Subject:
[SetUp]
public void SetUp()
{
Client = new Mock<IHabraClient>();
KarmaStream = new Subject<KarmaModel>();
}
Класс Subject<T> реализует оба интерфейса IObservable
и IObserver
. Мы можем вернуть KarmaStream
из метода GetKarmaForUser
и использовать его для ручного вызова OnNext
, OnCompleted
и OnError
. В данном случае нам не нужна "магия" c TestScheduler
.
Рассмотрим один из тестов:
[Test]
public void KarmaValueSetToPropertyWhenOnNextCalled()
{
Client.Setup(client => client.GetKarmaForUser(USER_NAME)).Returns(KarmaStream);
var viewModel = new MainViewModel(Client.Object, USER_NAME);
KarmaStream.OnNext(new KarmaModel {Karma = 10});
Assert.AreEqual(10, viewModel.Karma);
}
Исходный код
Полный, код к данной статье находится на GitHub.
Хотя в статье упоминается разработка под Windows Phone, код в репозитории написан под .Net 4.5. Я пошёл на этот шаг сознательно, так как те у кого не установлен WP SDK не смогли бы открыть и запустить проект. Однако, простым копированием файлов в проект с Class Library для WP8 вы можете получить компилирующуюся сборку. Кроме того, Rx поддерживают и некоторые PCL-профили.
Заключение
Полное и подробное описание всех возможностей и подходов реактивного программирования не являлось целью данной стати. Также я не ставил перед собой задачу написания и построения готовой библиотеки. Вашему вниманию был предложен некий шаблон, который я в том или ином виде успешно применял в нескольких личных и коммерческих проектах под Windows Phone.
С удовольствием приму обоснованную критику в комментариях и замечания об ошибках в личку.
Ссылки
Во время подготовки стати я использовал следующие источники:
- http://reactivex.io/
- The Reactive Extensions (Rx)... EN
- "Реактивные расширения" и асинхронные операции RU
- Read-Through, Write-Through, Write-Behind, and Refresh-Ahead Caching EN
- Cache-Aside Pattern EN
- Testing Rx EN
- Testing Rx Queries using Virtual Time Scheduling EN
- Testing and Debugging Observable Sequences EN
- UDP и C# Reactive Extensions RU
- https://www.websequencediagrams.com/