В данной статье описывается стандарт JSON Schema и его использование для проверки соответствия заданному формату на языке C++ средствами библиотеки valijson.

Немного истории

Для начала вспомним, что привело к повсеместному вытеснению JSON-ом XML-а и что в этом было плохого. XML изначально создавался как метаязык разметки документов, позволяя использовать унифицированный код парсера и валидатора документов. Будучи первым стандартом такого рода, да еще и пришедшимся на период бурного внедрения цифровых корпоративных информационных систем, XML послужил основой для бесчисленного множества стандартов сериализации данных и протоколов взаимодействия, т.е. хранения и передачи структурированных данных. Тогда как создавался он прежде всего для разметки документов.

Будучи разрабатываемым комитетами, стандарт XML оказался дополнен множеством расширений, позволяющих, в частности, избегать конфликтов имен и выполнять сложные запросы в XML-документах. И, самое важное, поскольку получающееся нагромождение тэгов оказывалось совершенно нечитаемым никаким человеком, был разработан и широко реализован стандарт XML Schema, позволяющий на том же XML абсолютно строго описать допустимое содержимое каждого документа с целью последующей автоматической проверки.

Тем временем, все больше разработчиков под влиянием зарождающихся интерактивных web-технологий стало знакомиться с языком JavaScript, и они начали осознавать, что для представления структурированных объектов в текстовом виде совершенно не обязательно изучать много сотен страниц XML-спецификаций. И когда Дуглас Крокфорд предложил стандартизовать подмножество JavaScript для сериализации объектов (но не разметки документов!) безотносительно к языку, идея была поддержана сообществом. В настоящее время JSON является одним из двух (вместе с XML) языков, поддерживаемых всеми сколько-либо популярными технологиями программирования. Тот же YAML, призванный сделать JSON более удобным и человекочитаемым, ввиду своей сложности (т.е. широты возможностей) распространен не так широко (в моей компании не так давно были проблемы с работой с YAML из MATLAB, тогда как с JSON все хорошо).

Так вот, массово начав использовать JSON для представления данных, разработчики столкнулись с необходимостью вручную проверять содержимое документов, каждый раз на каждом языке переизобретая логику валидации. Людей, знакомых с XML Schema, это не могло не бесить. И постепенно аналогичный стандарт JSON Schema таки сформировался и живет по адресу http://json-schema.org/.

JSON Schema

Рассмотрим пример простой, но показательной, схемы, задающей словарь 2D или 3D геометрических точек в пространстве (-1, 1)x(-1, 1)x(-1, 1) с ключами, состоящими из цифр:

{
    "type": "object",
    "patternProperties": {
        "^[0-9]+$": {
            "type": "object",
            "properties": {
                "value": {
                    "type": "number",
                    "minimum": 0
                }
                "x": { "$ref": "#/definitions/point_coord" },
                "y": { "$ref": "#/definitions/point_coord" },
                "z": { "$ref": "#/definitions/point_coord" }
            },
            "required": ["value", "x", "y"]
        }
    }
    "additionalProperties": false,
    "definitions": {
        "point_coord": {
            "type": "number",
            "minimum": -1,
            "maximum": 1
        }
    }
}

Если простить Крокфорду надоедливые кавычки, из данного докуменда должно быть ясно, что мы согласны иметь дело с объектом (словарем), ключи которого должны состоять из цифр (см регулярное выражение), значения которого обязаны иметь поля x, y, value, и могут иметь поле z, причем value — неотрицательное число, а x, y, z все имеют некий одинаковый тип point_coord, соответствующий числу от -1 до +1. Даже если предположить, что других возможностей JSON Schema не предоставляет (что далеко от истины), этого должно хватить для многих сценариев использования.

Но это в том случае, если для вашего языка/платформы реализован валидатор. В случае с XML такой вопрос вряд ли мог бы встать.

На http://json-schema.org/ сайте вы можете найти список ПО для валидации. И вот в этом месте незрелость JSON-Schema (и ее сайта) дает о себе знать. Для C++ указана одна (вроде бы интересная) библиотека libvariant, которая занимается валидацией лишь по совместительству и к тому же выпущена под зловредной лицензией LGPL (прощай, iOS). Для C у нас тоже один вариант, и тоже под LGPL.

Тем не менее, приемлемое решение существует и называется valijson. У этой библиотеки есть все что нам нужно (валидация схем и BSD-лицензия), и даже больше, — независимость от JSON-парсера. Valijson позволяет использовать любой json-парсер посредством адаптера (в комплекте адаптеры для jsoncpp, json11, rapidjson, picojson и boost::property_tree), таким образом не требуя переходить на новую json-библиотеку (или тащить за собой еще одну). Плюс ко всему, она состоит только из заголовочных файлов (header only) и не требует компиляции. Очевидный минус только один, и то не для всех, — зависимость от boost. Хотя есть надежда на избавление даже от этого недо-недостатка.

Разберем на примере документа составление JSON-схемы и валидацию этого документа.

Пример составления схемы

Допустим, у нас есть таблица неких полосатых объектов, для которых задана конкретная полосатая раскраска (в виде последовательности 0 и 1, соответствующих черному и белому).

{
    "0inv": {
        "width": 0.11,
        "stripe_length": 0.15,
        "code": "101101101110"
    },
    "0": {
        "width": 0.05,
        "stripe_length": 0.11,
        "code": "010010010001"
    },
    "3": {
        "width": 0.05,
        "stripe_length": 0.11,
        "code": "010010110001"
    },
    ...
}

Здесь мы имеем словарь с числовыми ключами, к которым может быть приписан суффикс «inv» (для инвертированных штрих-кодов). Все значения в словаре являются объектами и обязаны иметь поля «width», «stripe_length» (строго положительные числа) и «code» (строка нулей и единиц длины 12).

Начнем составлять схему, указав ограничения на формат имен полей верхнего уровня:

{
    "comment": "Schema for the striped object specification file",
    "type": "object",
    "patternProperties": {
        "^[0-9]+(inv)?$": { }
    },
    "additionalProperties": false
}

Здесь мы воспользовались конструктом patternProperties, разрешающим/специфицирующим значения, ключи которых удовлетворяют регулярному выражению. Также мы указали (additionalProperties=false), что неспецифицированные ключи запрещены. Используя additionalProperties, можно не только разрешить или запретить неуказанные явно поля, но и наложить ограничения на их значения, указав в качестве значения спецификатор типа, например, так:

{
    "additionalProperties": {
        "type": "string",
        "pattern": "^Comment: .*$"
    }
}

Далее опишем тип значения каждого объекта в словаре:

{
    "type": "object",
    "properties": {
        "width": {
            "type": "number",
            "minimum": 0,
            "exclusiveMinimum": true
        },
        "stripe_length": {
            "type": "number",
            "minimum": 0,
            "exclusiveMinimum": true
        },
        "code": {
            "type": "string",
            "pattern": "^[01]{12}$"
        }
    },
    "required": ["width", "stripe_length", "code"]
}

Здесь мы явно перечисляем разрешенные поля (properties), требуя их наличие (required), не запрещая (по умолчанию) любые дополнительные свойства. Числовые свойства у нас строго положительные, а строка code должна соответствовать регулярному выражению.

В принципе осталось только вставить описание типа отдельного объекта в вышеописанную схему таблицы. Но прежде чем это сделать, отметим, что у нас дублируется спецификация полей «width» и «stripe_length». В реальном коде, из которого взят пример, таких полей еще больше, поэтому полезно было бы один раз определить данный тип, а потомы ссылаться на него отосвюду. Именно для этого есть механизм ссылок ($ref). Обратите внимание на секцию definitions в итоговой схеме:

{
    "comment": "Schema for the striped object specification file",
    "type": "object",
    "patternProperties": {
        "^[0-9]+(inv)?$": {
            "type": "object",
            "properties": {
                "width": { "$ref": "#/definitions/positive_number" },
                "stripe_length": { "$ref": "#/definitions/positive_number" },
                "code": {
                    "type": "string",
                    "pattern": "^[01]{12}$"
                }
            },
            "required": ["width", "stripe_length", "code"]
        }
    },
    "additionalProperties": false,
    "definitions": {
        "positive_number": {
            "type": "number",
            "minimum": 0,
            "exclusiveMinimum": true
        }
    }
}

Сохраним ее в файл и приступим к написанию валидатора.

Применение valijson

В качестве json-парсера используем jsoncpp. Имеем обычную функцию загрузки json-документа из файла:

#include <json-cpp/json.h>

Json::Value load_document(std::string const& filename)
{
  Json::Value root;
  Json::Reader reader;
  std::ifstream ifs(filename, std::ifstream::binary);
  if (!reader.parse(ifs, root, false))
    throw std::runtime_error("Unable to parse " + filename + ": "
                             + reader.getFormatedErrorMessages());
  return root;
}

Минимальная функция-валидатор, сообщающая нам о расположении всех ошибок валидации, выглядит примерно так:

#include <valijson/adapters/jsoncpp_adapter.hpp>
#include <valijson/schema.hpp>
#include <valijson/schema_parser.hpp>
#include <valijson/validation_results.hpp>
#include <valijson/validator.hpp>

void validate_json(Json::Value const& root, Json::Value const& schema_js)
{
  using valijson::Schema;
  using valijson::SchemaParser;
  using valijson::Validator;
  using valijson::ValidationResults;
  using valijson::adapters::JsonCppAdapter;

  JsonCppAdapter doc(root);
  JsonCppAdapter schema_doc(schema_js);

  SchemaParser parser(SchemaParser::kDraft4);
  Schema schema;
  parser.populateSchema(schema_doc, schema);
  Validator validator(schema);
  validator.setStrict(false);
  ValidationResults results;
  if (!validator.validate(doc, &results))
  {
    std::stringstream err_oss;
    err_oss << "Validation failed." << std::endl;
    ValidationResults::Error error;
    int error_num = 1;
    while (results.popError(error))
    {
      std::string context;
      std::vector<std::string>::iterator itr = error.context.begin();
      for (; itr != error.context.end(); itr++)
        context += *itr;

      err_oss << "Error #" << error_num << std::endl
              << "  context: " << context << std::endl
              << "  desc:    " << error.description << std::endl;
      ++error_num;
    }
    throw std::runtime_error(err_oss.str());
  }
}

Обратим внимание, что в данном примере jsoncpp подключается как #include <json-cpp/json.h>, тогда как valijson/adapters/jsoncpp_adapter.hpp в текущей версии valijson предполагает, что jsoncpp подключается как #include <json/json.h>. Так что не удивляйтесь, если компилятор не найдет json/json.h, и просто поправьте valijson/adapters/jsoncpp_adapter.hpp.

Теперь мы можем загружать и валидировать документы:

Json::Value const doc = load_document("/path/to/document.json");
Json::Value const schema = load_document("/path/to/schema.json");
try
{
    validate_json(doc, schema);
    ...
    return 0;
}
catch (std::exception const& e)
{
    std::cerr << "Exception: " << e.what() << std::endl;
    return 1;
}

Все, мы научились валидировать json-документы. Но обратим внимание, что теперь нам придется думать, где хранить схемы! Ведь если документ каждый раз меняется и получается, например, из web-запроса или из аргумента командной строки, то схема неизменна и должна поставляться вместе с приложением. А для небольших программ без развитого механизма загрузки статических ресурсов необходимость введения такового представляет значительный барьер для внедрения валидачии через схемы. Вот было бы здорово компилировать схему вместе с программой, ведь изменение схемы в любом случае потребует изменения кода, обрабатывающего документ.

Это возможно и даже довольно удобно, если в нашем распоряжении есть C++11. Решение примитивное, но работает прекрасно: мы просто определяем строковую константу с нашей схемой. А чтоб не заботиться о кавычках внутри строки, мы используем raw string literal:

// Схема как R"(raw string)"
static std::string const MY_SCHEMA =
R"({
    "comment": "Schema for pole json specification",
    "type": "object",
    "patternProperties": {
        "^[0-9]+(inv)?$": {
            ...
            ...
        }
    }
    ...
})";

// Загрузка json из строки
Json::Value json_from_string(std::string const& str);
{
  Json::Reader reader;
  std::stringstream schema_stream(str);
  Json::Value doc;
  if (!reader.parse(schema_stream, doc, false))
    throw std::runtime_error("Unable to parse the embedded schema: "
                             + reader.getFormatedErrorMessages());
  return doc;
}

// Собственно валидация документа doc (validate_json определена выше)
validate_json(doc, json_from_string(MY_SCHEMA));

Таким образом, мы имеем удобный кроссплатформенный кросс-языковой механизм валидации json-документов, использование которого в C++ не требует ни линковки внешних библиотек с неудобными лицензиями, ни возни с путями к статическим ресурсам. Эта вещь может сэкономить действительно много сил, и, что немаловажно, помочь окончательно убить XML как формат представления объектов, ибо он неудобен ни для людей, ни для машин.

Комментарии (4)


  1. guyfawkes
    01.02.2016 14:02

    Пользуясь случаем, хочу спросить: есть ли рабочий генератор, который из json-схемы сможет сгенерировать файлик для swagger? По сути для документирования API и для валидации его ответа приходится писать практически дублирующийся код, что часто не очень удобно.


    1. ansgri
      01.02.2016 14:25

      Я не знаком со swagger, но, пролистав документацию swagger.io/specification, создается впечатление, что он и так использует json schema v4 (с небольшими расширениями). Подозреваю, ваш случай решается страницей кода на питоне. Хотя, опять же, с темой не знаком.


      1. guyfawkes
        01.02.2016 15:33

        Да, но так хочется писать код, а не тулзы для него :)


  1. pavelodintsov
    02.02.2016 11:31

    Хорошую тему вы подняли! В целом работа с JSON в C++ сродни кошмару. Самое удобное, что я нашел для себя, это Mongo BSON C++ 11 драйвер (несмотря на то, что он про BSON, c JSON работает идеально), который дает вот такой интерфейс для разбора JSON, github.com/mongodb/mongo-cxx-driver/blob/master/examples/bsoncxx/getting_values.cpp и вот такой для его генерации — github.com/mongodb/mongo-cxx-driver/blob/master/examples/bsoncxx/builder_basic.cpp

    На мой взгляд из того, что я видел — это наиболее удобный и «родной» подход для С++ 11. Но задачу автоматической сериализации класса в JSON и де-сериализации оно не решает.

    В итоге делаю не особо хорошо, есть yaml описание, в котором со своей структурой типов а-ля protobuf описана структура данных.

    Из нее генерируется описание класса и сериализатор/десериализатор на базе Mongo BSON CXX 11. Все это дело написано на Perl (в целом-то без разницы на чем его делать, логика довольно простая) и на выходе получаются огромные полотна по несколько тысяч строк вызовов bson структур с проверкой. Внутри скрипта генератора также реализован маппинг между типами Mongo BSON CXX (k_utf8) и типами С++ (std::string и прочие соотвественно).

    Например, чтение целочисленного поля выглядит вот так (тут еще добавлен опциональный валидатор на основе данных, которые используются в моем случае, он тоже автоматически генерируется):

        // Process option snmp_oid   
        auto config_snmp_oid = doc_view["snmp_oid"];
    
        if (config_snmp_oid.type() != bsoncxx::type::k_utf8) {
            logger << log4cpp::Priority::ERROR << "Could not read key snmp_oid from MongoDB because it has incorrect type";
            return false;
        }
    
        uplink_configuration.snmp_oid = config_snmp_oid.get_utf8().value.to_string();
        if (!validate_string(uplink_configuration.snmp_oid)) {
            logger << log4cpp::Priority::ERROR << "Validation of variable snmp_oid failed, please check it format.";
            return false;
        }
    


    Я бы не сказал, что данный подход мне нравится. В идеале хотелось бы что-то в стиле Go, когда прямо напротив переменной-класса описывается ее JSON имя и обозначается необходимости сериализации и сериализатор/десериализатор генерируется автоматически.

    Но увы без макро-процессора такое не сделать…