- PVSM.RU - https://www.pvsm.ru -

Как мы писали iOS-библиотеку для работы с Wargaming API

Как мы писали iOS библиотеку для работы с Wargaming API

World of Tanks Assistant [1] (WOT Assistant) и World of Warplanes Assitant [2] (WOWP Assistant) — это приложения–компаньоны для игроков, которые позволяют следить за внутриигровой статистикой, сравнивать свои боевые показатели с друзьями, а также предоставляют оффлайн-доступ к справочной информации по технике.


WOWP Assistant появился относительно недавно (ноябрь 2013), а версия для World of Tanks была переписана почти с нуля в начале 2013, что по времени совпало с переходом на новый Wargaming Public API [3]. 

Надеюсь, наиболее технически интересные моменты разработки iOS-библиотеки для взаимодействия Assistant’ов с API будут полезны для разработчиков и послужат источником вдохновения для участников конкурса Wargaming Developers Contest.

Требования

Основное требование высокого уровня к библиотеке — это простота в использовании и возможность оказывать помощь или даже полностью решать некоторые повседневные задачи (например, кэширование данных), для того чтобы упростить код клиентского приложения. Ниже я попытался формализовать список функциональных и нефункциональных требований к проекту:

  • гибкое кэширование данных;
  • поддержка «частичных» ответов;
  • удобный способ для обработки цепочек запросов;
  • удобный способ интеграции в приложения;
  • максимальное покрытие кода тестами.

Использованные сторонние решения

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

AFNetworking [4]

AFNetworking являтся де-факто стандартом библиотеки для работы с сетевыми данными. Хоть ее «универсальность» и тянет за собой кучу ненужной функциональности, свою мы решили не писать.

ReactiveCocoa [5]

Библиотека привносиит функционально-реактивные краски в мир iOS (статья на Хабре [6]). В данный момент активно используется в приложениях–ассистентах. На начальном этапе она показалась мне удобным способом описывать запросы как отдельные юниты работы API (зачем это понадобилось, будет рассказано в секции про цепочки запросов ниже).

Mantle [7]

Еще одна библиотека от iOS команды Github, которая позволяет значительно упростить слой модели данных, а именно парсинг ответов web-сервисов (пример в README весьма показателен). В качестве бонуса все объекты автоматически получают поддержку <NSCoding> и могут быть сериализованы.

Kiwi [8]

Этот BDD-фреймворк объединяет в себе RSpec-каркас для тестов, моки и матчеры. Эдакое решение «все-в-одном».

OHTTPStubs [9]

Библиотека просто незаменима, если вам нужно подменять ответы web-сервиса во время тестирования. Код для ее использования весьма «многословен», поэтому мы использовали свои упрощенные функции-обертки [10].

Теперь вернемся к нашим требованиям.

Гибкое кэширование данных

There are only two hard things in Computer Science: cache invalidation and naming things.

— Phil Karlton

Под «гибким кэшированием» подразумевается следующее:


  • данные пользователя могут обновляться каждые несколько часов
  • справочная информация о технике устаревает гораздо медленнее и т.д.

Соответственно, время кэша для этих и других сущностей может и должно быть разным.
Мы пробовали несколько разных версий кэша — была у нас и inMemory CoreData, были попытки обыграть решение с использование NSCache. Позже мы решили, что кэш хоть и является важной фичей, но его реализация в любом из предложенных вариантов — весьма объемна в сравнении с размером всей остальной библиотеки. Поэтому мы перенесли всю функциональность кэша на уровень NSURLConnection.

NSURLCache [11]

NSURLCache позволяет кэшировать ответы на запросы путем сопоставления NSURLRequest и соответствующего ответа. Хранить данные можно как на диске, так и в памяти.

Использование весьма лаконично:

NSURLCache *urlCache = [[NSURLCache alloc] initWithMemoryCapacity:kInMemoryCacheSize
											         diskCapacity:kOnDiskCacheSize
												        diskPath:path];
[NSURLCache setSharedURLCache:urlCache];

Проблема этого решения в том, что в таком виде оно абсолютно не позволяет управлять временем кэша.

У NSURLConnectionDelegate есть следующий метод, который позволяет нам немного «подправить» ответ перед его кэшированием:

- (NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse;

Почему бы и нет?

    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse*)[cachedResponse response];
    
    NSDictionary *headers = [httpResponse allHeaderFields];
    NSString *cacheControl = [headers valueForKey:@"Cache-Control"];
    NSString *expires = [headers valueForKey:@"Expires"];
    if((cacheControl == nil) && (expires == nil)) {
        NSMutableDictionary *modifiedHeaders = [httpResponse.allHeaderFields mutableCopy];
        NSString *expireDate = [NSDate RFC822StringFromDate:[[NSDate date]
                                                             dateByAddingTimeInterval:cacheTime]];
        NSDictionary *values = @{@"Expires": expireDate,
                                 @"Cache-Control": @"public"};
        [modifiedHeaders addEntriesFromDictionary:values];
        
        NSHTTPURLResponse *response =
            [[NSHTTPURLResponse alloc] initWithURL:httpResponse.URL
                                        statusCode:httpResponse.statusCode
                                       HTTPVersion:@"HTTP/1.1"
                                      headerFields:modifiedHeaders];
        
        NSCachedURLResponse *modifiedCachedResponse =
            [[NSCachedURLResponse alloc] initWithResponse:response
                                                     data:cachedResponse.data
                                                 userInfo:cachedResponse.userInfo
                                            storagePolicy:NSURLCacheStorageAllowed];
        return modifiedCachedResponse;
        
    }
    return cachedResponse;

Не буду углубляться в детали HTTP-заголовков и попытаюсь кратко изложить суть решения.


  1. Проверяем, поддерживается ли кэширование сервером (в нашем случае не поддерживается, и это даже хорошо, потому что мы хотим управлять временем жизни каждой сущности на стороне приложения).
  2. Если не поддерживается, устанавливаем свое время жизни (переменная cacheTime) путем правки заголовков.

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

Недостатки:


  • ответ каждый раз проходит всю цепочку обработки (получение, парсинг, валидация и т.д.);
  • если что-то сломается в NSURLCache, сломается и решение.

Достоинства:

  • абсолютно универсальный кэш в 30 строчек кода;
  • при желании можно перенести кэширование на сервер;
  • бонус: если нет интернета и в ответе вернется не 200-й код, NSURLConnection вернет закэшированный ответ.

Поддержка «частичных» ответов

Если мы посмотрим в докуменатцию по любому из запросов API (например, персональные данные игрока [12]), мы увидим там поле fields:

Список полей ответа. Поля разделяются запятыми. Вложенные поля разделяются точками. Если параметр не указан, возвращаются все поля.

То есть, получая объект Player, мы можем получить как полный JSON-граф, так и частичный. Первая и очевидная часть решения заключается в том, что если передаются поля в ключе fields, в ответе из библиотеки мы получаем нетипизированные NSDictionary. 

На этом можно было бы и остановиться, но все-таки удобнее было бы работать с типизированными объектами. А так как парсинг у нас полностью лежит внутри библиотеки, то и типизацию частичных ответов логично было бы делать там же.

Для маппинга JSON -> NSObject у нас используется Mantle, и имплементация запроса к API в общем случае выглядит так (про RACSignal и публичный API в целом я расскажу чуть позже):

- (RACSignal *)searchPlayers:(NSString *)query limit:(NSUInteger)limit {
	NSMutableDictionary *parameters = [NSMutableDictionary dictionary];
	parameters[@"search"] = query;
        if (limit) parameters[@"limit"] = @(limit);
	
	return [self getPath:@"account/list"
			  parameters:parameters
			 resultClass:WOTSearchPlayer.class];
}

Как видим, у нас есть уже есть параметр resultClass, так почему же не вынести его в сигнатуру метода? Получаем:

 - (RACSignal *)searchPlayers:(NSString *)query limit:(NSUInteger)limit resultClass:(Class)resultClass

Да, у нас раздувается публичный API, но зато теперь мы имеем способ типизировать объекты на стороне библиотеки.

Удобный способ для обработки цепочек запросов

Часто при работе с API мы сталкивались с вариантом использования, когда существовал как минимум один вложенный запрос и результатом операции являлась комбинация ответов двух запросов: например, получаем список пользователей, а затем дополнительно вытягиваем «неполный» список техники для каждого. (Иногда таких запросов бывает три). 

Все представляют, как выглядит использование трех вложенных блоков (псевдокод на основе AFJSONRequestOperation):

    op1 = [AFJSONRequestOperation JSONRequestOperationWithRequest:nil
                                                          success:^(id JSON) {
                                                              
        op2 = [AFJSONRequestOperation JSONRequestOperationWithRequest:nil
                                                              success:^(id JSON) {
            
            op3 = [AFJSONRequestOperation JSONRequestOperationWithRequest:nil
                                                                  success:^(id JSON) {
                // Combine all the responses and return
            } failure:^(NSError *error) {
                // Handle error
            }];
        } failure:^(NSError *error) {
            // Handle error
        }];
    } failure:^(NSError *error) {
        // Handle error
    }];

Мало того, что растет уровень вложенности, так еще и обработать ошибку в одном месте будет сложно. В тот момент я как раз играл с ReactiveCocoa и подумал, что неплохо бы ее попробовать в продакшене.

Публичное API библиотеки выглядит следующим образом:

- (RACSignal *)searchPlayers:(NSString *)query limit:(NSUInteger)limit;
- (RACSignal *)fetchPlayers:(NSArray *)playerIDs;

RACSignal — юнит работы, который лениво выполняется только в том случае, когда его об этом просят. Так как юниты — это просто представление некой работы в будущем, их можно всячески комбинировать. Ниже приведен абстрактный пример склейки трех запросов и получения ответа/обработки ошибки:

    RACSignal *fetchPlayersAndRatings =
        [[[[API searchPlayers:@"" limit:0]
          
          flattenMap:^RACStream *(id players) {
            // skipping data processing
            return [API fetchPlayers:@[]];
        }]
         
         flattenMap:^RACStream *(id fullPlayers) {
            // Skipping data processing
            return [API statsSliceForPlayer:nil hoursAgo:@[] fields:nil];
        }]
         
         flattenMap:^RACStream *(id value) {
            // Compose all responses here
            id composedResponse;
            return [RACSignal return:composedResponse];
        }];
    
    [fetchPlayersAndRatings subscribeNext:^(id x) {
        // Fully composed response
    } error:^(NSError *error) {
        // All erros go to one place
    }];

Использовать ReactiveCocoa или нет — само по себе является большим холиваром, так что оставим его за скобками. Если бы мы не использовали библиотеку в приложениях, вполне могли бы обойтись более легковесными библиотеками для Promises и Futures.

Удобный способ интеграции в приложения

Библиотека на данный момент состоит из трех частей:

  • Core (формирование запросов, парсинг);
  • WOT (методы по работе с World of Tanks–эндпоинтами);
  • WOWP (методы по работе с World of Warplanes–эндпоинтами)

Логично предположить, что код библиотеки хотелось бы хранить в одном месте, а вот встраивать в приложения — частями. Естественно, с самого начала (когда еще не было самолетов) мы поддерживали интеграцию через приватный репозиторий CocoaPods [13], так что разделение не составило большого труда. 
Мы использовали фичу под названием subspecs, которая позволяет разделить код библиотеки на три части:

  # Subspecs
  s.subspec 'Core' do |cs|
    cs.source_files = 'WGNAPIClient/Core/**/*.{h,m}'
  end

  s.subspec 'WOT' do |cs|
    cs.dependency 'WGNAPIClient/Core'
    cs.source_files = 'WGNAPIClient/WOT/**/*.{h,m}'
  end

  s.subspec 'WOWP' do |cs|
    cs.dependency 'WGNAPIClient/Core'
    cs.source_files = 'WGNAPIClient/WOWP/**/*.{h,m}'
  end

Теперь можно использовать «танковую» и «самолетную» части отдельно, развивая библиотеку в рамках одного проекта:

pod 'WGNAPIClient/WOT'
pod 'WGNAPIClient/WOWP'

Максимальное покрытие кода тестами

Я уже немного писал на Хабре про тестирование [14] и анализ покрытия кода тестами [15]. Частично эти наработки были использованы при тестировании библиотеки. 

Покрыть тестами код библиотеки оказалось делом весьма тривиальным (98%). 

Большинство тестовых сценариев можно условно поделить на два вида:

  • интеграционное тестирование запроса;
  • тестирования маппинга объекта модели.

Интеграционное тестирование

Код типичного теста представлен ниже:

	context(@"API works with players data", ^{
		it (@"should search players", ^{
			stubResponse(@"/wot/account/list", @"players-search.json");
			RACSignal *searchRequest = [client searchPlayers:@"garnett" limit:0];
			NSArray *response = [searchRequest asynchronousFirstOrDefault:nil success:NULL error:&error];

			[[error should] beNil];			
			[[response should] beKindOfClass:NSArray.class];
			[[response.lastObject should] beKindOfClass:WOTSearchPlayer.class];
			WOTSearchPlayer *player = response[0];
			[[player.ID should] equal:@"1785514"];
		});
    });

Замечу, что никаких плясок с асинхронностью/семафорами нет, так как у RACSignal есть замечательный метод специально для тестирования, который делает всю черную работу за программиста:




(id)asynchronousFirstOrDefault:(id)defaultValue success:(BOOL *)success error:(NSError **)error;

Тест модели

describe(@"WOTRatingType", ^{
	it(@"should parse json to model object", ^{
		NSDictionary *json = @{
							   @"threshold": @5,
							   @"type": @"1",
							   @"rank_fields": @[
									   @"xp_amount_rank",
									   @"xp_max_rank",
									   @"battles_count_rank",
									   @"spotted_count_rank",
									   @"damage_dealt_rank",
									   ]
							   };
		
		NSError *error;
		WOTRatingType *ratingType = [MTLJSONAdapter modelOfClass:WOTRatingType.class
                                              fromJSONDictionary:json
                                                           error:&error];
		
		[[error should] beNil];
		[[ratingType should] beKindOfClass:WOTRatingType.class];
		
		[[json[@"threshold"] should] equal:theValue(ratingType.threshold)]; *
		[[json[@"type"] should] equal:ratingType.type];
		[[ratingType.rankFields shouldNot] beNil];
		[[ratingType.rankFields should] beKindOfClass:NSArray.class];
		[[ratingType.rankFields should] haveCountOf:5];
		
		[[@"maximumXP" should] equal:ratingType.rankFields[1]];
	});
});

* — в тестах используется Йода-нотация из-за неприятного бага в Kiwi, который не указывает строку с ошибкой, если значение переменной равно nil.

Воркфлоу при добавлении нового запроса в API состоит из двух шагов:

  1. написать маппинги и запрос;
  2. Написать два теста.

В подходе, приведенном выше, есть один весьма очевидный и нетестируемый участок: составление query для http-запроса.<irony>Тикет заведен</irony>

Заключение

Работая над этой библиотекой, я очень плотно познакомился с ReactiveCocoa, и это в некоторой степени изменило мою жизнь (но это совсем другая история). Объем написанного кода (всей библиотеки) составляет около 2k LOC (из которых ~1k — маппинги ответов, а еще ~700 — повторяющийся код для описания эндпоинтов), что в очередной раз демонстрирует: не следует бояться сторонних решений и фрэймворков — при разумном их использовании они значительно упрощают жизнь разработчика.

Автор: garnett

Источник [16]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/ios/67179

Ссылки в тексте:

[1] World of Tanks Assistant : https://itunes.apple.com/us/app/wot-assistant/id500174696?mt=8

[2] World of Warplanes Assitant : https://itunes.apple.com/us/app/world-of-warplanes-assistant/id739076510?mt=8

[3] Wargaming Public API: http://ru.wargaming.net/developers/documentation/guide/getting-started/

[4] AFNetworking: https://github.com/AFNetworking/AFNetworking

[5] ReactiveCocoa: https://github.com/ReactiveCocoa/ReactiveCocoa

[6] статья на Хабре: http://habrahabr.ru/post/215033/

[7] Mantle: https://github.com/Mantle/Mantle

[8] Kiwi: https://github.com/kiwi-bdd/Kiwi

[9] OHTTPStubs: https://github.com/AliSoftware/OHHTTPStubs

[10] функции-обертки: https://gist.github.com/garnett/7267579

[11] NSURLCache: https://developer.apple.com/library/mac/documentation/Cocoa/Reference/Foundation/Classes/NSURLCache_Class/Reference/Reference.html

[12] персональные данные игрока: https://ru.wargaming.net/developers/api_reference/wowp/account/info/

[13] CocoaPods: http://cocoapods.org

[14] тестирование: http://habrahabr.ru/post/164073/

[15] анализ покрытия кода тестами: http://habrahabr.ru/post/150438/

[16] Источник: http://habrahabr.ru/post/232037/