Як обрати JSON парсер
Всім привіт. Я Сергій Моренець, розробник, викладач, спiкер і технічний письменник. Хочу поділитися своїм досвідом роботи з таким форматом даних як JSON. Я познайомився з JSON на початку 2010 року, після дуже довгого періоду використання XML/SOAP. Крім того, на своїх тренінгах ми дуже часто розповідаємо про цей формат, особливо щодо роботи з REST-сервісами або мікросервісами. Я думаю, що ця стаття буде корисна для всіх, хто хоче поглибити свої знання або обрати JSON парсер для нового проекту.
Якщо глобально перейматися таким питанням, а яке призначення у форматів даних, то можна виділити три сфери застосування:
1) Зберігання налаштувань (конфігурації) проекту.
2) Обмін даними у розподілених системах та їх обробка.
3) Зберігання даних користувача (наприклад, у файлах або базах даних).
Більшість форматів створюється для якоїсь однієї мети. Наприклад, у properties або YAML файлах зручно зберігати налаштування, а ProtoBuf або Thrift використовувати для пересилання даних. І в жодному разі не навпаки. JSON унікальний тим, що його використовують для всіх трьох випадків, хоча і не завжди оптимально. Чому так сталося?
JSON з’явився на початку
- Примітиви.
- Рядки.
- Списки.
- Вкладені документи.
По суті, єдиною відмінністю була відсутність коментарів і необхідність використовувати лапки для назв властивостей. Якщо хтось із вас застав цей час, то добре пам’ятає, що це була епоха царювання XML, який уже тоді багато програмістів не любили за громіздкість, але яким усі користувалися через відсутність зручних альтернатив. Тому поява нового формату справила справжній фурор, оскільки він був компактніший і зберіг при тому human-readability. Його почали використовувати, де можна, і де не можна. Дуже складно оцінити популярність форматів даних, але якщо скористатися статистикою пошукових запитів Google за останні 5 років, то JSON з великим відривом тримає перше місце, тоді як XML все більше йде в небуття.
Незалежно від того, який формат ви використовуєте у вашому проекті, у вас має бути можливість завантажити дані із зовнішнього джерела і перетворити на якусь in-memory Java структуру, швидше за все в POJO. Цей процес і називається десеріалізацією (а зворотний серіалізацією). При цьому в технічній літературі можна зустріти багато схожих термінів чи синонімів, таких як parsing, marshalling, decoding. Десеріалізація — це окремий випадок парсингу, тому що компіляція вихідного коду, наприклад, — це теж, по суті, парсинг, але не десеріалізація. Історично так склалося, що ці назви стали взаємозамінними, і про одну й ту саму бібліотеку можуть сказати і JSON парсер, і JSON serializer, і JSON процесор. Я в цій статті використовуватиму термін JSON парсер.
Потрібно відзначити, що парсинг не є атомарною операцією, а складається із трьох основних етапів:
1) Завантаження даних (із зовнішнього джерела) в оперативну пам’ять.
2) Перетворення на деяку проміжну (часто деревоподібну) структуру для доступу до вмісту або потокової обробки вмісту (Streaming API).
3) Перетворення в POJO об’єкт (опціонально).
Деякі бібліотеки не можуть самостійно виконати третій етап, і до них потрібно підключити зовнішній mapper.
Чому взагалі постає питання про вибір парсера? Невже Java за 26 років його так і не додали? На жаль, ні. На відміну від XML парсера (JAXB), який був включений в Java EE і навіть Java SE 6 в 2006 році, в останніх версіях Java немає ніяких компонентів для роботи з JSON. Більше того, навіть JAXB пакет видалили в JDK 11, вирішивши, що немає сенсу захаращувати JDK парсерами, а краще віддати це на реалізацію стороннім вендорам. Правда, в Java EE 7 в 2013 році нарешті з’явилася специфікація Java API для JSON Processing (JSON-P), а в Java EE 8 в 2017 році додали Java API для JSON Binding (JSON-B), але до цього часу їхній поїзд давно пішов. До того ж, зараз проектів на Java EE досить мало. Але про все по порядку.
Отже, ви вирішили використати JSON, але не знаєте, який парсер краще використовувати. Розберемо всі три випадки.
- Ви зберігаєте конфігурацію (налаштування). У такому разі ви маєте наступні початкові умови:
- завантаження/збереження конфігурації відбувається не так часто;
- ви оперуєте порівняно невеликими обсягами даних;
- конфігурація зберігає внутрішні дані вашого проекту, структура яких рідко змінюється і її можна вважати умовно статичною, тому її зручніше мепіти на ваші POJO для подальшої обробки в Java коді, і вам важливо, щоб парсер був досить гнучким у плані налаштувань;
- ви можете дозволити собі завантажити/зберегти конфігурацію цілком і не звертати увагу на ефективність цієї операції.
З такими умовами для вас головне — мати простий і зручний парсер, що включає API з мінімальним порогом входження, досить популярний, щоб він був знайомий більшості розробників, досить зрілий і стабільний, щоб ще довго підтримувався та розвивався. Зараз таких проектів два — Jackson та Gson.
Jackson — проект, який було розпочато у 2007 році у компанії FasterXML, і спочатку це був мінімально простий парсер, який підтримував лише JSON. Такий проект був життєво необхідний, тому що популярність JSON набирала обертів і потрібна була відповідна технологія для роботи з ним. У 2009 році вийшла версія 1.0 під кодовою назвою Hazelnut. Поступово Jackson почав підтримувати все більше форматів даних, починаючи від XML і закінчуючи новими бінарними форматами. Таким чином, основний модуль підтримував лише JSON, а решту можна підключити як додаткову залежність. Ще одна крута фішка Jackson — великі можливості для кастомізації парсингу завдяки анотаціям і вбудованим конвертерам. Ще одна його перевага — інтеграція з Spring Framework, JAX-RS та Guice.
Якщо вам потрібно серіалізувати Java об’єкт, код для цього складається всього з трьох рядків:
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.findAndRegisterModules();
String json = objectMapper.writeValueAsString(objectMapper);
Що таке ObjectMapper? Це потоково-безпечний (thread-safe) об’єкт, який є обгорткою для такого об’єкта як JsonFactory і реалізує дві головні операції:
- Серіалізацію за допомогою JsonGenerator API
- Десеріалізацію за допомогою JsonParser API
Таким чином, якщо ви створили свій файловий формат, ви можете написати власну JsonFactory та використовувати ObjectMapper разом з нею. Jackson — єдиний з JSON парсерів, який підтримує Java records (з’явилися в Java 14), таким чином, ви можете використовувати їх як DTO, наприклад
Для того, щоб змінити/налаштувати mapping, є великий набір Jackson анотацій, котрий з роками тільки розширюється:
@Getter
@Setter
@JsonNaming(UpperCamelCaseStrategy.class)
public class Book {
@JsonProperty("title")
private String name;
@JsonIgnore
private byte[] content;
}
Gson — альтернативний проект, який розпочав своє життя в ІТ у 2008 році. Спочатку це був внутрішній продукт Google, який був open-source, але при цьому так і не підтримувався і не просувався офіційно Google. Він підтримує тільки JSON формат і пропонує базові можливості для кастомізації, включаючи лише 5 анотацій (у Jackson 35(!)). Розробка продукту ведеться набагато більш розмірено, поточна версія 2.8.8, а версія 2.0 випущена ще в 2011 році. Його API мінімально простий:
Gson gson = new Gson();
String json = gson.toJson(user);
Який із двох проектів вибрати? У плані зручності API вони приблизно однакові, але Jackson перевершує конкурента за можливостями розширення, підтримки нових форматів та налаштувань. Крім того, він вбудований в Spring MVC і інтегрований в Spring Boot, тому якщо у вас Spring проект, то ви швидше за все виберете Jackson. Щоправда, є ще нефункціональні вимоги, і ми про них поговоримо трохи згодом..
- Тепер уявімо, що ви використовуєте JSON формат для відправки повідомлень між вашими сервісами (зовнішніми або внутрішніми), тут також можлива додаткова обробка повідомлень.
Взагалі кажучи, JSON формат не найідеальніший вибір у цьому випадку через свою надмірність. Більше того, навіть бінарні формати, які, як Bson, MsgPack або CBor, забезпечують додатковий стиск не більше
Json-iterator — найновіший парсер із усіх, що існують, який почав розроблятися у 2016 році. Дивно тут те, що було створено дві реалізації, одна з них на Go. Його головний плюс — висока продуктивність, підтверджена benchmarks. Окрім того, тут є унікальний динамічний режим роботи на основі бібліотеки Javassist.
Один з відносних мінусів будь-якого парсера — при десеріалізації він нічого не знає заздалегідь про наші POJO класи, тому змушений використовувати порівняно повільний Reflection API для доступу до полів/методів. Бібліотека Javassist дозволяє створювати байт-код на лету, і json-iterator з її допомогою генерує оптимізовані decoders/encoders для кожного використовуваного POJO класу:
JsonIterator.setMode(DecodingMode.DYNAMIC_MODE_AND_MATCH_FIELD_WITH_HASH);
JsonStream.setMode(EncodingMode.DYNAMIC_MODE);
Це теоретично має прискорити десеріалізацію. Ще один цікавий момент — Json-iterator підтримує три види API для парсингу:
- Bind API (конвертуємо JSON в POJO объект)
- Iterator API (низькорівневий обхід JSON документа)
- Any API
Якщо Bind API та Iterator API достатньо зрозумілі, то що за невідомий звір Any API? Уявимо собі, що ви хочете конвертувати JSON в наступний POJO:
public class User {
private String login;
private String pasword;
private Address address;
}
Її можна виконати за допомогою того ж таки Bind API. Але що, якщо вам адреса з JSON не потрібна у вашому випадку? Тоді можна додати анотацію @JsonIgnore або видалити поле address з класу User. А що, якщо у вас складніший варіант, коли адреса іноді потрібна, іноді ні? Якщо він не потрібен, ви все одно витрачатимете час на його парсинг. І тут вам допоможе Any API, який дозволяє робити lazy loading для окремих елементів:
public class User {
private String login;
private String pasword;
private Any address;
}
Тепер за промовчанням поле address ніяк не заповнюватиметься. Але ви завжди зможете його ініціалізувати/завантажити за запитом:
Any address = user.getAddress();
String country = address.get("country").toString();
Json Smart — ще один проект, який позиціонувався як найшвидший парсер. До його переваг можна віднести компактність (немає зовнішніх залежностей), робота на JDK 5+ і легку міграцію з ще одного легковажного парсера json-simple.
До цікавих особливостей цього проекту варто віднести те, що він підтримує два режими роботи — звичайний і silent (коли він ігнорує всі помилки та exceptions):
ExampleRecord exampleRecord = JSONValue.parse(userJson, ExampleRecord.class);
ExampleRecord exampleRecord = JSONValue.parseWithException(inputJson, ExampleRecord.class);
Який із двох парсерів вибрати? Для цього ми трохи пізніше порівняємо їхню продуктивність у тестах.
- Останній варіант використання JSON, який буде зберігатися в файлах користувача або базі даних
У цьому випадку ми часто маємо справу з неструктурованим (raw) JSON, коли нам доводиться запитувати дані про поля, які ми дізнаємося в run-time. Тут нам не допоможе binding, і нам потрібно вибирати між конвертацією JSON у Map:
objectMapper.readValue(inputJson, Map.class);
і використанням JsonNode API, коли ми працюємо з JSON як з деревом:
JsonNode tree = objectMapper.readTree(inputJson);
System.out.println(tree.get("glossary").get("title").textValue());
Такий спосіб простий і зрозумілий, але вимагає великої кількості коду і особливо незручний, якщо формат або тип запиту часто змінюється. Іноді зустрічається ще більш складне завдання — знайти поля/об’єкти в JSON лише за їхньою назвою. І тут нам знадобиться бібліотека Jayway JsonPath.
Json Path часто називають регулярними виразами для JSON, у цьому сенсі вона аналогічна бібліотеці XPath. За допомогою її DSL можна легко написати найскладніший запит для отримання інформації з JSON. Це її єдине завдання, тому вона не вміє робити конвертувати JSON в POJO, ні зберігати POJO в JSON. Щоправда, ви можете підключити Json Smart/Jackson для такого binding.
Отже, як нам отримати, наприклад місто користувача?
String city = JsonPath.parse(inputJson).read("$.address.city", String.class);
Тут $ — посилання на кореневий елемент JSON (аналогічно this в Java), а для навігації по вкладених документах можна використовувати крапку. Що якщо ми не знаємо, де точно знаходиться поле city? Не біда, для цього є спеціальний вираз — дві крапки:
String city = JsonPath.parse(inputJson).read("$..city", String.class);
Якщо такий синтаксис все ж таки здається вам незвичним, є спеціальний сайт, де ви можете в ньому попрактикуватися. Але у JsonPath є й складніші операції. Наприклад, вам потрібно отримати всіх користувачів з України, тоді для цього є такий вираз:
$.address[?(@.country == ’Ukraine’)]
Тут @ - це посилання на поточний JSON елемент, що обробляється. Через свою простоту JsonPath дуже зручно використовувати в тестах, коли нам потрібно перевірити наявність даних у JSON і нас не дуже непокоїть питання швидкодії.
Нефункціональні вимоги
Крім зручності та простоти використання бувають й інші характеристики бібліотек, у таблиці нижче я порівняв усі 5 проектів за 5 параметрами:
- Розмір jar-файлу (ів)
- Кількість комітів
- Число розробників на проекті
- Кількість питань з таким тегом на Stackoverflow
- Кількість бібліотек, які використовують цю технологію (як залежність)
Ці показники допомагають оцінити, наскільки сильно бібліотека ускладнить ваш проект, наскільки вона активно підтримується та розвивається, оскільки це опосередковано впливає на випуск нових версій та виправлення помилок, на її популярність, розмір community та наскільки швидко ви зможете знайти відповідь в Інтернеті на ваше запитання або вирішити проблему.
І тепер найцікавіший показник — швидкодія. Скористаємося знайомою бібліотекою JMH (Java Microbenchmark Harness) та порівняємо у часі (в наносекундах) виконання 4 типових операцій:
- Десеріалізація JSON в Java объект
- Десеріалізація JSON в Map
- Серіалізація Java объекта в JSON
- Запит на отримання властивості за назвою
Для цього використовуємо наступну конфігурацію:
- JMH 1.33
- JDK 17.0
- Intel Core i9, 8 cores
- 32 GB
- 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)
В результаті отримаємо наступні результати:
Тут цікаво кілька показників
- Jsoniter показав найкращу швидкодію у більшості тестів
- Jackson виявився найкращим під час серіалізації Java об’єкта (на другому місці Json Smart)
- Json Path найшвидше десеріалізує JSON у Map
Якщо ви пам’ятаєте, то Json-iterator дозволяє генерувати декодери/кодери на льоту за допомогою бібліотеки Javassist. Якщо ми увімкнемо цей режим, то benchmarks покажуть час 440 (нс) для першої операції, таким чином ми отримали прискорення у 2.5 рази і так для найшвидшого парсеру.
Чи всі існуючі JSON парсери ми розглянули? Я почав цю статтю з того, що представив автора JSON формату Дугласа Крокфорда, але ніяк не згадавши його під час розповіді про існуючі JSON парсери для Java. Насправді Крокфорд 2010 року розробив свою канонічну бібліотеку, яку назвав JSON-Java. У 2014 році він передав справи іншому розробнику Шону Лірі і більше не бере участі у її розробці.
JSON-Java — дуже проста бібліотека, яка підтримує декілька базових операцій та не містить жодної анотації.:
- Перетворення об’єкт -> JSON
JSONObject obj = new JSONObject(user);
String json = obj.toString();
- Парсинг або створення JSON об’єкта вручну
JSONObject obj2 = new JSONObject(json);
obj2.append("name", "Jones");
Ця бібліотека відрізняється найменшим розміром — 69 кб, але я можу її порадити зараз для навчальних проектів або там, де є серйозні обмеження за розміром дистрибутива (Android).
Чи варто використовувати JSON-B та JSON-P для нових проектів? З одного боку, це стандарт, уніфікація для enterprise додатків, з іншого боку, він з’явився досить пізно, коли Java-індустрія звикла до таких бібліотек як Jackson/Gson. В іншому, якщо у вас Java EE/Jakarta EE додаток, це може бути правильним вибором, тим більше що ці проекти активно розвиваються і скоро готується версія 2.1. Ви можете вибрати між кількома реалізаціями цієї специфікації, такими як Apache Johnzon та Eclipse Yasson.
З тієї ж причини я не розглянув деякі інші відомі JSON парсери, наприклад Simple JSON, який вже майже 8 років як не оновлюється, LoganSquare, який покинутий з 2016 року і багато інших.
Висновки
У цій статті ми розглянули основні JSON парсери для Java додатків і основні сценарії роботи з JSON. Якщо підсумувати, то я міг би порадити таке:
- Для парсингу JSON загального призначення використовувати бібліотеку Jackson як найбільш просунуту та гнучку в плані конфігурації. Іноді буває трохи інша вимога — потрібен простий невибагливий JSON парсер для тестів, тут я б порекомендував Gson знову ж таки через його простоту і невеликий розмір.
- Якщо вам потрібна максимальна швидкодія, використовуйте JsonIterator (або JSON Smart)
- Для роботи з неструктурованим JSON, складних запитів, особливо в інтеграційних тестах, використовуйте Json Path
67 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів