Пять вещей, которые вы не знали о... сериализации Java-объектов

Вы думали, что сериализованные данные в безопасности? Подумайте еще раз.

Сериализация является настолько фундаментальным компонентом платформы Java, что часто люди полагают, что знают о ней все. Однако, как и в случае многих других аспектов платформы Java, сериализация награждает тех, кто ищет. В этой первой статье нового цикла Тед Ньювард расскажет вам о пяти причинах еще раз просмотреть API сериализации Java-объектов, среди которых будут приемы (с примерами кода) рефакторинга, шифрования и проверки сериализованных данных.

Тед Ньювард, Глава, Neward & Associates

Тед Ньювард - глава Neward & Associates, где он консультирует, руководит, обучает и внедряет Java, .NET, XML Services и другие платформы. Он проживает возле Сиэтла, штат Вашингтон.



26.04.2010 (Впервые опубликовано 21.12.2011)

Несколько лет назад, работая с командой разработчиков, создававшей Java-приложение, мне пригодилось то, что я знал о сериализации Java-объектов немного больше, чем обычный программист.

Об этой серии статей

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

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

Это была элегантная и изменяемая система настроек до тех пор, пока команда не решила перейти с Hashtable на класс из библиотеки коллекций Java – HashMap.

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

Разработчики зашли в тупик, но только потому, что они не знали одного важного (но малоизвестного) факта о сериализации в Java: сериализация позволяет типам данных эволюционировать с течением времени. Когда я им показал, как автоматически заменять сериализованные данные, переход к HashMap прошел по плану.

Это первая статья из цикла, посвященного рассказам о полезных мелочах платформы Java – малоизвестных вещах, которые могут пригодиться при решении сложных задач программирования на Java.

Сериализация Java-объектов – это отличный API для начала работы, так как он присутствует в платформе с самого начала: с JDK 1.1. В этой статье вы узнаете о пяти связанных с сериализацией вещах, которые должны убедить вас еще раз просмотреть даже стандартные API Java.

Сериализация Java для начинающих

Сериализация Java-объектов появилась как один из основополагающих компонентов JDK 1.1, она является механизмом трансформации графа Java-объектов в массив байтов для хранения и передачи, который позднее можно обратно трансформировать в граф Java-объектов.

В сущности, идея сериализации состоит в том, чтобы "заморозить" граф объекта, переместить его (на диск, по сети и т.д.) и затем "разморозить" его обратно в пригодные для использования Java-объекты. Все это происходит более или менее магическим образом благодаря классам ObjectInputStream/ObjectOutputStream, абсолютно точным метаданным и желанию программистов поучаствовать в процессе, маркируя свои классы как поддерживающие интерфейс Serializable.

В листинге 1 показан класс Person, реализующий интерфейс Serializable.

Листинг 1. Класс Person с интерфейсом Serializable
package com.tedneward;

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;

}

Теперь можно сериализовать объект класса Person, после чего записать полученный граф объекта на диск и прочитать его снова, как показано в следующем юнит-тесте JUnit 4.

Листинг 2. Десериализуем объект класса Person
public class SerTest
{
    @Test public void serializeToDisk()
    {
        try
        {
            com.tedneward.Person ted = new com.tedneward.Person("Ted", "Neward", 39);
            com.tedneward.Person charl = new com.tedneward.Person("Charlotte",
                "Neward", 38);

            ted.setSpouse(charl); charl.setSpouse(ted);

            FileOutputStream fos = new FileOutputStream("tempdata.ser");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(ted);
            oos.close();
        }
        catch (Exception ex)
        {
            fail("Exception thrown during test: " + ex.toString());
        }
        
        try
        {
            FileInputStream fis = new FileInputStream("tempdata.ser");
            ObjectInputStream ois = new ObjectInputStream(fis);
            com.tedneward.Person ted = (com.tedneward.Person) ois.readObject();
            ois.close();
            
            assertEquals(ted.getFirstName(), "Ted");
            assertEquals(ted.getSpouse().getFirstName(), "Charlotte");

            // Clean up the file
            new File("tempdata.ser").delete();
        }
        catch (Exception ex)
        {
            fail("Exception thrown during test: " + ex.toString());
        }
    }
}

Ничто из того, что вы видели до сих пор, не является чем-то новым или удивительным – это основы сериализации, однако это хорошее место для начала работы. Мы воспользуемся классом Person, чтобы рассказать о пяти вещах, связанных с сериализацией Java-объектов, о которых вы, возможно, до этого не знали.


1. Сериализация позволяет делать рефакторинг кода

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

Вот наиболее важные изменения, с которыми спецификация Java Object Serialization может справляться автоматически:

  • добавление в класс новых полей;
  • изменение полей из статических в нестатические;
  • изменение полей из транзитных в нетранзитные.

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

Рефакторинг сериализованного класса

Зная, что сериализация позволяет выполнять рефакторинг, давайте посмотрим, что произойдет при добавлении в класс Person нового поля.

В классе PersonV2, показанном в листинге 3, по сравнению с изначальным классом Person появилось новое поле, определяющее пол человека.

Листинг 3. Добавляем новое поле к сериализованному классу Person
enum Gender
{
    MALE, FEMALE
}

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a, Gender g)
    {
        this.firstName = fn; this.lastName = ln; this.age = a; this.gender = g;
    }
  
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public Gender getGender() { return gender; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setGender(Gender value) { gender = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " gender=" + gender +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
    private Gender gender;
}

При сериализации используется хеш, для вычисления которого применяется практически все, что имеется в коде класса – имена методов, имена полей, типы полей, модификаторы доступа и т.д. Значение этого хеша сравнивается со значениями хеша в сериализованном потоке.

Чтобы убедить среду выполнения Java, что два типа фактически являются одним и тем же, во второй и всех последующих версиях класса Person должна быть та же самая версия хеша сериализации, что и в изначальном классе (она хранится в закрытом статическом поле serialVersionUID). Поэтому то, что нам нужно, – это поле serialVersionUID, которое вычисляется с помощью JDK-команды serialver, выполненной для изначальной версии класса Person.

При наличии поля serialVersionUID класса Person мы можем не только создавать объекты класса PersonV2 из сериализованных объектов изначального класса (новое поле в них будет иметь значение по умолчанию для данного типа, в большинстве случаев – "null"), но и наоборот: десериализовывать объекты класса PersonV2 в объекты изначального класса Person.


2. Сериализация не безопасна

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

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

К счастью, в API сериализации имеется возможность "вклиниться" в процесс сериализации, дабы обезопасить (или запутать) поля данных как перед сериализацией, так и после десериализации. Это можно сделать, определив метод writeObject объекта Serializable.

Запутываем сериализованные данные

Предположим, что в классе Person необходимо защитить данные в поле возраста (age); в конце концов, леди никогда не раскрывают своего возраста, а джентльмены никогда о нем не заговаривают. Мы можем замаскировать эти данные, сдвинув биты в этом поле перед сериализацией и убрав сдвиг после десериализации. (Я оставлю вам задачу разработки более безопасного алгоритма, этот алгоритм послужит нам лишь для примера.).

Чтобы модифицировать процесс сериализации, мы реализуем в классе Person метод writeObject, а чтобы модифицировать процесс десериализации, мы реализуем в том же классе метод readObject. Важно правильно понимать, как работают оба этих метода – если модификаторы доступа, параметры или имя отличаются от того, что показано в листинге 4, код просто не сработает, и поле age нашего класса Person будет доступно любому наблюдателю.

Листинг 4. Маскируем сериализованные данные
public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }
    
    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    private void writeObject(java.io.ObjectOutputStream stream)
        throws java.io.IOException
    {
        // "Encrypt"/obscure the sensitive data
        age = age >> 2;
        stream.defaultWriteObject();
    }

    private void readObject(java.io.ObjectInputStream stream)
        throws java.io.IOException, ClassNotFoundException
    {
        stream.defaultReadObject();

        // "Decrypt"/de-obscure the sensitive data
        age = age << 2;
    }
    
    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + (spouse!=null ? spouse.getFirstName() : "[null]") +
            "]";
    }      

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
}

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


3. Сериализованные данные можно подписывать и упаковывать

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

Если вам нужно зашифровать и подписать целый объект, проще всего поместить его в оберточный класс javax.crypto.SealedObject и/или java.security.SignedObject. Оба эти класса являются сериализуемыми, поэтому при оборачивании объекта в SealedObject создается нечто вроде "подарочной упаковки" вокруг исходного объекта. Для шифрования вам нужен симметричный ключ, управление которым должно осуществляться отдельно. Аналогично, для проверки данных можно использовать класс SignedObject, для работы с которым нужен симметричный ключ, управляемый отдельно.

Вместе эти два объекта позволяют упаковывать и подписывать сериализованные данные, не отвлекаясь на детали проверки и шифрования цифровых подписей. Красиво, не правда ли?


4. Для сериализации можно использовать прокси-класс

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

Если нашей главной заботой является сериализация, то лучше для нее использовать специальный прокси, из которого можно воссоздать весь объект. Определив для класса Person метод writeReplace, можно задать, какой объект следует сериализовывать вместо него; аналогично, если определить метод readResolve, то он будет вызываться во время десериализации, чтобы вернуть вызывающей стороне объект-замену.

Упаковываем и распаковываем прокси-класс

Вместе методы writeReplace и readResolve позволяют классу Person упаковывать все данные (или их наиболее важную часть) в объект класса PersonProxy, помещать его в поток и затем распаковать его при десериализации.

Листинг 5. Заполни меня, и я тебя заменю
class PersonProxy
    implements java.io.Serializable
{
    public PersonProxy(Person orig)
    {
        data = orig.getFirstName() + "," + orig.getLastName() + "," + orig.getAge();
        if (orig.getSpouse() != null)
        {
            Person spouse = orig.getSpouse();
            data = data + "," + spouse.getFirstName() + "," + spouse.getLastName() + ","  
              + spouse.getAge();
        }
    }

    public String data;
    private Object readResolve()
        throws java.io.ObjectStreamException
    {
        String[] pieces = data.split(",");
        Person result = new Person(pieces[0], pieces[1], Integer.parseInt(pieces[2]));
        if (pieces.length > 3)
        {
            result.setSpouse(new Person(pieces[3], pieces[4], Integer.parseInt
              (pieces[5])));
            result.getSpouse().setSpouse(result);
        }
        return result;
    }
}

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    private Object writeReplace()
        throws java.io.ObjectStreamException
    {
        return new PersonProxy(this);
    }
    
    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }   

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    
    
    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
}

Обратите внимание, что в классе PersonProxy необходимо отслеживать все данные класса Person. Часто это означает, что прокси должен быть внутренним классом класса Person, чтобы иметь доступ к его закрытым полям. Также в прокси иногда нужно отслеживать ссылки на другие объекты и сериализовывать их вручную, например супругу(а) нашего объекта класса Person.

Это один из немногих приемов, который не обязательно должен быть сбалансирован относительно чтения и записи. Например, в обновленной версии класса можно реализовать метод readResolve, который будет незаметно конвертировать сериализованный объект в новый тип. Аналогично, в нем может использоваться метод writeReplace для того, чтобы сериализовывать объекты старых классов в новые версии.


5. Доверяй, но проверяй

Хотелось бы предполагать, что данные в сериализованном потоке – это всегда те же самые данные, которые были изначально записаны в поток. Однако, как однажды заметил один из экс-президентов США, безопаснее "доверять, но проверять".

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


В заключение

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

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

Далее в нашем циклестатей мы расскажем о Java-коллекциях. А до тех пор забавляйтесь с API сериализации, как вам заблагорассудится!


Загрузка

ОписаниеИмяРазмер
Пример кода для этой статьи5things1-src.zip10 КБ

Ресурсы

  • Ознакомьтесь с оригиналом статьи: 5 things you didn't know about ... Java Object Serialization (developerWorks, май 2010г.).
  • В своем блоге Тед Ньювард обсуждает с dW свои новые статьи и API коллекций в Java.
  • Test object serialization (Elliotte Rusty Harold, IBM developerWorks, июнь 2006г.): узнайте, почему важно тестировать сериализованные формы объектов, а потом опробуйте различные способы тестирования сериализации объектов.
  • Discover the secrets of the Java Serialization API (Todd M. Greanier, JavaWorld, июль 2000г.): здесь представлен обзор API сериализации в Java, а также рассказывается о трех подходах к сериализации Java-объектов.
  • The Java Serialization algorithm revealed (Sathiskumar Palaniappan, JavaWorld, май 2009г.): здесь более подробно рассматривается механизм используемого в Java алгоритма сериализации.
  • Java Object Serialization: загрузите спецификацию сериализации Java в формате PDF.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=782005
ArticleTitle=Пять вещей, которые вы не знали о... сериализации Java-объектов
publish-date=04262010