Skip to content

Protobuf

Официальный сайт: Protocol Buffers | Google Developers

Введение

Официальный учебник: Protocol Buffer Basics: Go | Protocol Buffers | Google Developers

Protocol Buffers — это независимый от языка, независимый от протокола, расширяемый механизм сериализации структурированных данных, открытый Google в 2008 году. Он обеспечивает более быструю распаковку и упаковку, обычно используется в области RPC-коммуникации. Он может определять структурированный способ данных, а затем использовать специально сгенерированный исходный код для лёгкого чтения и записи структурированных данных из различных потоков данных и использования их на различных языках. Protocol Buffers в дальнейшем тексте называется protobuf.

protobuf довольно популярен, особенно в экосистеме Go, где gRPC использует его в качестве механизма сериализации для передачи протоколов.

Синтаксис

Сначала давайте посмотрим на пример, чтобы увидеть, как вообще выглядит файл protobuf. В целом, его синтаксис очень прост и может быть освоен примерно за десять минут. Ниже приведён пример файла с именем search.proto. Расширение файла для protobuf.proto.

protobuf
syntax = "proto3";

message SearchRequest {
  string query = 1;
  string number = 2;
}

message SearchResult {
  string data = 1;
}

service SearchService {
  rpc Search(SearchRequest) returns(SearchResult);
}
  • Первая строка syntax = "proto3"; указывает на использование синтаксиса proto3, который является значением по умолчанию.
  • Объявление message похоже на struct и является базовой структурой в proto.
  • SearchRequest определяет три поля, каждое с именем и типом.
  • service определяет службу, которая содержит один или несколько RPC-интерфейсов.
  • RPC-интерфейсы должны иметь ровно один параметр и одно возвращаемое значение, и их типы должны быть message, а не базовыми типами.

Кроме того, обратите внимание, что каждая строка в файле proto должна заканчиваться точкой с запятой.

Комментарии

Стиль комментариев точно такой же, как в Go.

protobuf
syntax = "proto3";

/* Комментарий
 * Комментарий */
message SearchRequest {
  string query = 1; // Комментарий
  string number = 2;
}

Типы

Модификаторы типов могут появляться только в message и не могут появляться отдельно.

Базовые типы

Тип protoТип Go
doublefloat64
floatfloat32
int32int32
int64int64
uint32uint32
uint64uint64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
boolbool
stringstring
bytes[]byte

Массивы

Добавление модификатора repeated перед базовым типом указывает на тип массива, соответствующий срезу в Go.

protobuf
message Company {
  repeated string employee = 1;
}

Map

Формат определения типа map в protobuf следующий:

map<key_type, value_type> map_field = N;

key_type должен быть числовым или строковым, а value_type не имеет ограничений по типу. Вот пример:

protobuf
message Person {
  map<string, int64> cards = 1;
}

Поля

Фактически proto не является традиционным типом ключ-значение. В объявленном файле proto конкретные данные не появляются. После = каждого поля должно быть уникальное число в текущем message. Эти числа используются для идентификации и определения этих полей в двоичных сообщениях. Числа начинаются с 1, при этом числа 1-15 занимают 1 байт, а 16-2047 занимают 2 байта. Поэтому часто появляющимся полям следует присваивать числа 1-15 для экономии места, и следует зарезервировать некоторое место для полей, которые могут часто появляться в будущем.

Поля в message должны следовать этим правилам:

  • singular: Это тип поля по умолчанию. В хорошо структурированном message может быть только 0 или 1 такое поле, что означает, что одно и то же поле не может существовать повторно. Следующее объявление вызовет ошибку.

    protobuf
    syntax = "proto3";
    
    message SearchRequest {
      string query = 1;
      string number = 2;
      string number = 3; // Дубликат поля
    }
  • optional: Похож на singular, но позволяет явно проверить, установлено ли значение поля. Могут быть следующие две ситуации:

    • set: Будет сериализовано
    • unset: Не будет сериализовано
  • repeated: Этот тип поля может появляться 0 или несколько раз. Повторяющиеся значения будут сохранены в порядке (проще говоря, это массив, который позволяет одному и тому же типу значения появляться несколько раз и сохраняет их в порядке появления, что является индексом).

  • map: Поле типа пары ключ-значение, объявляется следующим образом:

    protobuf
    map<string,int32> config = 3;

Зарезервированные поля

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

protobuf
syntax = "proto3";

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  reserved "a"; // Объявить конкретное имя поля как зарезервированное поле
  reserved 1 to 2; // Объявить последовательность чисел как зарезервированное поле
  reserved 3,4; // Объявить
}

С этим файл не пройдёт компиляцию.

Устаревшие поля

Если поле устарело, его можно записать следующим образом:

protobuf
message Body {
  string name = 1 [deprecated = true];
}

Enum

Вы можете объявить константы enum и использовать их как типы полей. Обратите внимание, что первый элемент enum должен быть нулём, потому что значение по умолчанию enum — первый элемент.

protobuf
syntax = "proto3";

enum Type {
  GET = 0;
  POST = 1;
  PUT = 2;
  DELETE = 3;
}

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  Type type = 5;
}

Когда внутри enum есть элементы enum с одинаковым значением, вы можете использовать псевдонимы enum:

protobuf
syntax = "proto3";

enum Type {
  option allow_alias = true; // Нужно включить конфигурацию псевдонимов
  GET = 0;
  GET_ALIAS = 0; // Псевдоним для элемента enum GET
  POST = 1;
  PUT = 2;
  DELETE = 3;
}

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  Type type = 5;
}

Вложенные сообщения

protobuf
message Outer {                  // Уровень 0
  message MiddleAA {  // Уровень 1
    message Inner {   // Уровень 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Уровень 1
    message Inner {   // Уровень 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

message может вкладывать объявления message, как вложенные struct.

Package

Вы можете добавить необязательный модификатор package в файлы protobuf для предотвращения конфликтов имён между типами сообщений протокола.

protobuf
package foo.bar;
message Open { ... }

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

protobuf
message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

Import

Импорт позволяет нескольким файлам protobuf совместно использовать определения. Синтаксис следующий, и расширения файлов нельзя опускать при импорте.

protobuf
import "a/b/c.proto";

Импорты используют относительные пути, но этот относительный путь не является относительным путём между импортирующим файлом и импортируемым файлом. Он зависит от пути сканирования, указанного при генерации кода компилятором protoc. Предположим, существует следующая структура файлов:

pb_learn
│  common.proto

├─monster
│      monster.proto

└─player
        health.proto
        player.proto

Если нам нужно только сгенерировать код для части каталога player и указать только каталог player при сканировании путей, то взаимные импорты между health.proto и player.proto могут напрямую писать имена файлов. Например, player.proto импортирует health.proto:

protobuf
import "health.proto";

Если в этот момент player.proto импортирует файлы в каталоге common.proto или monster, компиляция не удастся, поэтому следующая запись полностью неверна, потому что компилятор не может найти эти файлы:

go
import "../common.proto"; // Неправильная запись

TIP

Кстати, .., . эти символы не разрешены к появлению в путях импорта.

Предположим, pb_learn указан как путь сканирования во время компиляции, тогда вы можете импортировать файлы из других каталогов через относительные пути. Фактический путь импорта — это относительный адрес абсолютного адреса файла относительно pb_learn. Посмотрите на следующий пример импорта других файлов player.proto:

protobuf
import "common.proto";
import "monster/monster.proto";
import "player/health.proto";

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

TIP

Если вы используете редактор GoLand, для созданных вами каталогов protobuf он не может быть разрешён по умолчанию, что приводит к красным выделениям. Чтобы GoLand распознал его, нужно вручную установить путь сканирования. Принцип точно такой же, как описано выше. Метод настройки следующий: откройте следующие настройки:

File | Settings | Languages & Frameworks | Protocol Buffers

Вручную добавьте путь сканирования в Import Paths, который должен совпадать с путём, указанным во время компиляции.

Any

Тип Any позволяет использовать сообщения как встроенные типы без необходимости их определений proto. Вы можете напрямую импортировать определённые Google типы, которые встроены и не нуждаются в ручном написании.

protobuf
import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

Google также предварительно определил много других типов. Посетите protobuf/ptypes at master · golang/protobuf (github.com), чтобы увидеть больше, в основном включая:

  • Обёртки базовых типов
  • Типы времени
  • Типы длительности

Их определения protobuf должны быть в каталоге include компилятора protoc.

OneOf

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

protobuf
message Stock {
    // Данные, специфичные для акций
}

message Currency {
    // Данные, специфичные для валюты
}

message ChangeNotification {
  int32 id = 1;
  oneof instrument {
    Stock stock = 2;
    Currency currency = 3;
  }
}

Service

Ключевое слово service может определять RPC-службу. RPC-служба содержит несколько RPC-интерфейсов, которые делятся на унарные интерфейсы и потоковые интерфейсы.

protobuf
message Body {
  string name = 1;
}

service ExampleService {
  rpc DoSomething(Body) returns(Body);
}

Потоковые интерфейсы далее делятся на однонаправленную потоковую передачу и двунаправленную потоковую передачу, обычно модифицируются ключевым словом stream. Посмотрите на следующий пример:

protobuf
message Body {
  string name = 1;
}

service ExampleService {
  // Потоковая передача клиента
  rpc DoSomething(stream Body) returns(Body);
  // Потоковая передача сервера
  rpc DoSomething1(Body) returns(stream Body);
  // Двунаправленная потоковая передача
  rpc DoSomething2(stream Body) returns(stream Body);
}

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

Empty

Empty на самом деле является пустым message, соответствующим пустой struct в Go. Он редко используется для модификации полей и в основном используется для указания, что RPC-интерфейс не нуждается в параметрах или не имеет возвращаемого значения.

protobuf
syntax = "proto3";

import "google/protobuf/empty.proto";

service EmptyService {
  rpc Do(google.protobuf.Empty) returns(google.protobuf.Empty);
}

Option

Options обычно используются для управления некоторыми поведениями protobuf. Например, для управления пакетом, сгенерированным для исходного кода языка Go, вы можете объявить следующим образом:

protobuf
option go_package = "github/jack/sample/pb_learn;pb_learn"

Перед точкой с запятой — путь импорта для других исходных файлов после генерации кода, а после точки с запятой — имя пакета для соответствующего сгенерированного файла.

Он может делать некоторые оптимизации со следующими доступными значениями, которые нельзя объявлять повторно:

  • SPEED: Высший уровень оптимизации, наибольший объём сгенерированного кода, это значение по умолчанию.
  • CODE_SIZE: Уменьшает объём сгенерированного кода, но полагается на рефлексию для сериализации.
  • LITE_RUNTIME: Наименьший объём кода, но не хватает некоторых функций.

Вот пример использования:

protobuf
option optimize_for = SPEED;

Кроме того, options также могут добавлять некоторые метаданные в message и enum. Вы можете получить эту информацию через рефлексию, что особенно полезно для проверки параметров.

Компиляция

Компиляция — это генерация кода. Выше мы только определили файлы protobuf. В фактическом использовании они должны быть преобразованы в исходный код конкретного языка для использования. Мы завершаем это через компилятор protoc, который поддерживает несколько языков.

Установка

Чтобы загрузить компилятор, перейдите на protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com) для загрузки последнего Release, как правило, сжатый файл:

protoc-25.1-win64
│  readme.txt

├─bin
│      protoc.exe

└─include
    └─google
        └─protobuf
            │  any.proto
            │  api.proto
            │  descriptor.proto
            │  duration.proto
            │  empty.proto
            │  field_mask.proto
            │  source_context.proto
            │  struct.proto
            │  timestamp.proto
            │  type.proto
            │  wrappers.proto

            └─compiler
                    plugin.proto

После загрузки добавьте каталог bin в переменные среды для использования команды protoc. После завершения проверьте версию. Нормальный вывод указывает на успешную установку:

bash
$ protoc --version
libprotoc 25.1

Загруженный компилятор не поддерживает язык Go по умолчанию, потому что генерация кода языка Go — это отдельный исполняемый файл, в то время как другие языки объединены вместе. Поэтому установите плагин языка Go для перевода определений protobuf в исходный код языка Go:

bash
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

Если вам также нужно сгенерировать код службы gRPC, установите следующий плагин:

bash
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

После установки проверьте их версии:

bash
$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.3.0

$ protoc-gen-go --version
protoc-gen-go.exe v1.31.0

Эти плагины также являются отдельными двоичными файлами, но могут вызываться только через protoc и не могут выполняться независимо:

(эта программа должна запускаться protoc, а не напрямую)

Кроме того, есть много других плагинов, таких как плагины для генерации документации интерфейса openapi и т.д. Если интересно, вы можете поискать самостоятельно.

Генерация

Всё ещё используя предыдущий пример, структура следующая:

pb_learn
│  common.proto

├─monster
│      monster.proto

└─player
        health.proto
        player.proto

Для генерации кода нужно указать всего три параметра:

  1. Путь сканирования: сообщает компилятору, где искать файлы protobuf и как разрешать пути импорта.
  2. Путь генерации: где размещаются скомпилированные файлы.
  3. Целевые файлы: указывает, какие целевые файлы нужно скомпилировать.

Перед началом убедитесь, что go_package в файлах protobuf установлен правильно. Используйте protoc -h для проверки его поддерживаемых параметров. Наиболее часто используемые — -I или --proto_path, которые можно использовать несколько раз для указания нескольких путей сканирования, например:

bash
$ protoc --proto_path=./pb_learn --proto_path=./third_party

Простого указания путей сканирования недостаточно; вам также нужно указать путь генерации и целевые файлы protobuf. Здесь мы генерируем файлы Go, поэтому используйте параметр --go_out, поддерживаемый ранее загруженным плагином protoc-gen-go:

bash
$ cd pb_learn

$ protoc --proto_path=. --go_out=. common.proto

$ ls
common.pb.go  common.proto  monster/  player/

Параметр --go_out указывает путь генерации. . означает текущий путь, а common.proto указывает файл для компиляции. Если вы хотите сгенерировать код grpc (предварительное условие: установлен плагин grpc), вы можете добавить параметр --go-grpc_out (если файл protobuf не определяет service, соответствующий файл не будет сгенерирован):

bash
$ protoc --proto_path=. --go_out=. --go-grpc_out=. common.proto

$ ls
common.pb.go  common.proto  common_grpc.pb.go  monster/  player/

common.pb.go — это сгенерированное определение типа protobuf, а common_grpc.pb.go — это сгенерированный код gRPC, который основан на первом. Если определение языка не сгенерировано, код gRPC не может быть сгенерирован.

Если вы хотите скомпилировать все файлы protobuf в каталоге, вы можете использовать подстановочный знак *, например *.proto:

bash
$ protoc --proto_path=. --go_out=. --go-grpc_out=. ./*.proto

Если вы хотите включить все файлы, вы можете использовать подстановочный знак **, например ./**/*.proto:

bash
$ protoc --proto_path=. --go_out=. --go-grpc_out=. ./**/*.proto

Однако этот метод применим только к оболочкам, которые поддерживают этот подстановочный знак. Например, в Windows cmd или powershell не поддерживают эту запись:

powershell
D> protoc --proto_path=. --go_out=. --go-grpc_out=. ./**/*.proto
Неверный шаблон имени файла или отсутствующий входной файл "./**/*.proto"

К счастью, gitbash поддерживает многие команды Linux и также может заставить Windows поддерживать этот синтаксис. Чтобы избежать написания повторяющихся команд каждый раз, вы можете поместить их в makefile:

makefile
.PHONY: all

proto_gen:
  protoc --proto_path=. \
       --go_out=paths=source_relative:. \
       --go-grpc_out=paths=source_relative:. \
       ./**/*.proto ./*.proto

Вы можете заметить, что добавлено paths=source_relative:, которое устанавливает режим пути генерации файла. Существуют следующие опции:

  • paths=import: Это значение по умолчанию. Файлы генерируются в каталоге, указанном import. Это также может быть путь модуля. Например, если есть файл protos/buzz.proto и вы указываете paths=example.com/project/protos/fizz, он в конечном итоге сгенерирует example.com/project/protos/fizz/buzz.pb.go.
  • module=$PREFIX: Во время генрации префикс пути будет удалён. В приведённом выше примере, если вы указываете префикс example.com/project, он в конечном итоге сгенерирует protos/fizz/buzz.pb.go. Этот режим в основном используется для генерации непосредственно в модуле (ощущается, как будто нет разницы).
  • paths=source_relative: Сгенерированные файлы поддерживают ту же относительную структуру, что и файлы protobuf в указанном каталоге.

После двоеточия : указывается путь генерации:

|  common.proto
|  common.pb.go

├─monster
│      monster.pb.go
│      monster.proto

└─player
        health.pb.go
        health.proto
        health_grpc.pb.go
        player.pb.go
        player.proto

Рефлексия

Вы можете расширить enum и message через options. Сначала импортируйте "google/protobuf/descriptor.proto":

protobuf
import "google/protobuf/descriptor.proto";

extend google.protobuf.EnumValueOptions {
  optional string string_name = 123456789;
}

enum Integer {
  INT64 = 0[
    (string_name) = "int_64"
  ];
}

Это эквивалентно добавлению метаданных к значению enum. То же самое применимо к message:

protobuf
import "google/protobuf/descriptor.proto";

extend google.protobuf.MessageOptions {
  optional string my_option = 51234;
}

message MyMessage {
  option (my_option) = "Hello world!";
}

Это эквивалентно наличию рефлексии о protobuf. После генерации кода вы можете получить доступ через Descriptor:

go
func main() {
  message := pb_learn.MyMessage{}
  message.ProtoReflect().Descriptor().Options().ProtoReflect().Range(func(descriptor protoreflect.FieldDescriptor, value protoreflect.Value) bool {
    fmt.Println(descriptor.FullName(), ":", value)
    return true
  })
}

Вывод:

my_option:"Hello world!"

Этот подход можно сравнить с добавлением тегов к struct в Go; они ощущаются похожими. На основе этого подхода вы также можете реализовать функциональность проверки параметров. Вам просто нужно написать правила в options и проверить через Descriptor.

Golang by www.golangdev.cn edit