Привет, хабровчане. В рамках курса "Java Developer. Professional" подготовили для вас перевод полезного материала.

Также приглашаем посетить открытый вебинар на тему «gRPC для микросервисов или не REST-ом единым».


Недавно вышло третье издание книги "Effective Java" («Java: эффективное программирование»), и мне было интересно, что появилось нового в этой классической книге по Java, так как предыдущее издание охватывало только Java 6. Очевидно, что появились совершенно новые разделы, связанные с Java 7, Java 8 и Java 9, такие как глава 7 "Lambdas and Streams" («Лямбда-выражения и потоки»), раздел 9 "Prefer try-with-resources to try-finally" (в русском издании «2.9. Предпочитайте try-с-ресурсами использованию try-finally») и раздел 55 "Return optionals judiciously" (в русском издании «8.7. Возвращайте Optional с осторожностью»). Но я был слегка удивлен, когда обнаружил новый раздел, не связанный с нововведениями в Java, а обусловленный изменениями в мире разработки программного обеспечения. Именно этот раздел 85 "Prefer alternatives to Java Serialization" (в русском издании «12.1 Предпочитайте альтернативы сериализации Java») и побудил меня написать данную статью об использовании Google Protocol Buffers в Java.

В разделе 85 "Prefer alternatives to Java Serialization" (12.1 «Предпочитайте альтернативы сериализации Java») Джошуа Блох (Josh Bloch) выделяет жирным шрифтом следующие два утверждения, связанные с сериализацией в Java:

«Лучший способ избежать проблем, связанных с сериализацией, — никогда ничего не десериализовать».

«Нет никаких оснований для использования сериализации Java в любой новой системе, которую вы пишете».

После описания в общих чертах проблем с десериализацией в Java и, сделав эти смелые заявления, Блох рекомендует использовать то, что он называет «кроссплатформенным представлением структурированных данных» (чтобы избежать путаницы, связанной с термином «сериализация» при обсуждении Java). Блох говорит, что основными решениями здесь являются JSON (JavaScript Object Notation) и Protocol Buffers (protobuf). Мне показалось интересным упоминание о Protocol Buffers, так как в последнее время я немного читал о них и игрался с ними. В интернете есть довольно много материалов по использованию JSON (даже в Java), в то время как осведомленность о Protocol Buffers среди java-разработчиков гораздо меньше. Поэтому я думаю, что статья об использовании Protocol Buffers в Java будет полезной.

На странице проекта Google Protocol Buffers описывается как «не зависящий от языка и платформы расширяемый механизм для сериализации структурированных данных». Также там есть пояснение: «Как XML, но меньше, быстрее и проще». И хотя одним из преимуществ Protocol Buffers является поддержка различных языков программирования, в этой статье речь пойдет исключительно про использование Protocol Buffers в Java.

Есть несколько полезных онлайн-ресурсов, связанных с Protocol Buffers, включая главную страницу проекта, страницу проекта protobuf на GitHub, proto3 Language Guide (также доступен proto2 Language Guide), туториал Protocol Buffer Basics: Java, руководство Java Generated Code Guide, API-документация Java API (Javadoc) Documentation, страница релизов Protocol Buffers и страница Maven-репозитория. Примеры в этой статье основаны на Protocol Buffers 3.5.1.

Использование Protocol Buffers в Java описано в туториале "Protocol Buffer Basics: Java". В нем рассматривается гораздо больше возможностей и вещей, которые необходимо учитывать в Java, по сравнению с тем, что я расскажу здесь. Первым шагом является определение формата Protocol Buffers, не зависящего от языка программирования. Он описывается в текстовом файле с расширением .proto. Для примера опишем формат протокола в файле album.proto, который показан в следующем листинге кода.

album.proto

syntax = "proto3";

option java_outer_classname = "AlbumProtos";
option java_package = "dustin.examples.protobuf";

message Album {
    string title = 1;
    repeated string artist = 2;
    int32 release_year = 3;
    repeated string song_title = 4;
}

Несмотря на простоту приведенного выше определения формата протокола, в нем присутствует довольно много информации. В первой строке явно указано, что используется proto3 вместо proto2, используемого по умолчанию, если явно ничего не указано. Две строки, начинающиеся с option, указывают параметры генерации Java-кода (имя генерируемого класса и пакет этого класса) и они нужны только при использовании Java.

Ключевое слово "message" определяет структуру "Album", которую мы хотим представить. В ней есть четыре поля, три из которых строки (string), а одно — целое число (int32). Два из них могут присутствовать в сообщении более одного раза, так как для них указано зарезервированное слово repeated. Обратите внимание, что формат сообщения определяется независимо от Java за исключением двух option, которые определяют детали генерации Java-классов по данной спецификации.

Файл album.proto, приведенный выше, теперь необходимо «скомпилировать» в файл исходного класса Java (AlbumProtos.java в пакете dustin.examples.protobuf), который можно использовать для записи и чтения бинарного формата Protocol Buffers. Генерация файла исходного кода Java выполняется с помощью компилятора protoc, соответствующего вашей операционной системе. Я запускаю этот пример в Windows 10, поэтому я скачал и распаковал файл protoc-3.5.1-win32.zip. На изображении ниже показан мой запущенный protoc для album.proto с помощью команды protoc --proto_path=src --java_out=dist\generated album.proto

Перед запуском вышеуказанной команды я поместил файл album.proto в каталог src, на который указывает --proto_path, и создал пустой каталог build\generated для размещения сгенерированного исходного кода Java, что указано в параметре --java_out.

Сгенерированный Java-класс AlbumProtos.java содержит более 1000 строк, и я не буду приводить его здесь, он доступен на GitHub. Среди нескольких интересных моментов относительно сгенерированного кода я хотел бы отметить отсутствие выражений import (вместо них используются полные имена классов с пакетами). Более подробная информация об исходном коде Java, сгенерированном protoc, доступна в руководстве Java Generated Code. Важно отметить, что данный сгенерированный класс AlbumProtos пока никак не связан с моим Java-приложением, и сгенерирован исключительно из текстового файла album.proto, приведенного ранее.

Теперь исходный Java-код AlbumProtos надо добавить в вашем IDE в перечень исходного кода проекта. Или его можно использовать как библиотеку, скомпилировав в .class или .jar. 

Прежде чем двигаться дальше, нам понадобится простой Java-класс для демонстрации Protocol Buffers. Для этого я буду использовать класс Album, который приведен ниже (код на GitHub).

Album.java

package dustin.examples.protobuf;

import java.util.ArrayList;
import java.util.List;

/**
 * Music album.
 */
public class Album {
    private final String title;

    private final List < String > artists;

    private final int releaseYear;

    private final List < String > songsTitles;

    private Album(final String newTitle, final List < String > newArtists,
        final int newYear, final List < String > newSongsTitles) {
        title = newTitle;
        artists = newArtists;
        releaseYear = newYear;
        songsTitles = newSongsTitles;
    }

    public String getTitle() {
        return title;
    }

    public List < String > getArtists() {
        return artists;
    }

    public int getReleaseYear() {
        return releaseYear;
    }

    public List < String > getSongsTitles() {
        return songsTitles;
    }

    @Override
    public String toString() {
        return "'" + title + "' (" + releaseYear + ") by " + artists + " features songs " + songsTitles;
    }

    /**
     * Builder class for instantiating an instance of
     * enclosing Album class.
     */
    public static class Builder {
        private String title;
        private ArrayList < String > artists = new ArrayList < > ();
        private int releaseYear;
        private ArrayList < String > songsTitles = new ArrayList < > ();

        public Builder(final String newTitle, final int newReleaseYear) {
            title = newTitle;
            releaseYear = newReleaseYear;
        }

        public Builder songTitle(final String newSongTitle) {
            songsTitles.add(newSongTitle);
            return this;
        }

        public Builder songsTitles(final List < String > newSongsTitles) {
            songsTitles.addAll(newSongsTitles);
            return this;
        }

        public Builder artist(final String newArtist) {
            artists.add(newArtist);
            return this;
        }

        public Builder artists(final List < String > newArtists) {
            artists.addAll(newArtists);
            return this;
        }

        public Album build() {
            return new Album(title, artists, releaseYear, songsTitles);
        }
    }
}

Теперь у нас есть data-класс Album, Protocol Buffers-класс, представляющий этот Album (AlbumProtos.java) и мы готовы написать Java-приложение для "сериализации" информации об Album без использования Java-сериализации. Код приложения находится в классе AlbumDemo, полный код которого доступен на GitHub.

Создадим экземпляр Album с помощью следующего кода:

/**
 * Generates instance of Album to be used in demonstration.
 *
 * @return Instance of Album to be used in demonstration.
 */
public Album generateAlbum()
{
   return new Album.Builder("Songs from the Big Chair", 1985)
      .artist("Tears For Fears")
      .songTitle("Shout")
      .songTitle("The Working Hour")
      .songTitle("Everybody Wants to Rule the World")
      .songTitle("Mothers Talk")
      .songTitle("I Believe")
      .songTitle("Broken")
      .songTitle("Head Over Heels")
      .songTitle("Listen")
      .build();
}

Класс AlbumProtos, сгенерированный Protocol Buffers, включает в себя вложенный класс AlbumProtos.Album, который используется для бинарной сериализации Album. Следующий листинг демонстрирует, как это делается.

final Album album = instance.generateAlbum();
final AlbumProtos.Album albumMessage
    = AlbumProtos.Album.newBuilder()
        .setTitle(album.getTitle())
        .addAllArtist(album.getArtists())
        .setReleaseYear(album.getReleaseYear())
        .addAllSongTitle(album.getSongsTitles())
        .build();

Как видно из предыдущего примера, для заполнения иммутабельного экземпляра класса, сгенерированного Protocol Buffers, используется паттерн Строитель (Builder). Через ссылку экземпляр этого класса теперь можно легко преобразовать объект в бинарный вид Protocol Buffers, используя метод toByteArray(), как показано в следующем листинге:

final byte[] binaryAlbum = albumMessage.toByteArray();

Чтение массива byte[] обратно в экземпляр Album может быть выполнено следующим образом:

/**
 * Generates an instance of Album based on the provided
 * bytes array.
 *
 * @param binaryAlbum Bytes array that should represent an
 *    AlbumProtos.Album based on Google Protocol Buffers
 *    binary format.
 * @return Instance of Album based on the provided binary form
 *    of an Album; may be {@code null} if an error is encountered
 *    while trying to process the provided binary data.
 */
public Album instantiateAlbumFromBinary(final byte[] binaryAlbum) {
    Album album = null;
    try {
        final AlbumProtos.Album copiedAlbumProtos = AlbumProtos.Album.parseFrom(binaryAlbum);
        final List <String> copiedArtists = copiedAlbumProtos.getArtistList();
        final List <String> copiedSongsTitles = copiedAlbumProtos.getSongTitleList();
        album = new Album.Builder(
                copiedAlbumProtos.getTitle(), copiedAlbumProtos.getReleaseYear())
            .artists(copiedArtists)
            .songsTitles(copiedSongsTitles)
            .build();
    } catch (InvalidProtocolBufferException ipbe) {
        out.println("ERROR: Unable to instantiate AlbumProtos.Album instance from provided binary data - " +
            ipbe);
    }
    return album;
}

Как вы заметили, при вызове статического метода parseFrom(byte []) может быть брошено проверяемое исключение InvalidProtocolBufferException. Для получения «десериализованного» экземпляра сгенерированного класса, по сути, нужна только одна строка, а остальной код — это создание исходного класса Album из полученных данных.

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

BEFORE Album (1323165413): 'Songs from the Big Chair' (1985) by [Tears For Fears] features songs [Shout, The Working Hour, Everybody Wants to Rule the World, Mothers Talk, I Believe, Broken, Head Over Heels, Listen]

AFTER Album (1880587981): 'Songs from the Big Chair' (1985) by [Tears For Fears] features songs [Shout, The Working Hour, Everybody Wants to Rule the World, Mothers Talk, I Believe, Broken, Head Over Heels, Listen]

Здесь мы видим, что в обоих экземплярах соответствующие поля одинаковы и эти два экземпляра действительно разные. При использовании Protocol Buffers, действительно, нужно сделать немного больше работы, чем при «почти автоматическом» механизме сериализации Java, когда надо просто наследоваться от интерфейса Serializable, но есть важные преимущества, которые оправдывают затраты. В третьем издании книги Effective Java («Java: эффективное программирование») Джошуа Блох обсуждает уязвимости безопасности, связанные со стандартной десериализацией в Java, и утверждает, что «Нет никаких оснований для использования сериализации Java в любой новой системе, которую вы пишете».


Узнать подробнее о курсе "Java Developer. Professional".

Смотреть открытый вебинар на тему «gRPC для микросервисов или не REST-ом единым».