Один из набирающих популярность протоколов общения между двумя гетерогенными системами - Protocol Buffers. Сегодня мы разберемся с тем, что это такое, для чего нужно и как применять.
Контекст
Когда перед нами встает задача общения двух удалённых систем, первое что приходит в голову сегодня это HTTP запросы, кто-то любит использовать слово REST (REpresentational State Transfer), хотя и на самом деле строит API в виде RPC (Remote Procedure Call), реализация которого опирается на HTTP вызовы. Наиболее распространенный сегодня HTTP/1.1 был принят в далёком 1999 году. У протокола была и есть одна особенность (язык не поворачивается назвать это недостатком) - он текстовый, это говорит об одновременно двух следствиях. Процесс общения легко отлаживать, мы сразу видим в человекочитаемом виде какая информация передается. Однако, информация часто занимает больше места чем нужно. На смену HTTP/1.1 приходит относительно новый HTTP/2 - бинарный протокол. Сама процедура бинарной передачи данных как бы намекает: “ты использовал json и xml для передачи компактных и читаемых данных, но теперь данные передаются в бинарном виде, может быть нам нужен другой формат?”
Основная идея
Protocol Buffers - это бинартый протокол сериализации (передачи) структурированных данных. Google предложили его как эффективную альтернативу xml и правильно сделали. В моём окружении все не легаси проекты уже давно используют json и счастливы, а здесь следующий шаг, если быть точнее, другой взгляд на передаваемые данные. Данные хранятся в виде набора байт, но как работать с бинарнарным протоколом сериализации, где взять сериализаторы и десериализаторы, как они поймут, что именно нужно сделать?
Язык общения
Для того, чтобы обе стороны взаимодействия общались на “одном языке”, необходимо создать специальный .proto файл, который опишет виды сообщений и будет основой для построения бинарного формата. Пример такого файла вы увидите дальше. Когда файл с требуемой сруктурой данных готов его необходимо скомпилировать специально для вашего языка программирования. Результат компиляции это код в терминах необходимого вам языка, который упрощает процесс работы с данными, сериализацию и десериализацию. В Java это классы и их методы. Сгенерированный класс будет содержать методы доступа ко всем полям, а также методы сериализации и десириализации в/из массива байт.
Общие приемущества
- Сокращение издержек на передачу в сравнении с текстовыми форматами.
- Хорошо дружит с HTTP/2
- При добавлении новых полей на старых клиентах они игнорируются, сохраняя совместимость.
Недостатки
Пожалуй, о недостатках лучше всего скажет тот, кто с ними столкнулся и здесь я посоветую вам прочитать вот эту статью на хабре, дабы развеять ненужные иллюзии безоблачного неба.
Будьте внимательны с использованием обязательных полей. Нужно понимать, что если у клиента версия .proto файла, где поле Х обязательно, а сервер решит удалить его из следующей версии API, то такое изменение будет обратно-несовместимым. Таким образом, обязательные поля могут принести больше вреда чем пользы. Рекомендуется, следуя паттерну TolerantReader, быть готовым к изменениям модели для максимально долгого сохранения обратной совместимости.
Пример
Хорошая новость. Как минимум для Intellij IDEA есть плагин для .proto файлов. В тот момент, когда вы создадите и откроете такой файл, вы увидите хинт сверху, который предложит вам установить плагин. Здесь вы увидите пример .proto файла для второй версии протобафа, хотя сейчас уже появилась третья. Возможно, о ней я буду писать позже, а любопытный читатель уже сейчас может посмотреть Language Guide (proto3).
Шаг 1. Определяем формат протокола
На первом шаге нам нужно описать .proto файл. Разберем сокращенную версию предметной области учебного заведения. Исходный .proto файл выглядит следующим образом:
syntax = "proto2";
package academy;
option java_package = "ru.i_osipov.academy";
option java_outer_classname = "AcademyProtos";
message Student {
required string name = 1;
optional int32 id = 2;
repeated string email = 3;
optional Gender gender = 4 [default = MALE];
enum Gender {
MALE = 0;
FEMALE = 1;
}
}
message Group {
required string name = 1;
repeated Student student = 2;
}
Разберемся с синтаксисом файла. Прежде всего мы указываем какую версию protobuf мы используем, в нашем случае это вторая
версия. Затем указываем package, который необходим здесь для разделения пространств имён. Т.к. мы знаем, что будем пользоваться
java, то указываем две дополнительные настройки: java_package
и java_outer_classname
. Первая, очевидно, говорит в
какой пакет и соответственно иерархию директорий необходимо сложить результат компиляции, а java_outer_classname
определяет имя файла, который будет в себя заворачивать весь сгенерированный контент. Если это не будет сделано, то
компилятор определит имя в соответствии с CamelCase по названию .proto файла. Эти настройки, как вы понимаете, java-специфичны.
Далее мы указываем описание наших messages, по сути, message (сообщение) - это структура данных и, судя по документации, без возможности наследования. Каждое сообщение, состоит из полей. В нашем примере, каждое поле имеет тип, название, уникальный в контексте сообщения тег и модификатор. Тег - это уникальный маркер поля, т.е. пока вы не задействовали один и тот же тег для нового поля в сообщении - ваши поля остаются совместимыми с предыдущей версией. Итак, мы определили тип студента, определили его поля: строковое имя, целочисленный идентификатор, строковый email и пол.
Модификаторы дают нам больше представления о том как поле используется, например, модификатор required позволяет описать обязательное поле в сообщении, если десериализатор не обнаружит этого поля, то весь процесс десериализации закончится с ошибкой. Это важно учитывать при проектировании API (снова взгляните на второй абзац в разделе “Недостатки” этой статьи). Модификатор optional, говорит о том, что поле может быть, а может отсутствовать, своего рода nullable поле. Модификатор repeated используется для работы с множеством значений для одного поля (аналогично коллекциям в Java).
Вы можете вкладывать messages друг в друга, использовать перечисления enum, в общем очень похоже на Java. Кроме того, есть возможность определить значения по умолчанию.
*Шаг 2. Компилируем файл
* опциональный, для понимания
Созданный .proto файл нужно скомпилировать и прежде всего нам нужен компилятор. Скачиваем
protoc
архив. В архиве к нам прилетает компилятор и некоторый набор типов, которые мы можем использовать из коробки.
Когда вы нашли место для файла в вашей файловой системе добавьте его в PATH. В Windows это делается в Параметрах окружения,
а в linux будет достаточно выполнить export PATH=$PATH:your_path
. Теперь нам доступен компилятор из терминала, давайте скомпилируем.
Перейдем в папку с .proto файлом и выполним команду:
protoc --java_out=./ ./academy.proto
Флаг --java_out
указывает на папку куда будет сгенерирован java код. В этой папке мы получили иерархию, которая
определяет java package, который мы указали в .proto файле. Результат компиляции - .java файл, который пока не
компилируется javac’ом, для этого нам необходима дополнительная библиотека для работы с protobuf из java. В целях
избежения ненужных проблем, перенесем наши эксперименты в плоскость обычного проекта.
Шаг 3. Собираем проект
Прежде всего хочу сказать, что не смотря на то, что все примеры на java, работа на других платформах с protobuf аналогична.
Поигрались с терминалом и хватит, перейдем к практическому применению. Создадим gradle проект, цель которого будет перегнать через массив байт группу со студентами. Для автоматизации рутинной деятельности нам поможет инструмент автоматизации сборки gradle. Для вашего случая инструмент может отличаться, но идея должна быть понятна. Для того, чтобы добавить поддержку protocol buffers в цикле сборки нашего проекта, дополним типичный build.gradle файл следующими настройками:
/* добавляем в проект плагин, который добавляет
к процессу сборки проекта генерацию java
файлов по .proto файлам
*/
plugins {
id "com.google.protobuf" version "0.8.3"
}
protobuf {
/* мы можем брать протобаф компилятор прямо из
репозитория в качестве зависимости, при желании
мы можем указать путь до protoc файла
*/
protoc {
artifact = 'com.google.protobuf:protoc:3.5.1-1'
}
// указываем нашу директорию в проекте для сгенерированных файлов
generatedFilesBaseDir = "$projectDir/src"
// по умолчанию плагин ищет .proto файлы в /src/main/proto
}
dependencies {
// + зависимость без которой сгенерированный код не скомпилируется
compile group: 'com.google.protobuf', name: 'protobuf-java', version: '3.5.1'
}
Комментарии к коду исчерпывающие, а в конце статьи я оставлю ссылку на репозиторий, в котором вы найдете запускаемый код.
В папку проекта src/main/proto
помещаем наш .proto файл из первого шага. Теперь при сборке проекта или при выполнении
gradle команды generateProto
мы получим сгенерированный код по .proto файлу внутри нашего проекта.
Шаг 4. Взаимодействуем со сгенерированным кодом
Компилятор создает весь код внутри файла AcademyProtos.java
, это название мы указали в .proto файле. Весь сгенерированный
код доступен в одноименном классе. Messages превратились в несколько внутренних классов, которые помогают создавать,
сериализовывать и десериализовывать описанную модель. По message Student компилятор создал класс AcademyProtos.Student и
AcademyProtos.Student.Builder. Это типичная реализация паттерна “Строитель”. Объекты класса Student всегда неизменяемы,
т.е. после создания мы не можем изменить каких-либо значений. Все манипуляции происходят с классом Builder, для этого у него есть
достаточно методов.
Разберем код. Нам небходимо создать группу, для которой определено обязательное имя и набор студентов в виде repeated
поля. Создание группы выглядит следующим образом:
AcademyProtos.Group group = AcademyProtos.Group.newBuilder()
.setName("Math")
.addStudent(...)
.addStudent(...)
.build();
Для того, чтобы создать новый объект мы должны вызвать его Builder, заполнить его поля, а затем, в качестве звершающей
изменения операции вызвать метод build()
, который создаст группу. Repeated поля мы можем заполнять как по одному, так и
добавлять целую коллецию.
Как вы уже поняли, создавать студентов мы можем аналогично:
AcademyProtos.Student student = AcademyProtos.Student.newBuilder()
.setId(123456)
.addEmail("student@example.com")
.addEmail("student2@example.com")
.setGender(AcademyProtos.Student.Gender.FEMALE)
.setName("Ivanova")
.build()
Итак, данные мы создали, получили заполненный объект типа Group, теперь необходимо перегнать его в массив байт. Сделать это можно следующим образом:
byte[] serializedGroup = group.toByteArray();
Вот так просто! Сериализованная группа теперь - набор байт в protocol buffers формате.
Затем нам необходимо прочитать сохраненные данные. Воспользуемся статическим методом parseFrom
.
AcademyProtos.Group unserialinedGroup = AcademyProtos.Group.parseFrom(serializedGroup);
Для того, чтобы проверить результат выведем его на экран (компилятор создает человекопонятные методы toString для классов, так что с отладкой нет проблем).
System.out.println(unserialinedGroup);
В результате, в консоли мы видим:
name: "Math"
student {
name: "Ivanova"
id: 123456
email: "student@example.com"
email: "student2@example.com"
gender: FEMALE
}
student {
name: "Ivanov"
id: 123457
email: "student3@example.com"
email: "student4@example.com"
gender: MALE
}
За ширмой, для полноты примера, я добавил еще одного студента к группе.
Заключение
Protocol Buffers - отличный инструмент для кросс-платформенной сериализации данных. В некоторых случаях, он позволяет сохранять обратную совместимость, однако, при безрассудном подходе может и нанести вред. Сегодня мы познакомились с основами формата, разобрали .proto файл и пример Java кода, который работает с описанными структурами. Protocol Buffers - это кирпичик, который стоит в основе других технологий для интеграции гетерогенных систем, также существуют и аналоги, которые мы рассмотрим позже. Как всегда - это не серебряная пуля, но хороший инструмент интеграции.
Код проекта
Официальный Java Tutorial