Появилась тут вдруг необходимость связать 2 приложения на С++ и на Python между собой. После недолгих раздумий был выбран вполне очевидный вариант - HTTP(s) + json. Human readable, удобно тестировать и всё такое прочее. API было решено строить на базе REST (хоть и на не совсем идиоматичном), ибо он сейчас модный и хипстерский. Можно было бы сделать всё к примеру на SOAP, но боюсь потом спать не смогу. Ещё хотелось отметить, что сервером является приложение на С++, проекты находятся в разных репозиториях и есть вероятность, что в дальнейшем Python часть будет делать человек не знающий плюсы.
Осталось определиться с тем, как на одной стороне формировать json, а на другой - парсить его. Это касается даже не самой сериализации в json, ведь в конце концов, в Python, из коробки, работа с ним не представляет проблем, а для плюсов есть тот же JSONCpp. Тут скорее дело в том, как поддерживать оба приложения в синхронном состоянии. Что бы например проблему: изменили название поля на серверной части, а забыли на клиентской - легко и просто было диагностировать. Что бы после добавления нового метода в API - разработчику клиентской части не приходилось слишком уж сильно залезать в недра C++ кода, а в идеале вообще не нужно было. Что бы, когда(если) тестировщики на основе этого API будут писать автоматические тесты, им не нужно было дёргать разработчиков, что бы составить запрос и понять ответ. Надеюсь проблема ясна, а кому-то вероятно ещё и близка.
Вариантов решения проблемы рассматривалось два. Первый - с protobuf 3, который научился разговаривать на языке json, там можно описывать схему ответов и запросов в ".proto" файлах, вероятно есть какие-то средства валидации и вообще его ведь сделал сам Google, они то плохого не посоветуют. Второй можно кратко назвать - без protobuf. Я "поигрался" с обеими вариантами и расскажу, что из этого получилось.
Осталось определиться с тем, как на одной стороне формировать json, а на другой - парсить его. Это касается даже не самой сериализации в json, ведь в конце концов, в Python, из коробки, работа с ним не представляет проблем, а для плюсов есть тот же JSONCpp. Тут скорее дело в том, как поддерживать оба приложения в синхронном состоянии. Что бы например проблему: изменили название поля на серверной части, а забыли на клиентской - легко и просто было диагностировать. Что бы после добавления нового метода в API - разработчику клиентской части не приходилось слишком уж сильно залезать в недра C++ кода, а в идеале вообще не нужно было. Что бы, когда(если) тестировщики на основе этого API будут писать автоматические тесты, им не нужно было дёргать разработчиков, что бы составить запрос и понять ответ. Надеюсь проблема ясна, а кому-то вероятно ещё и близка.
Вариантов решения проблемы рассматривалось два. Первый - с protobuf 3, который научился разговаривать на языке json, там можно описывать схему ответов и запросов в ".proto" файлах, вероятно есть какие-то средства валидации и вообще его ведь сделал сам Google, они то плохого не посоветуют. Второй можно кратко назвать - без protobuf. Я "поигрался" с обеими вариантами и расскажу, что из этого получилось.
Генерация ответа на стороне сервера (C++)
Добавление нового метода в API, обычно начинается с сервера, что ж, тоже пойдём этим путём.
Как это выглядит с protobuf:
Теперь реализация "в лоб"
Просто генерация json с помощью JSONCpp:
На выходе и там и там получаем:
Хм, назвать версию с protobuf минималистичной конечно нельзя, посмотрим, что будет дальше.
Как это выглядит с protobuf:
- Он, зараза, устанавливается из репозитория минут 30, что они туда засунули? Ну, в принципе, это один раз - не страшно.
- В файла со схемами protobuf (у меня это types.proto) добавляем новый тип:
- Компилируем его:
- Наконец пишем код (я "обвязку" и прочие несущественные части буду опускать, они пишутся один раз и на понятность основной части не влияют):
syntax = "proto3"; package types; message Person { uint32 id = 1 [json_name = "id"]; string name = 2 [json_name = "name"]; };
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/types.proto
...
person->set_id(5);
person->set_name("Ivan");
...
Теперь реализация "в лоб"
Просто генерация json с помощью JSONCpp:
... person["id"] = 5 person["name"] = "Ivan" ...
{ "name": "Ivan", "id": 5 }
Разбираем ответ на стороне клиента (Python)
Обычно на этом этапе нужно разобрать ответ сервера и после небольшой обработки положить его в базу, пример с базой я делать конечно не буду, но покажу, как распечатать ответ в stdout.
C protobuf:
Опять с protobuf несколько лишних шагов, которые можно забыть, сам код и там и там простой и понятный.
C protobuf:
- Синхронизируем types.proto из серверного в клиентский репозиторий. Я писал, что приложения в разных репозиториях и автоматически синхронизировать при разработке в множестве веток его не получится.
- Компилируем его:
- Пишем код:
protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/types.proto
... person = json_format.Parse(json_str, types_pb2.Person()) print("id =", person.id) print("name =", person.name) ...
... person = json.loads(json_str) print("id =", person["id"]) print("name =", person["name"]) ...
Форматы данных на сервере и клиенте "разъехались"
Рано или поздно это случится и кто-нибудь переименует поле на сервере и забудет это сделать на клиенте или сделает это не во всех местах. Или, что случается конечно гораздо реже, изменит тип поля. Посмотрим как решаются подобные проблемы.
C protobuf:
Если в серверной части изменить "name" на "fullname", то в runtime при парсинге сообщения мы получим исключение вот с такой ошибкой: "Message type "types.Person" has no field named fullname". А вот, если мы в Python коде почти везде поля переименовали, а где-то в редко используемом месте забыли - то всплывёт это только если мы при тестировании это место вызовем, ну тут всё как обычно - в тестах стремимся к 100% покрытию. Если поменять тип поля "id" на строковое, то опять же в runtime мы получим сообщение: "Failed to parse id field: invalid literal for int() with base 10: 'df5'", если данные из этого поля на клиенте не удалось привести к числу.
В целом неплохо, но не так хорошо, как если бы клиент был компилируемым, где ошибки были бы найдены до запуска. Но ведь в Python есть статические анализаторы кода, может они помогут?
Запускаем, например, pylint на коде, который я приводил выше:
И выясняем, что person по мнению pylint - не имеет ни поля "id", ни "name". Google в генерируемом коде использовал столько магии, что статические анализаторы её не понимают. Т.е. ошибки protobuf не только не выявляются линтерами, а наоборот - полностью рабочий код, будет выдавать ошибки при анализе. Отлично, будем продираться сквозь сотни ложных срабатываний. За это огромный минус.
Без protobuf:
Тут всё так же ищется только в runtime, только ошибки будут находиться не в месте парсинга json, а при непосредственно обращении например к несуществующему элементу, в случае переименования поля. И сообщение в этом случае будут менее информативными и прийдётся посмотреть в последнюю строчку stack trace, что бы понять контекст. А вот с изменением типа данных проблем почти никогда не будет, т.к. если нам прислали json, где id стал строковым, парсер создаст нам именно строковый элемент. Проблемы будут если мы не будем менять код, который работает с этим элементом как с числом, но и в варианте с protobuf эта проблема никуда не девается.
Этот вариант немного по удобству проигрывает предыдущему. Но если учесть, что в этом случае у нас не будет проблем с линтерами - то я даже не знаю, что лучше.
В любом случае давайте попытаемся улучшить ситуацию и получать человекочитаемые ошибки в момент парсинга сообщения. Есть такой замечательный инструмент, как json-schema. Это формат описания, который позволяет сказать какие поля должны быть в json, какого они типа, написать комментарий и т.п., а потом любой json проверить на соответствие этим правилам. На официальном сайте есть замечательный пример описывающий основы. Небезынтересно будет посмотреть список библиотек, для работы со json-scheme, там же редакторы, генераторы и т.д.
Приведу пример валидации моего сообщения с помощью python библиотеки jsonschema:
Если передать json, где тип id изменился на текстовый:
На мой вкус ошибки читаются даже лучше чем в protobuf. Правда пришлось добавить выделенный файл с описанием сообщений, но его не нужно компилировать и в принципе не нужно копировать между репозиториями, достаточно оставить только на клиентской стороне. Плюс я далее покажу, как ещё его можно интересно использовать.
C protobuf:
Если в серверной части изменить "name" на "fullname", то в runtime при парсинге сообщения мы получим исключение вот с такой ошибкой: "Message type "types.Person" has no field named fullname". А вот, если мы в Python коде почти везде поля переименовали, а где-то в редко используемом месте забыли - то всплывёт это только если мы при тестировании это место вызовем, ну тут всё как обычно - в тестах стремимся к 100% покрытию. Если поменять тип поля "id" на строковое, то опять же в runtime мы получим сообщение: "Failed to parse id field: invalid literal for int() with base 10: 'df5'", если данные из этого поля на клиенте не удалось привести к числу.
В целом неплохо, но не так хорошо, как если бы клиент был компилируемым, где ошибки были бы найдены до запуска. Но ведь в Python есть статические анализаторы кода, может они помогут?
Запускаем, например, pylint на коде, который я приводил выше:
person = json_format.Parse(json_str, types_pb2.Person()) print("id =", person.id) print("name =", person.name)
Без protobuf:
Тут всё так же ищется только в runtime, только ошибки будут находиться не в месте парсинга json, а при непосредственно обращении например к несуществующему элементу, в случае переименования поля. И сообщение в этом случае будут менее информативными и прийдётся посмотреть в последнюю строчку stack trace, что бы понять контекст. А вот с изменением типа данных проблем почти никогда не будет, т.к. если нам прислали json, где id стал строковым, парсер создаст нам именно строковый элемент. Проблемы будут если мы не будем менять код, который работает с этим элементом как с числом, но и в варианте с protobuf эта проблема никуда не девается.
Этот вариант немного по удобству проигрывает предыдущему. Но если учесть, что в этом случае у нас не будет проблем с линтерами - то я даже не знаю, что лучше.
В любом случае давайте попытаемся улучшить ситуацию и получать человекочитаемые ошибки в момент парсинга сообщения. Есть такой замечательный инструмент, как json-schema. Это формат описания, который позволяет сказать какие поля должны быть в json, какого они типа, написать комментарий и т.п., а потом любой json проверить на соответствие этим правилам. На официальном сайте есть замечательный пример описывающий основы. Небезынтересно будет посмотреть список библиотек, для работы со json-scheme, там же редакторы, генераторы и т.д.
Приведу пример валидации моего сообщения с помощью python библиотеки jsonschema:
- Создаём файл со схемой types.schema:
- В код чтения json добавляем одну строку с проверкой схемы:
{ "title": "Person Schema", "type": "object", "required": ["id", "name"], "properties": { "id": { "description": "Уникальный идентификатор клиента", "type": "integer" }, "name": { "description": "Имя клиента", "type": "string" } } }
... person = json.loads(json_str) jsonschema.validate(person, json.load(open("types.schema"))) print("id =", person["id"]) print("name =", person["name"]) ...
Error message: 'name' is a required property Failed validating 'required' in schema: {'properties': {'id': {'description': 'Уникальный идентификатор ' 'клиента', 'type': 'integer'}, 'name': {'description': 'Имя клиента', 'type': 'string'}}, 'required': ['id', 'name'], 'title': 'Person Schema', 'type': 'object'} On instance: {'fullname': 'Ivan', 'id': 5}
Error message: '5' is not of type 'integer' Failed validating 'type' in schema['properties']['id']: {'description': 'Уникальный идентификатор клиента', 'type': 'integer'} On instance['id']: '5'
Использование API
Допустим мы дожили то того прекрасного момента, когда тестировщики решили написать десяток другой интересных тестов на мой API. Или хотя бы решили использовать мой API для автоматизации каких-то своих задач. Или может я передал часть проекта на python человеку, который не знает плюсов. Конечно они придут ко мне и спросят, а за какой URL нам подёргать, какие параметры туда передать, что бы получить какую-то функциональность и как собственно интерпретировать результат. Выберете правильный на ваш взгляд вариант моего ответа:
Я не могу сказать, что хорошо изучил Swagger, но похоже это то, что нужно. В любом случае он далеко не единственный представитель семейства, если он по какой-то причине не подходит - есть другие, например часто упоминают raml. Последний я рекомендовать не могу, т.к. не успел на него ещё посмотреть. А вот protobuf такой интеграцией похвастаться не может, если вы хотите генерировать красивую документацию, то вам прийдётся в коде использовать protobuf, а для документации писать json-схемы с неизбежным расхождением между кодом и документацией.
- Мы в продукте использовали замечательный protobuf, там описаны все форматы json, которые мы используем, они понятны любому, они лежат вот тут. А вот что бы связать URL с сообщением описанным в файле types.proto, посмотри в xxx.cpp там описаны регулярки URL-ей и к ним привязаны обработчики. Обработчики поищите либо в директории "aaa", либо в "bbb", они там не большие, в основном меньше одного экрана кода. И если вы писали на плюсах хотя бы год-два, то там вы найдёте какие события для данного URL входящие, какие исходящие, какие статусы может возвращать метод и какие параметры нужно передать через URL, какие заголовки установить и т.п.
- Брать и описывать каждый URL в документации со ссылками на файлы protobuf\json-schema в каком-нибудь текстовом файле и выкладывать в общий доступ.
- Использовать генераторы документации.
Я не могу сказать, что хорошо изучил Swagger, но похоже это то, что нужно. В любом случае он далеко не единственный представитель семейства, если он по какой-то причине не подходит - есть другие, например часто упоминают raml. Последний я рекомендовать не могу, т.к. не успел на него ещё посмотреть. А вот protobuf такой интеграцией похвастаться не может, если вы хотите генерировать красивую документацию, то вам прийдётся в коде использовать protobuf, а для документации писать json-схемы с неизбежным расхождением между кодом и документацией.
Итоги
Номинация | С protobuf | Без protobuf |
---|---|---|
Простота написания кода сервера | - | + |
Простота написания кода клиента | - | + |
Понятные ошибки runtime | + | + |
Дружественность к линтерам | - | + |
Документирование API | - | + |
Вероятно для компилируемых языков имеет смысл использовать protobuf-json, но для python он только мешает. Я просто не могу придумать причины по которым его нужно тащить в проект на python.