Pull to refresh
0
ex-Wargaming
Издатель и разработчик free-to-play MMO

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

Reading time9 min
Views9.7K


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 весьма показателен). В качестве бонуса все объекты автоматически получают поддержку и могут быть сериализованы.

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-заголовков и попытаюсь кратко изложить суть решения.

  1. Проверяем, поддерживается ли кэширование сервером (в нашем случае не поддерживается, и это даже хорошо, потому что мы хотим управлять временем жизни каждой сущности на стороне приложения).
  2. Если не поддерживается, устанавливаем свое время жизни (переменная 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 состоит из двух шагов:
  1. написать маппинги и запрос;
  2. Написать два теста.

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

Заключение


Работая над этой библиотекой, я очень плотно познакомился с ReactiveCocoa, и это в некоторой степени изменило мою жизнь (но это совсем другая история). Объем написанного кода (всей библиотеки) составляет около 2k LOC (из которых ~1k - маппинги ответов, а еще ~700 - повторяющийся код для описания эндпоинтов), что в очередной раз демонстрирует: не следует бояться сторонних решений и фрэймворков — при разумном их использовании они значительно упрощают жизнь разработчика.
Tags:
Hubs:
Total votes 26: ↑21 and ↓5+16
Comments1

Articles

Information

Website
lesta.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия