Илья Гершман
Ведущий разработчик 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, или ещё по каким-то причинам;
Ещё какой-либо формат.