Значительную часть своей профессиональной жизни я выступаю против использования Protocol Buffers. Они явно написаны любителями, невероятно узкоспециализированы, страдают от множества подводных камней, сложно компилируются и решают проблему, которой на самом деле нет ни у кого, кроме Google. Если бы эти проблемы протобуферов остались в карантине абстракций сериализации, то мои претензии на этом и закончились бы. Но, к сожалению, плохой дизайн Protobuffers настолько навязчив, что эти проблемы могут просочиться и в ваш код.
Узкая специализация и разработка любителями
Остановитесь. Закройте свой почтовый клиент, где уже написали мне полписьма о том, что «в Google работают лучшие в мире инженеры», что «их разработки по определению не могут быть созданы любителями». Не хочу этого слышать.
Давай просто не будем обсуждать эту тему. Полное раскрытие: мне доводилось работать в Google. Это было первое (но, к сожалению, не последнее) место, где я когда-либо использовал Protobuffers. Все проблемы, о которых я хочу поговорить, существуют в кодовой базе Google; это не просто «неправильное использование протобуферов» и тому подобная ерунда.
Безусловно, самая большая проблема с Protobuffers — ужасная система типов. Поклонники Java должны чувствовать себя здесь как дома, но, к сожалению, буквально никто не считает Java хорошо спроектированной системой типов. Ребята из лагеря динамической типизации жалуются на излишние ограничения, в то время как представители лагеря статической типизации, вроде меня, жалуются на излишние ограничения и отсутствие всего того, что вы на самом деле хотите от системы типов. Проигрыш в обоих случаях.
Узкая специализация и разработка любителями идут рука об руку. Многое в спецификациях словно прикручено в последний момент — и оно явно было прикручено в последний момент. Некоторые ограничения заставят вас остановиться, почесать голову и спросить: «Какого чёрта?» Но это всего лишь симптомы более глубокой проблемы:
Очевидно, протобуферы созданы любителями, потому что предлагают плохие решения широко известных и уже решённых проблем.
Отсутствие композиционности
Protobuffers предлагают несколько «фич», которые не работают друг с другом. Например, посмотрите на список ортогональных, но в то же время ограниченных функций типизации, которые я нашёл в документации.
- Поля
oneof
не могут бытьrepeated
. - В полях
map<k,v>
есть специальный синтаксис для ключей и значений, но он не используется ни в каких других типах. - Хотя поля
map
можно параметризовать, больше никакой определённый пользователем тип нельзя. Это значит, что вы застряли с указанием вручную собственных специализаций общих структур данных. - Поля
map
не могут бытьrepeated
. - Ключами
map
могут бытьstring
, но неbytes
. Также запрещеныenum
, хотя последние рассматриваются как эквивалент целым числам во всех остальных частях спецификации Protobuffers. - Значения
map
не могут быть другимиmap
.
Этот безумный список ограничений — результат беспринципного выбора дизайна и прикручивания функций в последний момент. Например, поля oneof
не могут быть repeated
, потому что вместо побочного типа генератор кода выдаст взаимоисключающие необязательные поля. Такое преобразование справедливо только для сингулярного поля (и, как мы увидим позже, не работает даже для него).
Ограничение полей map
, которые не могут быть repeated
, примерно из той же оперы, но показывает другое ограничение системы типов. За кулисами map<k,v>
преобразуется в нечто похожее на repeated Pair<k,v>
. И поскольку repeated
— это волшебное ключевое слово языка, а не нормальный тип, то он не сочетается сам с собой.
Ваши догадки о проблеме с enum
так же верны, как и мои.
Что так расстраивает во всём этом, так это слабое понимание, как работают современные системы типов. Это понимание позволило бы кардинально упростить спецификацию Protobuffers и одновременно удалить все произвольные ограничения.
Решение заключается в следующем:
- Сделайте все поля в сообщении
required
. Это делает каждое сообщение типом продукта (product type). - Повысить значение поля
oneof
до автономных типов данных. Это будет тип сопродукта (coproduct type). - Дать возможность параметризации типов продуктов и сопродуктов другими типами.
Вот и всё! Эти три изменения — всё, что вам нужно для определения любых возможных данных. С этой простой системой можно переделать все остальные спецификации Protobuffers.
Например, можно переделать поля optional
:
product Unit {
// no fields
}
coproduct Optional<t> {
t value = 0;
Unit unset = 1;
}
Создание полей repeated
тоже просто:
coproduct List<t> {
Unit empty = 0;
Pair<t, List<t>> cons = 1;
}
Конечно, реальная логика сериализации позволяет делать что-то умнее, чем пушить связанные списки по сети — в конце концов, реализация и семантика не обязательно должны соответствовать друг другу.
Сомнительный выбор
Protobuffers в духе Java различает скалярные типы и типы сообщений. Скаляры более или менее соответствуют машинным примитивам — таким вещам, как int32
, bool
и string
. С другой стороны, типы сообщений — это всё остальное. Все библиотечные и пользовательские типы являются сообщениями.
Конечно же, в двух разновидностях типов совершенно разная семантика.
Поля со скалярными типами присутствуют всегда. Даже если вы их не установили. Я уже говорил, что (по крайней мере в proto31) все протобуферы инициализируются нулями, даже если в них нет абсолютно никаких данных? Скалярные поля получают «липовые» значения: например, uint32
инициализируется в 0
, а string
инициализируется как ""
.
Невозможно отличить поле, которое отсутствовало в протобуфере, от поля, которому присвоено значение по умолчанию. Предположительно, это решение сделано для оптимизации, чтобы не пересылать скалярные значения по умолчанию. Это лишь предположение, потому что в документации не упоминается эта оптимизация, так что ваше предположение будет не хуже моего.
Когда будем обсуждать претензии Protobuffers на идеальное решение для обратной и будущей совместимости с API, мы увидим, что эта неспособность различать неустановленные значения и значения по умолчанию — настоящий кошмар. Особенно если это действительно сознательное решение, чтобы сохранить один бит (установлено или нет) для поля.
Сравните это поведение с типами сообщений. В то время как скалярные поля являются «тупыми», поведение полей сообщений совершенно безумно. Внутренне, поля сообщений либо есть, либо их нет, но поведение сумасшедшее. Небольшой псевдокод для их аксессора стоит тысячи слов. Представьте такое в Java или где-то ещё:
private Foo m_foo;
public Foo foo {
// only if `foo` is used as an expression
get {
if (m_foo != null)
return m_foo;
else
return new Foo();
}
// instead if `foo` is used as an lvalue
mutable get {
if (m_foo = null)
m_foo = new Foo();
return m_foo;
}
}
По идее, если поле foo
не установлено, вы увидите инициализированную по умолчанию копию, просите вы об этом или нет, но не сможете изменять контейнер. Но если вы измените foo
, он также изменит своего родителя! Всё это просто чтобы избежать использования типа Maybe Foo
и связанной с ним «головной боли» выяснять, что должно означать неустановленное значение.
Такое поведение особенно вопиюще, потому что оно нарушает закон! Мы ожидаем, что задание msg.foo = msg.foo;
не будет работать. Вместо этого реализация фактически втихаря изменяет msg
на копию foo
с инициализацией нулями, если её раньше не было.
В отличие от скалярных полей, здесь хотя бы можно определить, что поле сообщения не задано. Языковые привязки для протобуферов предлагают что-то вроде сгенерированного метода bool has_foo()
. Если оно присутствует, то в случае частого копирования поля сообщения из одного протобуфера в другой необходимо написать следующий код:
if (src.has_foo(src)) {
dst.set_foo(src.foo());
}
Обратите внимание, что, по крайней мере, в языках со статической типизацией, этот шаблон нельзя абстрагировать из-за номинальной связи между методами foo()
, set_foo()
и has_foo()
. Поскольку все эти функции являются собственными идентификаторами, у нас нет средств для их программной генерации, за исключением макроса препроцессора:
#define COPY_IFF_SET(src, dst, field)
if (src.has_##field(src)) {
dst.set_##field(src.field());
}
(но макросы препроцессора запрещены руководством по стилю Google).
Если бы вместо этого все дополнительные поля были реализованы как Maybe
, вы смогли бы спокойно поставить абстрагированные точки вызова.
Чтобы сменить тему, поговорим о другом сомнительном решении. Хотя вы можете в протобуферах определить поля oneof
, их семантика не соответствует типу сопродукта! Ошибка новичка, парни! Вместо этого вы получаете опциональное поле для каждого случая oneof
и магический код в сеттерах, который просто отменит любое другое поле, если это установлено.
На первый взгляд кажется, что это должно быть семантически эквивалентно правильному типу объединения. Но вместо этого мы получаем отвратительный, неописуемый источник ошибок! Когда такое поведение объединяется с незаконной реализацией msg.foo = msg.foo;
, такое с виду нормальное присвоение молча удаляет произвольные объёмы данных!
В итоге это значит, что поля oneof
не образуют законопослушных Prism
, а сообщения не образуют законопослушных Lens
. Так что удачи вам в попытках написать нетривиальные манипуляции протобуферами без багов. Буквально невозможно написать универсальный, безошибочный, полиморфный код на протобуферах.
Это не очень приятно слышать, тем более тем из нас, кто любит параметрический полиморфизм, который обещает в точности противоположное.
Ложь обратной и будущей совместимости
Одна из часто упоминаемых «киллер-фич» Protobuffers — их «беспроблемная способность писать обратно- и вперёд-совместимые API». Это утверждение повесили у вас перед глазами, чтобы заслонить правду.
Что Protobuffers являются разрешительными. Им удаётся справиться с сообщениями из прошлого или будущего, потому что они не дают абсолютно никаких обещаний, как будут выглядеть ваши данные. Всё опционально! Но если вам это нужно, Protobuffers с удовольствием приготовит и подаст вам что-то с проверкой типов, независимо от того, имеет ли это смысл.
Это означает, что Protobuffers выполняют обещанные «путешествия во времени», втихую делая неправильные вещи по умолчанию. Конечно, осторожный программист может (и должен) написать код, выполняющий проверку корректности полученных протобуферов. Но если на каждом сайте писать защитные проверки корректности, может, это просто означает, что шаг десериализации был слишком разрешительным. Всё, что вам удалось сделать, это децентрализовать логику проверки корректности с чётко определённой границы и размазать её по всей кодовой базе.
Один из возможных аргументов — что протобуферы сохранят в сообщении любую информацию, которую не понимают. В принципе, это означает неразрушающую передачу сообщения через посредника, который не понимает эту версию схемы. Это же явная победа, не так ли?
Конечно, на бумаге это классная функция. Но я ни разу не видел приложения, где действительно сохраняется это свойство. За исключением программного обеспечения для маршрутизации, ни одна программа не хочет проверять только некоторые биты сообщения, а затем пересылать его в неизменном виде. Подавляющее большинство программ на протобуферах будут декодировать сообщение, трансформировать его в другое и отправлять в другое место. Увы, эти преобразования делаются на заказ и кодируются вручную. И ручные преобразования из одного протобуфера в другой не сохраняют неизвестные поля, потому что это буквально бессмысленно.
Это повсеместное отношение к протобуферам как универсально совместимым проявляется и другими уродливыми способами. Руководства по стилю для Protobuffers активно выступают против DRY и предлагают по возможности встраивать определения в код. Они аргументируют тем, что это позволит в будущем использовать отдельные сообщения, если определения разойдутся. Подчеркну, они предлагают отказаться от 60-летней практики хорошего программирования на всякий случай, вдруг когда-то в будущем вам потребуется что-то изменить.
Корень проблемы в том, что Google объединяет значение данных с их физическим представлением. Когда вы находитесь в масштабе Google, такое имеет смысл. В конце концов, у них есть внутренний инструмент, который сравнивает почасовую оплату программиста с использованием сети, стоимостью хранения X байтов и другими вещами. В отличие от большинства технологических компаний, зарплата программистов — одна из самых маленьких статей расходов Google. Финансово для них имеет смысл тратить время программистов, чтобы сэкономить пару байтов.
Кроме пяти ведущих технологических компаний, больше никто не находится в пределах пяти порядков масштаба Google. Ваш стартап не может позволить тратить инженерные часы на экономию байтов. Но экономия байтов и трата времени программистов в процессе — это именно то, для чего оптимизированы Protobuffers.
Давайте посмотрим правде в глаза. Вы не соответствуете масштабу Google, и никогда не будете соответствовать. Прекратите карго-культ использования технологии только потому, что «Google использует её», и потому что «это лучшие отраслевые практики».
Protobuffers загрязняет кодовые базы
Если бы можно было ограничить использование Protobuffers только сетью, я бы не высказывался так жёстко об этой технологии. К сожалению, хотя в принципе существует несколько решений, ни одно из них не достаточно хорошо, чтобы фактически использоваться в реальном программном обеспечении.
Protobuffers соответствуют данным, которые вы хотите отправить по каналу связи. Они часто соответствуют, но не идентичны фактическим данным, с которыми приложение хотело бы работать. Это ставит нас в неудобное положение, необходимо выбирать между одним из трёх плохих вариантов:
- Поддерживать отдельный тип, описывающий данные, которые вам действительно нужны, и гарантировать одновременную поддержку обоих типов.
- Упаковать полные данные в формат для передачи и использования приложением.
- Извлекать полные данные каждый раз, когда они нужны, из краткого формата для передачи.
Вариант 1 — однозначно «правильное» решение, но оно непригодно для Protobuffers. Язык недостаточно мощный для кодирования типов, которые могут выполнять двойную работу в двух форматах. Это означает, что вам придётся написать совершенно отдельный тип данных, развивать его синхронно с Protobuffers и специально писать код сериализации для них. Но поскольку большинство людей, кажется, используют Protobuffers, чтобы не писать код сериализации, такой вариант, очевидно, никогда не реализуется.
Вместо этого код, использующий протобуферы, позволяет им распространяться по всей кодовой базе. Это реальность. Моим основным проектом в Google был компилятор, который брал «программу», написанную на одной разновидности Protobuffers, и выдавал эквивалентную «программу» на другой. Форматы ввода и вывода достаточно отличались, чтобы их правильные параллельные версии C++ никогда не работали. В результате мой код не мог использовать ни одну из богатых техник написания компиляторов, потому что данные Protobuffers (и сгенерированный код) были слишком жёстким, чтобы сделать с ними что-нибудь интересное.
В результате вместо 50 строк схем рекурсии использовались 10 000 строк специального тасования буфера. Код, который я хотел написать, был буквально невозможен при наличии протобуферов.
Хотя это один случай, он не уникален. В силу жёсткой природы генерации кода, проявления протобуферов в языках никогда не будут идиоматическими, и их невозможно сделать такими — разве что переписать генератор кода.
Но даже тогда у вас останется проблема встроить дерьмовую систему типов в целевой язык. Поскольку большинство функций Protobuffers плохо продуманы, эти сомнительные свойства просачиваются в наши кодовые базы. Это означает, что мы вынуждены не только реализовывать, но и использовать эти плохие идеи в любом проекте, который надеется взаимодействовать с Protobuffers.
На прочной основе легко реализовать бессмысленные вещи, но если пойти в другом направлении, в лучшем вы столкнётесь со сложностями, а в худшем — с настоящим древним ужасом.
В общем, оставь надежду каждый, кто внедрит Protobuffers в свои проекты.
1. По сей день в Google идёт бурная дискуссия о proto2 и о том, следует ли когда-либо отмечать поля как
required
. Одновременно распространяются манифесты «optional
считается вредным» и «required
считается вредным». Удачи разобраться с этим, ребята. ↑
Автор: m1rko