Илья Гершман
Ведущий разработчик 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, или ещё по каким-то причинам;
-
Ещё какой-либо формат.
ссылка на оригинал статьи https://habr.com/ru/company/usetech/blog/665046/
Добавить комментарий