Статьи

Буферы протокола Google в Java

обзор

Буферы протокола — это механизм кодирования с открытым исходным кодом для структурированных данных. Разработанный в Google, он был разработан, чтобы быть нейтральным по отношению к языку / платформе и расширяемым. В этой статье моя цель — охватить основное использование буферов протокола в контексте платформы Java.

Протобуфы быстрее и проще, чем XML, и более компактны, чем JSON. В настоящее время есть поддержка C ++, Java и Python. Однако есть и другие платформы, которые поддерживаются (не Google) как проекты с открытым исходным кодом — я пробовал реализацию PHP, но она не была полностью разработана, поэтому я перестал ее использовать; тем не менее, поддержка завоевывает популярность. С Google, объявляющим о поддержке PHP в Google App Engine, я верю, что они поднимут это на следующий уровень.

По сути, вы определяете, как вы хотите, чтобы ваши данные были структурированы один раз, используя файл спецификации .proto . Это аналог файла IDL или языка спецификации для описания программного компонента. Этот файл используется компилятором буфера протокола (protoc), который будет генерировать вспомогательные методы, чтобы вы могли записывать и читать объекты в различные потоки и из них.

Формат сообщения очень прост. Каждый тип сообщения имеет одно или несколько уникально пронумерованных полей (мы увидим, почему это будет позже). Вложенные типы сообщений имеют свой собственный набор полей с уникальной нумерацией. Типы значений могут быть числами, логическими значениями, строками, байтами, коллекциями и перечислениями (вдохновлено перечислением Java). Кроме того, вы можете вкладывать другие типы сообщений, что позволяет иерархически структурировать ваши данные почти так же, как это позволяет JSON.

Поля могут быть указаны как необязательные , обязательные или повторные . Не позволяйте типу поля (например, enum, int32, float, string и т. Д.) Сбить вас с толку при реализации буферов протокола в Python. Типы в поле — это всего лишь подсказки для того, чтобы показать, как сериализовать значение поля и создать закодированный формат сообщения (подробнее об этом позже). Кодированный формат выглядит сглаженным и сжатым представлением вашего объекта. Вы бы написали эту спецификацию точно так же, независимо от того, используете ли вы буферы протокола в Python, Java или C ++.

Протобуфы являются расширяемыми, вы можете обновить структуру ваших объектов позже, не нарушая программы, которые использовали старый формат. Если вы хотите отправить данные по сети, вы должны закодировать данные с помощью Protocol Buffer API, а затем сериализовать полученную строку.

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

Кроме того, вы можете определить имя пакета для ваших файлов .proto с помощью ключевого слова java_package. Это хорошо, чтобы избежать конфликтов имен из сгенерированного кода. Другой альтернативой является конкретное именование сгенерированного файла класса, как я сделал в моем примере ниже. Я поставил перед сгенерированными классами «Proto», чтобы указать, что это сгенерированный класс.

Вот простая спецификация сообщения, описывающая пользователя со встроенным адресным сообщением User.proto:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
option java_outer_classname="ProtoUser";
 
message User {
 
   required int32  id = 1// DB record ID
   required string name = 2;
   required string firstname = 3;
   required string lastname = 4;
   required string ssn= 5;
 
  
 
   // Embedded Address message spec
 
    message Address {
      required int32 id = 1;
      required string country = 2 [default = "US"];;
      optional string state = 3;
      optional string city = 4;
      optional string street = 5;
      optional string zip = 6;
 
  
 
      enum Type {
         HOME = 0;
 
         WORK = 1;
 
       }
 
       optional Type addrType = 7 [default = HOME];
 
 }
   repeated Address addr = 16;
}

Давайте немного поговорим о номерах тегов, которые вы видите справа от каждого свойства, поскольку они очень важны. Эти теги определяют порядок полей вашего сообщения в двоичном представлении объекта этой спецификации. Значения тега 1–15 будут храниться как 1 байт, тогда как поля, помеченные значениями 16–2047, занимают 2 байта для кодирования — не уверен, почему они это делают. Google рекомендует использовать теги 1 — 15 для очень часто встречающихся данных, а также резервировать некоторые значения тегов в этом диапазоне для любых будущих обновлений.
Примечание: Вы не можете использовать числа 19000, хотя 19999. Там зарезервированы для реализации protobuff. Кроме того, вы можете определить поля, которые должны быть обязательными, повторными и необязательными. Из документации Google:

  • required : правильно сформированное сообщение должно иметь ровно одно из этого поля, т.е. попытка создать сообщение с неинициализированным обязательным полем вызовет исключение RuntimeException.
  • optional : правильно сформированное сообщение может иметь ноль или одно из этого поля (но не более одного).
  • repeated : это поле может повторяться любое количество раз (включая ноль) в правильно сформированном сообщении. Порядок повторяющихся значений будет сохранен.

Документация предупреждает разработчиков об осторожности при использовании обязательных полей , так как поля этого типа будут вызывать проблемы, если вы решите отказаться от них. Это классическая проблема обратной совместимости, от которой страдают все механизмы сериализации. Инженеры Google даже рекомендуют использовать по желанию все.

Кроме того, я указал адрес спецификации вложенного сообщения. Я мог бы так же легко разместить это определение вне объекта User в том же файле прото. Поэтому для связанных определений сообщений имеет смысл иметь их все в одном файле .proto. Хотя тип сообщения Address не очень хороший пример этого, я бы использовал вложенный тип, если тип сообщения не имеет смысла существовать вне своего «родительского» объекта. Например, если вы хотите сериализовать узел LinkedList . Тогда узел в этом случае будет определением встроенного сообщения. Это зависит от вас и вашего дизайна.

Необязательные свойства сообщения принимают значения по умолчанию, когда они опущены. В частности, вместо этого используется значение по умолчанию для конкретного типа: для строк значением по умолчанию является пустая строка; для bools значением по умолчанию является false; для числовых типов значение по умолчанию равно нулю; для перечислений значением по умолчанию является первое значение, указанное в определении типа перечисления (это довольно круто, но не так очевидно).

Перечисления довольно хорошие. Они работают кроссплатформенно во многом так же, как enum работает в Java. Значение поля enum может быть просто одним значением. Вы можете объявить перечисления внутри определения сообщения или снаружи, как если бы это была его собственная независимая сущность. Если указано внутри типа сообщения, вы можете предоставить ему другой тип сообщения через [Message-name]. [Enum-name].

Protoc

При запуске компилятора буфера протокола для файла .proto, компилятор будет генерировать код для выбранного языка. Он преобразует ваши типы сообщений в расширенные классы, предоставляя, среди прочего, методы получения и установки для ваших свойств. Компилятор также генерирует удобные методы для сериализации сообщений в выходные потоки и строки и из них.

В случае типа enum сгенерированный код будет иметь соответствующее enum для Java или C ++ или специальный класс EnumDescriptor для Python, который используется для создания набора символических констант с целочисленными значениями в классе, генерируемом во время выполнения.

Для Java компилятор будет генерировать файлы .java с классными классами Builder для каждого типа сообщений, чтобы упростить создание и инициализацию объектов. Классы сообщений, генерируемые компилятором, являются неизменяемыми; однажды построенные, они не могут быть изменены.

Вы можете прочитать о других платформах (Python, C ++) в разделе ресурсов с подробной информацией о кодировках полей здесь:

https://developers.google.com/protocol-buffers/docs/reference/overview.

В нашем примере мы вызовем protoc с флагом командной строки –java_out. Этот флаг указывает компилятору выходной каталог для сгенерированных классов Java — один класс Java для каждого файла прото.

API

Сгенерированный API обеспечивает поддержку следующих удобных методов:

  • IsInitialized ()
  • нанизывать()
  • mergeFrom (…)
  • Чисто()

Для разбора и сериализации:

  • byte [] toByteArray ()
  • parseFrom ()
  • writeTo (OutputStream) Используется в примере кода для кодирования
  • parseFrom (InputStream) Используется в примере кода для декодирования

Образец кода

Давайте создадим простой проект. Мне нравится следовать стандартному архетипу Maven:

protobuff-example / src / main / java / [Код приложения] protobuff-example / src / main / java / gen [Сгенерированные классы Proto]

Чтобы сгенерировать классы буфера протокола, я выполню следующую команду:

1
2
3
#  protoc --proto_path=/home/user/workspace/eclipse/trunk/protobuff/
               --java_out=/home/user/workspace/eclipse/trunk/protobuff/src/main/java
               /home/user/workspace/eclipse/trunk/protobuff/src/main/proto/User.proto

Я покажу некоторые фрагменты сгенерированного кода и кратко расскажу о них. Созданный класс довольно большой, но его легко понять. Он предоставит сборщикам возможность создавать экземпляры User и Address.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public final class ProtoUser {
 
 
   public interface UserOrBuilder
     extends com.google.protobuf.MessageOrBuilder
 
...
 
 
   public interface AddressOrBuilder
        extends com.google.protobuf.MessageOrBuilder {
 
 ....
 
}

Сгенерированный класс содержит интерфейсы Builder, которые обеспечивают действительно быстрое создание объектов. Эти интерфейсы компоновщика имеют геттеры и сеттеры для каждого свойства, указанного в нашем прото-файле, например:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public String getCountry() {
        java.lang.Object ref = country_;
        if (ref instanceof String) {
          return (String) ref;
        } else {
          com.google.protobuf.ByteString bs =
              (com.google.protobuf.ByteString) ref;
          String s = bs.toStringUtf8();
          if (com.google.protobuf.Internal.isValidUtf8(bs)) {
            country_ = s;
          }
          return s;
        }
      }

Поскольку это настраиваемый механизм кодирования, логически все поля имеют настраиваемые байтовые обертки. Наше простое поле String при сохранении сжимается с помощью ByteString, который затем десериализуется в строку UTF-8.

01
02
03
04
05
06
07
08
09
10
11
// required int32 id = 1;
                                          
public static final int ID_FIELD_NUMBER = 1;
                       
private int id_;
                         
public boolean hasId() {
                    
      return ((bitField0_ & 0x00000001) == 0x00000001);
                      
}     

В этом звонке мы видим важность номеров тегов, о которых мы говорили в начале. Эти номера тегов, по-видимому, представляют какую-то битовую позицию, которая определяет, где данные расположены в байтовой строке. Далее мы видим фрагменты методов записи и чтения, которые я упоминал ранее.

Запись экземпляра в выходной поток:

01
02
03
04
05
06
07
08
09
10
11
12
public void writeTo(com.google.protobuf.CodedOutputStream output)
                    throws java.io.IOException {
 
        getSerializedSize();
 
        if (((bitField0_ & 0x00000001) == 0x00000001)) {
          output.writeInt32(1, id_);
        }
        if (((bitField0_ & 0x00000002) == 0x00000002)) {
          output.writeBytes(2, getCountryBytes());
....
}

Чтение из входного потока:

1
2
3
4
public static ProtoUser.User parseFrom(java.io.InputStream input)
      throws java.io.IOException {
    return newBuilder().mergeFrom(input).buildParsed();
}

Этот класс составляет около 2000 строк кода. Существуют и другие подробности, например, как сопоставляются типы Enum и как хранятся повторяющиеся типы. Надеемся, что предоставленные мной фрагменты дают вам общее представление о структуре этого класса.

Давайте посмотрим на некоторый код уровня приложения для использования сгенерированного класса. Чтобы сохранить данные, мы можем просто сделать:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Create instance of Address
                                          
 Address addr = ProtoUser.User.Address.newBuilder() 
              .setAddrType(Address.Type.HOME)       
              .setCity("Weston")
              .setCountry("USA")
              .setId(1)
              .setState("FL")
              .setStreet("123 Lakeshore")
              .setZip("90210")
              .build();
                 
// Serialize instance of User
                                              
   User user = ProtoUser.User.newBuilder()
              .setId(1)
              .setFirstname("Luis")
              .setLastname("Atencio")
              .setName("luisat")
              .setSsn("555-555-5555")         
              .addAddr(addr)
              .build();
                        
  // Write file
                      
   FileOutputStream output = new FileOutputStream("target/user.ser"); 
   user.writeTo(output);         
   output.close();

После того, как оно сохранилось, мы можем прочитать так:

1
2
3
4
5
User user = User.parseFrom(
       
   new FileInputStream("target/user.ser");
                            
System.out.println(user);               

Чтобы запустить пример кода, используйте:

java -cp.: ../ lib / protobuf-java-2.4.1.jar app.Serialize ../target/user.ser

Протобуфф против XML

Google утверждает, что буферы протокола в 20-100 раз быстрее (в наносекундах), чем XML, и в 3-10 раз меньше для удаления пробелов. Однако до тех пор, пока не будут поддерживаться и внедряться на всех платформах (не только вышеупомянутых 3), XML будет оставаться очень популярным механизмом сериализации. Кроме того, не у всех есть требования к производительности и ожидания, которые есть у пользователей Google. Альтернативой XML является JSON.

Протобуфф против JSON

Я провел сравнительное тестирование, чтобы оценить использование буферов протокола поверх JSON. Результаты оказались довольно драматичными, простой тест показал, что протобаффы на 50% более эффективны с точки зрения хранения. Я создал простую версию POJO своих классов User-Address и использовал библиотеку GSON для кодирования экземпляра с тем же состоянием, что и в примере выше (я опущу детали реализации, пожалуйста, проверьте проект gson, на который ссылаются ниже). Кодируя те же данные пользователя, я получил:

1
2
-rw-rw-r-- 1 luisat luisat 206 May 30 09:47 json-user.ser
-rw-rw-r-- 1 luisat luisat 85 May 30 09:42  user.ser

Что замечательно. Я также нашел это в другом блоге (см. Ресурсы ниже):

Это определенно стоит прочитать.

Заключение и дальнейшие замечания

Буферы протокола могут быть хорошим решением для кросс-платформенного кодирования данных. С клиентами, написанными на Java, Python, C ++ и многих других, хранение / отправка сжатых данных действительно проста.

Один хитрый момент: «Помните, ТРЕБУЕТСЯ навсегда». Если вы сходите с ума и делаете обязательным каждое поле вашего файла .proto , то удалить или редактировать эти поля будет крайне сложно.

Немного обнадеживает то, что в хранилищах данных Google используются протобаффы: в дереве кода Google определено 48 462 различных типа сообщений в 12 183 файлах .proto.

Протоколные буферы способствуют хорошему объектно-ориентированному проектированию, поскольку файлы .proto в основном являются глупыми держателями данных (как структуры в C ++). Согласно документации Google, если вы хотите добавить более богатое поведение к сгенерированному классу или у вас нет контроля над дизайном файла .proto , лучший способ сделать это — обернуть сгенерированный класс буфера протокола в приложение. конкретный класс.

Наконец, помните, что вы никогда не должны добавлять поведение к сгенерированным классам, наследуя их. Это сломает внутренние механизмы и в любом случае не является хорошей объектно-ориентированной практикой.

Большая часть информации, представленной здесь, взята из личного опыта, других ресурсов и, самое главное, кода разработчика Google. Пожалуйста, ознакомьтесь с документацией в разделе ресурсов.

Ресурсы

  1. https://developers.google.com/protocol-buffers/docs/overview
  2. https://developers.google.com/protocol-buffers/docs/proto
  3. https://developers.google.com/protocol-buffers/docs/reference/java-generated
  4. https://developers.google.com/protocol-buffers/docs/reference/overview
  5. http://code.google.com/p/google-gson/
  6. http://afrozahmad.hubpages.com/hub/protocolbuffers

Ссылка: Java Protocol Buffers от нашего партнера JCG Луиса Атенсио в блоге Reflective Thought .