Игра в прятки: кодогенерация против JSON

    Страшно подумать, но ещё каких-то десять лет назад разработка системы самого заштатного RPC была целым праздником в жизни разработчика. Болезненным и длительным праздником, как свадьба для лошади: голова в цветах, зад в мыле. Это было страшно увлекательно и одновременно невероятно запарно. Один выбор протокола чего стоил. Я уж не говорю о борьбе с могучими и чудовищными фреймворками, типа DCOM или CORBA. Реализация транспортного уровня вообще была уделом людей с длинными бородами.

    В наше счастливое время жизнь программиста под iOS должна быть легка и приятна. Транспорт давно перестал быть проблемой. А RPC? Легко: достаём из кобуры Apache Thrift или на худой конец Google Protocol Buffers и пожалуйста, с минимальным напряжением головного мозга готов и протокол, и сервер, и клиент. Подавляющему количеству приложений в AppStore только это и нужно: простой и понятный интерфейс к удаленным процедурам, желательно в приятных обертках из нативных классов, и такая же простая и понятная обработка ошибок. Всё.

    Но. К сожалению, и Thrift, и Protobuf заточены под одновременную разработку клиента и сервера. А такая удача случается в карьере программиста не часто. В основном приходится иметь дело с ворохом давным-давно написанных и данных нам в ощущениях сетевых ресурсов, каждый из которых желает общаться с внешним миром в своей собственной неповторимой манере. Конечно, всё не так плохо, как десять лет назад, и часто все пожелания сервер-сайда сводятся к REST+JSON в небольших вариациях. Но даже на эту де-факто стандартизацию больно смотреть человеку, избалованному Thrift-ом. Никакой типизации, сплошное сравнение строк, выливающееся в тонны однотипного кода с утомительными однообразными проверками. Разумеется, проблему давно осознали, и для многих языков, например, имеется целый зоопарк средств прозрачной конверсии JSON в нативные объекты. Для Objective-C, конечно, есть комбайн RestKit, но он полагается на интроспекцию и мапит все структуры в рантайме. Опять же, настройка такого динамического маппинга далека от изящества на мой вкус. Остальные библиотеки и утилиты, которые я пробовал, для реальной жизни подходили еще хуже. Например, какой-нибудь JsonPack с его формочками в браузере трудновато встроить в непрерывную сборку.

    Я выбирал инструмент для нового проекта i-Free, в котором ожидался пяток разнородных REST-подобных сервисов, и своих родных, и внешних, каждый с пучком методов, выводком разлапистых структур и другими интересными подробностями. Мысль о необходимости ручного разбора словарей была невыносима. Как и предвкушение рефакторинга после неизбежных изменений в протоколах. Жизнь слишком коротка, чтобы занимать её неинтересной работой, так что лучше день потерять, потом за пять минут долететь. Через день три дня первая версия ifacegen уже генерировала код, наивный, но много.

    Что такое ifacegen?

    Это незамысловатый консольный инструмент (python-скрипт), генерирующий классы Objective-C из простого IDL, максимально похожего на исходный протокольный JSON. Классы эти прозрачно перепаковывают своё состояние в JSON и обратно. Вдобавок ifacegen может генерировать классы-обёртки для прозрачного вызова методов удалённых сервисов.

    Для чего он нужен?

    ifacegen работает как Thrift наоборот. Он позволяет взять существующий REST+JSON API и натянуть на него сопоставить структурам JSON и методам REST соответствующие классы Objective-C. Вы манипулируете в программе только вызовами методов естественных для языка типов, а сгенерированный код прячет все ненужные подробности.

    Для чего он точно не пригодится?

    ifacegen точно не подойдет для сериализации произвольных классов в JSON и создания классов из любого JSON-а на лету. Никакого рантайма, только компайл-тайм, только хардкор. Никакого NSCoder. И никаких других языков, кроме Objective-C.

    Насколько сложный протокол можно адаптировать?

    Довольно сложный. ifacegen переваривает вложенные словари, массивы, в том числе массивы словарей (словарей с массивами, ну вы поняли). Словари конвертируются в классы, элементы словарей конвертируются в поля (@property) классов. IDL поддерживает описание атомарных типов элементов: int32, int64, double, string, bool, raw и rawstr. raw — это произвольный словарь, напрямую копируемый в поле типа NSDictionary, rawstr — тоже произвольный словарь, но закодированный в строку типа такой: "{\"weird\":42,\"str\":\"yes\"}".

    Насколько это просто?

    Cинтаксис IDL максимально следует за данными (и да, язык описания — сам по себе полноценный JSON). За примером пойдём к Google Places API. Вот как в его документации описывается гео-точка в ответах сервиса:
    "geometry" : {
                "location" : {
                  "lat" : -33.8583790,
                  "lng" : 151.2100270
                }
            }
    

    ifacegen IDL, описывающий эту структуру, должен дословно её повторить (имена полей и их вложенность должны совпадать), добавив немного разметки для удобства. Поля с именами “id” и “void" или начинающиеся со строки «new», «alloc», «copy» и «mutableCopy» автоматически получат префикс “the”, чтоб не мешать объявлениям в коде. Все объявления размещаются в массиве с заголовком “iface”:
    {"iface": [
    {
        "struct":"GoogleGeometry",
        "typedef" : {
                "location" : {
                    "lat" : "double",
                    "lng" : "double"
                }
        }
    }
    }
    

    Самое время для легкой кодогенерации. При условии, что мы поместили объявления IDL в файл GoogleClient.json, она будет выглядеть так:
    $ python ifacegen.py GoogleClient.json
    

    В результате получим два файла: GoogleClient.h и GoogleClient.m. Заголовок будет содержать объявления интерфейсов сгенерированных структур:
    @interface GoogleGeometryLocation: NSObject
    @property (nonatomic) double_t lat;
    @property (nonatomic) double_t lng;
    @end;
    
    @interface GoogleGeometry: NSObject
    - (NSData*)dumpWithError:(NSError* __autoreleasing*)error;
    - (id)initWithLocation:(GoogleGeometryLocation*)location;
    - (id)initWithDictionary:(NSDictionary*)dictionary error:(NSError* __autoreleasing*)error;
    - (id)initWithJSONData:(NSData*)jsonData error:(NSError* __autoreleasing*)error;
    @property (nonatomic) GoogleGeometryLocation* location;
    @end;
    

    Для всех вложенных словарей тоже будут сгенерированы простые классы. Но собственные методы сериализации/десериализации будут только у структур верхнего уровня, иначе очень распухает генераторный код. Разумеется, вложенный словарь тоже можно описать отдельной структурой с произвольным именем. Теперь везде, где встречается этот JSON-фрагмент, мы можем просто вставить его имя:

    {
    "struct":"GoogleLocation",
    "typedef" : {
          "lat" : "double",
          "lng" : "double"
    	}
    },
    
    {
    "struct":"GoogleGeometry",
    "typedef" : {
        	"location" : "GoogleLocation"
    	}
    }
    

    Поддерживается наследование объявленных таким образом структур. Например, если вдруг понадобится описать расширенный вариант GoogleLocation, не обязательно будет повторять все её поля, достаточно отнаследоваться:
    {
    "struct":"GoogleLocationEx",
    "extends": "GoogleLocation", 
    "typedef" : {
          "elv" : "double"
    	}
    } 
    

    Можно даже импортировать типы, объявленные во внешнем модуле. Модули эти ищутся в том же каталоге, что и исходный. Генерации кода, кстати, при импорте не будет, просто засосутся типы.
    {"iface": [
    { "import": "OtherModule.json" }
    ...
    


    Ок, это структуры. А что с вызовами?

    Опять идем к Google Places API. Возьмём простой вызов: запрос данных о локации. У него есть URL, типа такого:
    maps.googleapis.com/maps/api/place/details/json?
    И, конечно, оно умеет возвращать JSON. Структуру возвращаемого значения я назову GoogleDetailsResult и опущу для простоты, а описание самого вызова будет таким:
    { "procedure": "placeDetails", "prefix": "place/details/json", "prerequest": { "key":"string", "reference": "string", "sensor": "string", "language": "string" }, "request" : {}, "response": "GoogleDetailsResult" }

    Поле prefix определяет конкретную функцию сервиса, которую мы будем вызывать в этом методе. Поле prerequest принимает строковые параметры, которые лягут в URL (почти по RFC 3986), а request принимает описание структуры JSON, если параметры вызова надо передавать через неё. В нашем примере оставляем его пустым. Всё. Опять жмем кнопку «сгенерировать» и откидываемся в кресле (на самом деле нет):
    - (GoogleDetailsResult*)placeDetailsWithKey:(NSString*)key
    andReference:(NSString*)reference
    andSensor:(NSString*)sensor
    andLanguage:(NSString*)language
    andError:(NSError* __autoreleasing*)error;
    

    В клиентском коде вызов удалённого метода будет выглядеть примерно так:
    GoogleDetailsResult* result = [self.google placeDetailsWithKey:key
                                                      andReference:ref
                                                         andSensor:@"true"
                                                       andLanguage:locale
                                                          andError:&error];
    


    Для неленивых немного автоматического Objective-C кода: GoogleClient.m
    - (GoogleDetailsResult*)placeDetailsWithKey:(NSString*)key
    		andReference:(NSString*)reference
    		andSensor:(NSString*)sensor
    		andLanguage:(NSString*)language
    		andError:(NSError* __autoreleasing*)error {
    	id tmp;
    	[transport setRequestParams:@{
    		@"key" : NULLABLE(key),
    		@"reference" : NULLABLE(reference),
    		@"sensor" : NULLABLE(sensor),
    		@"language" : NULLABLE(language)
    	}];
    	if ( ![transport writeAll:nil prefix:@"/place/details/json" error:error] ) {
    		return nil;
    	}
    	NSData* outputData = [transport readAll];
    	if ( outputData == nil ) {
    		return nil;
    	}
    	NSDictionary* output = [NSJSONSerialization JSONObjectWithData:outputData options:NSJSONReadingAllowFragments error:error];
    	if ( *error != nil ) {
    		return nil;
    	}
    	GoogleDetailsResult* GoogleDetailsResult235;
    	GoogleDetailsResult235 = [GoogleDetailsResult new];
    		GooglePlaceDetails* GooglePlaceDetails236;
    		NSDictionary* outputResult237 = [output objectForKey:@"result"];
    		if ( outputResult237 != nil && ![outputResult237 isEqual:[NSNull null]]) {
    			GooglePlaceDetails236 = [GooglePlaceDetails new];
    				NSArray* outputResult237Address_components238 = [outputResult237 objectForKey:@"address_components"];
    				NSMutableArray* address_components4;
    				if ( outputResult237Address_components238 != nil && ![outputResult237Address_components238 isEqual:[NSNull null]]) {
    					address_components4 = [NSMutableArray arrayWithCapacity:[outputResult237Address_components238 count]];
    					for ( id item in outputResult237Address_components238) {
    						GoogleAddressComponent* GoogleAddressComponent239;
    						GoogleAddressComponent239 = [GoogleAddressComponent new];
    						GoogleAddressComponent239.longName = ( tmp = [item objectForKey:@"long_name"], [tmp isEqual:[NSNull null]] ? nil : (NSString*)tmp );
    						GoogleAddressComponent239.shortName = ( tmp = [item objectForKey:@"short_name"], [tmp isEqual:[NSNull null]] ? nil : (NSString*)tmp );
    							NSArray* itemTypes240 = [item objectForKey:@"types"];
    							NSMutableArray* types7;
    							if ( itemTypes240 != nil && ![itemTypes240 isEqual:[NSNull null]]) {
    								types7 = [NSMutableArray arrayWithCapacity:[itemTypes240 count]];
    								for ( id item in itemTypes240) {
    									[types7 addObject:item];
    								}
    							}
    						GoogleAddressComponent239.types = types7;
    						[address_components4 addObject:GoogleAddressComponent239];
    					}
    				}
    			GooglePlaceDetails236.addressComponents = address_components4;
    
    // ... Еще очень много такого же выразительного нескучного кода
    
    	GoogleDetailsResult235.result = GooglePlaceDetails236;
    	GoogleDetailsResult235.status = ( tmp = [output objectForKey:@"status"], [tmp isEqual:[NSNull null]] ? nil : (NSString*)tmp );
    	return GoogleDetailsResult235;
    }
    
    



    Что там с транспортом?

    С транспортом тоже всё просто. Сгенерированный код полагается в вызовах на объект, реализующий протокол IFTransport:
    @protocol IFTransport
    - (void)setRequestParams:(NSDictionary*)params;
    - (BOOL)writeAll:(NSData*)data prefix:(NSString*)prefix error:(NSError* __autoreleasing*)error;
    - (NSData*)readAll;
    @end
    

    Из коробки прилагается примитивная реализация транспорта IFHTTPTransport, использующая NSURLConnection и протокол HTTP(S). Если имеются входные JSON-параметры, автоматически используется метод POST. В остальных случаях — GET. Статусы HTTP меньше 200 и больше 202 интерпретируются как ошибки, заворачиваются в NSError и передаются наверх. Если есть необходимость дополнительно отмодифицировать реквест или построить URL-параметры по-своему, можно воспользоваться категорией IFHTTPTransport (Protected). В ней объявлены методы для наследников:
    - (NSMutableURLRequest*)prepareRequestWithURL:(NSURL*)url data:(NSData*)data;
    - (NSString*)buildRequestParamsString:(NSDictionary*)requestParams;
    - (BOOL)shouldBreakOnError:(NSError*)error;
    


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

    В итоге сборка всей RPC-подсистемы для клиента выглядит так:
    NSURL* googleURL = [NSURL URLWithString:@"https://maps.googleapis.com/maps/api"];
    id<IFTransport> transport = [[IFHTTPTransport alloc] initWithURL:googleURL];
    self.google = [[google alloc] initWithTransport:transport];
    


    В чём подвох?

    Как всегда, сроки поджимали, поэтому пришлось срезать пару углов. На чём сэкономили: во-первых, код генерируется только для ARC, извините. Во-вторых, внутри для простоты всё равно используется NSJSONSerialization, и имеется некоторое избыточное перепаковывание данных в промежуточный словарь. Память тоже не экономится, поэтому для больших наборов данных ifacegen подходит плохо. Код изначально не планировалось открывать, поэтому python используется в режиме продвинутого шелла, без особенных изысков.

    Что касается ограничений системы типов: нет возможности задать префикс для всех типов, а также нет опережающих объявлений, невозможно рекурсивное определение типа. Атомарных типов маловато. Нет различных плюшек типа дат, перечислений и прочего. Система импорта типов из других объявлений слабовата и не рассчитана на хитрые трюки типа циклов или ромбов, в общем, легко поведётся на любой обман.

    Из косяков: тип параметров в prerequest принимается только "string" в надежде, что автор ничего не напутает. Ну и как всегда, сообщения об ошибках не блещут конкретностью. Если есть ошибка в синтаксисе IDL, скорее всего парсер вывалится со стек-трейсом.

    Где брать?

    На Bitbucket. Там же кратенько инструкция по IDL. Пример прилагается.
    $ git clone https://bitbucket.org/ifreefree/ifacegen.git
    
    • +17
    • 11.6k
    • 5
    i-Free Group
    55.55
    Компания
    Share post

    Comments 5

      0
      Звучит интересно, надо попробовать :)
      Такой вопрос интересует: если статусы ошибок реализованы не через статусы HTTP, а через специальное поле в json-ответе, как такие ошибки можно обрабатывать? Я так понимаю, в сгенерированный код методов лучше не лезть (имена переменных вроде outputResult237Address_components238 как бы намекают нам на это)?
        0
        Предположим, что в случае успеха сервер возвращает json-структуру по ключу result, а в случае внутренней, семантической ошибки — error. В таком случае имеет смысл описать в IDL результат работы удалённого метода так:
        ...
        "response": {
            "result": { ... },
            "error": { ... }
        }
        

        После завершения вызова сгенерированный код извлечёт из ответа те структуры, которые там есть, и не проинициализирует те, данные для которых не пришли. Они останутся nil. То есть после вызова достаточно будет убедиться, что структура error не nil, чтобы получить информацию об ошибке. Кстати, этот момент описан в примере, прилагающемся к коду в репозитории.
          0
          Это в общем-то понятно, хотелось бы, чтобы метод placeDetailsWithKey записывал таккую ошибку в (NSError* )error.
            0
            Понял. Ну, с одной стороны, логично, конечно, унифицировать обработку ошибок. С другой — не очень прозрачно получится через NSError пропихивать конкретные типы, плохо он для этого приспособлен. Но мысль интересная, спасибо, как появится время, поэкспериментирую.
        +6
        Бедный ёжинька на картинке :(

        Only users with full accounts can post comments. Log in, please.