Pull to refresh

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

Reading time8 min
Views11K
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<KarmaModel> GetKarmaForUser(string userName)
        {
            var karmaResponse = await HttpClient.Get(userName);
            if (!karmaResponse.IsSuccessful)
            {
                throw karmaResponse.Exception;
            }
            return karmaResponse.Data;
        }
    }


Добавим кэширование



Работа с мобильным приложением серьёзно отличается от работы с десктопным или веб-приложением. Мобильное приложение используется «на бегу», одной рукой, зачастую в условиях плохой связи. Конечно же, пользователь ожидает максимально быстрого отображения интересующей его информации. Очевидным образом возникает необходимость кэшировать данные.

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

В приложениях подобным нашему достаточно распространена следующая логика:
  1. приложение должно быстро запуститься;
  2. показать закешированные данные;
  3. попытаться достать свежие данные с бэк-энда;
  4. в случае успеха, сохранить данные в кэш;
  5. отобразить новые данные пользователю или сообщить об ошибке.


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

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<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/
Tags:
Hubs:
Total votes 14: ↑14 and ↓0+14
Comments9

Articles