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

в 9:18, , рубрики: java, javascript, 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.trove.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, но такое решение выглядит куда менее красивым чем честного парсинга токенов). Сразу отвечаю, другие библиотеки по ряду причин не хотелось использовать в данном случае, но советы в каких библиотеках эти проблемы можно решить проще, тоже для меня будут полезны.

Всем спасибо за внимание.

Автор: vedenin1980

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js