[Из песочницы] JSON Schema и ее использование для валидация JSON-документов в C++

В данной статье описывается стандарт 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::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 
#include 
#include 
#include 
#include 

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::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 , тогда как valijson/adapters/jsoncpp_adapter.hpp в текущей версии valijson предполагает, что jsoncpp подключается как #include . Так что не удивляйтесь, если компилятор не найдет 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 как формат представления объектов, ибо он неудобен ни для людей, ни для машин.

© Habrahabr.ru