Сериализация в Java: как заглянуть внутрь черного ящика

Автор оригинала: Eamonn McManus
  • Перевод
Испокон веку в Java есть чудесный механизм сериализации, который позволяет, не прилагая особых умственных усилий, сохранять в виде последовательности байт сколь угодно сложные графы объектов. Формат хранения хорошо документирован, есть куча примеров, сериализованные объекты «весят» вполне себе немного, пересылаются по сети на раз, есть куча возможностей для кастомизации… Все это звучит прекрасно, но только до тех пор, пока вы не останетесь один на один каким-нибудь многомегабайтным бинарным файлом, содержащим очень-очень ценные и нужные именно сейчас данные.

Как голыми руками залезть в этот файл, и понять, что же хранится внутри этого огромного сериализованного графа объектов, не имея исходного кода? На эти и многие другие вопросы может ответить Serialysis – библиотека, которая позволит вам детально проанализировать сериализованные java-объекты (сериализованная форма — это мой вариант перевода выражения serial forms, решил не уходить далеко от оригинала). Таким образом можно получить информацию об объекте, которая не доступна через его публичный API. Библиотека также является полезным инструментом при тестировании сериализации ваших собственных классов.


От переводчика:
Суббота. Вечер. Ничто не предвещало работу в этот день, но вдруг я вспоминаю, что неплохо бы проверить, как поживают наши джобы на hadoop-кластере, просто так, для успокоения совести — ведь проблема-то была решена…

<Лирическое отступление>
В последние несколько дней довольно много задач стали завершаться с OutOfMemoryError на нашем hadoop-кластере в production-е, наращивать объем выделяемой памяти возможности больше не было, и мы с IT-отделом потратили изрядное количество времени на попытки найти причину. Закончилось это тем, что наш американский коллега задумчиво посмотрел на конфиги, поправил пару строчек, и сказал что проблема решена.
И действительно, в пятницу все стало хорошо, и мы в очередной раз порадовались наличию Cloudera Certified Developer-а в команде.
</Лирическое отступление>

Но не тут-то было!
Хадуп показывал, что ни одна задача в эту злополучную субботу не выполнилась.
Причина падений несколько отличалась от прежней: task tracker не мог запустить задачу, потому что ему не хватало памяти, чтобы загрузить xml-файл конфигурации задачи.

Мне, конечно, сразу стало интересно, что же за монструозные конфигурации там хранятся? Увы, большую часть занимал сериализованный блоб на полсотни мегабайт. Блоб состоит из графа объектов десятка разных классов, для которых у меня конечно же нет исходников.

Что же можно сделать с этим многомегабайтным бинарником вечером субботы с помощью одних лишь подручных средств?

Тут на сцену выходит мой спаситель: Serialysis. Пара строк кода — и у меня на руках есть полный дамп внутренностей сериализованного объекта, с именами классов и полей. Имея на руках полный дамп, нахожу проблему, включаю gzip компрессию для словаря строк, патчу классы с помощью JBE. Вуаля — проблема решена!

Это хак, конечно, но иногда без хаков — никуда.

P.S. Библиотека давнишняя, но в данный момент оказалась как нельзя кстати. Признаться, часть применений, которые нашел для библиотечки автор, мне кажутся весьма странными. Ради Бога, ну не дали узнать порт, значит и не очень надо! На мой взгляд, лучшее применение этой технологии — это отладка и troubleshooting всех видов, в этой области равных ей действительно нет.

Собственно, статья:

Когда публичного API не достаточно

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

Например, у вас есть заглушка (stub) для удаленного объекта RMI, и вы хотите узнать, через какой адрес или порт она будет подключаться, или какую фабрику сокетов RMI (RMIClientSocketFactory) будет использовать. Стандартный API RMI не дает способа извлечь информацию из заглушки. Чтобы заглушка могла функционировать после десериализации, эта информация должна присутствовать в сериализованной форме. Поэтому мы могли бы получить необходимую информацию, если б только удалось каким-то образом разобрать сериализованную заглушку.

Второй пример взят из JMX API. Запросы к серверу MBean представлены интерфейсом QueryExp. Примеры QueryExp построены на использовании методов Query class. Если ваш объект принадлежит к QueryExp, как вы узнаете, какие запросы он выполняет? JMX API не предлагает никакого пути это узнать. Информация должна быть представлена в сериализованной форме так, чтобы, когда клиент делает запрос к удаленному серверу, её возможно было восстановить на сервере. Если мы можем увидеть сериализованную форму, мы сможем и определить, какой был запрос.

Второй пример как раз и побудил меня написать эту библиотеку. Существующие стандартные JMX-коннекторы базируются на Java-сериализации, поэтому в них не требуется каким-либо особым образом обрабатывать QueryExps. Но в новом Web Services
Connector введенном в JSR 262 используется XML для сериализации. Как анализировать QueryExp, чтобы затем преобразовать его в XML? Ответ прост: WS-коннектор использует версию данной библиотеки, чтобы заглянуть внутрь сериализованных QueryExp.

Все эти примеры объединяет одно: они демонстрируют пробелы в соответствующих интерфейсах API. А значит, нужны методы, позволяющие извлекать информацию из RMI-заглушки. Равно как нужен способ преобразовать QueryExp обратно к исходному методу Query, который его породил. (Достаточно даже стандартного вывода toString(), пригодного к разбору). Но таких методов сейчас нет, и если мы хотим код, который будет работать с этими API в их нынешнем виде, нам нужен другой подход.

Проникаем в приватные поля объектов

Если у вас есть исходный код интересующих вас классов, то велик соблазн просто влезть и взять желанные данные. В примере с заглушкой RMI, мы путем эксперимента можем узнать что метод заглушки getRef() возвращает sun.rmi.server.UnicastRef, и изучив исходники JDK, мы выясним, что этот класс содержит поле ref типа sun.rmi.transport.LiveRef, как раз с той информацией, которая нам нужна. Так что получим примерно такой код (но скажу заранее, не стоит этого делать):

import sun.rmi.server.*;
import sun.rmi.transport.*;
import java.rmi.*;
import java.rmi.server.*;

public class StubDigger {
    public static getPort(RemoteStub stub) throws Exception {
        RemoteRef ref = stub.getRef();
    	UnicastRef uref = (UnicastRef) ref;
    	Field refField = UnicastRef.class.getDeclaredField("ref");
    	refField.setAccessible(true);
    	LiveRef lref = (LiveRef) refField.get(uref);
    	return lref.getPort();
    }
}

Возможно, результат вас вполне устроит, но, повторяю, делать так не советую — этот код никуда не годится. Во-первых, никогда не используйте зависимость от классов sun.*, потому что никто не может гарантировать, что они не изменятся до неузнаваемости при любом обновлении JDK, к тому же ваш код однозначно будет нелегко портировать на другие платформы JDK. Во-вторых, когда вы видите что-то вроде Field.setAccessible, то вам следует воспринимать это, как знак стоп. Это означает, что ваш код зависит от недокументированных полей, которые могут меняться от релиза к релизу или, того хуже, которые могут сохраниться, но с измененной семантикой.

(Этот код был написан для JDK 5. Как оказалось, в JDK 6 LiveRef приобрел публичный метод getPort(), поэтому вам больше не нужен Field.setAccessible. Но в любом случае, не стоит зависеть от sun.* классов.)

Конечно иногда не получится найти лучшего решения. Но если те классы, которыми вы всерьез заинтересовались, оказались сериализуемыми, то вполне возможно, вам это удастся. Дело в том, что сериализованная форма класса является частью его контракта. Если API не совсем пропащий, то его внешний контракт будет совместим с его предыдущими версиями. Это очень важное условие, в частности для платформы JDK.

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

Описание сериализованной формы включается в Javadoc в разделе «See Also» для каждого сереализируемого класса. Вы можете найти сериализованные формы всех общедоступных JDK классов здесь , на одной огромной странице.

Здравствуй, Serialysis!

Моя библиотека для получения метаданных сериализованных объектов называется Serialysis, от соединения слов «serial analysis».

Приведу простой пример, как это действует. Этот код…

	SEntity sint = SerialScan.examine(new Integer(5));
	System.out.println(sint);

… выведет вот это…

SObject(java.lang.Integer){
  value = Prim(int){5}
}

Это говорит о том, что объект типа java.lang.Integer, который мы передали в SerialScan.examine, сериализуется как объект с единственным полем типа int внутри. Если мы проверим документированную сериализованную форму java.lang.Integer , то увидим, что это как раз то, что и ожидалось.

Если вы заглянете в исходный код java.lang.Integer, то увидите, что сам класс также имеет единственное поля value типа int:

/**
     * The value of the <code>Integer</code>.
     *
     * @serial
     */
    private final int value;

Но приватные поля – это детали реализации. В обновлении поле может быть переименовано или заменено на новое, унаследованное от родительского класса java.lang.Number, или любого другого. И нет никакой гарантии, что этого не произойдет, но есть гарантия, что сериализованная форма останется без изменений. Сериализация предоставляет механизм сохранения сериализованной формы в изначальном виде, даже если поля класса изменились.

Вот более сложный пример. Предположим, что по некоторым причинам мы хотим узнать, насколько велик массив внутри ArrayList. API не дает нам нужных сведений, хотя он позволяет нам заставить выделить массив не меньше заданного.

Если мы заглянем в сериализованную форму ArrayList, то увидим, что он содержит информацию, которую мы ищем. Там указано сериализованное поле size, которое представляет собой число элементов в списке, но это не то, что нам нужно. А вот бинарные данные в методе WriteObject как раз содержат то, что нужно:

Serial Data:
содержит длину внутреннего массива ArrayList, а за ней — все элементы (каждый в качестве объекта) в заданном порядке.

Если мы запустим этот код…

List<Integer> list = new ArrayList<Integer>();
	list.add(5);
	SObject slist = (SObject) SerialScan.examine(list);
	System.out.println(slist);

… то получим следующий вывод…

SObject(java.util.ArrayList){
  size = SPrim(int){1}
  -- data written by class's writeObject:
  SBlockData(blockdata){4 bytes of binary data}
  SObject(java.lang.Integer){
    value = SPrim(int){5}
  }
}

Здесь мы попадаем в темные дебри сериализации. В дополнение к сериализации полей объекта, или же вместо неё, класс может иметь метод writeObject(ObjectOutputStream), который записывает произвольные данные в поток, используя методы типа ObjectOutputStream.writeInt. Класс также должен содержать соответствующей метод readObject, который читает те же данные, и с помощью тега @serialData следует документировать, что именно записывает метод WriteObject, также как сделано в ArrayList.

Данные writeObject в Serialysis можно получить через метод SObject.getAnnotations(), который возвращает List. Каждый объект, записанный с помощью метода ObjectOutputStream.writeObject(Object) представляется в этом списке как SObject. Каждый кусок данных записанный одним или несколькими последовательными обращениями к методам ObjectOutputStream, унаследованным от DataOutput (writeInt, writeUTF и так далее) представляется как SBlockData. Сериализованный поток не позволяет выделить отдельные элементы внутри этого куска; эта информация – соглашение между писателем и читателем, задокументированое в тэге @serialData.

Основываясь на документации ArrayList, мы можем получить размер массива таким образом:

SObject slist = (SObject) SerialScan.examine(list);
	List<SEntity> writeObjectData = slist.getAnnotations();
	SBlockData data = (SBlockData) writeObjectData.get(0);
	DataInputStream din = data.getDataInputStream();
	int alen = din.readInt();
	System.out.println("Array length: " + alen);

Как Serialysis решает мои тестовые задачи

Опуская полный исходный код, приведу только набросок решения к задаче QueryExp, о которой я говорил в начале. Предположим, у меня QueryExp строится вот так:

QueryExp query =
    Query.or(Query.gt(Query.attr("Version"), Query.value(5)),
	     Query.eq(Query.attr("SupportsSpume"), Query.value(true)));

Это означает: «дай мне MBean-ы с атрибутом Version больше 5 или атрибутом SupportsSpume, равным true». toString() для этого запроса в JDK выглядит так:

((Version) > (5)) or ((SupportsSpume) = (true))

А так выглядит результат SerialScan.examine:

SObject(javax.management.OrQueryExp){
  exp1 = SObject(javax.management.BinaryRelQueryExp){
    relOp = SPrim(int){0}
    exp1 = SObject(javax.management.AttributeValueExp){
      attr = SString(String){"version"}
    }
    exp2 = SObject(javax.management.NumericValueExp){
      val = SObject(java.lang.Long){
        value = SPrim(long){5}
      }
    }
  }
  exp2 = SObject(javax.management.BinaryRelQueryExp){
    relOp = SPrim(int){4}
    exp1 = SObject(javax.management.AttributeValueExp){
      attr = SString(String){"supportsSpume"}
    }
    exp2 = SObject(javax.management.BooleanValueExp){
      val = SPrim(boolean){true}
    }
  }
}

Легко представить себе код, который погружается в эту структуру, создавая XML-эквивалент. От каждой совместимой реализации JMX API требуется создавать точно такую же сериализованную форму, поэтому анализирующий её код гарантированно будет работать где угодно.

Теперь код, который решает проблему номера порта в RMI заглушке:

 public static int getPort(RemoteStub stub) throws IOException {
	SObject sstub = (SObject) SerialScan.examine(stub);
	List<SEntity> writeObjectData = sstub.getAnnotations();
	SBlockData sdata = (SBlockData) writeObjectData.get(0);
	DataInputStream din = sdata.getDataInputStream();
	String type = din.readUTF();
	if (type.equals("UnicastRef"))
	    return getPortUnicastRef(din);
	else if (type.equals("UnicastRef2"))
	    return getPortUnicastRef2(din);
	else
	    throw new IOException("Can't handle ref type " + type);
    }

    private static int getPortUnicastRef(DataInputStream din) throws IOException {
	String host = din.readUTF();
	return din.readInt();
    }

    private static int getPortUnicastRef2(DataInputStream din) throws IOException {
	byte hasCSF = din.readByte();
	String host = din.readUTF();
	return din.readInt();
    }

Чтобы разобраться в нем, взгляните на описание сериализованной формы RemoteObject.

Этот код, конечно, трудный, но зато он легко портируем и перспективен в использовании. Думаю, нет смысла объяснять, как извлекать из RMI-заглушек все остальные данные — используйте этот же способ.

Заключение

Скорее всего, вы не захотите копаться в сериализованных формах до тех пор, пока не возникнет серьезная необходимость. Но если без этого не обойтись, Serialysis способен существенно упростить вам задачу.

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

Скачать библиотеку Serialysis можно отсюда: http://weblogs.java.net/blog/emcmanus/serialysis.zip.
Maxifier Development
Компания
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 1

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

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое