четверг, 3 мая 2007 г.

JSON validation: the Schema By Example.

[lang_en]Есть по-русски
There are a lot of JSON data currently around, and virtually no standard means to validate it against a schema, the way everybody accustomed to in the XML world.

A fair number of proposals for a schema language occurred recently, and I'm going to add my 5 cents. The idea is that the schema should be expressed as JSON (no need to invent languages), and represent an example of a valid data structure of the data to be validated against. I call this "Schema By Example".[/lang_en]
[lang_ru]English version available
Формат JSON стал очень популярен в сфере обмена данными, но до сих пор пока не существует стандартных механизмов валидации, таких как схема, к каким люди уже давно привыкли, если имеют дело с XML.

Немало хороших предложений языка схемы для валидации JSON возникло в последнее время, и я хочу добавить к ним еще кое-что от себя. Моя идея в том, что схема должна быть описана в том же формате JSON (нет нужды изобретать новые языки), и представлять собой пример правильной структуры данных. По этому примеру и следует проверять действительность полученных данных. Назовем это "Схема по примеру".[/lang_ru]


[lang_en]

The Schema By Example


Well, let's start with an example.

A Flickr API method can be invoked to return JSON data. Say, flickr.people.findByUsername may return a JSON-formatted output on successful call:[/lang_en]

[lang_ru]

Схема по примеру


Начнем с ... хм ... примера.

Метод Flickr API может вернуть данные в формате JSON. Например, метод flickr.people.findByUsername при успешном выполнении может вернуть нам такую строку:[/lang_ru]

{"user":{"id":"99926721@N00", "nsid":"99926721@N00",
"username":{"_content":"mderk"}}, "stat":"ok"}


[lang_en]The string above has a lot of obvious information about the structure of possible JSON reply:
  1. The structure is an object, with two attributes — user and stat;

  2. The user attribute is an object, with three attributes — id, nsid and username;

  3. The id and nsid are strings, and username is an object, with one attribute — _content, which is a string;

  4. The stat attribute is a string


I assume, any successful output of flickr.people.findByUsername would be of the structure described above. If we know how to parse that string (any JSON parser would do), traverse objects in the parsed result and check the types of values in them, any valid reply can act as a schema.

That's simple. If you want a schema to validate data against, simply describe the valid example. So, the schema can be defined as the very string that is specified above, or as an annotated string such as:[/lang_en]
[lang_ru]Эта строка содержит довольно много очевидной информации о том, как должен выглядеть любой "правильный" JSON-ответ данного метода:
  1. Структура данных — объект, c двумя атрибутами — user и stat;

  2. Атрибут user — объект, с тремя атрибутами — id, nsid и username;

  3. Атрибуты id и nsid — строки, а username — объект, с одним атрибутом — _content, тип которого — строка;

  4. Атрибут stat — строка


Предположим, что любой успешный результат вызова flickr.people.findByUsername вернет нам структуру, описанную выше. Если мы сможем отпарсить JSON-строку (с чем справится любой парсер JSON), пройтись по полученной структуре данных и проверить типы значений в ней, то мы сможем использовать это значение результата вызова метода как схему для валидации других данных из того же источника.

Еще проще. Если вам нужна схема, по которой вы хотите проверять данные на действительность, просто опишите пример правильных данных. Схема может быть определена как та самая строка выше, либо как строка той же структуры, с добавлением описания данных, например:[/lang_ru]

{"user":
{"id":"the user id (string)", "nsid":"a string",
"username":{"_content":"username content"}},
"stat":"status code (should be ok)"}


[lang_en]The more formal description of the schema language:
  • Strings can be defined a string literals ("anything").

  • Numeric values can be defined as number literals (3.14).

  • Boolean values can be defined as boolean literals (true or false).

  • Null values are null literals.

  • Objects are object literals ({}). That literal may have attributes defined ({"foo":1}), in which case the attributes specified are required, and no attributes other than specified are allowed.

  • Arrays are (surprise!) array literals ([]). The array literal can optionally define allowed types of the values in array, e.g. ["string"] or ["string", 1]. If so, no other value types are allowed in array.


For convenience, several types can be described as strings, with "?" appended to indicate that the value can be undefined or null:
  • "number" or "number?" for numeric values, the latter is to indicate that the value can be skipped;

  • "string?" for string values that can be skipped;

  • "bool" or "bool?" for booleans, required or not


In my current implementation, there is no rule to indicate objects or arrays as optional, e.g. "object?". The problem is how we could describe an object structure in this case? The general rule is if we define an object, we should define its attributes as well. I thought about "recursive" JSON descriptions, e.g.
{"foo":"json:{\"type\": \"object\", \"required\": false, \"definition\":{\"bar\":1}}"},
but it makes life ugly, and, apart from that, who may know how deep one may dig into this recursion? The simpler solution would be to validate data against two different schemas both, one of which defined the object in question, and other — did not. If only one of them failed, then the data is valid.

Well, to examples.
The following schemas are equivalent:[/lang_en]
[lang_ru]Формальное описание языка схемы будет такое:
  • Строки могут быть определены как строковые константы ("что угодно").

  • Числовые значения определены как числовые константы (3.14).

  • Булевы значения определены как булевы константы (true или false).

  • Значения null — как константа null.

  • Объекты — объектные константы ({}). Могут определять атрибуты ({"foo":1}). В этом случае определенные атрибуты обязательны, и никакие другие атрибуты кроме определенных присутствовать не могут.

  • Массивы (сюрприз!) также определены как принято в JSON ([]). Опционально можно определить допустимые типы значений в массиве, например как ["string"] или ["string", 1]. Если это сделано, никакие другие типы данных в массиве недопустимы.


Для удобства, некоторые типы могут быть определены в строках, и знак "?" может быть поставлен в конце определения, что будет указывать на то, что значение может быть не определено или равно null:
  • "number" или "number?" — числовое значение, во втором случае значение может быть null или пропущено;

  • "string?" для строк, которые могут быть пропущены;

  • "bool" или "bool?" для булевых значений, обязательных или нет


В текущей реализации не предусмотрено возможности для значений-объектов или массивов указать на то, что они могут быть опциональны, например "object?". Проблема в том, что мне пока неясно, как малой кровью при этом позволять описывать возможную структуру таких значений. Обычно, если мы определяем объект, то нам нужно также определить его атрибуты. Я думал над вариантом "рекурсивного" JSON, например такого:
{"foo":"json:{\"type\": \"object\", \"required\": false, \"definition\":{\"bar\":1}}"}.
Однако это очень неудобно, к томуже кто знает как глубоко в рекурсию захочется залезть? Гораздо проще предоставить две схемы, с определенным объектом и без него, и проверить данные по обеим схемам. Если хотя бы одна проверка пройдет успешно, то данные — действительны.

Примеры:
Следующие схемы эквивалентны:[/lang_ru]
  • {"a":"there can be a string"}, {"a": "string"}, {"a": ""}

  • {"b":4563}, {"b": 0}, {"b": "number"}


[lang_en]The schemas ["string", "number"], ["anything", 1]
is valid for [], [3], [""], ["something", 4, "foo"]
and invalid for {}, 1, "", [true], [1, false]

The schema {"one": 1, "two": {"three":"string?"}}
is valid for {"one":0, "two":{}}, {"one":0, "two":{"three":"something"}}, {"one":2, "two":{"three":""}}
and invalid for 1, "", [], {}, {"one":0}, {"one":0, "two":{"three":1}}, {"one":0, "two":{}, "foo":"bar"} (extra attribute "foo" in the latter case)

What is not validated


Only the type of each of the data elements and the objects' structure is validated, not the actual value. E.g., in the Flickr example above, the value of stat attribute should be "ok", but it's not guaranteed to be so by the validator. The validator checks for the fact that the value is a string only, nothing more. The validation would fail for integer or boolean value, but would pass for "maybe" in stat.

The problem here is similar to the "optional objects" problem above — the additional language should be defined, e.g. "string;regex:^ok$", or a JSON equivalent. The simple and elegant decision doesn't come to my mind yet, so I'll leave it for tomorrow.

Try it!


Input your schema and data (or copy from the examples above), and press Validate. Play with values. Look what happens.

Schema:

Data:




The code


I've implemented the validator in JavaScript and Python. The code is available under BSD license at Google Code.[/lang_en]
[lang_ru]Схемам ["string", "number"], ["anything", 1]
соответствуют следующие структуры: [], [3], [""], ["something", 4, "foo"];
и не соответствуют {}, 1, "", [true], [1, false].

Схеме {"one": 1, "two": {"three":"string?"}}
соответствуют {"one":0, "two":{}}, {"one":0, "two":{"three":"something"}}, {"one":2, "two":{"three":""}};
не соответствуют 1, "", [], {}, {"one":0}, {"one":0, "two":{"three":1}}, {"one":0, "two":{}, "foo":"bar"} (неопределенный атрибут "foo" в последнем случае).

Что не проверяет валидатор


Валидатор проверяет только соответствие типов значений и структуру объектов, но не сами значения. Например, в результате вызова метода Flickr выше, значение атрибута stat должно быть "ok", но валидатор не сможет это проверить. Валидатор лишь проверяет, что это значение — строка, и ничего больше. Проверка выдаст ошибку если значение — булевого типа, но не отреагирует, если stat будет равен, скажем, "maybe".

Проблема тут опять же в степени усложнения схемы, как и в случае с "необязательными объектами", описанном выше. Для того, чтобы проверять значения, нужен более сложный язык схемы, и должен быть определен дополнительный синтаксис, например, "string;regex:^ok$", или нечто подобное в формате JSON. Простое и элегантное решение в голову пока не пришло, так что оставлю это на потом.

Рабочий пример


Введите свои значения схемы и данных (или скопируйте из примеров выше), и нажмите "Проверить". Поиграйте со значениями. Посмотрите что происходит. Если найдутся ошибки, дайте знать.

Схема:

Данные:




Код


Я сделал реализации валидатора для JavaScript и Python. Код доступен под лицензией BSD на Google Code.[/lang_ru]

11 комментариев:

  1. Мое глубокое убеждение состоит в том, что у автоматической валидации те же проблемы, что и у статической типизации в языках: она проверяет очень далеко *не все*. А значит гарантией корректности служить не может. Именно поэтому ее польза пропадает сразу же, как только появляется способ проверки данных, который отлавливает больше ошибок. Этот способ -- тесты (разных вкусов). Они проверяют не только "больше", но можно даже сказать, что проверяют практически все.

    И именно поэтому польза что проверок типов в статически типизированных языках, что проверки XML по схемам или DTD со временем сходит на нет. Вот :-)

    ОтветитьУдалить
  2. Так ведь третий пункт полностью покрывает второй (это зависит от того, как писать, но факт в том, что можно писать именно так). Если я ожидаю целое число, которое будет больше нуля, то я сделаю тест:

    assert int(a) > 0

    Он свалится в любом случае, будь там целое меньше нуля или же, скажем, строка. То есть тестом можно покрыть и типовую корректность тоже, и надобность в отдельном шаге отпадает.

    Это тот же принцип, который в основе duck typing: если объект по факту *ведет* себя правильно, то нам абсолютно неважно, как *называется* его тип данных.

    ОтветитьУдалить
  3. Я тебя понял. Только не понял, ты предлагаешь ставить assert-ы в момент использования данных, или все прокрутить перед использованием?

    Чем валидатор по схеме отличается от одного из таких тестов кроме того, что это -- тест, который отлавливает универсальные ситуации, прежде чем перейти к специальным? Если после этого теста вылетел неожиданный эксепшн, то фиксим тест, т.е. валидатор, потом пригодится. Если ожидаемый, то тест сработал. Если всё тихо, то тебе остается проверить только специфические ошибки в данных, и ожидать именно то что ты хочешь ожидать.
    В итоге само тестирование нехило рефакторится. Я к идее схемы я пришел как раз потому что есть тут одна такая проверка, уж очень кучерявая. Сами проверки данных бывают не очень простыми. Каждая дополнительная проверка и эксепшн добавляет логику, которую неплохо было бы вынести, что валидатор и делает.
    Другое дело, если валидатор уж очень тяжел для задачи. Ну это уже вопрос оптимизации конкретного кода, а не общих случаев.

    ОтветитьУдалить
  4. Макс, а что делать с функциями? Пример:

    {foo: decodeURIComponent('...')}

    Иногда, в силу специфики, JSON возвращается как часть некого XML-кода, а не в чистом виде. Приходится енокодить и оборачивать потенциально опасные строки decodeURIComponent.

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

    ОтветитьУдалить
  5. Предложенная вами схема удобна при работе с относительно низкоуровневыми языками, вроде C++ и PHP. Для того же Python'а совершенно не к чему плодить различные сущности -- стек разношёрстных проверок.

    Всё можно быстро и красиво описать средствами языка, как например в www.formencode.org или формах Django.

    Про "крякает как утка" Иван Сагалаев в данном случае абсолютно прав. Мне всё равно в каком виде пришли данные: False, "false", 0 или "". Главное -- чтобы программа смогла разобраться "хто енто", а программист обработать.

    ОтветитьУдалить
  6. 2 Bond.
    Да, ты правильно сам себе ответил. Твой пример - невалидный JSON. В JSON не может быть ничего кроме чисел, строк, маппингов, массивов, булевых и null. Ни один нормальный парсер такое не пропустит, т.к. это было бы нарушением безопасности.

    ОтветитьУдалить
  7. 2 Maximbo.
    У Ивана немного про другое, а именно про то, что достаточно написать тест, который проверит конечное значение без отдельной проверки структуры и типов.
    В вашем случае с False, "false" и т.п. хороший тест "крякнул" бы совершенно по-другому, т.к. в Python считать все это эквивалентами -- ошибка (хотя, с некоторой натяжкой это и прокатило бы в "низкоуровневом" PHP, т.к. он автоматически приводит тип в соответствие с контекстом).
    Цели данной схемы -- в том, чтобы абстрагировать несколько проверок, которые можно формалтизовать для любого случая, а именно - структуру сообщения. Проверки все равно могут получиться достаточно многоступенчатые, как бы ни хотелось этого избежать. В интерпретации Ивана эта сложность выражается в усложнении кода для обработки различных исключений. В моем случае вся эта обработка - в одном вызове для проверки структуры, после чего можно заняться данными.
    Быстро и красиво средствами языка -- не тот случай. JSON - язык обмена данными, так что придется быстро и красиво это делать на всех языках, участвующих в обмене. Валидация по схеме -- переносимый способ, в данный момент работает как в Python, так и в JavaScript, и не требует никакого специфичного для разных фреймворков кода.

    ОтветитьУдалить
  8. Для меня False и "false" -- одно и тоже. Язык должен подстраиваться под человека, а не человек под язык. Python подстраивается.

    У нас же Wild World Web, и запросы могут делать как наши клиентские javascript'ы, так и чужие скриптоботы и даже чьи-то шаловливые ручонки.

    Устойчивость к различным представлениям данных -- одно из преимуществ, которое уничтожается схемными проверками.

    Про Duck Typing: но оно же крякает и переваливается. Что рабочий (не просто декларированный) код, что разношёртность входящих представлений данных.

    Кря :)

    ОтветитьУдалить
  9. Возможно, для вас False и "false" -- одно и тоже, но это не так ни в случае Python, ни в JavaScript, ни в JSON. Python тут никуда не подстраивается. Да и в случае человеческого мозга эта равнозначность очень зависит от контекста. Если в каком-то контексте это -- одно и то же, то этот контекст -- за пределами данного обсуждения, т.к. для таких данных как минимум нужно написать свой парсер. И этот подход не будет работать в случае другого контекста -- когда захочется передать строку со значением "false", не имея в виду булево значение -- утка замяукает.
    Если вы пишите на Python, то вы будете блоки кода выделять одинаковым количеством пробельных символов, а не смесью tab/space разной длины или фигурными скобками по вкусу, и писать False, а не false или "false". Точно так же в JSON булево значение будет false, а не False или "false" (в случае False вы просто получите ошибку парсера). Язык не будет подстраиваться под желания каждого конкретного человека, именно для того чтобы не допускать двусмысленностей. Поэтому мы пока и пишем программы не на естественном языке, а на специальных.
    Именно от чьих-то шаловливых ручонок данная схема и защищает, потому что по крайней мере свои скрипты можно заставить выдавать валидные данные.

    ОтветитьУдалить
  10. Как раз ради этих "шаловливых ручек" и существует вся сфера IT. И Duck typing в любом своём проявлении способствует очеловечиванию технологий.

    Всё, это уже давно пустой флейм с моей стороны. Прошу прощения :)

    ОтветитьУдалить
  11. Конечно, только очеловечивание уже должно происходить на несколько более высоком уровне. Никто не должен заставлять людей вводить в форме сырой JSON (как и XML) -- преобразовывать данные должна программа. Если этим занимаются руки, то вполне возможно они слишком шаловливы :)

    ОтветитьУдалить

Постоянные читатели