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 — повторяющийся код для описания эндпоинтов), что в очередной раз демонстрирует: не следует бояться сторонних решений и фрэймворков — при разумном их использовании они значительно упрощают жизнь разработчика.
Автор: garnett