GSON. Додамо йому трохи строгості і вирішуємо проблему переповнення пам'яті при обробки великих JSON файлів

Ймовірно багато хто стикався з бібліотекою GSON від Google, яка легко перетворює JSON файли в Java об'єкти і назад.

Для тих, хто з нею не стикався, я підготував короткий опис під спойлером. А так же описав рішення на GSON двох проблем, з якими реально стикався у своїй роботі (рішення не обов'язково оптимальні або кращі, але, можливо, комусь вони можуть стати в нагоді):

1) Перевірки що ми не втратили жодного поля з JSON'a файлу, а також перевірки того, що всі обов'язкові поля Java класі були заповнені (робимо GSON більш суворим);
2) Ручної парсинг з допомогою GSON, коли доводиться обробляти дуже великий JSON файл, щоб уникнути помилки out of memory.

Отже, для початку про те, що таке GSON на пальцях:…
Тим хто вже знає про GSON швидше за все буде не цікаво, можна пропустити… GSON дозволяє буквально двома рядками перетворити JSON в Java об'єкти. Дуже часто використовується для інтеграції між різними платформами і системами, серіалізації та десеріалізації, а також для взаємодії між вебчастью на javascript і беком на Java.

Отже, у нас є скажімо такий json, отриманий від іншого додатка:

{
"summary": {
"test1_id": "1444415",
"test2_id": "4444935"
},
"results": {
"details": [
{
"test1_id": "1444415",
"test2_id": "4444935"
},
{
"test1_id": "1444415",
"test2_id": "4444935"
}
]
}
}

Описуємо аналогічну структуру в Java об'єкти (гетери і сетеры і т. п. для простоти писати не буду):

static class JsonContainer {
DataContainer summary;
ResultContainer results;
}

static class ResultContainer {
List<DataContainer> details;
}

static class DataContainer {
String test1_id;
String test1_id;
}


І буквально двома рядками перетворимо одне в інше.

Gson gson = new GsonBuilder().create();
JsonContainer jsonContainer = gson.fromJson(json, JsonContainer.class);// з Json в Java
... // що робимо 
String json = gson.toJson(jsonContainer);// з Java в json


Як можна бачити, все дуже просто. Якщо нам не подобаються неправильні для Java імена, використовуємо анотацію SerializedName, тобто пишемо:

static class JsonContainer {
DataContainer summary;
ResultContainer results;
}

static class ResultContainer {
List<DataContainer> details;
}

static class DataContainer {
@SerializedName("test1_id")
String test1Id;
@SerializedName("test2_id")
String test2Id;
}

В якості типів полів, звісно, автоматично можуть використовуватися не тільки String, але і будь-які примітивні і їх обгортки, enum, дата (формат дати, можна задати), об'єкти з дженериками і багато іншого. Для значень enum також можна вказати SerializedName, якщо значення в json не збігається з ім'ям константи enum. Також можна, звичайно, додати свої обробники для окремих класів, наприклад так:

Gson gson = new GsonBuilder().registerTypeAdapter(DataContainer.class, new DataContainerDeserializer<DataContainer>()).create();

class DataContainerDeserializer<T> implements JsonDeserializer<T> { 
@Override
public T deserialize(JsonElement json, Type type,
JsonDeserializationContext context)
throws JsonParseException { 
... // сам перетворимо JsonElement в потрібний нам об'єкт 
return /* повертаємо отриманий Java об'єкт */
}
}

Трохи нижче, покажу докладніше приклад використання JsonDeserializer . Загалом, про вступ GSON'е на пальцях закінчилося можна переходити до найцікавішого.



Проблема номер 1. Несуворі перетворення
GSON при перетворенні з json в Java ігнорує всі поля, які відсутні в Java класі і ніяк не звертає увагу на анотації NotNull. Здавалося б, тут немає особливої проблеми, ну ігнорує і ігнорує, для багатьох цілей (наприклад, еволюції класів при серіалізації/десеріалізації) це дуже зручно. Так, дійсно іноді зручно. Але уявімо, що ми інтегрувалися з системою іншій компанії і раптово поле object перетворилося в objects (помилково розробників «на тій стороні», тому що ми не помітили в дизайні тієї системи приписки «після 12 ночі карета перетворюється на гарбуз, тобто поле field1 стає field2», по мільйону інших причин). Або вони додали важливе поле, але забули сказати. Гірше, якщо інтеграція працює в обидві сторони: система А прислала нам об'єкт із зайвими полями (про яких ми не знали), GSON їх проігнорував, ми щось з об'єктом зробили і відправили його назад в ту систему А, а вони його благополучно записали в базу, вирішивши, що зайві поля ми за своїм причин видалили. Все — зіпсований телефон у дії і добре, якщо його встигнуть зловити QA або аналітиці на якійсь стороні, а можуть і не зловити.

Нормального рішення у самому GSON'е, як зробити його більш строгому, мені знайти не вдалося. Так, можна було прикрутити окрему валідацію з допомогою json схем або як зробити валідацію вручну, але мені здалося що куди краще використовувати можливості самого GSON'a, а саме JsonDeserializer, перетворений в Validator (можливо хтось зможе підказати кращий шлях), власне клас:

Великий вихідний код
package com.test;

import com.google.common.collect.ObjectArrays;
import com.google.gson.*;
import com.google.gson.annotations.SerializedName;
import gnu.скарб.set.hash.THashSet;
import javax.validation.constraints.NotNull;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.util.*;

public class TestGson {
private static String json = "{\n" +
" \"summary\": {\n" +
" \"test1_id\": \"1444415\",\n" +
" \"test2_id\": \"4444935\"\n" +
" },\n" +
" \"results\": {\n" +
" \"details\": [\n" +
" {\n" +
" \"test1_id\": \"1444415\",\n" +
" \"test2_id\": \"4444935\"\n" +
" },\n" +
" {\n" +
" \"test1_id\": \"1444415\",\n" +
" \"test2_id\": \"4444935\"\n" +
" }\n" +
" ]\n" +
" }\n" +
"}";


public static void main(String [ ] args)
{
Gson gson = new GsonBuilder()
.registerTypeAdapter(DataContainer.class, new VaidateDeserializer<DataContainer>()) // встановлюємо валідатор на об'єкт DataContainer
.create();
JsonContainer jsonContainer = gson.fromJson(json, JsonContainer.class);
}

static class JsonContainer {
DataContainer summary;
ResultContainer results;
}

static class ResultContainer {
List<DataContainer> details;
}

static class DataContainer {
@NotNull
@SerializedName("test1_id")
String test1Id;
@SerializedName("test2_id")
String test2Id;
}

static class VaidateDeserializer<T> implements JsonDeserializer<T> {
private Set<String> fields = null; // Масив імен всіх полів класу
private Set<String> notNullFields = null; // Масив імен всіх полів з анотацією NotNull

private void init(Type type) {
Class cls = (Class) type;
Field[] fieldsArray = ObjectArrays.concat(cls.getDeclaredFields(), cls.getFields(), Field.class); // Об'єднуємо всі поля класу (приватні, публічні, одержані в результаті успадкування в один масив
fields = new THashSet<String>(fieldsArray.length);
notNullFields = new THashSet<String>(fieldsArray.length);
for(Field field: fieldsArray) {
String name = field.getName().toLowerCase(); // враховуємо можливість різних регістрів
Annotation[] annotations = field.getAnnotations(); // отримуємо всі анотації поля

boolean isNotNull = false;
for(Annotation annotation: annotations) {
if(annotation instanceof NotNull) { // отримуємо всі поля позначені NotNull
isNotNull = true;
} else if(annotation instanceof SerializedName) {
name = ((SerializedName) annotation).value().toLowerCase(); // якщо анотація SerializedName задана використовуємо її замість поля класу в fields і notNullFields
}
}
fields.add(name);
if(isNotNull) {
notNullFields.add(name);
}
}
}

@Override
public T deserialize(JsonElement json, Type type,
JsonDeserializationContext context)
throws JsonParseException {
if(fields == null) {
init(type); // Отримуємо структуру кожного класу через рефлексію тільки один раз для продуктивності
}
Set<Map.Entry<String, JsonElement>> entries = json.getAsJsonObject().entrySet();
Set<String> keys = new THashSet<String>(entries.size());
for (Map.Entry<String, JsonElement> entry : entries) {
if(!entry.getValue().isJsonNull()) { // Ігноруємо поля json, у яких значення null
keys.add(entry.getKey().toLowerCase()); // збираємо колекцію всіх імен полів в json
}
}
if (!fields.containsAll(keys)) { // поле є в json, але ні в Java класі - помилка
throw new JsonParseException("Parse error! The json has keys that isn't found in Java object:" + type);
}
if (!keys.containsAll(notNullFields)) { // поле в Java класі позначено як NotNull, але в json його немає - помилка
throw new JsonParseException("Parse error! The NotNull fields is absent in json for object:" + type);
}
return new Gson().fromJson(json, type); // запускаємо стандартний механізм обробки GSON
}
}
}


Власне, що ми робимо. В коментарях досить докладно все описано, але суть в тому що ми призначаємо JsonDeserializer на той клас, який ми збираємося перевіряти (або на всі класи). При першому зверненні до нього рефлексією піднімаємо структуру класу та анотацій до полів потім вони вже збережені і ми не витрачаємо на рефлексію час), якщо виявляємо зайві поля в json або відсутність полів, позначених нами як NotNull падаємо з JsonParseException. Природно, на продакшені падати можна більш м'яко, записуючи помилки в логи або в окрему колекцію. У будь-якому випадку, ми відразу можемо дізнатися «що це неправильні бджоли, і вони дають неправильний мед» і щось поміняти поки не встигли втратити важливі дані. Але тепер у нас GSON буде працювати строго.

Проблема номер 2. Великі файли і переповнення пам'яті
Наскільки я знаю, GSON всі дані отримує відразу в пам'ять, тобто зробивши fromJson ми отримаємо важкий об'єкт з усією структурою json в пам'яті. Поки json файли маленькі це не проблема, але от якщо там раптом виявиться масив на пару мільйонів об'єктів ми ризикуємо отримати out of memory. Звичайно, можна було б відмовитися від GSON і працювати у своєму проекті з двома різними бібліотеками парсинга json (але з ряду причин такого б не хотілося), але на щастя є gson.stream.JsonReader, який дозволяє парсити json за токенам не завантажуючи все відразу в пам'ять (а скажімо скидаючи на диск в якомусь форматі або періодично записуючи результати в базу даних). По суті сам GSON працює з допомогою JsonReader'а. Загальний алгоритм роботи з JsonReader теж дуже простий (напишу коротко лише суть роботи, так як тут все залежить від структури кожного конкретного json'а, тим більше, що в javadoc'e JsonReader є чудові приклади використання):

JsonReader jsonReader = new JsonReader(reader); // приймає будь-reader, наприклад fileReader, якщо ви вже зберегли json як локальний файл,

jsonReader має наступні методи:

- hasNext() - наступний токен в поточному об'єкті (ім'я, об'єкт, масив тощо)
- peek() - тип поточного сертифіката (ім'я, рядок, початку або кінець об'єкта масиву і т. д.)
- skipValue - пропустити токен
- beginObject(), beginArray() - почати обхід нового об'єкта/масиву і перейти до наступного токена
- endObject(), endArray() - закінчити обхід об'єкта/масиву і перейти до наступного токена
- nextString() - отримати і перейти до наступного токена
- і т. д.


Зверніть увагу тільки на те, що hasNext() повертає значення тільки для поточного об'єкта/масиву, а не всього файлу (це для мене виявилося несподіваним), і те що треба завжди акуратно перевіряти тип токена з допомогою peek(). В іншому, парсинг великих файлів таким способом, буде, звичайно, дещо менш зручним, ніж просто одна команда fromJson(), але тим не менш для простої структури json він пишеться буквально за кілька годин. Якщо ви знаєте кращий спосіб, як змусити GSON працювати з файлом по частинах без завантаження в пам'ять важкого об'єкта, напишіть в коментарях, буду дуже вдячний (мені приходило в голову тільки робити збереження розібраних об'єктів в JsonDeserializer і віддавати null, але таке рішення виглядає куди менш красивим ніж чесного парсинга токенів). Відразу відповідаю, інші бібліотеки з ряду причин не хотілося використовувати в даному випадку, але поради в яких бібліотеках ці проблеми можна вирішити простіше, теж для мене будуть корисні.

Всім дякую за увагу.

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

0 коментарів

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