Если для компиляции proto-файлов вы всё ещё используете protoc, самое время перестать и перейти на buf. Разберём, как это сделать и почему это необходимо. Также рассмотрим проблемы доступа к buf.build.
Меня зовут Эдгар Сипки, я Go-разработчик в Ozon Fintech. buf — мощная утилита для линтинга протофайлов, проверки обратной совместимости API, генерации кода и валидации запросов. Однако, из-за санкций она недоступна в России. Поэтому я расскажу, как мы разрабатывали собственное решение в рамках импортозамещения.
gRPC и его проблемы
Все мы знаем gRPC. Это высокопроизводительный фреймворк RPC. Вот традиционный тулсет для него:
Golang
Protoc
Plugins:
protoc-gen-go
protoc-gen-go-grpc
Неконсистентное описание API
Это одна из самых важных проблем. Она включает:
отсутствие жёсткой структуры описания;
вариации наименований (sneak, camel);
легко сломать обратную совместимость.
Вот хороший пример:
service ServiceAPI {
rpc example_one(google.protobuf.Empty)
returns (google.protobuf.Empty);
rpc ExampleSecond(google.protobuf.Empty)
returns (google.protobuf.Empty);
}
Здесь использованы sneak и camel кейсы. И с этим достаточно тяжело работать. Особенно, когда проект из двух документов превращается в тысячу.
Классическая ситуация с пулл реквестами: один человек присылает код в одной стилистике, а другой — в другой. Тяжело даже запомнить, что по-разному написанные блоки кода на самом деле имеют один смысл.
Ещё оказывается, что для прода не важен стиль. Например:
message Request {
string example_one = 1;
string ExampleSecond = 2;
}
message request {
string example_one = 1;
string ExampleSecond = 2;
}
Здесь функции будут абсолютно идентичны. При компиляции будет ошибка.
Проблема с зависимостями
Так как отсутствует пакетный менеджер, то все в компании переносят пакеты вручную или используют git submodules. Мы обычно создаём определённые директории, называем third party, закидываем в них вендоров и прописываем все зависимости. Но это далеко не самый удобный способ работы.
Breaking change
С одной стороны, Protoc создавался как формат, в котором будет очень тяжело сломать обратную зависимость. Однако на деле сломать очень легко. Особенно, если человек элементарно не читал документацию.
Возьмём хороший пример:
syntax = "proto3";
...
service ServiceAPI {
rpc Auth(AuthInfo) returns
(google.protobuf.Empty);
}
message AuthInfo {
string username = 1;
string password = 2;
}
Есть сервис API, отвечающий за авторизацию. Его ручка принимает пароль пользователя. Бизнес сказал нам реализовать функцию, чтобы пользователь мог авторизовываться через почтовый ящик. Мы выкатили pull request и замержили:
syntax = "proto3";
...
service ServiceAPI {
rpc Auth(AuthInfo) returns
(google.protobuf.Empty);
}
message AuthInfo {
string username = 1;
string password = 2;
}
message AuthInfo {
oneof login {
string username = 1;
string email = 2;
}
string password = 3;
}
Но всё сломается, потому что нумерация полей парсится неправильно.
Сначала прилетает username и пароль от клиента. Username распарсится нормально, так как индекс совпадает. Затем пароль распарится во второй индекс. Потом прилетит e-mail и тоже попытается распарсится во второй индекс. В итоге мы в логах видели, что человек имел почтовый ящик в виде пароля, а пароль был пустой. Мы не могли никак его авторизовать. Это было тяжело дебажить, пока мы не заметили ситуацию и быстренько не откатили.
Версионирование плагинов
Также возможна такая ситуация: у меня стоит плагин генерации 1.28, а у коллеги — 1.26. И при перегенерации прото-контрактов возникает несколько сотен изменений:
Хотя в реальности эти изменения представляют собой изменения одной строчки в сгенерированном файле, просто меняется название версии. Это объективно всех бесит.
buf
Мы поговорили об основных проблемах toolset’а. Теперь рассмотрим, как решает эти проблемы один из современных инструментов мира Proto — buf.
В начале он представлял собой следующую экосистему:
lint
breaking checker
generate
pkg manager
formatter
Но сейчас он представляет собой уже полноценный линтер для протофайлов. Он может автоматически за вас проверять поломку обратной совместимости. Он же является и компилятором вместо Protoc. В Protobuf реализовали собственный внутренний компилятор, он же представляет собственный пакетный менеджер, форматор. А сейчас ещё и альтернативу для работы с вебом. Вместо GRPC Gateway можно использовать их buf коннект. GRPC-хендлеры смогут автоматически работать с вебом.
Линтер
Давайте вспомним, что такое линтер:
инструмент анализа кода;
помощь в нахождении и исправлении ошибок;
выявление несоответствий стилей кодирования;
обнаружение подозрительных конструкций в коде;
единообразие.
Рассмотрим такой пример:
syntax = "proto3";
package api;
import 'google/protobuf/empty.proto';
service ServiceAPI {
rpc Auth(AuthInfo) returns
(google.protobuf.Empty);
}
message AuthInfo {
string username = 1;
string password = 2;
}
И давайте натравим на него линтер:
Мы видим огромное количество ошибок. И это лишь треть того, что я там увидел. Разберём каждую по отдельности.
→ Версионирование
В нашем примере мы не указали никакое версионирование. Исправить это можно вот так:
Версионирование нужно, чтобы иметь возможность бесшовно деплоить и сохранять обратную совместимость с клиентом. Особенно в условиях, когда он не может автоматически обновиться вместе с вами. Таким образом, когда нам нужно поддерживать две версии API в рамках одного бинарника, мы можем использовать разные версии proto-контрактов.
→ Документация
Следующие пункты касались уже документации. Она позволяет понять, что происходит в коде:
Так мы поняли, почему, к примеру, АuthAPI не возвращал ничего в своём теле. Ведь в документации описано, что он возвращает token в metadata. Мы описали кейсы по bad request и not found, потому что это бизнесовая фича. А когда расписали, тестировщики нас поблагодарили — им стало гораздо проще с этим работать. И клиентским разработчикам стало гораздо проще интегрироваться.
Но есть интересный момент. У нас было тело возврата Google Protobuf empty, а стал AuthResponse. И если мы посмотрим, AuthResponse не содержит никаких полей. Это особенность линтера.
Если вы используете Google Protobuf empty, и, к примеру, в ручке авторизации решили возвращать тело пользователя, то полностью сломаете новую совместимость. Нужно делать полную ручку. Иначе на клиентской стороне попросту не смогут с этим работать. Единственный вариант — это создавать полностью пустое тело у мессенджа. Так рекомендует линтер и многие крупные компании. Потому что если вам нужно будет расширяться: добавить юзера, вы просто добавляете новое поле в тело. Это решит проблему.
Дополнительно, документирование упрощает взаимодействие и интеграцию с кодом.
Настройка
Настраивается линтер невероятно просто. Мы просто перечисляем все правила, которые нам нужны:
Их там несколько десятков, в реальности их можно все включить одной строчкой: правилом all.
Breaking change
Вот простой пример проверки поломки обратной совместимости:
buf breaking --against '.git#branch=main'
Идея в том, что мы можем сравнивать наши текущие прото-контракты в нашей фич- или бак-ветке с той веткой, в которой находится оригинальный прод. Так мы можем точно знать, сломали ли прод или нет.
Но бывают ситуации, когда бизнес хочет изменений, и новая логика никак не укладывается в ту API, которую мы реализовали. Он нас обяжет создать вторую директорию, куда мы перенесём логику с V1, переделанную под новый стандарт. По итогу в рамках вашего бинаря вы должны поддерживать теперь и V1, и V2. На реальных проектах это делается достаточно просто. Чаще всего просто происходит проксирование V1 с V2. Тем не менее это гораздо проще и лучше, чем ломать обратную совместимость и ломать клиента.
А вот это пример команды, как мы используем это в CI:
buf breaking --against \
"../../.git#branch=${CHANGE_TARGET},recurse_submodules
=true,depth=1,subdir=api/proto"
У нас есть возможность очень легко настраивать команду для проверки. К примеру, здесь в CHANGE_TARGET просто подсовывается ветка, в которую был отправлен pull request. Таким образом, автоматически поломать продакшн не получится.
Плагины
Сам protoc — чистый компилятор для языка описания интерфейсов Protocol Buffers (protobuf). Чтобы генерировать код под разные языки, фреймворки и прочее ему нужны плагины. К примеру, основные плагины для Go — это proto gen go и proto gen grpc. Они вдвоём генерируют нужный нам код для запуска GRPC-сервера и генерации GRPC-клиента.
Устанавливаются они достаточно легко, так как сами эти плагины чаще всего написаны на Go. Поэтому спокойно через go install можно поставить новую версию:
В свою очередь это превращается в следующее:
Это супер-простой, тривиальный пример, в котором мы не перечисляем большое количество конфигураций, а просто передаём всё через флаги.
Но если мы начинаем расширяться, например, добавляем плагины для других языков, ситуация становится гораздо хуже:
С каждым новым плагином или расширением конфигурации, мы будем получать огромную простыню текста. С одной стороны, можно закинуть всё в shell-скрипт и спокойно использовать, однако гораздо проще сделать так:
Мы указываем нужные нам плагины и версию, что позволяет решить проблему, при которой люди генерируют разными версиями. И объективно Yml-файл гораздо проще редактировать и настраивать, чем огромную bush-команду. В конфигурации всё передаётся достаточно просто через те же параметры.
Дополнительно buf имеет возможность удалённо генерировать ваш код, отправляя к себе на сервера. То есть, если вы используете локальный плагин, то генерируете и получаете код. А если используете удалённый код, то отправляете ваши protoc-контракты на сервера буфа, там генерируете это всё за счёт docker-контейнера. На выходе вы получаете нужные proto-файлы. Gроисходит это в GRPC. Всё добавляется легко и просто:
Самая интересная часть — это пакетный менеджер:
Всё, что мы должны сделать, это:
прописать адрес до самого пакета;
вызвать команду buf mod update.
Нужные пакеты скачиваются локально. Причём все популярные IDE: Visual Studio Code и JetBrain имеют плагин для работы с buf. Также автоматически идёт подсветка и настройки всего, что нужно. Вы просто дальше работаете с proto-файлами также хорошо, как работаете с Go.
Дальше можно спокойно использовать все нужные вам пакеты, не перенося их в директорию.
Конфигурации
В целом у buf достаточно большая экосистема. Всё это настраивается, по большей части, в трёх конфигурационных файлах:
buf.work.yml — позволяет разделять отдельные окружения друг от друга и дополнительно расширять конфигурации нон-спейсами;
buf.yml — содержит конфигурации для правила линтинга, наименования и прочего;
buf.gen.yml — позволяет генерировать всё, что нужно.
Проблемы buf
Можно подумать, что buf — наш грааль. Он действительно решает огромное количество проблем, возникающих при работе с GRPC. Но одновременно с этим он завозит другие проблемы.
Декоммит
Почему-то ребята из buf решили сделать свой отдельный хостинг на домене buf.build. Закинули туда proto-контракты Google, валидации. Другие прото-контракты, которые хотите расшаривать как пакеты, также нужно заливать к ним на хостинг, а не на привычный Github или Gitlab.
Это создаёт ряд проблем, потому что этот Git-хостинг имеет API в виде proto-контракта, то есть всё передаётся в виде GRPC-месседжа. Получается, что коммита попросту нет. Они просто дропнули всё, что было.
Удалённая генерация
С одной стороны, это потрясающий инструмент, потому что вам локально больше не надо ничего ставить. Вы просто ставите buf и спокойно генерируете всё, что нужно. Но одновременно с этим вы передаёте данные к ним на сервера, в том числе и прото-контракты. Они на своей стороне могут сделать с ними всё, что захотят, сгенерировать и выслать обратно.
С одной стороны можно сказать, что тот же Github имеет доступ ко всем данным, однако он хотя бы не заблокирован в РФ.
Блокировка в РФ и РБ
Это означает, что нам нельзя использовать пакетный менеджер, удалённую генерацию, registry. И это основная проблема с учётом удаления коммитов и с тем, что они могут получить доступ к нашим внутренним контрактам. Причём все, кто использует proto, знают, что часто GRPC используется для межсерверной коммуникации. Соответственно, мы предоставляем полный доступ нашей внутренней системе. Причём ещё с комментариями в коде, с описанием, как это работает. И это всё в условиях, когда нас могут в любой момент забанить и дропнуть наши данные.
Решение
Все проблемы, с которыми мы столкнулись, не так критичны и страшны, как блокировка. Вот она создаёт очень высокий риск. Особенно при работе в больших компаниях, потому что фактически мы открываем доступ к внутренней системе внешним компаниям. Причём еще и тем, которые нас фактически забанили.
VPN и proxy
Первое, что мы решили сделать, это запустить VPN и прокси. Это можно было сделать легко и быстро. Но недостаток такого подхода — невозможность запустить в CI без открытия контура.
У нас всё заработало, но мы поняли, что нужно генерировать прото-контракты в CI и через Go Get подтягивать их к библиотеке. Мы подошли к DevOps и сказали, что нам нужно просунуть прокси до сервиса в иностранную компанию, которая нас забанила. Они просто посмеялись и отказались это делать, потому что ситуация комичная. Поэтому этот вариант пришлось отмести.
Prototool
Это альтернативный buf инструмент, который обладает тем же самым функционалом — только местами хуже. Но есть одна большая проблема: они ещё в 2018-2019 годах, когда buf только зарождался, стали сами рекомендовать посмотреть в его сторону, а где-то в марте 2022 полностью заблокировали свой репозиторий в Github.
easyp
Тогда мы стали сидеть и думать по поводу написания велосипеда: своей инструменты с нуля. Плюс в том, что можно его адаптировать под свою компанию и, если выйдет успешно, заопенсорсить. Но хоть мы и любим писать всё с нуля, это большой объём работы, а ресурсы на разработку ограничены. Кроме того все в команде и так нагружены своими задачами. Тем не менее мы стали копать в эту сторону. Мы залезли в исходники buf и стали смотреть, как он устроен.
Во-первых, нас удивило, что они используют большое количество велосипедов внутри: большие собственные библиотеки и технологии. С этим всё равно можно было жить, поэтому мы задумались над вариантом форка. Но в таком случае, нам бы пришлось обязать как минимум всех в нашей компании переезжать на наш форк, начать его использовать. Если это произойдёт успешно, то уговаривать остальных. Этим нам заниматься не хотелось.
Мы продолжили думать и заметили исходники proto. У них там чуть ли не сотни контрактов. У нас появилась идея создать обратно совместимый сервер, который будет работать с их CLI притворяться им же. Мы взяли proto-контракты и начали с ними экспериментировать. Первое, что стали делать, это реверс сервера:
Мы сгенерировали весь сервер, подсунули в конфигурацию адрес до нашего локалхост 8080 и стали через их CLI стучаться к себе на сервер. Мы просто логировали каждое сообщение: что происходило и в какой момент. Как только мы где-то ломались, мы попросту начинали редактировать в этом месте код. В основном хардкодили какие-то значения.
Экспериментировали и с Google API — поэтапно разобрались, что при подтягивании пакетов Google API использует три сервиса. В первую очередь, подтягивает информацию о том, где находится сам пакет. Однако это, оказывается, адрес не для самого пакета, а для резолвера, который получает адрес и узнаёт, где реально находится пакет. CLI с этой информацией идёт в репозиторий сервиса, подтягивает оттуда метаинформацию о репозитории: кто владелец, есть ли доступ и прочее. Мы со всем этим разобрались, тоже получили доступ.
Последний шаг — самый сложный. Нам нужно было из нашего CLI пойти в download-services и скачать proto-контракты. Там было всё зашифровано, поэтому пришлось с этим разбираться, проверять хэш-суммы, их CLI и повторять их алгоритм.
Но в ходе reverse-инжиниринга мы смогли разобраться, что если реализовать три ручки, то в целом наш сервер должен заработать. По крайней мере главная фича точно будет работать.
Дальше мы стали задумываться, что с этим делать.
→ Git-Hosting
Первый вариант — реализовать Git-hosting, как сделал это buf. Но компании в этом случае придётся затягивать его к себе вместе с Gitlab. Мы решили, что это плохой вариант. Тем более, что мы не хотели наступать на те же грабли, на которые наступил buf — речь про полный vendor-locking в нашем софте.
→ Git Proxy
Мы получали запрос в proto-формате и перенаправили его на Git-сервер. А полученные пакеты возвращали обратно клиенту в нужном формате для buf через proto. Всё заработало. Но, если proto-validate весит несколько мегабайт, с этим можно как-то работать, то Google AP занял у нас 1,5 гигобайта оперативки. Это достаточно большое количество крупных пакетов, с которыми не так легко работать.
→ Git proxy to disk
Фактически мы решили делать то же проксирование, но на наш диск. Для этого через конфигурационные файлы скачали все нужные репозитории на диск. А дальше при приходе запроса просто берём нужную версию с диска и возвращаем это клиенту.
Дальше мы точно так же делаем buf mod update, и всё работает.
Вам не придётся заменять CLI и вообще что-либо. Просто меняйте url. Важная деталь: вам нужно очистить кэш. Если вы не очистите буфовский кэш, у вас это не заработает.
Заключение
В целом мы сохранили CLI-функционал. Вы можете так же использовать buf, как и раньше. Кроме того, мы решили самую важную для нас проблему с пакетным менеджером. Теперь его можно спокойно использовать в рамках РФ и Республики Беларусь.
Сейчас у нас версия 0.1.0. Пока он поддерживает только package message support, то есть пакетный менеджер. Но следующим шагом — 0.2.0 — мы будем поддерживать уже удаленную генерацию (remote generation support).
В отличие от bufa, мы не будем вас обязывать присылать хранить у нас свои пакеты, генерировать зависимости. У нас будет полностью открыт докер-файл, вы сможете спокойно запустить его во внутренней конфигурации и спокойно в рамках своей компании использовать: скачать нужные вам пакеты, генерировать нужные вам версии плагинов. Всё будет отлично работать.
Кодовую базу можете посмотреть здесь. Будем рады любым pullrequests и issue.
Вы можете посмотреть мой выступление по этой теме на Golang Conf: