JavaRush /Курси /JAVA 25 SELF /Налаштування поведінки серіалізації: кастомні методи

Налаштування поведінки серіалізації: кастомні методи

JAVA 25 SELF
Рівень 43 , Лекція 3
Відкрита

1. Методи writeReplace і readResolve: теорія

Іноді стандартних засобів серіалізації недостатньо. Уявіть ситуацію: у вас є синглтон (клас, у якого може бути лише один екземпляр в усій програмі), і ви хочете, щоб після десеріалізації він залишився єдиним екземпляром (а не з’явився новий клон). Або ви хочете серіалізувати не сам об’єкт, а його «полегшену» версію (проксі), щоб приховати деталі реалізації або заощадити місце.

У Java для цього існують спеціальні методи: writeReplace і readResolve. Їхнє завдання — замінити серіалізований або десеріалізований об’єкт іншим.

Проста аналогія:
Це якби ви надсилали посилку другові, але замість себе в коробку поклали іграшкового двійника. А коли друг розпаковує посилку, замість іграшки в нього в руках опиняєтеся ви — справжні! (У реальному житті так не працює, але в Java — цілком.)

writeReplace

Метод private Object writeReplace() викликається у об’єкта перед серіалізацією. Він може повернути будь-який об’єкт, який буде фактично серіалізований замість вихідного. Якщо метод не реалізовано — серіалізується сам об’єкт.

Сигнатура:


private Object writeReplace() throws ObjectStreamException

readResolve

Метод private Object readResolve() викликається у об’єкта після десеріалізації. Він дає змогу замінити щойно створений об’єкт іншим (наприклад, повернути синглтон або кешований екземпляр).

Сигнатура:

private Object readResolve() throws ObjectStreamException

Важливо:
Обидва методи мають бути private і повертати Object. Це вимога специфікації серіалізації Java. Якщо зробити їх public — механізм серіалізації просто проігнорує ці методи.

2. Застосування writeReplace і readResolve на практиці

Синглтон і readResolve

Синглтон — це просто клас, у якого може бути лише один екземпляр в усій програмі. Якщо такий об’єкт серіалізувати й потім відновити, то без методу readResolve з’явиться новий екземпляр, і правило «єдиності» порушиться. З readResolve можна повернути саме той самий об’єкт, зберігши ідею синглтона.

import java.io.*;

public class MySingleton implements Serializable {
    private static final MySingleton INSTANCE = new MySingleton();
    private MySingleton() {}

    public static MySingleton getInstance() {
        return INSTANCE;
    }

    // Гарантуємо, що після десеріалізації повернеться саме INSTANCE
    private Object readResolve() throws ObjectStreamException {
        return INSTANCE;
    }
}

Пояснення:
Без readResolve після десеріалізації з’явиться новий об’єкт, не рівний (за ==) оригінальному синглтону. З readResolve — завжди повертається INSTANCE.

Перевіримо на практиці:

MySingleton s1 = MySingleton.getInstance();
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("singleton.bin"));
out.writeObject(s1);
out.close();

ObjectInputStream in = new ObjectInputStream(new FileInputStream("singleton.bin"));
MySingleton s2 = (MySingleton) in.readObject();
in.close();

System.out.println(s1 == s2); // true, якщо є readResolve; false — без нього

writeReplace: серіалізація проксі-об’єкта

Іноді об’єкт занадто «важкий» для серіалізації, або містить чутливі дані, або просто не має потрапляти назовні в повному вигляді. У цьому разі можна серіалізувати «заміник» — проксі-об’єкт.

Приклад:
Припустімо, у нас є клас User із приватним паролем. Ми не хочемо, щоб пароль серіалізувався.

import java.io.*;

public class User implements Serializable {
    private String username;
    private transient String password; // transient — не серіалізується

    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }

    // Замість User серіалізуємо лише UserProxy
    private Object writeReplace() throws ObjectStreamException {
        return new UserProxy(username);
    }

    // Проксі-клас — лише для серіалізації
    private static class UserProxy implements Serializable {
        private String username;
        public UserProxy(String username) {
            this.username = username;
        }

        private Object readResolve() throws ObjectStreamException {
            // У реальному житті пароль не відновити — повертаємо User з порожнім паролем
            return new User(username, "");
        }
    }
}

Пояснення:

  • Під час серіалізації User перетворюється на UserProxy (без пароля).
  • Під час десеріалізації UserProxy перетворюється назад на User (але пароль уже порожній).

3. Кастомізація серіалізації для незмінних об’єктів

Незмінні (immutable) об’єкти часто використовують приватні final-поля і не мають сеттерів. За стандартної серіалізації Java може обійти це обмеження, але іноді краще явно контролювати процес через writeReplace/readResolve.

Приклад: Value Object

import java.io.*;

public final class Money implements Serializable {
    private final int amount;
    private final String currency;

    public Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    private Object writeReplace() throws ObjectStreamException {
        return new MoneyProxy(amount, currency);
    }

    private static class MoneyProxy implements Serializable {
        private final int amount;
        private final String currency;

        MoneyProxy(int amount, String currency) {
            this.amount = amount;
            this.currency = currency;
        }

        private Object readResolve() throws ObjectStreamException {
            return new Money(amount, currency);
        }
    }
}

Пояснення:

  • Під час серіалізації Money перетворюється на MoneyProxy (POJO).
  • Під час десеріалізації MoneyProxy перетворюється назад на Money.

Взаємодія з writeObject/readObject

Методи writeReplace/readResolve працюють незалежно від writeObject/readObject. Якщо обидва механізми визначені, то спочатку викликається writeReplace, а вже у поверненого об’єкта — writeObject (якщо він реалізує Serializable).

Схема:

flowchart LR
    A[Об'єкт] -- writeReplace --> B[Проксі-об'єкт]
    B -- writeObject --> C[Потік байтів]
    C -- readObject --> D[Проксі-об'єкт]
    D -- readResolve --> E[Підсумковий об'єкт]

4. Практика: серіалізація з підміною об’єкта

Додаймо кастомну серіалізацію у ваш навчальний застосунок — наприклад, для класу Person, щоб під час серіалізації записувалося лише ім’я, а вік ігнорувався (припустімо, ми дбаємо про приватність).

Крок 1. Основний клас

import java.io.*;

public class Person implements Serializable {
    private String name;
    private int age; // не хочемо серіалізувати

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

    private Object writeReplace() throws ObjectStreamException {
        return new PersonProxy(name);
    }

    private static class PersonProxy implements Serializable {
        private final String name;

        PersonProxy(String name) {
            this.name = name;
        }

        private Object readResolve() throws ObjectStreamException {
            return new Person(name, -1); // -1 — "вік невідомий"
        }
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + "}";
    }
}

Крок 2. Тестуємо

public class TestCustomSerialization {
    public static void main(String[] args) throws Exception {
        Person original = new Person("Alice", 30);

        // Серіалізація
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("person.bin"));
        out.writeObject(original);
        out.close();

        // Десеріалізація
        ObjectInputStream in = new ObjectInputStream(new FileInputStream("person.bin"));
        Person deserialized = (Person) in.readObject();
        in.close();

        System.out.println("До серіалізації: " + original);
        System.out.println("Після десеріалізації: " + deserialized);
    }
}

Результат:

До серіалізації: Person{name='Alice', age=30}
Після десеріалізації: Person{name='Alice', age=-1}

Як бачите, вік не серіалізувався — усе за планом!

5. Особливості та нюанси

Коли використовувати writeReplace/readResolve?

  • Коли потрібно серіалізувати лише частину стану об’єкта.
  • Для серіалізації/десеріалізації проксі-об’єктів.
  • Для підтримки патерна Singleton.
  • Для незмінних або складних об’єктів, чия внутрішня структура може змінюватися.

Коли не варто використовувати?

  • Якщо можна обійтися полями transient або writeObject/readObject.
  • Якщо об’єкт не повинен підмінятися іншим.

Сумісність з успадкуванням

Якщо суперклас визначає writeReplace/readResolve, вони будуть викликані й для підкласів (якщо не перевизначені). Будьте обережні з ієрархіями!

6. Типові помилки під час кастомної серіалізації

Помилка № 1: Неправильна видимість методів. Якщо зробити writeReplace/readResolve не private, механізм серіалізації їх не викличе. Лише private!

Помилка № 2: Невідповідність типів, що повертаються. writeReplace/readResolve мають повертати Object. Навіть якщо фактично повертаєте власний тип, метод оголошуйте з типом повернення Object.

Помилка № 3: Втрата даних. Якщо проксі-об’єкт не містить усіх потрібних даних для відновлення вихідного об’єкта, частину інформації буде втрачено. Завжди перевіряйте, що зможете відновити об’єкт.

Помилка № 4: Порушення інваріантів. readResolve має повертати об’єкт, який відповідає очікуванням програми (наприклад, для синглтона — саме INSTANCE).

Помилка № 5: Необроблені винятки. writeReplace/readResolve можуть кидати ObjectStreamException. Обробляйте або явно оголошуйте його у сигнатурі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ