Сериализация объектов

Сериализация объекта представляет процесс перевода какой-либо структуры данных в последовательность битов. Обратной к операции сериализации является операция десериализации, т.е. восстановление начального состояния структуры данных из битовой последовательности. Существует два способа сериализации объекта : стандартная сериализация java.io.Serializable и «расширенная» сериализация java.io.Externalizable.

Интерфейс java.io.Serializable

При использовании Serializable применяется стандартный алгоритм сериализации, который с помощью рефлексии (Reflection API) выполняет

  • запись в поток метаданных о классе, ассоциированном с объектом (имя класса, идентификатор SerialVersionUID, идентификаторы полей класса),
  • рекурсивную запись в поток описания суперклассов до класса java.lang.Object (не включительно),
  • запись примитивных значений полей сериализуемого экземпляра, начиная с полей самого верхнего суперкласса,
  • рекурсивную запись объектов, которые являются полями сериализуемого объекта.

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

Для выполнения десериализации под объект выделяется память, после чего его поля заполняются значениями из потока. Конструктор объекта при этом не вызывается. Однако при десериализации будет вызван конструктор без параметров родительского несериализуемого класса, а его отсутствие повлечёт ошибку десериализации.

Интерфейс java.io.Externalizable

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

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

Где используется сериализация Serializable?

Сериализация была введена в JDK 1.1 и позволяет преобразовать отдельный объект или группу объектов в поток битов для передачи по сети или для сохранения в файл. И как было сказано выше, данный массив байтов или поток битов, можно обратно преобразовать в объекты Java. Главным образом это происходит автоматически благодаря классам ObjectInputStream и ObjectOutputStream.

Класс сериализации Person

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

public class Person implements java.io.Serializable
{
    private static final long serialVersionUID = 1L;

    private  String  firstName;
    private  String  lastName;
    private  int     age;
    private  Person  spouse; // супруг(а)

    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName  = lastName;
        this.age       = age;
    }

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

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

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

Модификатор поля transient

Использование при описании поля класса модификатора transient позволяет исключить указанное поле из сериализации. Это бывает полезно для секретных (пароль) или не особо важных данных. Если, например, при описании объекта Person включить следующее поле address

transient public String address;

то в результате сериализации и десериализации адрес объекта принимает значение по умолчанию или будет null.

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

Модификатор поля static

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

Модификатор поля final

Поля с модификатором final сериализуются как и обычные. За одним исключением – их невозможно десериализовать при использовании Externalizable, поскольку final-поля должны быть инициализированы в конструкторе, а после этого в readExternal изменить значение этого поля будет невозможно. Соответственно, если необходимо сериализовать объект с final-полем неоходимо использовать только стандартную сериализацию.

Пример тестирования сериализации и десериализации

Для тестирования сериализации и десериализации объекта Person будем использовать юнит-тест JUnit, в котором создадим 2 объекта, запишем объекты в файл, после чего восстановим их. Более подробно об использовании JUnit сказано на странице Тестирование программы.

import static org.junit.Assert.*;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

import org.junit.Test;
import org.junit.AfterClass;
import org.junit.BeforeClass;

import example.Person;

public class JUnitPerson
{
    private  static  final  String  FILE       = "data.ser";
    private  static  final  String  FNAME_Alex = "Алексей" ;
    private  static  final  String  FNAME_Olga = "Ольга"   ;
    private  static  final  String  LAST_NAME  = "Иванов"  ;
    private  static  final  int     AGE_Alex   = 39        ;
    private  static         Person  alex       = null      ;
    private  static         Person  olga       = null      ;

    @BeforeClass
    public static void setUpBeforeClass() throws Exception
    {
        try {
            alex = new Person(FNAME_Alex, LAST_NAME, AGE_Alex);
            olga = new Person(FNAME_Olga, LAST_NAME, 38);

            alex.setSpouse(olga);
            olga.setSpouse(alex);

            FileOutputStream   fos = new FileOutputStream(FILE);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(alex);
            oos.writeObject(olga);
            oos.close();
        } catch (Exception e) {
            fail("Exception thrown during test: " + e.toString());
        }
    }

    @AfterClass
    public static void tearDownAfterClass() throws Exception {
        // Удаление файла
        new File(FILE).delete();
    }
    @Test
    public void testSerialization()
    {
        try {
            FileInputStream fis = new FileInputStream(FILE);
            ObjectInputStream ois = new ObjectInputStream(fis);
            Person alex = (Person) ois.readObject();
            Person olga = (Person) ois.readObject();
            ois.close();

            assertEquals(alex.getFirstName(), FNAME_Alex);
            assertEquals(alex.getLastName() , LAST_NAME );
            assertEquals(olga.getFirstName(), FNAME_Olga);
            assertEquals(alex.getAge()      , AGE_Alex  );
            assertEquals(alex.getSpouse().getFirstName(), FNAME_Olga);

        } catch (Exception e) {
            fail("Exception thrown during test: " + e.toString());
        }
    }
}

В примере ничего нового или удивительного не представлено – это основы сериализации, которые желательно знать, особенно при разработке WEB-приложений.

Переопределение сериализации в интерфейсе Serializable

Если у сериализуемого объекта реализован один из следующих методов, то механизм сериализации будет использовать его, а не метод по умолчанию :

  • writeObject - запись объекта в поток;
  • readObject - чтение объекта из потока;
  • writeReplace - позволяет заменить себя экземпляром другого класса перед записью;
  • readResolve - позволяет заменить на себя другой объект после чтения.

Ниже приводятся примеры использования данных методов.

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

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

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

Изменение сериализованных данных, writeObject, readObject

Чтобы изменить процесс сериализации в классе Person реализуем метод writeObject. Для модифицирования процесса десериализации определим в том же классе метод readObject. При реализации этих методов необходимо корректно восстановить данные.

private void writeObject(ObjectOutputStream stream) throws IOException
{
    // "Криптование"/скрытие истинного значения
    age = age << 2;
    stream.defaultWriteObject();
}

private void readObject(ObjectInputStream stream) throws IOException, 
                                               ClassNotFoundException
{
    stream.defaultReadObject();
    // "Декриптование"/восстановление истинного значения
    age = age >> 2;
}

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

Сериализация и рефакторинг кода

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

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

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

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

Для тестирования сериализации с измененной структурой класса Person на основе предыдущего примера необходимо проделать следующие предварительные шаги :

  1. Создать файл данных с использованием метода setUpBeforeClass класса JUnitPerson и блокировкой удаления файла в методе tearDownAfterClass; можно закоментировать строку удаления файла.
  2. Блокировать создание файла данных в методе setUpBeforeClass класса JUnitPerson при следующем выполнении; также можно закоментировать строки создание файла.
  3. Изменить код класса Person, как это представлено в следующем листинге.

Листинг изменений класса Person

// Определение вне класса Person
enum Gender { Male, Female }

// Следующие изменения внутри класса Person

// Переменная
private Gender gender;

// Методы get и set
public Gender getGender() {
    return gender;
}
public void setGender(Gender gender) {
    this.gender = gender;
}
// Текстовое описание объекта
public String toString() {
    return "[Person: firstName = " + firstName + 
           ", lastName = " + lastName +
           ", gender = " + gender +
           ", age = " + age +
           ", spouse = " + spouse.getFirstName() + "]";
}    

Теперь можно выполнить тест testSerialization класса JUnitPerson и увидеть, что тест прошел успешно, т.е. класс ObjectInputStream прочитал данные и объект был восстановлен корректно. При желании можно в конце кода testSerialization вставить строку

fail(alex.toString());

чтобы убедиться, что значения объекта восстановлены правильно, и что параметр gender равен null.

Проверка десериализованного объекта, ObjectInputValidation и validateObject

Если есть необходимость выполнения контроля за значениями десериализованного/восстановленного объекта, то можно использовать интерфейс ObjectInputValidation с переопределением метода validateObject. В следующем листинге представлены изменения, которые следует внести в описание класса Person, чтобы контролировать возраст.

// Добавление интерфейса ObjectInputValidation
public class Person implements java.io.Serializable,
                               java.io.ObjectInputValidation {
    ...
    @Override
    public void validateObject() throws InvalidObjectException {
        if ((age < 39) || (age > 60))
            throw new InvalidObjectException("Invalid age");
    }
}

Если вызвать метод validateObject после десериализации объекта, то будет вызвано исключение InvalidObjectException при значении возраста за пределами 39...60.

Подписывание сериализованных данных

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

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

Листинг теста подписи объекта

@Test
public void testSigning()
{
    try {
        //Generate a 1024-bit Digital Signature Algorithm (DSA) key pair
        KeyPairGenerator keyPairGenerator =
                                   KeyPairGenerator.getInstance("DSA");
        keyPairGenerator.initialize(1024);
        KeyPair    keyPair    = keyPairGenerator.genKeyPair();
        PrivateKey privateKey = keyPair.getPrivate();
        PublicKey  publicKey  = keyPair.getPublic();

        // Подписывать можно только сериализуемый объект
        String       unsignedObject = alex.toString();
        Signature    signature      = 
                     Signature.getInstance(privateKey.getAlgorithm());
        SignedObject signedObject   = new SignedObject(unsignedObject, 
                                                       privateKey, 
                                                       signature);
        // Verify the signed object
        signature = Signature.getInstance(publicKey.getAlgorithm());
        boolean verified = signedObject.verify(publicKey, signature);

        assertTrue("Проверка 'подписанного' объекта", verified);

        // Retrieve the object
        unsignedObject = (String) signedObject.getObject();
        assertEquals("Проверка описания 'подписанного' объекта",
                     unsignedObject, alex.toString());
    } catch (SignatureException e) {
    } catch (InvalidKeyException e) {
    } catch (NoSuchAlgorithmException e) {
    } catch (ClassNotFoundException e) {
    } catch (IOException e) {
        fail("Exception thrown during test: " + e.toString());
    }
}

Прокси-класс для сериализации

Иногда класс может включать элемент, который позволяет получить значения отдельных полей класса по определенному алгоритму. В этих случаях необязательно сериализовывать весь объект. Можно было бы пометить восстанавливаемые поля как «транзитные». Однако в классе всё равно требуется явно указывать код (определять метод), который при обращении к полю каждый раз проверял бы его инициализацию. Для этих целей лучше использовать специальный прокси-класс, из которого можно восстановить объект.

Листинг прокси-класса

В прокси-классе определим метод readResolve, которой будет вызываться во время десериализации объекта, чтобы вернуть объект-замену. Конструктор прокси-класса будет упаковывать объект PersonY во внутреннее поле data.

public class PersonProxy implements java.io.Serializable
{
    private static final long serialVersionUID = 1L;

    public String data;
    public PersonProxy(PersonY original)
    {
        data = original.getFirstName() + "," + original.getLastName() + 
		       "," + String.valueOf(original.getAge());
        if (original.getSpouse() != null) {
            PersonY spouse = original.getSpouse();
            data = data + "," + spouse.getFirstName() + "," + 
                                spouse.getLastName() + "," + 
                                String.valueOf(spouse.getAge());
        }
    }
    
    private Object readResolve() throws java.io.ObjectStreamException
    {
        String[] pieces = data.split(",");
        PersonY result = new PersonY(pieces[0], pieces[1], 
                                   Integer.parseInt(pieces[2]));
        if (pieces.length > 3) {
            result.setSpouse(new PersonY(pieces[3], pieces[4],
                                        Integer.parseInt(pieces[5])));
            result.getSpouse().setSpouse(result);
        }
        return result;
    }
}

Класс PersonY создадим на основе базового класса Person с добавлением метода writeReplace следующего вида :

private Object writeReplace() throws java.io.ObjectStreamException
{
    return new PersonProxy(this);
}

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

Пример тестирования прокси-класса JUnitPersonProxy

public class JUnitPersonProxy
{
    private  static  final  String  FILE_proxy = "proxy.ser";
    private  static  final  String  FNAME_Alex = "Алексей" ;
    private  static  final  String  FNAME_Olga = "Ольга"   ;
    private  static  final  String  LAST_NAME  = "Иванов"  ;
    private  static  final  int     AGE_Alex   = 39        ;

    private  static         PersonY alex       = null      ;
    private  static         PersonY olga       = null      ;
    
    @BeforeClass
    public static void setUpBeforeClass() throws Exception {
        try {
            alex = new PersonY(FNAME_Alex, LAST_NAME, AGE_Alex);
            olga = new PersonY(FNAME_Olga, LAST_NAME, 38);

            alex.setSpouse(olga);
            olga.setSpouse(alex);

            // Сохранение сериализованных прокси-объектов
            PersonProxy proxy_alex = new PersonProxy(alex);
            PersonProxy proxy_olga = new PersonProxy(olga);

            FileOutputStream fos = new FileOutputStream(FILE_proxy);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(proxy_alex);
            oos.writeObject(proxy_olga);
            oos.close();
        } catch (Exception e) {
            fail("Exception thrown during test: " + e.toString());
        }
    }

    @AfterClass
    public static void tearDownAfterClass() throws Exception {
        // Удаление файла
        new File(FILE_proxy).delete();
    }
    @Test
    public void testProxy()
    {
        try {
            FileInputStream fis = new FileInputStream(FILE_proxy);
            ObjectInputStream ois = new ObjectInputStream(fis);
            PersonY alex = (PersonY) ois.readObject();
            PersonY olga = (PersonY) ois.readObject();
            ois.close();

            assertEquals(alex.getFirstName(), FNAME_Alex);
            assertEquals(alex.getLastName() , LAST_NAME);
            assertEquals(olga.getFirstName(), FNAME_Olga);
            assertEquals(olga.getFirstName(), FNAME_Olga);
            assertEquals(alex.getAge()      , AGE_Alex);
            assertEquals(alex.getSpouse().getFirstName(), FNAME_Olga);
            // Описание объекта
        } catch (Exception e) {
            fail("Exception thrown during test: " + e.toString());
        }
    }
}

Скачать пример Serialization

Исходный код рассмотренного примера Serialization в виде проекта Eclipse можно скачать здесь (15.0 Kб).

  Рейтинг@Mail.ru