Семантика размера в Go
Объясняет, как (не) использовать proto.Size
Функция proto.Size
возвращает размер в байтах wire-формата кодировки
proto.Message, проходя по всем его полям (включая подсообщения).
В частности, она возвращает размер того, как Go Protobuf закодирует сообщение.
Примечание о Protobuf Editions
С Protobuf Editions файлы .proto могут включать функции, которые изменяют
поведение сериализации. Это может повлиять на значение, возвращаемое proto.Size. Например,
установка features.field_presence = IMPLICIT приведет к тому, что скалярные поля,
установленные в значения по умолчанию, не будут сериализованы и, следовательно, не будут
влиять на размер сообщения.
Типичные случаи использования
Идентификация пустых сообщений
Проверка, возвращает ли
proto.Size
0, является распространенным способом проверки пустых сообщений:
if proto.Size(m) == 0 {
// Ни одно поле не установлено; пропустить обработку этого сообщения,
// или вернуть ошибку, или подобное.
}
Ограничение размера вывода программы
Допустим, вы пишете конвейер пакетной обработки, который производит рабочие задачи для другой системы, которую мы назовем «нисходящей системой» в этом примере. Нисходящая система настроена на обработку небольших и средних задач, но тестирование нагрузки показало, что система сталкивается с каскадным отказом, когда ей представляется рабочая задача размером более 500 МБ.
Лучшим решением является добавление защиты в нисходящую систему (см. https://cloud.google.com/blog/products/gcp/using-load-shedding-to-survive-a-success-disaster-cre-life-lessons), но когда реализация сброса нагрузки невозможна, вы можете решить добавить быстрое исправление в ваш конвейер:
func (*beamFn) ProcessElement(key string, value []byte, emit func(proto.Message)) {
task := produceWorkTask(value)
if proto.Size(task) > 500 * 1024 * 1024 {
// Пропускать каждую рабочую задачу более 500 МБ, чтобы не перегружать
// хрупкую нисходящую систему.
return
}
emit(task)
}
Неправильное использование: нет связи с Unmarshal
Поскольку proto.Size
возвращает количество байт для того, как Go Protobuf закодирует сообщение, это
небезопасно использовать proto.Size при демаршалинге (декодировании) потока входящих
сообщений Protobuf:
func bytesToSubscriptionList(data []byte) ([]*vpb.EventSubscription, error) {
subList := []*vpb.EventSubscription{}
for len(data) > 0 {
subscription := &vpb.EventSubscription{}
if err := proto.Unmarshal(data, subscription); err != nil {
return nil, err
}
subList = append(subList, subscription)
data = data[:len(data)-proto.Size(subscription)]
}
return subList, nil
}
Когда data содержит сообщение в неминимальном wire-формате,
proto.Size может вернуть размер, отличный от фактически демаршаленного,
что приводит к ошибке разбора (в лучшем случае) или неправильно разобранным данным в худшем
случае.
Следовательно, этот пример работает надежно только до тех пор, пока все входные сообщения сгенерированы (той же версией) Go Protobuf. Это неожиданно и, вероятно, не предполагалось.
Совет: Используйте
protodelim package
вместо этого для чтения/записи потоков сообщений Protobuf с указанием размера.
Продвинутое использование: предварительное определение размера буферов
Продвинутое использование
proto.Size — это
определение необходимого размера для буфера перед маршалингом:
opts := proto.MarshalOptions{
// Возможно избежать дополнительного вызова proto.Size в самом Marshal (см. документацию):
UseCachedSize: true,
}
// НЕ ОТПРАВЛЯЙТЕ код без реализации этой возможности оптимизации:
// вместо выделения памяти, возьмите буфер достаточного размера из пула.
// Знание размера буфера означает, что мы можем отбрасывать
// выбросы из пула, чтобы предотвратить неконтролируемый
// рост памяти в долгоживущих RPC-сервисах.
buf := make([]byte, 0, opts.Size(m))
var err error
buf, err = opts.MarshalAppend(buf, m) // не выделяет память
// Обратите внимание, что len(buf) может быть меньше cap(buf)! Читайте ниже:
Обратите внимание, что когда включено ленивое декодирование, proto.Size может вернуть больше байтов,
чем proto.Marshal (и варианты, такие как proto.MarshalAppend) запишет! Так
что когда вы помещаете закодированные байты в сеть (или на диск), обязательно работайте
с len(buf) и отбрасывайте любые предыдущие результаты proto.Size.
В частности, (под-)сообщение может «уменьшиться» между вызовами proto.Size и
proto.Marshal, когда:
- Включено ленивое декодирование
- и сообщение прибыло в неминимальном wire-формате
- и к сообщению не обращались до вызова
proto.Size, то есть оно еще не декодировано - и к сообщению обратились после
proto.Size(но доproto.Marshal), что вызывает его ленивое декодирование
Декодирование приводит к тому, что любые последующие вызовы proto.Marshal кодируют
сообщение (в отличие от простого копирования его wire-формата), что приводит к
неявной нормализации к тому, как Go кодирует сообщения, что в настоящее время выполняется в минимальном
wire-формате (но не полагайтесь на это!).
Как видите, сценарий довольно специфичен, но тем не менее лучшая
практика — рассматривать результаты proto.Size как верхнюю границу и никогда не предполагать, что
результат соответствует фактическому размеру закодированного сообщения.
Предыстория: Неминимальный wire-формат
При кодировании сообщений Protobuf существует один минимальный размер wire-формата и несколько больших неминимальных wire-форматов, которые декодируются в то же сообщение.
Неминимальный wire-формат (иногда также называемый «денормализованным wire-форматом») относится к сценариям, таким как появление неповторяющихся полей несколько раз, неоптимальное кодирование varint, упакованные повторяющиеся поля, которые появляются неупакованными в wire-формате, и другим.
Мы можем столкнуться с неминимальным wire-форматом в разных сценариях:
- Намеренно. Protobuf поддерживает конкатенацию сообщений путем конкатенации их wire-формата.
- Случайно. (Возможно, сторонний) кодировщик Protobuf кодирует не идеально (например, использует больше места, чем необходимо, при кодировании varint).
- Злонамеренно. Злоумышленник может специально создавать сообщения Protobuf, чтобы вызвать сбои по сети.