Reactive Extensions: клиент для условного api со стратегией Cache-Aside & Refresh-Ahead
ВведениеВ данной статье я хочу рассмотреть разработку клиентской библиотеки к условному 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
Ключевая особенность нашего приложения — редко обновляемые данные, невысокая критичность свежести и актуальности данных. То есть мы можем позволить себе показать информацию с предыдущего запуска. Подобным свойством обладают многие погодные приложения, 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
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
И наконец, определим реализацию нашего 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
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
Arrange
Настроим поведение Mock-ов: кэш будет пустой, данные загрузятся успешно.
Cache.Setup (c => c.HasCached (It.IsAny
var expected = Scheduler.CreateHotObservable (OnNext (2, Model), OnCompleted
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
Рассмотрим один из тестов:
[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/