Reactive Extensions: клиент для условного api со стратегией Cache-Aside & Refresh-Ahead

rx-logoВведениеВ данной статье я хочу рассмотреть разработку клиентской библиотеки к условному 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 GetKarmaForUser (string userName) { var karmaResponse = await HttpClient.Get (userName); if (! karmaResponse.IsSuccessful) { throw karmaResponse.Exception; } return karmaResponse.Data; } } Добавим кэширование Работа с мобильным приложением серьёзно отличается от работы с десктопным или веб-приложением. Мобильное приложение используется «на бегу», одной рукой, зачастую в условиях плохой связи. Конечно же, пользователь ожидает максимально быстрого отображения интересующей его информации. Очевидным образом возникает необходимость кэшировать данные.

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

В приложениях подобным нашему достаточно распространена следующая логика:

приложение должно быстро запуститься; показать закешированные данные; попытаться достать свежие данные с бэк-энда; в случае успеха, сохранить данные в кэш; отобразить новые данные пользователю или сообщить об ошибке. Или то же самое, но в виде диаграммы (надеюсь, я не окончательно забыл, как рисовать диаграммы последовательности).

image

Существует несколько основных стратегий работы с локальным кэшем приложения. Я не буду рассматривать их все, в данной статье нас интересует подход (или паттерн) 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 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 GetKarmaForUser (string userName); } Ключевой момент здесь: мы возвращаем IObservable, то есть «поток» событий, на который можно подписаться.

И наконец, определим реализацию нашего 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 GetKarmaForUser (string userName) { return Observable.Create(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(); Scheduler = new TestScheduler (); HttpClient = new Mock(); } И приступим к написанию самого теста.

Arrange Настроим поведение Mock-ов: кэш будет пустой, данные загрузятся успешно. Cache.Setup (c => c.HasCached (It.IsAny())).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(2)); Тут потребуются пояснения. Метод OnNext (2, Model) это метод, определённый в ReactiveTest.Сигнатура у него следующая: public static Recorded> OnNext(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(); KarmaStream = new Subject(); } Класс Subject реализует оба интерфейса 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/

© Habrahabr.ru