Работа с несовместимым изменением версии фреймворка сериализации

Описание проблемы

У нас есть кластер Hadoop, в котором мы храним данные, сериализованные в байты с помощью Kryo (инфраструктура сериализации). Версия Kryo, которую мы использовали для этого, была создана из официального релиза 2.21 для применения наших собственных исправлений к проблемам, с которыми мы столкнулись при использовании Kryo. Текущая версия Kryo 2.22 также устраняет эти проблемы, но с другими решениями. В результате мы не можем просто изменить используемую нами версию Kryo, потому что это означало бы, что мы больше не сможем читать данные, которые уже хранятся в нашем кластере Hadoop. Чтобы решить эту проблему, мы хотим запустить задание Hadoop, которое

  1. считывает сохраненные данные
  2. десериализует данные, хранящиеся в старой версии Kryo
  3. сериализует восстановленные объекты с помощью новой версии Kryo
  4. записывает новое сериализованное представление обратно в наше хранилище данных

Проблема в том, что использование двух разных версий одного и того же класса в одной Java-программе (точнее, в классе картографа задания Hadoop) — нетривиальная задача.

Коротко о вопросе

Как можно десериализовать и сериализовать объект с двумя разными версиями одной и той же среды сериализации в одном задании Hadoop?

Обзор релевантных фактов

  • У нас есть данные, хранящиеся в кластере Hadoop CDH4, сериализованные с помощью Kryo версии 2.21.2-ourpatchbranch.
  • Мы хотим сериализовать данные с помощью Kryo версии 2.22, которая несовместима с нашей версией.
  • Мы создаем JAR-файлы заданий Hadoop с помощью Apache Maven.

Возможные (и невозможные) подходы

(1) Переименование пакетов

Первый подход, который пришел нам в голову, состоял в том, чтобы переименовать пакеты в нашей собственной ветке Kryo, используя функции перемещения подключаемого модуля Maven Shade и выпустить его с другим идентификатором артефакта, чтобы мы могли полагаться на оба артефакта в нашей Конверсионный рабочий проект. Затем мы создаем экземпляр одного объекта Kryo как старой, так и новой версии и используем старый для десериализации, а новый — для повторной сериализации объекта.

Проблемы
Мы не используем Kryo явно в заданиях Hadoop, а получаем к нему доступ через несколько уровней наших собственных библиотек. Для каждой из этих библиотек необходимо

  1. переименовать вовлеченные пакеты и
  2. создать выпуск с другой группой или идентификатором артефакта

Чтобы еще больше запутать ситуацию, мы также используем сериализаторы Kryo, предоставляемые другими сторонними библиотеками, для которых нам придется делать то же самое.


(2) Использование нескольких загрузчиков классов

Второй подход, который мы придумали, заключался в том, чтобы вообще не зависеть от Kryo в проекте Maven, который содержит задание на преобразование, а загружать необходимые классы из JAR для каждой версии, которая хранится в распределенном кеше Hadoop. Тогда сериализация объекта будет выглядеть примерно так:

public byte[] serialize(Object foo, JarClassLoader cl) {
    final Class<?> kryoClass = cl.loadClass("com.esotericsoftware.kryo.Kryo");
    Object k = kryoClass.getConstructor().newInstance();
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    final Class<?> outputClass = cl.loadClass("com.esotericsoftware.kryo.io.Output");

    Object output = outputClass.getConstructor(OutputStream.class).newInstance(baos);
    Method writeObject = kryoClass.getMethod("writeObject", outputClass, Object.class);
    writeObject.invoke(k, output, foo);
    outputClass.getMethod("close").invoke(output);
    baos.close();
    byte[] bytes = baos.toByteArray();
    return bytes;
}

Проблемы
Хотя этот подход может работать для создания экземпляра ненастроенного объекта Kryo и сериализации/восстановления некоторого объекта, мы используем гораздо более сложную конфигурацию Kryo. Это включает в себя несколько пользовательских сериализаторов, зарегистрированные идентификаторы классов и так далее. Например, мы не смогли найти способ установить пользовательские сериализаторы для классов без получения ошибки NoClassDefFoundError — следующий код не работает:

Class<?> kryoClass = this.loadClass("com.esotericsoftware.kryo.Kryo");
Object kryo = kryoClass.getConstructor().newInstance();
Method addDefaultSerializer = kryoClass.getMethod("addDefaultSerializer", Class.class, Class.class);
addDefaultSerializer.invoke(kryo, URI.class, URISerializer.class); // throws NoClassDefFoundError

Последняя строка выдает

java.lang.NoClassDefFoundError: com/esotericsoftware/kryo/Serializer

потому что класс URISerializer ссылается на класс Serializer Kryo и пытается загрузить его, используя свой собственный загрузчик классов (который является загрузчиком классов System), который не знает класс Serializer.


(3) Использование промежуточной сериализации

В настоящее время наиболее многообещающим подходом кажется использование независимой промежуточной сериализации, например. JSON с использованием Gson или аналогичного, а затем запустить два отдельных задания:

  1. kryo:2.21.2-ourpatchbranch в нашем обычном хранилище -> JSON во временном хранилище
  2. JSON во временном хранилище -> kryo:2-22 в нашем обычном хранилище

Проблемы
Самая большая проблема с этим решением заключается в том, что оно примерно вдвое увеличивает объем обрабатываемых данных. Кроме того, нам нужен другой метод сериализации, который без проблем работает со всеми нашими данными, которые нам нужно будет изучить в первую очередь.


person Michael Schmeißer    schedule 18.04.2013    source источник
comment
Я бы порекомендовал третий вариант на первый взгляд. У вас есть номер версии или что-то в вашей полезной нагрузке?   -  person skirsch    schedule 22.04.2013
comment
По поводу (2): почему вы не звоните kyro.addDefaultSerializer(URI.class, URISerializer.class) напрямую? Зачем использовать отражение? И какой класс вызывает NoClassDefFoundError?   -  person skirsch    schedule 22.04.2013
comment
@skirsch Нет, у нас нет номера версии в наших сохраненных данных. По поводу (2): Я расширил описание проблемы в вопросе. В этот момент я использовал отражение, потому что тестовый проект не зависел от артефакта Kryo и, следовательно, тип Kryo не был известен во время компиляции.   -  person Michael Schmeißer    schedule 22.04.2013
comment
Так в чем именно огромная проблема? Создайте два задания MR, добавьте свой собственный Kryo в путь к классам для первого, который сбрасывает Kryo -> SequenceFile или что-то еще, что новый Kryo может затем подобрать, и используйте второе задание с новой версией Kryo в пути к классам для дампа в новый крио формат...   -  person TC1    schedule 22.04.2013
comment
@ TC1 Проблемы с этим подходом изложены в (3) вопроса - мы имеем дело с огромными объемами данных, и этот подход требует вдвое больше времени и места, чем оптимальное решение. Кроме того, нам нужен другой формат сериализации, кроме Kryo, для записи объектов в файлы.   -  person Michael Schmeißer    schedule 22.04.2013
comment
@MichaelSchmeißer: так что ваша основная проблема заключается в том, как читать старый формат и записывать новый - почему бы не создать другую версию крио, которая может читать ваш старый формат, и при необходимости исправить его из нового крио, чтобы записать новый? Не зная внутренностей крио, я не знаю, было бы это смехотворным предложением. Но, если это выполнимо, это позволит вам избежать проблем с переименованием пакетов, веселья и временного использования нескольких загрузчиков классов, дублирования хранилища в каком-либо другом сериализованном формате.   -  person philwb    schedule 27.04.2013
comment
@philwb Это было бы очень сложно и отняло бы много времени, потому что нам потенциально пришлось бы смотреть на различия обеих версий, и многое может пойти не так - Kryo - довольно сложная библиотека (поскольку я думаю, что все фреймворки сериализации ). Однако, если бы это было менее сложно, я попробовал вашу идею.   -  person Michael Schmeißer    schedule 28.04.2013


Ответы (3)


Я бы использовал подход с несколькими загрузчиками классов.

(Переименование пакетов также сработает. Это выглядит некрасиво, но это одноразовый прием, поэтому красота и правильность могут отойти на второй план. Промежуточная сериализация кажется рискованной — была причина, по которой вы используете Kryo, и эта причина будет отменена. с использованием другой промежуточной формы).

Общий дизайн будет таким:

child classloaders:      Old Kryo     New Kryo   <-- both with simple wrappers
                                \       /
                                 \     /
                                  \   /
                                   \ /
                                    |
default classloader:    domain model; controller for the re-serialization
  1. Загрузите классы объектов домена в загрузчик классов по умолчанию.
  2. Загрузите банку с модифицированной версией Kryo и кодом оболочки. Оболочка имеет статический метод main с одним аргументом: имя файла для десериализации. Вызовите основной метод через отражение из загрузчика классов по умолчанию:

        Class deserializer = deserializerClassLoader.loadClass("com.example.deserializer.Main");
        Method mainIn = deserializer.getMethod("main", String.class);
        Object graph = mainIn.invoke(null, "/path/to/input/file");
    
    1. This method:
      1. Deserializes the file as one object graph
      2. Помещает объект в общее пространство. ThreadLocal — это простой способ или его возврат к скрипту-оболочке.
  3. Когда вызов вернется, загрузите второй Jar с новой структурой сериализации с помощью простой оболочки. У оболочки есть статический «основной» метод и аргумент для передачи имени файла для сериализации. Вызов основного метода через отражение из загрузчика классов по умолчанию:

        Class serializer = deserializerClassLoader.loadClass("com.example.serializer.Main");
        Method mainOut = deserializer.getMethod("main", Object.class, String.class);
        mainOut.invoke(null, graph, "/path/to/output/file");
    
    1. This method
      1. Retrieves the object from the ThreadLocal
      2. Сериализирует объект и записывает его в файл

Соображения

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

for (String file: files) {
    Object graph = mainIn.invoke(null, file + ".in");
    mainOut.invoke(null, graph, file + ".out");
}

Имеют ли объекты предметной области какую-либо ссылку на какой-либо класс Kryo? Если это так, у вас есть трудности:

  1. Если ссылка является просто ссылкой на класс, например, для вызова метода, то при первом использовании класса будет загружена одна из двух версий Kryo в загрузчик классов по умолчанию. Это вероятно вызовет проблемы, поскольку часть сериализации или десериализации может быть выполнена неправильной версией Kryo.
  2. Если ссылка используется для создания экземпляров любых объектов Kryo и сохранения ссылки в модели предметной области (члены класса или экземпляра), то Kryo фактически сериализует часть себя в модели. Это может стать препятствием для такого подхода.

В любом случае, ваш первый подход должен состоять в том, чтобы изучить эти ссылки и удалить их. Один из способов убедиться, что вы это сделали, — убедиться, что загрузчик классов по умолчанию не имеет доступа к какой-либо версии Kryo. Если объекты домена каким-либо образом ссылаются на Kryo, ссылка завершится ошибкой (с ClassNotFoundError, если на класс ссылаются напрямую, или ClassNotFoundException, если используется отражение).

person Andrew Alcock    schedule 24.04.2013

Для 2 вы можете создать два файла jar, которые содержат сериализатор и все зависимости для новой и старой версий вашего сериализатора, как показано здесь. Затем создайте задание по сокращению карты, которое загружает каждую версию вашего кода в отдельный загрузчик классов, и добавьте некоторый связующий код в середине, который десериализуется со старым кодом, а затем сериализуется с новым кодом.

Вы должны быть осторожны, чтобы ваш объект домена загружался в тот же загрузчик классов, что и ваш связующий код, а код для сериализации/десериализации зависит от того же загрузчика классов, что и ваш связующий код, чтобы они оба видели один и тот же класс объекта домена.

person sbridges    schedule 23.04.2013
comment
Не могли бы вы предложить связующему коду использовать обе реализации с помощью отражения, как показано в моем примере кода, или есть ли лучший способ? - person Michael Schmeißer; 23.04.2013
comment
Вам нужно будет использовать отражение, как вы делаете сейчас для связующего кода, поскольку вы не можете получить доступ к классу напрямую, поскольку они будут загружены в другой загрузчик классов. Вероятно, вы можете свести отражение к одному методу в каждой банке. - person sbridges; 23.04.2013

Самый простой способ, который я придумал, не задумываясь, — это использование дополнительного Java-приложения, выполняющего преобразование за вас. Таким образом, вы отправляете двоичные данные во вторичное Java-приложение (простые локальные сокеты прекрасно справятся с этой задачей), поэтому вам не нужно возиться с загрузчиками классов или пакетами.

Единственное, о чем нужно думать, это промежуточное представление. Возможно, вы захотите использовать другой механизм сериализации или, если время не имеет значения, вы можете использовать внутреннюю сериализацию Java.

Использование второго Java-приложения избавляет вас от необходимости иметь дело с временным хранилищем и делать все в памяти.

И когда у вас есть эти сокеты + второй код приложения, вы обнаружите массу ситуаций, когда это пригодится.

Также можно построить локальный кластер с помощью jGroups и в конце концов избавить себя от хлопот с сокетами. jGroups — это самый простой API для связи, который я знаю. Просто сформируйте логический канал и проверьте, кто присоединяется. И лучше всего это даже работает в одной и той же JVM, что упрощает тестирование, и, если это делается удаленно, можно связать разные физические серверы вместе точно так же, как это работает для локальных приложений.

Еще одна переменная альтернатива — использование ZeroMQ с его протоколом ipc (межпроцессное взаимодействие).

person Martin Kersten    schedule 04.07.2015