![](http://habrastorage.org/files/055/060/d85/055060d852be4888922e33ca6f29a5a6.jpg)
World of Tanks Assistant (WOT Assistant) и World of Warplanes Assitant (WOWP Assistant) — это приложения–компаньоны для игроков, которые позволяют следить за внутриигровой статистикой, сравнивать свои боевые показатели с друзьями, а также предоставляют оффлайн-доступ к справочной информации по технике.
WOWP Assistant появилась относительно недавно (ноябрь 2013), а версия для World of Tanks была переписана почти с нуля в начале 2013, что по времени совпало с переходом на новый Wargaming Public API.
Надеюсь, наиболее технически интересные моменты разработки iOS-библиотеки для взаимодействия Assistant’ов с API, будут полезны для разработчиков и послужат источником вдохновения для участников конкурса Wargaming Developers Contest.
Требования
Основное требование высокого уровня к библиотеке — это простота в использовании и возможность оказывать помощь или даже полностью решать некоторые повседневные задачи (например, кэширование данных), для того чтобы упростить код клиентского приложения. Ниже я попытался формализовать список функциональных и нефункциональных требований к проекту:
- гибкое кэширование данных;
- поддержка «частичных» ответов;
- удобный способ для обработки цепочек запросов;
- удобный способ интеграции в приложения;
- максимальное покрытие кода тестами.
Использованные сторонние решения
Прежде чем мы вернемся к деталям реализации этих требований, стоит кратко упомянуть, какие библиотеки мы использовали.
AFNetworking
AFNetworking являтся де-факто стандартом библиотеки для работы с сетевыми данными. Хоть ее «универсальность» и тянет за собой кучу ненужной функциональности, свою мы решили не писать.
ReactiveCocoa
Библиотека привносиит функционально-реактивные краски в мир iOS (статья на Хабре). В данный момент активно используется в приложениях–ассистентах. На начальном этапе она показалась мне удобным способом описывать запросы как отдельные юниты работы API (зачем это понадобилось, будет рассказано в секции про цепочки запросов ниже).
Mantle
Еще одна библиотека от iOS команды Github, которая позволяет значительно упростить слой модели данных, а именно парсинг ответов web-сервисов (пример в README весьма показателен). В качестве бонуса все объекты автоматически получают поддержку <NSCoding>
и могут быть сериализованы.
Kiwi
Этот BDD-фреймворк объединяет в себе RSpec
-каркас для тестов, моки и матчеры. Эдакое решение «все-в-одном».
OHTTPStubs
Библиотека просто незаменима, если вам нужно подменять ответы web-сервиса во время тестирования. Код для ее использования весьма «многословен», поэтому мы использовали свои упрощенные функции-обертки.
Теперь вернемся к нашим требованиям.
Гибкое кэширование данных
There are only two hard things in Computer Science: cache invalidation and naming things.
— Phil Karlton
Под «гибким кэшированием» подразумевается следующее:
- данные пользователя могут обновляться каждые несколько часов
- справочная информация о технике устаревает гораздо медленнее и т.д.
Соответственно, время кэша для этих и других сущностей может и должно быть разным.
Мы пробовали несколько разных версий кэша — была у нас и inMemory CoreData
, были попытки обыграть решение с использование NSCache
. Позже мы решили, что кэш хоть и является важной фичей, но его реализация в любом из предложенных вариантов — весьма объемна в сравнении с размером всей остальной библиотеки. Поэтому мы перенесли всю функциональность кэша на уровень NSURLConnection
.
NSURLCache
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-заголовков и попытаюсь кратко изложить суть решения.
- Проверяем, поддерживается ли кэширование сервером (в нашем случае не поддерживается, и это даже хорошо, потому что мы хотим управлять временем жизни каждой сущности на стороне приложения).
- Если не поддерживается, устанавливаем свое время жизни (переменная
cacheTime
) путем правки заголовков.
Вот и весь кэш — для любого запроса и любой новой сущности (достаточно переопределить время жизни при ее объявлении). Как и любое решение, здесь есть свои плюсы и минусы, о которых стоит упомянуть.
Недостатки:
- ответ каждый раз проходит всю цепочку обработки (получение, парсинг, валидация и т.д.);
- если что-то сломается в
NSURLCache
, сломается и решение.
Достоинства:
- абсолютно универсальный кэш в 30 строчек кода;
- при желании можно перенести кэширование на сервер;
- бонус: если нет интернета и в ответе вернется не 200-й код,
NSURLConnection
вернет закэшированный ответ.
Поддержка «частичных» ответов
Если мы посмотрим в докуменатцию по любому из запросов API (например, персональные данные игрока), мы увидим там поле 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, так что разделение не составило большого труда.
Мы использовали фичу под названием 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'
Максимальное покрытие кода тестами
Я уже немного писал на Хабре про тестирование и анализ покрытия кода тестами. Частично эти наработки были использованы при тестировании библиотеки.
Покрыть тестами код библиотеки оказалось делом весьма тривиальным (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 состоит из двух шагов:
- написать маппинги и запрос;
- Написать два теста.
В подходе, приведенном выше, есть один весьма очевидный и нетестируемый участок: составление query для http-запроса.<irony>Тикет заведен</irony>
Заключение
Работая над этой библиотекой, я очень плотно познакомился с ReactiveCocoa
, и это в некоторой степени изменило мою жизнь (но это совсем другая история). Объем написанного кода (всей библиотеки) составляет около 2k LOC (из которых ~1k — маппинги ответов, а еще ~700 — повторяющийся код для описания эндпоинтов), что в очередной раз демонстрирует: не следует бояться сторонних решений и фрэймворков — при разумном их использовании они значительно упрощают жизнь разработчика.
ссылка на оригинал статьи http://habrahabr.ru/company/wargaming/blog/232037/
Добавить комментарий