понедельник, 21 марта 2016 г.

Стоит ли использовать json protobuf в проекте с Python?

Появилась тут вдруг необходимость связать 2 приложения на С++ и на Python между собой. После недолгих раздумий был выбран вполне очевидный вариант - HTTP(s) + json. Human readable, удобно тестировать и всё такое прочее. API было решено строить на базе REST (хоть и на не совсем идиоматичном), ибо он сейчас модный и хипстерский. Можно было бы сделать всё к примеру на SOAP, но боюсь потом спать не смогу. Ещё хотелось отметить, что сервером является приложение на С++, проекты находятся в разных репозиториях и есть вероятность, что в дальнейшем Python часть будет делать человек не знающий плюсы.


Осталось определиться с тем, как на одной стороне формировать json, а на другой - парсить его. Это касается даже не самой сериализации в json, ведь в конце концов, в Python, из коробки, работа с ним не представляет проблем, а для плюсов есть тот же JSONCpp. Тут скорее дело в том, как поддерживать оба приложения в синхронном состоянии. Что бы например проблему: изменили название поля на серверной части, а забыли на клиентской - легко и просто было диагностировать. Что бы после добавления нового метода в API - разработчику клиентской части не приходилось слишком уж сильно залезать в недра C++ кода, а в идеале вообще не нужно было. Что бы, когда(если) тестировщики на основе этого API будут писать автоматические тесты, им не нужно было дёргать разработчиков, что бы составить запрос и понять ответ. Надеюсь проблема ясна, а кому-то вероятно ещё и близка.

Вариантов решения проблемы рассматривалось два. Первый - с protobuf 3, который научился разговаривать на языке json, там можно описывать схему ответов и запросов в ".proto" файлах, вероятно есть какие-то средства валидации и вообще его ведь сделал сам Google, они то плохого не посоветуют. Второй можно кратко назвать - без protobuf. Я "поигрался" с обеими вариантами и расскажу, что из этого получилось.

Генерация ответа на стороне сервера (C++)

Добавление нового метода в API, обычно начинается с сервера, что ж, тоже пойдём этим путём.

Как это выглядит с protobuf:
  1. Он, зараза, устанавливается из репозитория минут 30, что они туда засунули? Ну, в принципе, это один раз - не страшно.
  2. В файла со схемами protobuf (у меня это types.proto) добавляем новый тип:
  3. syntax = "proto3";
    package types;
    message Person {
      uint32 id = 1 [json_name = "id"];
      string name = 2 [json_name = "name"];
    };
    
    Вполне, нужно отметить, читаемый формат.
  4. Компилируем его:
  5. protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/types.proto
    
  6. Наконец пишем код (я "обвязку" и прочие несущественные части буду опускать, они пишутся один раз и на понятность основной части не влияют):
  7. ...
    person->set_id(5);
    person->set_name("Ivan");
    ...
    

Теперь реализация "в лоб"
Просто генерация json с помощью JSONCpp:
...
person["id"] = 5
person["name"] = "Ivan"
...
На выходе и там и там получаем:
{
  "name": "Ivan",
  "id": 5
}
Хм, назвать версию с protobuf минималистичной конечно нельзя, посмотрим, что будет дальше.

Разбираем ответ на стороне клиента (Python)

Обычно на этом этапе нужно разобрать ответ сервера и после небольшой обработки положить его в базу, пример с базой я делать конечно не буду, но покажу, как распечатать ответ в stdout.

C protobuf:
  1. Синхронизируем types.proto из серверного в клиентский репозиторий. Я писал, что приложения в разных репозиториях и автоматически синхронизировать при разработке в множестве веток его не получится.
  2. Компилируем его:
  3. protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/types.proto
    
  4. Пишем код:
  5. ...
    person = json_format.Parse(json_str, types_pb2.Person())
    print("id =", person.id)
    print("name =", person.name)
    ...
    
Без protobuf:
...
person = json.loads(json_str)
print("id =", person["id"])
print("name =", person["name"])
...
Опять с 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)
И выясняем, что person по мнению pylint - не имеет ни поля "id", ни "name". Google в генерируемом коде использовал столько магии, что статические анализаторы её не понимают. Т.е. ошибки protobuf не только не выявляются линтерами, а наоборот - полностью рабочий код, будет выдавать ошибки при анализе. Отлично, будем продираться сквозь сотни ложных срабатываний. За это огромный минус.

Без protobuf:
Тут всё так же ищется только в runtime, только ошибки будут находиться не в месте парсинга json, а при непосредственно обращении например к несуществующему элементу, в случае переименования поля. И сообщение в этом случае будут менее информативными и прийдётся посмотреть в последнюю строчку stack trace, что бы понять контекст. А вот с изменением типа данных проблем почти никогда не будет, т.к. если нам прислали json, где id стал строковым, парсер создаст нам именно строковый элемент. Проблемы будут если мы не будем менять код, который работает с этим элементом как с числом, но и в варианте с protobuf эта проблема никуда не девается.

Этот вариант немного по удобству проигрывает предыдущему. Но если учесть, что в этом случае у нас не будет проблем с линтерами - то я даже не знаю, что лучше.

В любом случае давайте попытаемся улучшить ситуацию и получать человекочитаемые ошибки в момент парсинга сообщения. Есть такой замечательный инструмент, как json-schema. Это формат описания, который позволяет сказать какие поля должны быть в json, какого они типа, написать комментарий и т.п., а потом любой json проверить на соответствие этим правилам. На официальном сайте есть замечательный пример описывающий основы. Небезынтересно будет посмотреть список библиотек, для работы со json-scheme, там же редакторы, генераторы и т.д.

Приведу пример валидации моего сообщения с помощью python библиотеки jsonschema:
  1. Создаём файл со схемой types.schema:
  2. {
      "title": "Person Schema",
      "type": "object",
      "required": ["id", "name"],
      "properties": {
        "id": {
          "description": "Уникальный идентификатор клиента",
          "type": "integer"
        },
        "name": {
          "description": "Имя клиента",
          "type": "string"
        }
      }
    }
    
  3. В код чтения json добавляем одну строку с проверкой схемы:
  4. ...
    person = json.loads(json_str)
    jsonschema.validate(person, json.load(open("types.schema")))
    print("id =", person["id"])
    print("name =", person["name"])
    ...
    
Прогоним через те же примеры, что и protobuf, при переименовании поля "name" на "fullname" получаем ошибку:
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}
Если передать json, где тип id изменился на текстовый:
Error message: '5' is not of type 'integer'

Failed validating 'type' in schema['properties']['id']:
    {'description': 'Уникальный идентификатор клиента', 'type': 'integer'}

On instance['id']:
    '5'
На мой вкус ошибки читаются даже лучше чем в protobuf. Правда пришлось добавить выделенный файл с описанием сообщений, но его не нужно компилировать и в принципе не нужно копировать между репозиториями, достаточно оставить только на клиентской стороне. Плюс я далее покажу, как ещё его можно интересно использовать.

Использование API

Допустим мы дожили то того прекрасного момента, когда тестировщики решили написать десяток другой интересных тестов на мой API. Или хотя бы решили использовать мой API для автоматизации каких-то своих задач. Или может я передал часть проекта на python человеку, который не знает плюсов. Конечно они придут ко мне и спросят, а за какой URL нам подёргать, какие параметры туда передать, что бы получить какую-то функциональность и как собственно интерпретировать результат. Выберете правильный на ваш взгляд вариант моего ответа:
  1. Мы в продукте использовали замечательный protobuf, там описаны все форматы json, которые мы используем, они понятны любому, они лежат вот тут. А вот что бы связать URL с сообщением описанным в файле types.proto, посмотри в xxx.cpp там описаны регулярки URL-ей и к ним привязаны обработчики. Обработчики поищите либо в директории "aaa", либо в "bbb", они там не большие, в основном меньше одного экрана кода. И если вы писали на плюсах хотя бы год-два, то там вы найдёте какие события для данного URL входящие, какие исходящие, какие статусы может возвращать метод и какие параметры нужно передать через URL, какие заголовки установить и т.п.
  2. Брать и описывать каждый URL в документации со ссылками на файлы protobuf\json-schema в каком-нибудь текстовом файле и выкладывать в общий доступ.
  3. Использовать генераторы документации.
Не знаю какой вариант выбрали вы, но я чуть подробнее раскрою 3-й пункт, что бы было понятнее. Есть множество продуктов для генерации документации для REST API, например swagger. Пример того, как может выглядеть результат, можно посмотреть тут. Что интересно, прямо из кода документации можно заполнить необходимые поля, выполнить тестовый запрос и посмотреть ответ сервера. В качестве описания формата ответа используется несколько ограниченное подмножество json-schema, т.е. можно брать и напрямую вставлять в документацию схемы которыми валидируется json в коде. Плюс существует огромное количество разнообразных утилит так или иначе работающих со swagger и расширяющих его возможности. Вот, кстати, пример документации для запроса person:

Я не могу сказать, что хорошо изучил Swagger, но похоже это то, что нужно. В любом случае он далеко не единственный представитель семейства, если он по какой-то причине не подходит - есть другие, например часто упоминают raml. Последний я рекомендовать не могу, т.к. не успел на него ещё посмотреть. А вот protobuf такой интеграцией похвастаться не может, если вы хотите генерировать красивую документацию, то вам прийдётся в коде использовать protobuf, а для документации писать json-схемы с неизбежным расхождением между кодом и документацией.

Итоги

Номинация С protobuf Без protobuf
Простота написания кода сервера - +
Простота написания кода клиента - +
Понятные ошибки runtime + +
Дружественность к линтерам - +
Документирование API - +

Вероятно для компилируемых языков имеет смысл использовать protobuf-json, но для python он только мешает. Я просто не могу придумать причины по которым его нужно тащить в проект на python.

Комментариев нет:

Отправить комментарий