Илья Гершман

Ведущий разработчик Usetech

В этой статье Илья Гершман, ведущий разработчик Юзтех, рассматривает понятия сериализации и десериализации в сравнении между двумя языками программирования — Java и Kotlin.

Немного об определениях “сериализация” и “десериализация”

Существует несколько примеров использования этих механизмов:

  • Хранение объектов в каком-либо хранилище. В этом случае мы сериализуем объект в массив байт, записываем его в хранилище, а затем, через какое-то время, когда нам этот объект понадобится, мы десериализуем его из массива байт, полученного из хранилища.

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

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

  • Внутренний формат. Этот формат понимает только та реализация, которая его и сделала. Java Serializable — наглядный пример реализации такого формата.

  • XML. Достаточно широкий формат. На его основе существует множество “подформатов”, которые реализуются различными библиотеками.

  • JSON. Наиболее популярный формат, так как поддерживается различными языками программирования и имеет практически однозначный вариант преобразования объекта в него.

  • Avro. Двоичный формат, который поддерживается многими языками программирования.

  • Protobuf. Ещё один двоичный формат, который поддерживается многими языками программирования.

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

Механизмы сериализации по-разному обеспечивают это свойство. Давайте рассмотрим несколько вариантов.

Стандартный

В Java есть стандартный способ сериализации. Его минус в том, что прочитать данные можно лишь из Java, а в classpath у нас должны быть классы, которые мы сериализовали.

import java.io.Serializable;

public class Address implements Serializable {
    private final int countryCode;
    private final String city;
    private final String street;

    public Address(int countryCode, String city, String street) {
        this.countryCode = countryCode;
        this.city = city;
        this.street = street;
    }

    @Override
    public String toString() {
        return "[Address " +
                "countryCode=" + countryCode +
                ", city='" + city + '\'' +
                ", street='" + street + '\'' +
                ']';
    }
}
import java.io.Serializable;

public class Person implements Serializable {
    private final String firstName;
    private final String lastName;
    private final Address address;

    public Person(String firstName, String lastName, Address address) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.address = address;
    }

    @Override
    public String toString() {
        return "[Person " +
                "firstName='" + firstName + '\'' +
                ", lastName='" + lastName + '\'' +
                ", address=" + address +
                ']';
    }
}
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class Main {
    public static void main(String[] args) throws Throwable {
        Path path = Paths.get("vasya.dat");
        try (ObjectOutputStream oos = new ObjectOutputStream(
                Files.newOutputStream(path))) {
            Person person = new Person("Вася", "Пупкин",
                    new Address(7, "Н", "Бассейная"));
            oos.writeObject(person);
        }

        try (ObjectInputStream ois = new ObjectInputStream(
                Files.newInputStream(path))) {
            Person read = (Person) ois.readObject();
            System.out.printf("Read person: %s", read);
        }
    }
}

Заметьте, как удобно — не пришлось ничего делать дополнительно. JVM сама за нас записала все поля объектов, а затем их сама прочитала.

Если мы поменяем классы, например, добавим номер дома в адрес, то при чтении старого файла произойдет ошибка java.io.InvalidClassException. Давайте попробуем этого избежать.

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

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class Address implements Serializable {
    // сами задаём значение, чтобы JVM не генерировала его
    private static final long serialVersionUID = -4554333115192365232L;
    private static final int VER = 2;

    private int countryCode;
    private String city;
    private String street;
    private int houseNumber;

    public Address(int countryCode, String city, String street,
            int houseNumber) {
        this.countryCode = countryCode;
        this.city = city;
        this.street = street;
        this.houseNumber = houseNumber;
    }

    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.writeInt(VER);
        oos.writeInt(countryCode);
        oos.writeUTF(city);
        oos.writeUTF(street);
        oos.writeInt(houseNumber);
    }

    private void readObject(ObjectInputStream ois) throws IOException {
        int ver = ois.readInt();
        if (ver == 1) {
            countryCode = ois.readInt();
            city = ois.readUTF();
            street = ois.readUTF();
            houseNumber = 0;
        } else if (ver == 2) {
            countryCode = ois.readInt();
            city = ois.readUTF();
            street = ois.readUTF();
            houseNumber = ois.readInt();
        } else {
            throw new IOException("Неизвестная версия: " + ver);
        }
    }

    @Override
    public String toString() {
        return "[Address " +
                "countryCode=" + countryCode +
                ", city='" + city + '\'' +
                ", street='" + street + '\'' +
                ", houseNumber=" + houseNumber +
                ']';
    }
}

Внешние библиотеки

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

FasterXML Jackson

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

Jackson JSON

Добавим конструкторы по умолчанию и getter’ы к нашим классам, как того требует библиотека.

public class Address {
    private final int countryCode;
    private final String city;
    private final String street;

    public Address(int countryCode, String city, String street) {
        this.countryCode = countryCode;
        this.city = city;
        this.street = street;
    }

    public Address() {
    }

    public int getCountryCode() {
        return countryCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    @Override
    public String toString() {
        return "[Address " +
                "countryCode=" + countryCode +
                ", city='" + city + '\'' +
                ", street='" + street + '\'' +
                ']';
    }
}
public class Person {
    private final String firstName;
    private final String lastName;
    private final Address address;

    public Person(String firstName, String lastName, Address address) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.address = address;
    }

    public Person() {
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public Address getAddress() {
        return address;
    }

    @Override
    public String toString() {
        return "[Person " +
                "firstName='" + firstName + '\'' +
                ", lastName='" + lastName + '\'' +
                ", address=" + address +
                ']';
    }
}
import com.fasterxml.jackson.databind.ObjectMapper;

public class Main {
    public static void main(String[] args) throws Throwable {
        ObjectMapper om = new ObjectMapper();
        Person person = new Person("Вася", "Пупкин",
                new Address(7, "Н", "Бассейная"));

        String json = om.writeValueAsString(person);

        Person read = om.readValue(json, Person.class);
        System.out.printf("Read person: %s\n", read);
    }
}

Получим такую строку:

{"firstName":"Вася","lastName":"Пупкин","address":{"countryCode":7,"city":"Н","street":"Бассейная"}}

А что будет, если мы захотим добавить номер дома? Ничего страшного не случится: поле просто останется тем, каким оно было после вызова конструктора по умолчанию.

А если наоборот, добавим в JSON houseNumber, а будем читать старым кодом? Получим ошибку com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException. Чтобы её избежать, можно добавить аннотацию на класс Address:

@JsonIgnoreProperties(ignoreUnknown = true)
public class Address {

На самом деле, в библиотеке есть очень много различных настроек, с помощью которых можно сделать практически всё, что вы хотите.

Jackson XML

Ничего не меня в классах Person и Address мы с минимальными изменениями (создав другой ObjectMapper) можем сериализовать наш объект в XML:

ObjectMapper om = new XmlMapper();

Получим при этом вот такую строку:

<Person><firstName>Вася</firstName><lastName>Пупкин</lastName><address><countryCode>7</countryCode><city>Н</city><street>Бассейная</street></address></Person>

Jackson Avro

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

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

Давайте опишем схему вручную. Делается это в JSON формате:

{
  "type": "record",
  "name": "Person",
  "fields": [
    {
      "name": "firstName", "type": "string"
    },
    {
      "name": "lastName", "type": "string"
    },
    {
      "name": "address",
      "type": {
        "type": "record",
        "name": "Address",
        "fields": [
          {
            "name": "countryCode", "type": "int"
          },
          {
            "name": "city", "type": "string"
          },
          {
            "name": "street", "type": "string"
          }
        ]
      }
    }
  ]
}

Наш main теперь будет выглядеть так:

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.avro.AvroMapper;
import com.fasterxml.jackson.dataformat.avro.AvroSchema;
import org.apache.avro.Schema;

import java.io.File;

public class Main {
    public static void main(String[] args) throws Throwable {
        Schema raw = new Schema.Parser()
                .setValidate(true)
                .parse(new File("avro-schema.json"));
        AvroSchema schema = new AvroSchema(raw);

        ObjectMapper om = new AvroMapper();

        Person person = new Person("Вася", "Пупкин",
                new Address(7, "Н", "Бассейная"));
        byte[] bytes = om.writer(schema).writeValueAsBytes(person);

        Person read = om.readerFor(Person.class)
                .with(schema)
                .readValue(bytes);
        System.out.printf("Read person: %s\n", read);
    }
}

Jackson Protobuf

Protobuf — формат, который тоже требует предварительного описания схемы данных. На этот раз мы воспользуемся генератором из POJO:

import com.fasterxml.jackson.dataformat.protobuf.ProtobufMapper;
import com.fasterxml.jackson.dataformat.protobuf.schema.ProtobufSchema;

public class Main {
    public static void main(String[] args) throws Throwable {
        ProtobufMapper om = new ProtobufMapper();
        ProtobufSchema schema = om.generateSchemaFor(Person.class);

        Person person = new Person("Вася", "Пупкин",
                new Address(7, "Н", "Бассейная"));
        byte[] bytes = om.writer(schema).writeValueAsBytes(person);

        Person read = om.readerFor(Person.class)
                .with(schema)
                .readValue(bytes);
        System.out.printf("Read person: %s\n", read);
    }
}

Jackson Smile

Smile – это просто бинарный формат представления JSON’а. Нам нужно просто создать соответствующий ObjectMapper:

ObjectMapper om = new SmileMapper();

Kryo

Kryo — это библиотека для сериализации, которая нацелена на скорость и эффективность.

import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class Main {
    public static void main(String[] args) throws Throwable {
        Kryo kryo = new Kryo();

        // нужно либо зарегистрировать все используемые классы,
        kryo.register(Person.class);
        kryo.register(Address.class);

        // либо указать, что мы доверяем источнику и можно инстанцировать
        // любые классы
        kryo.setRegistrationRequired(false);

        Path path = Paths.get("vasya.dat");
        try (Output output = new Output(Files.newOutputStream(path))) {
            Person person = new Person("Вася", "Пупкин",
                    new Address(7, "Н", "Бассейная"));
            kryo.writeObject(output, person);
        }

        try (Input input = new Input(Files.newInputStream(path))) {
            Person read = kryo.readObject(input, Person.class);
            System.out.printf("Read person: %s\n", read);
        }
    }
}

Для обеспечения прямой и обратной совместимости можно указать:

kryo.setDefaultSerializer(CompatibleFieldSerializer.class);

Kotlin

А теперь давайте посмотрим, что интересного по поводу сериализации сделали в Kotlin. Так как Kotlin — это JVM based язык, то мы можем пользоваться всеми предыдущими библиотеками для сериализации. Но у Kotlin’а есть очень полезная библиотека kotlinx.serialization, которая позволяет строить схему на этапе компиляции, а не пользоваться Reflection API во время выполнения. Это обеспечивает более быструю работу.

Давайте для начала перепишем наши классы на Kotlin:

import kotlinx.serialization.Serializable

@Serializable
data class Address(
    val countryCode: Int,
    val city: String,
    val street: String,
)

@Serializable
data class Person(
    val firstName: String,
    val lastName: String,
    val address: Address,
)

JSON

Теперь сделаем сериализацию в JSON:

import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

fun main() {
    val json = Json

    val person = Person("Вася", "Пупкин",Address(7, "Н", "Бассейная"))
    val str = json.encodeToString(person)

    val read = json.decodeFromString<Person>(str)
    println("Read person: $read")
}

Добавляя поле в Address, получим kotlinx.serialization.MissingFieldException. Чтобы этого избежать можно указать значение по умолчанию для этого поля:

@Serializable
data class Address(
    val countryCode: Int,
    val city: String,
    val street: String,
    val houseNumber: Int = 0,
)

Protobuf

В Protobuf сериализация делается не сложнее:

import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf

fun main() {
    val protobuf = ProtoBuf

    val person = Person("Вася", "Пупкин",Address(7, "Н", "Бассейная"))
    val bytes = protobuf.encodeToByteArray(person)

    val read = protobuf.decodeFromByteArray<Person>(bytes)
    println("Read person: $read")
}

Что можно сказать в конце?

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

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

Если вы можете использовать Kotlin, то я бы посоветовал использовать его библиотеку – это удобное и эффективное решение.

Ну а если вы ограничены чистой Java, то, на мой взгляд, библиотека Jackson – отличный вариант. Она довольно быстрая, имеет множество настроек, а также вы легко можете поменять формат, не переписывая свой код. Формат можно выбрать под вашу конкретную задачу:

  • JSON – на все случаи жизни, так как он поддерживается всеми языками и фреймворками, а также из-за его наглядности;

  • Protobuf или Avro – если нужна скорость и минимальный размер (у них есть различия, но их обсуждение – это дело отдельной статьи);

  • XML – например, если вам нужно валидировать данные по XSD, или ещё по каким-то причинам;

  • Ещё какой-либо формат.

Комментарии (4)


  1. visirok
    11.05.2022 22:53

    Спасибо. Полезный обзор.

    Можно было бы свести свойства в табличку. Но и так полезный материал.


    1. UseTech Автор
      12.05.2022 06:02

      Спасибо за обратную связь!


  1. Hett
    12.05.2022 21:11

    Из заголовка подумал, что сейчас узнаю что-то интересное про котлин, а тут банальнейший пример :(


    1. UseTech Автор
      13.05.2022 13:49

      А что бы вы хотели узнать? Раскроем в следующей статье :)