Пайплайн генерации кода Buf для Go, TypeScript и CI
Соберите пайплайн генерации кода Buf, который сохраняет читаемость protobuf-файлов, синхронизирует вывод Go и TypeScript и помогает избежать расхождений в CI.

Почему рабочие процессы с protobuf быстро превращаются в хаос
На первый взгляд protobuf выглядит аккуратно. Вы пишете несколько .proto файлов, запускаете генерацию, и Go с TypeScript получают всё, что им нужно.
Проблемы начинаются, когда каждая команда делает по одному маленькому выбору сама по себе. Бэкенд-разработчики кладут файлы в api/proto, фронтенд ожидает другую форму импортов, а кто-то копирует старую настройку из другого репозитория. По отдельности эти решения кажутся безобидными, но вместе они превращают простую схему в подвижную цель.
Обычно первой проблемой становится структура папок. В одном репозитории общие сообщения лежат рядом с сервисами, в другом они разделены по языкам, а в третьем спрятаны в internal. После этого импорты перестают быть очевидными. Разработчик меняет один путь, сгенерированный код переезжает, пути импортов меняются, а в ревью появляется куча шума.
Go и TypeScript тянут в разные стороны. Go важны имена пакетов и пути модулей. Инструменты TypeScript чаще думают о том, куда попадают файлы и как импорты разрешаются при сборке приложения. Если в репозитории эти правила не зафиксированы, люди начинают добавлять локальные правки. И такие правки очень быстро накапливаются.
Отдельную боль вызывает расхождение версий. Один разработчик генерирует код с одной версией плагина, CI использует другую, и вывод меняется, хотя схема не менялась. В итоге в pull request'ах появляется тонна сгенерированного шума. Никому не хочется это ревьюить, и никто не уверен, какой результат правильный.
Локальные команды и CI тоже тихо расходятся. Кто-то генерирует код shell-скриптом на ноутбуке, а пайплайн проверяет что-то чуть другое. Локально ветка работает, а потом CI падает из-за правила путей, правила линта или сгенерированного файла, который не совпадает. Такие сбои отнимают время, потому что сам код может быть в порядке.
Маленькие команды чувствуют это почти сразу. Один человек правит сервис, другой обновляет веб-клиент, а третий пытается выпустить релиз до обеда. Если на каждой машине получаются разные сгенерированные файлы, доверие исчезает.
Вот почему простой пайплайн генерации кода Buf лучше хитроумного. Когда пути, версии плагинов и команды зафиксированы, protobuf перестаёт удивлять людей и снова становится частью сборки.
Выберите один вариант структуры репозитория и придерживайтесь его
Большая часть проблем с protobuf начинается с папок, а не с синтаксиса. Если один разработчик хранит .proto файлы рядом с Go-кодом, другой прячет их в services/, а сгенерированные файлы попадают туда, куда их записал скрипт, сборка начинает казаться случайной.
Храните все исходные .proto файлы в одной корневой папке. Назовите её proto/ или api/, но выберите один вариант и не усложняйте. Пайплайн Buf гораздо легче читать, когда каждая схема начинается там, независимо от того, генерируете ли вы Go-стабы, TypeScript-клиенты или запускаете проверки в CI.
proto/
billing/v1/*.proto
users/v1/*.proto
gen/go/
gen/ts/
Это разделение важно. Исходники описывают API. Сгенерированные файлы — это результат сборки. Не смешивайте их. Когда кто-то открывает репозиторий, он должен за пять секунд понять, какие файлы редактирует, а какие генерация заменяет.
С именами пакетов нужна та же дисциплина. Один раз выберите имена protobuf-пакетов и Go-пути модулей, а потом держите их стабильными. Переименование кажется безобидным, но оно может сломать импорты, создать шумные diff'ы и изменить полные имена сервисов, которые люди используют с grpcurl.
То же правило работает и для папок вывода. Дайте Go своё место, например gen/go/, и дайте TypeScript своё место, например gen/ts/. Избегайте полумер вроде одного скрипта, который пишет в pkg/, другого — в src/generated/, а третьего — во временную папку в CI. Такая схема быстро запутывает людей.
Когда структура зафиксирована, запишите это в короткой заметке README и перестаньте спорить. Когда появляется новый сервис, вы добавляете одну папку в proto/, запускаете ту же команду генерации и идёте дальше.
Настройте Buf в несколько шагов
Пайплайн генерации кода Buf остаётся понятным, когда правила принадлежат самому репозиторию. Не полагайтесь на скопированные shell-команды или локально установленные плагины. Храните конфигурацию в версионных файлах и заставьте каждую машину выполнять одни и те же шаги.
Начните с buf.yaml. Этот файл задаёт границы модуля, правила lint и проверку на breaking changes. Сначала держите его небольшим.
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
Это даёт команде одну папку для .proto файлов и одну общую политику lint. Если имена, пути пакетов или структура файлов начинают расходиться, Buf поймает это раньше, чем плохие паттерны попадут в сгенерированный код.
Затем добавьте buf.gen.yaml. В этом файле должны быть ответы на три простых вопроса: какие плагины запускаются, куда попадают файлы и какую версию плагина использует каждый разработчик. Закрепление версий важнее, чем ожидает большинство команд. Один человек с более новым плагином может переписать десятки файлов и оставить остальных разгребать шум.
version: v2
plugins:
- remote: buf.build/protocolbuffers/go:v1.35.2
out: gen/go
opt: paths=source_relative
- remote: buf.build/grpc/go:v1.5.1
out: gen/go
opt: paths=source_relative
- remote: buf.build/bufbuild/es:v2.2.3
out: gen/ts
Используйте отдельные папки вывода для Go и TypeScript. Так сгенерированные файлы проще находить, проще чистить и труднее случайно смешать с ручным кодом.
Сначала запускайте buf lint, потом buf generate. Lint работает быстро и останавливает плохие изменения схемы до того, как они превратятся в шумные diff'ы. Генерация должна идти второй.
Большинству команд нужна одна команда репозитория, а не пять. make proto, task proto или package script достаточно, если он всегда запускает одну и ту же последовательность:
buf lintbuf generate
Используйте ту же самую команду в CI. Не делайте специальную версию для CI с дополнительными флагами и другими путями вывода. Если разработчик локально запускает одну команду, а CI позже запускает ту же самую, доверие растёт очень быстро.
Генерируйте Go и TypeScript без сюрпризов
Одни и те же .proto файлы должны давать один и тот же Go- и TypeScript-вывод на каждом ноутбуке и в CI. Если Go получается одной командой, а TypeScript — другой, расхождения начинаются рано.
Используйте одно дерево исходников для контрактов и одну конфигурацию Buf для генерации. Держите имена пакетов ближе к границе сервиса, а не к оргструктуре. billing.v1 и auth.v1 хорошо живут долго. Названия вроде common.platform.shared.billingservice — нет. Глубокие деревья выглядят аккуратно неделю, а потом каждый импорт превращается в поиски.
Пути тоже держите короткими. Файл вроде proto/billing/v1/invoice.proto легко просмотреть. Генерируйте код в стабильные папки, которые повторяют пакет, например gen/go/billing/v1 и gen/ts/billing/v1. Когда люди могут угадать путь, они перестают бороться с инструментом.
Одна конфигурация должна делать большую часть работы. Buf может запускать Go- и TypeScript-плагины за один проход, поэтому почти никогда не нужны shell-скрипты, которые переименовывают файлы, правят импорты или копируют результаты. Небольшая обёртка вроде make proto — нормально. Набор bash-скриптов, которые «исправляют» генерацию, обычно означает, что проблема в структуре.
Для команды с двумя сервисами хорошо читается такое разделение:
user.v1для входа, профилей и настроек аккаунтаbilling.v1для счетов, платежей и webhooks
Так у обоих языков остаётся одна и та же карта. Go-обработчик, TypeScript-клиент и CI-задача смотрят на одни и те же имена пакетов.
Решите заранее, что делать с сгенерированными файлами. Команды теряют время, когда половина репозитория ждёт закоммиченный вывод, а другая половина — локальную генерацию.
- Коммитьте сгенерированный код, если его используют другие репозитории или если фронтенд-разработчики должны просто клонировать репозиторий и запускать его без дополнительной настройки.
- Не коммитьте вывод, если CI всегда пересобирает его заново, а в каждом окружении закреплены те же версии плагинов.
Выберите одно правило и придерживайтесь его. Чем меньше движущихся частей, тем меньше странных сбоев в конце дня. Когда папки, имена пакетов и правила генерации совпадают, protobuf-пайплайн становится обычной частью сборки, а не постоянной уборкой.
Используйте grpcurl, чтобы проверить реальный сервис
Сгенерированный код может выглядеть аккуратно, а работающий сервис — говорить совсем другое. grpcurl даёт прямой взгляд на живой endpoint, поэтому это хороший способ проверить реальность, прежде чем винить Go-код, TypeScript-типизацию или генератор.
Если включён server reflection, начните с того, что посмотрите, что сервис вообще отдаёт:
grpcurl -plaintext localhost:50051 list
Эта быстрая проверка ловит много скучных, но дорогих ошибок. Вы можете увидеть старое имя сервиса, неправильный пакет или контейнер, который так и не подхватил последнее изменение proto. Во многих случаях это первый знак того, что генерация в порядке, а проблема в деплое.
Затем посмотрите один метод, прежде чем писать клиентский код:
grpcurl -plaintext localhost:50051 describe acme.user.v1.UserService.GetUser
Внимательно прочитайте форму запроса и ответа. Если поле переехало, enum изменился или имя сообщения отличается от того, что ждёт клиент, вы увидите это сразу.
После этого отправьте очень маленький JSON-запрос:
grpcurl -plaintext -d '{\"id\":\"123\"}' localhost:50051 acme.user.v1.UserService/GetUser
Держите payload минимальным. Сейчас вы не проверяете все правила. Вы смотрите, совпадает ли JSON-форма с той proto-формой, которую, как вам кажется, вы сгенерировали.
Если reflection выключен, используйте Buf image, чтобы grpcurl и генераторы читали один и тот же контракт:
buf build -o buf.binpb
grpcurl -plaintext -protoset buf.binpb -d '{\"id\":\"123\"}' localhost:50051 acme.user.v1.UserService/GetUser
Теперь сравните живой ответ со сгенерированными типами. В Go проверьте поля структур, значения enum и обработку timestamp или oneof. В TypeScript проверьте сгенерированные интерфейсы или message classes и код преобразования JSON. Если grpcurl возвращает displayName, а ваш сгенерированный код ждёт name, остановитесь и исправьте схему или задеплоенный сервис.
Команды, которые делают это рано, позже избегают большого количества шумных ошибок CI. Одна проверка живого метода может сэкономить длинный круг догадок.
Небольшой пример для команды
Бэкенд-разработчица в небольшой ecommerce-команде должна добавить в Order ещё одну деталь: поле tracking_url, чтобы приложение показывало статус доставки. Сначала она по привычке пишет trackingURL. Buf lint падает в её ветке ещё до ревью, поэтому она за минуту исправляет имя на tracking_url и идёт дальше вместо того, чтобы спорить о стиле в pull request.
Команда использует одну команду генерации, например buf generate. Она обновляет и типы Go-сервера, и TypeScript-код клиента. Никто не правит сгенерированные файлы вручную, и никому не нужно помнить отдельный шаг для фронтенда.
Pull request остаётся простым для чтения, потому что ревьюеры смотрят на схему и бизнес-логику, а не на ручные правки в двух языках. Если diff выглядит странно, разработчица просто запускает ту же команду локально и получает тот же результат, что и все остальные.
Go-код читает новое поле из сервисного слоя, а веб-приложение получает обновлённый тип в том же коммите. Когда другой инженер забирает ветку, репозиторий уже совпадает со схемой.
CI всё равно проверяет скучные вещи, потому что люди часто пропускают именно скучные вещи. В этом случае в ветке всё ещё лежит старый сгенерированный файл от предыдущего пути пакета. Job сборки пересобирает код, сравнивает рабочее дерево и падает, потому что этот устаревший файл больше не нужен. Это грубая проверка, но грубые проверки работают.
Перед merge кто-то вызывает staging-сервис через grpcurl и отправляет пример запроса GetOrder. В ответе есть новое поле, и значение совпадает с тем, что ждёт приложение. Эта быстрая проверка важна, потому что сгенерированный код может выглядеть нормально, даже если сервис забыл заполнить поле.
Здесь не произошло ничего особенно сложного. Одно изменение схемы, одна команда генерации, одна CI-защита и один реальный запрос к staging. Именно такой ритм маленькая команда может уверенно повторять неделя за неделей.
Ошибки, которые подрывают доверие к пайплайну
Работа с protobuf перестаёт казаться безопасной в тот момент, когда два человека запускают одну и ту же команду и получают разные результаты. Обычно это начинается не с больших провалов, а с маленьких привычек. Кто-то запускает генерацию из вложенной папки, кто-то — из корня репозитория, и теперь пути импортов или папки вывода меняются без причины.
Исправление простое. В пайплайне Buf каждая команда должна запускаться из одного места, обычно из корня репозитория, и с одной и той же конфигурацией, закоммиченной в git. Если новому участнику команды нужно угадывать, где стоять перед тем, как ввести buf generate, значит в настройке уже есть трещина.
Следующий круг проблем создаёт расхождение версий плагинов. На одном ноутбуке старый Go-плагин, на другом новый TypeScript-плагин, а в CI ещё что-то третье. Сгенерированный код в большинстве дней всё ещё компилируется, и это только ухудшает ситуацию. Люди перестают доверять diff'ам, потому что не могут понять, пришло ли изменение из схемы или с чьей-то машины.
Ручные правки в сгенерированных файлах создают другой вид хаоса. В моменте это кажется быстрым. Разработчик поправил один импорт, переименовал один метод или добавил комментарий, а затем следующая генерация всё стирает. После этого коллеги начинают защищать сгенерированные файлы специальными скриптами и странными исключениями. Так простой пайплайн превращается в еженедельный спор.
Переименования пакетов ещё жёстче. Команда меняет protobuf-пакет после того, как клиенты уже на него завязались, и поломки быстро расходятся. Go-импорты переезжают. TypeScript-клиенты генерируют другие имена. CI краснеет. Если вам нужна новая форма, добавляйте её так, чтобы старые клиенты могли жить дальше.
Последняя ошибка — не проверять живой сервис. Diff схемы может выглядеть чисто, а работающий сервер при этом ведёт себя неожиданно. Быстрый вызов grpcurl к реальному endpoint ловит проблемы, которые генерация не может увидеть сама, например выключенный reflection, неправильное сопоставление полей или метод, который компилируется, но возвращает не те данные.
Одно правило работает хорошо: генерируйте в одном месте, с закреплёнными версиями, никогда не редактируйте вывод, относитесь к именам пакетов как к публичному контракту и перед merge один раз проверьте живой сервис через grpcurl.
Быстрые проверки перед merge
Изменение в protobuf должно казаться рутинным. Если из-за маленького переименования поля люди начинают гадать, что надо регенерировать и работает ли сервис, значит пайплайн слишком хрупкий.
Каждый раз запускайте одни и те же проверки:
- Начните с
buf lint. Он ловит расхождения в именах, ошибки в пакетах и проблемы схемы до того, как сгенерированные файлы начнут шуметь в diff. - Пересоберите код, а потом посмотрите
git status. Ожидаемые изменения в сгенерированных Go- и TypeScript-файлах — это нормально. Неожиданные diff'ы в посторонних папках обычно означают, что нужно проверить конфигурацию или плагины. - Соберите Go-код с новым выводом. Именно здесь всплывают сломанные импорты, переносы пакетов и несовпадения в handler-коде.
- Заодно скомпилируйте и TypeScript-часть. Ошибки фронтенда часто появляются после изменения enum, переименования полей или обновления сигнатуры метода.
- Отправьте реальный запрос к работающему сервису через
grpcurl. Один живой запрос может показать неправильный маршрут, отсутствие reflection или сервис, который всё ещё работает на старом коде.
Небольшой пример хорошо это показывает. Допустим, вы добавляете поле status в ответ. Генерация может пройти сразу. Потом сборка Go падает, потому что mapper забыл заполнить новое поле. Или TypeScript-приложение компилирует почти всё, но ломается в одном экране, который ожидал более маленький enum. Быстрый вызов grpcurl подтверждает, возвращает ли задеплоенный сервис действительно ту форму, которую вы ждёте.
Локальная работа и CI должны использовать абсолютно одни и те же команды. Если разработчик запускает один скрипт на ноутбуке, а CI выполняет другой набор скрытых шагов, доверие падает быстро. Поместите проверки за один скрипт, один Make target или один task-файл и позвольте и локальным запускам, и CI вызывать одну и ту же точку входа.
Что делать дальше
Хороший рабочий процесс Buf кажется скучным в лучшем смысле. Одна команда должна линтить proto-файлы и генерировать код, и та же самая команда должна запускаться на каждом ноутбуке и в CI. Если команде приходится помнить три скрипта, две версии плагинов и одну локальную правку, настройка уже дрейфует.
Запишите команду, которую вы хотите видеть у всех, и положите её в документацию репозитория и в CI-job. Для многих команд этого достаточно:
buf lint && buf generate
Если вам удобнее обёртка вроде make proto, оставьте её тонкой и отдайте реальную работу Buf. Затем удалите один старый скрипт, который делает то же самое, но по-другому. Обычно виноват оставшийся protoc shell-скрипт, которому доверяет только один человек. Держать оба варианта кажется безобидным, пока Go- и TypeScript-выводы не перестают совпадать.
Добавьте один smoke test на grpcurl в релизный процесс и запускайте его по настоящему сервису после деплоя. Держите его маленьким. Проверки health достаточно, и одного безопасного метода чтения тоже достаточно. Один такой вызов может поймать устаревший образ, неправильный маршрут или сервис, который стартует, но не отвечает контрактом, которого ждёт команда.
Короткий план уборки работает лучше большого переписывания:
- задокументируйте одну публичную команду для lint и генерации
- удалите один дублирующий скрипт
- запускайте одну проверку grpcurl после релиза
- пересмотрите любые правила путей, плагинов или CI, которые ведут себя по-разному на ноутбуках и в пайплайне
Если после этого процесс всё ещё кажется хрупким, поможет внешний аудит. Именно такую уборку Oleg Sotnikov делает через oleg.is в своей работе как частичный CTO и advisor для стартапов: зафиксировать версии, убрать дублирующие скрипты и сделать так, чтобы локальные запуски совпадали с CI, чтобы команда перестала бороться с генерацией кода.
Часто задаваемые вопросы
Какая структура папок лучше всего подходит для protobuf-файлов?
Используйте один корневой каталог, например proto/, для всех .proto файлов, а сгенерированный код складывайте в отдельные папки вроде gen/go/ и gen/ts/. Так вручную написанный код не смешивается с результатами сборки, и пути легко угадать.
Зачем фиксировать версии плагинов Buf?
Фиксация версий убирает случайный шум в сгенерированных файлах. Когда на всех ноутбуках и в CI стоят одни и те же версии плагинов, из одной и той же схемы получается один и тот же результат.
Стоит ли коммитить сгенерированный Go- и TypeScript-код?
Коммитьте сгенерированные файлы, если другие репозитории или фронтенд-разработчики должны сразу получить их после pull. Не коммитьте их, если каждое окружение всегда может пересобрать тот же результат с закреплёнными версиями, а CI проверяет это каждый раз.
Какую команду должны запускать разработчики и CI?
Сделайте всё просто: запускайте везде одну и ту же команду репозитория, обычно buf lint && buf generate или тонкую обёртку вроде make proto. И разработчики, и CI должны вызывать один и тот же вход.
Как использовать grpcurl, чтобы проверить живой gRPC-сервис?
Сначала выполните grpcurl -plaintext localhost:50051 list, чтобы увидеть, что сервер действительно отдаёт, затем вызовите describe для нужного метода и после этого отправьте маленький запрос через -d. Так вы увидите настоящий контракт сервиса ещё до того, как начнёте обвинять генерацию кода.
Что делать, если server reflection отключён?
Соберите Buf image и передайте её grpcurl через -protoset. Тогда проверка будет опираться на тот же контракт, что и генераторы, даже если на сервере выключен reflection.
Как удержать Go и TypeScript в синхроне?
Пусть Buf генерирует Go и TypeScript из одного и того же дерева .proto за один запуск. Если один скрипт отвечает за Go, а другой за TypeScript, расхождения начинаются быстро, и дифф перестаёт вызывать доверие.
Почему переименования protobuf-пакетов так рискованны?
Имена пакетов работают как публичные API-имена. Если поменять их после того, как на них уже завязались клиенты, сдвинутся Go-импорты, изменятся имена в TypeScript, а вместе с ними и имена сервисов.
Что чаще всего вызывает шумные diff'ы в сгенерированном коде?
Обычно шумные diff'ы появляются из-за расхождений версий плагинов, смешанных путей вывода, старых сгенерированных файлов или ручных правок в генерации. Сначала уберите это, а уже потом возвращайтесь к структуре схемы.
Что нужно проверить перед merge proto-изменения?
Запустите buf lint, пересоберите код, проверьте git status, соберите и Go-, и TypeScript-часть, а затем отправьте один реальный запрос через grpcurl на живой endpoint. Этот короткий ритуал помогает поймать ошибки в именах, старые файлы, сломанные импорты и несовпадения между схемой и сервером до слияния ветки.