Масштабована бібліотека серіалізації/десеріалізації JSON

Не так давно я брав участь у проекті написання прошивки для деякого пристрою. У процесі роботи виникло питання, а як, власне, взаємодіяти з «великим братом» (керуючим комп'ютером)? Оскільки в якості «великого брата» закладалися абсолютно різні пристрої (різні смартфони, планшети, ноутбуки з різними ОС та інше), планувалося використовувати web-додаток, що диктувало використання JSON для обміну повідомленнями.

В результаті вийшла легка і швидка бібліотека серіалізації/десеріалізації JSON. Основні фічі цієї бібліотеки:

  • у базовому функціоналі (без використання контейнерів STL) не використовує динамічну пам'ять, взагалі;
  • складається тільки із заголовних файлів (headers-only);
  • є підтримка контейнерів STL;
  • дозволяє створювати розширення для обробки довільних типів.

Трохи лірики

Спочатку, на написання своєї бібліотеки серіалізації мене сподвиг пост. На жаль, той варіант мені не підходив, оскільки використовує STL і використовувати його на контролері, в якому всього-то 1МБайт флеша і 198кБайт ОЗУ, м'яко кажучи, дивно. Але сподобалася ідея опису полів для серіалізації. Приблизно аналогічно виглядає синтаксис і у boost::serialization. Він і був узятий за основу.

Процес серіалізації є тривіальним – ми, фактично, обходимо сериализуемую структуру даних в глибину. Як відомо, це дія можна описати рекурсивно, за рахунок чого можливо відмовитися від виділення динамічної пам'яті (використовувати тільки стек). Зрозуміло, що все завалиться (точніше, зависне) при спробі сериализации пару посилаються один на одного об'єктів, але мені такі випадки поки не траплялися.

З десериализацией все дещо складніше. У процесі роздумів і після, знайомлячись з чужим творчістю, у мене склалося враження, що може існувати два види десериализаторов (за вихідними даними для побудови дерева розбору):

  1. будують дерево розбору по вхідному рядку, потім приміряють дане дерево до об'єкта, який десериализуют повідомлення (це, наприклад, Qt, jsoncpp або jsmn);
  2. будують дерево розбору по переданому об'єкту, а вже до нього приміряють прийняту рядок (парсер з cxxtools і пропонована бібліотека).
Також парсери можна розділити на:

  1. парсери, що вимагають для своєї роботи рядка, цілком містить JSON повідомлення (Qt, jsoncpp і jsmn);
  2. «онлайн»-парсери, переробні окремі символи повідомлення (cxxtools і пропонована бібліотека).
У парсерів, будують дерево розбору по вхідної рядку, є невелика перевага (на мій погляд — міфічне). Припустимо, ми посилаємо на сервер якийсь запит і чекаємо відповідь у такому форматі:

{ one : 10.01, two : 20.02 },

Але відповідь приходить такий:

{ error : <якась причина> }.

У даній ситуації ми можемо спробувати відобразити поля повідомлення на очікувану структуру і якщо не вийде — а ми знаємо, що нам можуть повернути помилку, — спробувати («вручну») відобразити повідомлення на структуру з помилкою. Парсери, будують дерево розбору по переданому об'єкту, менш гнучкі — вони просто відкинуть повідомлення як помилкове.

Чому я вважаю цю перевагу сумнівним? Розглянемо приклад на основі REST API від digitalocean.

Візьмемо, приміром, серверну частину. При взаємодії з сервером, клієнт звертається до конкретного ДО у конкретним методом, в тілі повідомлення передаючи JSON. Наприклад:

Create a new Domain
To create a new domain, send a POST request to /v2/domains. Set the «name» attribute to the domain name you are adding. Set the «ip_address» attribute to the IP address you want to point the domain to.


URL «api.digitalocean.com/v2/domains».
Метод — POST.

JSON повідомлення:
{"name":"example.com","ip_address":"1.2.3.4"}.

Будь-яке інше повідомлення буде помилкою.

Теж і з клієнтською частиною. У разі успіху сервер відповідає статусом «201 Created» і конкретним JSON повідомленням:

{
"-": {
"name": "example.com",
"ttl": 1800,
"zone_file": null
}
}.

Якщо при виконанні запиту відбувається помилка, відповідно, змінюється статус:

HTTP/1.1 403 Forbidden

{
"id": "forbidden",
"message": "You do not have access for the attempted action."
}.

Таким чином, при грамотному побудові протоколу взаємодії клієнта і сервера, проблем не виникне ні в одного десериализатора.

У десериализаторов ж, будують дерево розбору по переданому об'єкту, є своя перевага — при деяких умовах вони дозволяють вести розбір прийнятої рядки, не використовуючи динамічну пам'ять. На мій погляд, це важлива перевага, т. к. динамічна пам'ять порівняно зі стеком є істотно більш повільною, схильна до витоків і фрагментації. Ситуація зі швидкістю виділення пам'яті ще більше погіршується в многопоточной середовищі.

І тут ми приходимо до головного. У розроблювальному пристрої було вирішено використовувати scmRTOS (ну подобається вона мені — дрібна і працює), а в ній немає ніякого диспетчера пам'яті, що натякало на написання свого. А оскільки вся інша логіка була реалізована без використання динамічної пам'яті з курсу алгоритмів давно відомо, що контекстно-вільні граматики (до яких і ставитися JSON) можна парсити стековим автоматом, було вирішено спробувати написати десериализатор з використанням тільки стека.

Сериализатор в прошивці використовувався вельми активно і (суб'єктивно, бо ні з чим не порівнювався) показав відмінні результати. На жаль до моменту закінчення роботи над приладом десериализатор ще не був написаний. Тим не менш, ідея захопила і в результаті він був дописаний, т. о. «продакшені» поки не використовувався.

Сериализатор

Сам сериализатор складається з двох класів: класу JSONSerializer і наследующегося від нього класу Serializer (що дозволить в подальшому реалізувати серіалізацію в XML, принаймні, я на це сподіваюся). Власне Serializer реалізує логіку обходу дерева, а JSONSerializer — перетворення даних в текст і передачі тексту Handler'у для подальшої відправки контрагенту.

Інтерфейс Handler'а виглядає наступним чином:

struct SerializeHandler {
bool operator()( const char *str, uint32_t len );
bool SerializeEnd( );
};

Оператор bool operator()( const char *str, uint32_t len ) отримує порціями повідомлення по мірі його серіалізації. Виклик bool SerializeEnd( ) повідомляє про завершення серіалізації об'єкта. Так було зроблено з однієї простої причини: тому що сериализатор нічого не знає про те, куди виводиться підсумкове повідомлення (наприклад, USB) і, відповідно, не знає, чи буде повідомлення фрагментуватися при передачі або обертатися додатковими полями — формування (при необхідності), заповнення та пересилання буфера була покладена на Handler.

Серіалізація конкретного класу
Для серіалізації об'єкта певного класу потрібно наслідувати даний клас від класу jsmincpp::serialize::Serialized. Це необхідно для вибору правильної перевантаженої функції всередині сериализатора. Це не несе особливого навантаження, т. к. клас jsmincpp::serialize::Serialized не містить полів.

Так само, необхідно реалізувати функцію
bool Serialize(Serializer &) const {
...
}

Наприклад, для деякого класу це буде виглядати так:
struct SerializedClass : public Serialized {
int8_t One;
uint8_t Two;

SerializedClass( )
:
One( 0 ),
Two( 0 ) {
}

SerializedClass( int8_t one, uint8_t two )
:
One( one ),
Two( two ) {
}

template < typename S >
bool Serialize( S &serializer ) const {
SERIALIZE( One );
SERIALIZE( Two );
return true;
}
};

При необхідності сериализации вкладені об'єкти їх класи необхідно піддати таким же доопрацюванням.

Далі серіалізація виглядає елементарно:
typedef Serializer < JSONSerializer < SerializeHandler > > Serializer_t;

SerializeHandler handler;
Serializer_t serializer( handler );
SerializedClass obj;

...

serializer.Serialize( obj )


Серіалізація з вказівника на базовий клас або, як протягнути верблюд у голкове вушко
Якщо у Вас, як і у мене, сериализатор знаходиться в окремому потоці і забирає об'єкти для серіалізації зі своєї черги повідомлень, попередній варіант не підходить (навіщо тягнути RTTI на контролер, коли ми відмовилися від динамічної пам'яті?). Виникає горезвісний питання з верблюдом. Вирішити його допоможе певний базовий клас, від якого ми успадкуємо наш сериализуемый клас і покажчик на який покладемо в чергу. Таким класом є AbstractSerialized. Він, у свою чергу, успадковується від Serialized, що дозволяє сериализации отриманий клас і по вище наведеним сценарієм (при необхідності).

Виглядає це так:

typedef Serializer < JSONSerializer < SerializeHandler > > Serializer_t;

class SerializeObj : public AbstractSerialized < Serializer_t > {

... 
віртуальний bool Serialize( Serializer_t &serializer ) const override {
... 
}
};
... 
SerializeObj *obj = new SerializeObj;
... 
queue.Send( obj );
... 

SerializeHandler handler;
Serializer_t serializer( handler );
AbstractSerialized < Serializer_t > *obj = queue.Get( );
serializer.Serialize( obj );
... 

За сім з серіалізацією об'єктів ми покінчили.

Розширення функціоналу сериализатора
Мені категорично не подобаються «речі в собі». Якщо є вибір, я волію щось, що можна розширювати (особливо здорово, якщо це житлоплощу) і пристосовувати під свої потреби. Даний сериализатор так само можна розширювати, дозволяючи специфічним чином виводити в канал довільні класи. Поясню на прикладі, що це означає.

У розроблювальному пристрої обмін повідомленнями з «великим братом» здійснювався (у першому наближенні) через USART зі швидкістю 19200 біт/с. найдовше повідомлення містило масив з 6 float'тов. Оскільки в розроблювальному пристрої використовувалися 6 абсолютних енкодерів з точністю близько 0.5 градуса і, відповідно, абсолютні величини значень не перевищували 360 градусів, сериализованное значення для float'а виглядало так: 222.001999. В ньому 5 зайвих цифр (власне, остання половина символів зайва і не несе смислового навантаження). Можна дещо прискорити обмін повідомленнями, якщо викинути зайві символи. Впливати на серіалізацію float'бібліотекою а ми ніяк не можемо, але можемо написати сериализатор для довільного класу. Таким чином був створений клас FloatPoint_3x1.

Сам клас виглядає так:
class FloatPoint_3x1 {
public:
FloatPoint_3x1( )
:
_val( 0.0 f ) {
}

FloatPoint_3x1( float val )
:
_val( val ) {
}

float GetValue( ) const {
return _val;
}

private:
float _val;

};

Нічого особливого — контейнер для даних. Зауважте, успадковувати його від Serialized не потрібно!

Функція серіалізації для нього виглядає так:
template < typename S >
bool operator <<( S &serializer, const FloatPoint_3x1 &data ) {
const char f [ ] = "%3.1 f";
char buffer [ 10 ];
uint32_t len = ::sprintf( buffer, f, data.GetValue( ) );
if( len > 0 )
return serializer.GetHandler( )( buffer, len );
return false;
}

Все просто — формуємо в буфері текстовий рядок і виводимо її в Handler. В результаті сериализованное значення виглядає так: 222.0.

Дана можливість є дуже могутньою штукою — у нас з'являється повний контроль над потоком виводу.

Десериализатор

Десериализация здійснюється класом Deserializer (несподівано, так?), який може працювати в двох режимах:

  1. отримує при створенні список класів, які буде намагатися десериализовать прийняті повідомлення;
  2. отримує на вхід об'єкт конкретного класу, в поля якого спробує десериализовать прийняте повідомлення.
При инстанцировании класу десериализатора першим шаблонним параметром і першим параметром конструктору передається клас, відповідальний за читання даних, які приймаються, з наступним мінімальним набором методів:

class InputStream {
public:
SymbolStream & operator++( );

char operator*( );

bool operator==( const SymbolStream &other );
SymbolStream End( );
};

Ідея підглянена у ітераторів введення STL і робота з ним повинна виглядати знайоме.

Метод SymbolStream & operator++( ) читає наступний символ з пристрою вводу (якщо читання буферизовано — переходить до наступного символу в буфері) або встає в очікуванні надходження нового символу.

Метод char operator*( ) повертає поточний символ.

Методи SymbolStream End( ) bool operator==( const SymbolStream &other ) призначені для визначення досягнення потоком введення стану end-of-stream (кінець потоку).

Десериализация з автоматичним вибором зі списку класів
При инстанцировании класу десериализатора другим шаблонним параметром передається список класів, в поля якого можлива десериализация прийнятих повідомлень.

Так як телепати, як зазвичай, у відпустці, а магія нам не доступна, десериализатор повинен на основі якихось ознак приймати рішення, яке конкретне повідомлення було прийнято. Титанічним зусиллям волі було вирішено прийняти формат повідомлення наступним:

{"<ім'я десериализуемого класу>":{<поля десериализуемого класу>}}.

Вкладені класи допускаються, рівень вкладеності визначається вільним місцем у стеку. Цей режим підходить, наприклад, для вбудованих пристроїв з обміном даними через USB CDC ACM (віртуальний COM-порт) або USART, тобто там, де є один канал для обміну повідомленнями і немає ніяких ознак для визначення до якого класу відноситься прийняте повідомлення. Це дещо обмежує використання десериализатора в готових рішеннях без додаткової адаптації, зате ідеально підходить для знову проектованих систем.

У програмі це виглядає наступним чином:

class Object;
class OtherObject;

typedef ObjectsList <
DESERIALIZEOBJ( Object ),
DESERIALIZEOBJ( OtherObject )
> SerializeList_t;

Де «Object» «OtherObject» – назви десериализуемых класів (не инстанцированных об'єктів! Об'єкти десериализатор створить сам).

Третім шаблонним параметром і другим параметром конструктора передається аллокатор (хоча, напевно, правильніше сказати — фабрика). Цей клас має наступний інтерфейс:

class Creator {
public:
template < typename T >
T * Create( const T & );

template < typename T >
void Delete( T * );
}; 

Метод T * Create( const T & ) виділяє пам'ять і розміщує в ній створюється об'єкт типу «T».

Метод void Delete( T * ), відповідно, видаляє конкретний об'єкт, створений раніше.
Сама десериализация виконується методом bool Deserialize( H &handler ) десериализатора. Йому на вхід передається обробник, який отримає управління в разі успішної десеріалізації прийнятого повідомлення. Його інтерфейс виглядає наступним чином:

class Handler {
public:
bool operator()( Object *param );
bool operator()( OtherObject *param );
...
};

Власне, по одному перевантаженому operator( ) на клас зі списку десериализуемых повідомлень.

Це необхідно через те, що я не можу повертати різні класи з методу bool Deserialize( H &handler ) десериализатора. Тільки в методах Handler'а ми маємо доступ до типу десериализованного повідомлення. Знаючи тип, прийняте повідомлення можна, наприклад, помістити в потрібну чергу для подальшої обробки іншим потоком або обробити «на місці».
У разі успішної десеріалізації, метод bool Deserialize( H &handler ) повертає true false у разі помилки.

За раз десериализуем по одному повідомленню, для продовження обробки викликаємо метод ще раз (потрібну кількість разів).
Виглядет вищеописане так:

class Object;
class OtherObject;

...
typedef ObjectsList <
DESERIALIZEOBJ( Object ),
DESERIALIZEOBJ( OtherObject )
> SerializeList_t;

...
SocketStream s( socket );
DeserializeHandler h( );

Deserializer <
SymbolStream,
SerializeList_t
> d( s );

if( !d.Deserialize( h ) ) DeserializeErrorHandler( );

Кілька слів про невикористання динамічної пам'яті
За замовчуванням Creator'а використовується клас StaticCreator:

template < uint32_t BuffSize >
class StaticCreator {
public:
uint32_t _buff [ BuffSize / 4 + 1 ];

template < typename T >
T * Create( const T & ) {
return new ( _buff ) T;
}

template < typename T >
void Delete( T * ) {
}
};

Він створює необхідні об'єкти в своєму буфері.

Таким чином, після десеріалізації, в оброблювачі Handler'а необхідно скопіювати десериализованный об'єкт куди-небудь. У прототипі даного сериализатора я передавав об'єкт по значенню в чергу іншого потоку для подальшої обробки (що і дозволило повністю відмовитися від використання динамічної пам'яті). Якщо ця ситуація не влаштовує і є можливість використовувати динамічну пам'ять, потрібно використовувати інший Creator або написати свій.

В бібліотеці доступний MallocCreator, що використовує Malloc( ) для розміщення об'єктів і SharedPrtCreator, що дозволяє використовувати розумні покажчики (std::shared_ptr). На більше в мене не вистачило фантазії.

Десериализация конкретного класу
Якщо ми знаємо конкретний тип десериализуемого повідомлення, можна використовувати другий режим роботи десериализатора. Просто передати вказівник на об'єкт, в який ми хочемо десериализовать прийняте повідомлення, переобтяженій методом bool Deserialize( O *obj ).

В якості єдиного члена списку десеріалізації можна вказати об'єкт-пустушку NullObj. Виглядає це так:

SocketStream s( socket );

typedef ObjectsList <
DESERIALIZEOBJ(NullObj)
> SerializeList_t;

Deserializer <
SymbolStream,
SerializeList_t
> d( s );

Object obj;
d.Deserialize( &obj );

Або в разі використання shared_ptr:

SocketStream s( socket );

typedef ObjectsList <
DESERIALIZEOBJ(NullObj)
> SerializeList_t;

Deserializer <
SymbolStream,
SerializeList_t
> d( s );

auto obj = make_shared< Object >();
d.Deserialize( obj.get( ) );

У даному режимі роботи (незалежно від переданого Creator'а) динамічна пам'ять десериализатором не використовується (Creator инстанцируется, але його методи не викликаються).

Розширення функціоналу десериализатора
Даний десериализатор дозволяє розширення свого функціоналу. По-перше можливістю розширення числа десериализуемых класів користувача. По-друге можливістю зміни стратегії виділення пам'яті під десериализуемый клас.

Розглянемо розширення функціоналу на прикладі деякого класу StaticString. Даний клас дозволить нам десериализовать рядка в системах, де відсутня динамічна пам'ять. Звичайно, він містить багато обмежень, але при деякій вправності можна користуватися. Виглядає клас так:

template < uint32_t Num >
class StaticString {
public:
StaticString( )
:
_length( 0 ) {
_buff [ 0 ] = 0;
}

StaticString( const char *str ) {
Assign( str );
}

bool Add( char symbol ) {
if( Num == _length )
return false;
_buff[ _length++ ] = symbol;
_buff[ _length ] = 0;

return true;
}

bool Add( const char *str ) {
uint32_t strSize = ::strlen( str );
if( strSize > Num - _length )
return false;
::strcpy( _buff, str );
_length += strSize;
_buff[ _length + 1 ] = 0;
return true;
}

bool Assign( const char *str ) {
_length = 0;
_buff[ _length ] = 0;
return Add( str );
}

const char * GetString( ) {
return _buff;
}

uint32_t GetLength( ) {
return _length;
}

private:
uint32_t _length;
char _buff [ Num + 1 ];
};

Власне, нічого особливого — статичний масив для символів, навколо якого побудована логіка роботи з рядком.

Десериализация здійснюється наступним кодом:

template < uint32_t Hash, uint32_t Num >
class StaticStringParam {
public:
enum {
HASH = Hash
};

StaticStringParam( StaticString< Num > ¶m )
:
_param( param ) {
}

template < typename D >
bool Parse( D &deserializer ) {
return ParseStaticString( _param, deserializer.GetStream( ) );
}

private:
StaticString< Num > &_param;
};

template < uint32_t Hash, uint32_t Num >
StaticStringParam < Hash, Num > MakeParam( StaticString< Num > ¶m ) {
return StaticStringParam < Hash, Num >( param );
}

Як бачимо, у нас повний доступ до вхідного потоку символів, отже, можна десериализовать все, що завгодно.

Деякі обмеження при використанні даного десериализатора
Ще на стадії написанні концепту виникла наступна проблема: сам по собі JSON ніяк не обмежує довжину імені параметра, що вимагає, взагалі кажучи, необхідності використання динамічної пам'яті (з можливістю збільшення розміру виділеного буфера), або статичного буфера достатнього розміру для розміщення самого довгого імені. Пам'яті було шкода (розробка, нагадаю, спочатку велася для мікроконтролера), так що була реалізована наступна ідея: а що якщо не накопичувати символи імені параметра в буфері і подальшим порівнювати його з іменами десериализуемых параметрів, а вважати CRC32 від вхідних рядка, а згодом порівнювати з посчитанными на етапі компіляції (constexpr функція) CRC32 від імен полів десериализуемого класу. Це економить нам пам'ять (замість рядка зберігатися лише uint32_t) і прискорює порівняння, але додає головного болю з можливими колізіями CRC32 від імен параметрів. Що тут сказати… Тестуйте ваш код більше, тести повинні відловити подібні проблеми! Адже ви тестуєте свій код?

Підтримка контейнерів STL

У процесі читання форумів часом натикався на повідомлення стражденних з проханням вказати їм сериализатор/десериализатор JSON з підтримкою STL «искаропки». Виникло непереборне бажання підтримати стражденних. Що і було реалізовано — начебто всі основні контейнери STL підтримуються, як сериализатором, так і десериализатором. Якщо щось не підтримується, завжди можна допилити підтримку. Більше сказати з цього приводу нічого.

Порівняння і бенчмарки

Сериализатор сам по собі досить тривіальний, так що порівнювати його ні з ким бажання навіть і не виникло.
Цікавіше було порівняти десериализатор з конкуруючими бібліотеками. Оскільки C++ бібліотек придатних для використання на контролері я не знайшов (та загалом і не шукав), а додавання підтримки STL і можливість розширення перевело продукт в іншу споживацьку категорію – можливість використовувати на повноцінних серверах, порівняння проводив з тими бібліотеками, рекомендації використовувати які знаходив на форумах. Порівняння, звичайно, не всеосяжне — всього-то чотири конкурента. Але мені перехотілося тестувати конкурентів далі, бо результати, на мій погляд, були дуже обтяжливими. Самі тести лежать тут, в підкаталогах каталогу /src в файлах *.mk поправте шляху до компілятор і бібліотек (крім Qt. Для його складання створіть проект в QtCreator'е і копіюйте *.cpp в нього). Складання здійснюється в кореневому каталозі викликом make <бенчмарк>. Подивитися проекти для складання можна просто викликавши make.

Отже, тестувалися такі бібліотеки (крім розробленою):

  • jsmn — проект на C, але не використовує динамічну пам'ять, цікаво було порівняти;
  • Qt;
  • jsoncpp;
  • cxxtools.
Порівняння проводилося наступним чином: по документації конкретної бібліотеки (ну, природно, так як я її розумів) писалося додаток, завдання якого полягало в десеріалізації деякої (синтаксично і семантично коректної) рядки в об'єкт деякого класу. І так 1 000 000 разів, бо на моєму ноутбуці (i3) менше число ітерацій проходив за зовсім короткий час. Час роботи вимірювалося командою time і бралося з рядки «user: XXX». Зрозуміло, що тест не претендує на серйозність, але певні висновки зробити можна. Тести проводилися 100 разів. Результати представлені в таблиці.
Бібліотека / фреймворк
Найкращий час виконання, з
Ставлення до лідера тесту
Середнє час виконання, з
Ставлення до лідера тесту
jsmincpp
0.219
0.22252
jsmn
0.595
2.716895
0.60017
2.697151
Qt
1.359
6.205479
1.54353
6.93659
jsoncpp
4.981
22.74429
5.89901
26.51002
cxxtools
5.26
24.01826
5.95608
26.76649
Деякі коментарі.

  • За швидкості виконання ми майже в троє уділали програму з бібліотекою C (хто там стверджував, що C++ повільний? Якщо Ви пишете на C++, чи не варто замислитися про свою профпридатність?).
  • По можливості всі функції, вызывающиеся в циклі, линковались статично. Це було зроблено після того, як з'ясувалося, що для cxxtools при використанні shared бібліотек найкращий час погіршується до 6.391 с, а середня до 7.63804 з, т. е. більше секунди в результаті з'їдають виклики функцій з shared бібліотек. Звідси випливає цікавий висновок: оскільки Qt бралося з офф. сайту, а там в наявності тільки shared бібліотеки, можливо, при статичному лінкування час Qt виявилася б істотно менше — близько 0.4 — 0.6 с. (Ви все ще стверджуєте, що C++ повільний?!).
  • Ну і розмір striped бінарників програм:
    • для jsmincpp – 8040 Байт,
    • для jsmn – 8792 Байт.

    Більш ніж на 700 Байт (майже 10%) менше, ніж у Давай шної реалізації бібліотеки. (Хто там стверджував, що C++ робить монструозна програми? Безумовно, задумайтеся про власної профпридатності!)
А якщо серйозно, може хто знає швидкі бібліотечки десеріалізації JSON? Цікаво було б порівняти по наданих можливостей і глянути на внутрішній устрій.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.