Go Opaque API FAQ

Список часто задаваемых вопросов о Opaque API.

Opaque API — это последняя версия реализации Protocol Buffers для языка программирования Go. Старая версия теперь называется Open Struct API. Смотрите Go Protobuf: The new Opaque API (блогпост) для введения.

Этот FAQ отвечает на распространенные вопросы о новом API и процессе миграции.

Какой API следует использовать при создании нового файла .proto?

Мы рекомендуем выбирать Opaque API для новой разработки.

Protobuf Edition 2024 (см. Обзор редакций Protobuf) сделал Opaque API значением по умолчанию.

Как включить новый Opaque API для моих сообщений?

Начиная с Protobuf Edition 2023, вы можете выбрать Opaque API, установив функцию editions api_level в API_OPAQUE в вашем файле .proto. Это можно установить для каждого файла или для каждого сообщения:

edition = "2023";

package log;

import "google/protobuf/go_features.proto";
option features.(pb.go).api_level = API_OPAQUE;

message LogEntry { … }

Protobuf Edition 2024 по умолчанию использует Opaque API, что означает, что вам не понадобятся дополнительные импорты или опции:

edition = "2024";

package log;

message LogEntry { … }

Для вашего удобства вы также можете переопределить уровень API по умолчанию с помощью флага командной строки protoc:

protoc […] --go_opt=default_api_level=API_HYBRID

Чтобы переопределить уровень API по умолчанию для конкретного файла (вместо всех файлов), используйте флаг сопоставления apilevelM (аналогично флагу M для путей импорта):

protoc […] --go_opt=apilevelMhello.proto=API_HYBRID

Флаги командной строки также работают для файлов .proto, все еще использующих синтаксис proto2 или proto3, но если вы хотите выбрать уровень API из самого файла .proto, вам нужно сначала перенести этот файл на editions.

Как включить ленивое декодирование?

  1. Мигрируйте ваш код для использования opaque реализации.
  2. Установите опцию [lazy = true] на поля подсообщений proto, которые должны быть лениво декодированы.
  3. Запустите ваши модульные и интеграционные тесты, а затем разверните в промежуточной среде.

Игнорируются ли ошибки при ленивом декодировании?

Нет. proto.Marshal всегда проверяет данные wire-формата, даже когда декодирование отложено до первого доступа.

Где можно задать вопросы или сообщить о проблемах?

Если вы нашли проблему с инструментом миграции open2opaque (например, некорректно переписанный код), пожалуйста, сообщите об этом в трекере проблем open2opaque.

Если вы нашли проблему с Go Protobuf, пожалуйста, сообщите об этом в трекере проблем Go Protobuf.

Каковы преимущества Opaque API?

Opaque API поставляется с многочисленными преимуществами:

  • Он использует более эффективное представление в памяти, тем самым уменьшая память и стоимость Сборки Мусора.
  • Он делает возможным ленивое декодирование, что может значительно улучшить производительность.
  • Он исправляет ряд острых углов. Ошибки, возникающие из-за сравнения адресов указателей, случайного совместного использования или нежелательного использования рефлексии Go, все предотвращаются при использовании Opaque API.
  • Он делает идеальное размещение в памяти возможным, включая оптимизации на основе профилей.

Смотрите блогпост Go Protobuf: The new Opaque API для более подробной информации по этим пунктам.

Что быстрее, Builders или Setters?

В общем, код, использующий builders:

_ = pb.M_builder{
  F: &val,
}.Build()

медленнее, чем следующий эквивалент:

m := &pb.M{}
m.SetF(val)

по следующим причинам:

  1. Вызов Build() перебирает все поля в сообщении (даже те, которые не установлены явно) и копирует их значения (если есть) в финальное сообщение. Эта линейная производительность имеет значение для сообщений со многими полями.
  2. Есть потенциальное дополнительное выделение в куче (&val).
  3. Builder может быть значительно больше и использовать больше памяти при наличии полей oneof. Builders имеют поле для каждого члена объединения oneof, тогда как сообщение может хранить сам oneof как одно поле.

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

Как использовать Builders?

Builders предназначены для использования как значения и с немедленным вызовом Build(). Избегайте использования указателей на builders или хранения builders в переменных.

m := pb.M_builder{
    // ...
}.Build()
// ПЛОХО: Избегайте использования указателя
m := (&pb.M_builder{
    // ...
}).Build()
// ПЛОХО: избегайте хранения в переменной
b := pb.M_builder{
    // ...
}
m := b.Build()

Сообщения Proto неизменяемы в некоторых других языках, поэтому пользователи склонны передавать тип builder в вызовы функций при конструировании сообщения proto. Сообщения Go proto изменяемы, следовательно, нет необходимости передавать builder в вызовы функций. Просто передавайте сообщение proto.

// ПЛОХО: избегайте передачи builder
func populate(mb *pb.M_builder) {
  mb.Field1 = proto.Int32(4711)
  //...
}
// ...
mb := pb.M_builder{}
populate(&mb)
m := mb.Build()
func populate(mb *pb.M) {
  mb.SetField1(4711)
  //...
}
// ...
m := &pb.M{}
populate(m)

Builders предназначены для имитации композитного литерального конструирования Open Struct API, а не как альтернативное представление сообщения proto.

Рекомендуемый шаблон также более производителен. Предполагаемое использование Build(), где он вызывается непосредственно на литерале структуры builder, может быть хорошо оптимизировано. Отдельный вызов Build() гораздо сложнее оптимизировать, так как компилятор может не легко определить, какие поля заполнены. Если builder живет дольше, также высока вероятность того, что небольшие объекты, такие как скаляры, должны быть выделены в куче и позже должны быть освобождены сборщиком мусора.

Следует ли использовать Builders или Setters?

При создании пустого протокольного буфера вы должны использовать new или пустой композитный литерал. Оба одинаково идиоматичны для создания нулевого инициализированного значения в Go и более производительны, чем пустой builder.

m1 := new(pb.M)
m2 := &pb.M{}
// ПЛОХО: избегайте: излишне сложно
m1 := pb.M_builder{}.Build()

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

// Рекомендуется: использование builders
m1 := pb.M1_builder{
    Submessage: pb.M2_builder{
        Submessage: pb.M3_builder{
            String: proto.String("hello world"),
            Int:    proto.Int32(42),
        }.Build(),
        Bytes: []byte("hello"),
    }.Build(),
}.Build()
// Также допустимо: использование сеттеров
m3 := &pb.M3{}
m3.SetString("hello world")
m3.SetInt(42)
m2 := &pb.M2{}
m2.SetSubmessage(m3)
m2.SetBytes([]byte("hello"))
m1 := &pb.M1{}
m1.SetSubmessage(m2)

Вы можете комбинировать использование builder и сеттеров, если определенные поля требуют условной логики перед установкой.

m1 := pb.M1_builder{
    Field1: value1,
}.Build()
if someCondition() {
    m1.SetField2(value2)
    m1.SetField3(value3)
}

Как я могу повлиять на поведение Builder в open2opaque?

Флаг --use_builders инструмента open2opaque может иметь следующие значения:

  • --use_builders=everywhere: всегда использовать builders, без исключений.
  • --use_builders=tests: использовать builders только в тестах, в остальных случаях использовать сеттеры.
  • --use_builders=nowhere: никогда не использовать builders.

Какого выигрыша в производительности можно ожидать?

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

  • Какой процент использования вашего CPU приходится на Go Protobuf? Некоторые рабочие нагрузки, такие как конвейеры анализа логов, которые вычисляют статистику на основе записей Protobuf, могут тратить около 50% использования CPU на Go Protobuf. Улучшения производительности, вероятно, будут четко видны в таких рабочих нагрузках. На другом конце спектра, в программах, которые тратят только 3-5% использования CPU на Go Protobuf, улучшения производительности часто будут незначительными по сравнению с другими возможностями.
  • Насколько ваша программа поддается ленивому декодированию? Если большие части входных сообщений никогда не доступны, ленивое декодирование может сэкономить много работы. Этот шаблон обычно встречается в таких задачах, как прокси-серверы (которые передают входные данные как есть), или конвейеры анализа логов с высокой избирательностью (которые отбрасывают многие записи на основе предиката высокого уровня).
  • Содержат ли ваши определения сообщений много элементарных полей с явным присутствием? Opaque API использует более эффективное представление в памяти для элементарных полей, таких как целые числа, логические значения, перечисления и числа с плавающей точкой, но не для строк, повторяющихся полей или подсообщений.

Как Proto2, Proto3 и Editions относятся к Opaque API?

Термины proto2 и proto3 относятся к разным версиям синтаксиса в ваших файлах .proto. Редакции Protobuf — это преемник как proto2, так и proto3.

Opaque API влияет только на сгенерированный код в файлах .pb.go, а не на то, что вы пишете в ваших файлах .proto.

Opaque API работает одинаково, независимо от того, какой синтаксис или редакцию используют ваши файлы .proto. Однако, если вы хотите выбрать Opaque API для каждого файла (в отличие от использования флага командной строки при запуске protoc), вы должны сначала перенести файл на editions. Смотрите Как включить новый Opaque API для моих сообщений? для подробностей.

Почему изменяется только макет памяти элементарных полей?

Раздел "Opaque structs use less memory" в анонсном блогпосте объясняет:

Это улучшение производительности [более эффективное моделирование присутствия поля] сильно зависит от формы вашего сообщения protobuf: изменение затрагивает только элементарные поля, такие как целые числа, логические значения, перечисления и числа с плавающей точкой, но не строки, повторяющиеся поля или подсообщения.

Естественный последующий вопрос: почему строки, повторяющиеся поля и подсообщения остаются указателями в Opaque API. Ответ двоякий.

Соображение 1: Использование памяти

Представление подсообщений как значений вместо указателей увеличило бы использование памяти: каждый тип сообщения Protobuf несет внутреннее состояние, которое потребляло бы память, даже когда подсообщение фактически не установлено.

Для строк и повторяющихся полей ситуация более нюансированная. Давайте сравним использование памяти строкового значения по сравнению с указателем на строку:

Тип переменной Goустановлено?словоа#байт
stringда2 (данные, длина)16
stringнет2 (данные, длина)16
*stringда1 (данные) + 2 (данные, длина)24
*stringнет1 (данные)8

(Ситуация похожа для срезов, но заголовки срезов нуждаются в 3 словах: данные, длина, емкость.)

Если ваши строковые поля подавляюще не установлены, использование указателя экономит ОЗУ. Конечно, эта экономия достигается за счет введения большего количества выделений памяти и указателей в программу, что увеличивает нагрузку на Сборщик Мусора.

Преимущество Opaque API в том, что мы можем изменить представление без каких-либо изменений в пользовательском коде. Текущий макет памяти был оптимальным для нас, когда мы вводили его, но если бы мы измеряли сегодня или через 5 лет в будущем, возможно, мы выбрали бы другой макет.

Как описано в разделе "Making the ideal memory layout possible" анонсного блогпоста, мы стремимся принимать эти решения по оптимизации для каждой рабочей нагрузки в будущем.

Соображение 2: Ленивое декодирование

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

Сообщения Protobuf безопасны для конкурентного доступа (но не конкурентной модификации), поэтому если две разные горутины вызывают ленивое декодирование, им нужно как-то координироваться. Эта координация реализована через использование пакета sync/atomic, который может обновлять указатели атомарно, но не заголовки срезов (которые превышают слово).

Хотя protoc в настоящее время разрешает ленивое декодирование только для (неповторяющихся) подсообщений, это рассуждение применимо ко всем типам полей.