# Пайплайн генерации кода 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.

```text
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. Сначала держите его небольшим.

```yaml
version: v2
modules:
  - path: proto
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
```

Это даёт команде одну папку для `.proto` файлов и одну общую политику lint. Если имена, пути пакетов или структура файлов начинают расходиться, Buf поймает это раньше, чем плохие паттерны попадут в сгенерированный код.

Затем добавьте `buf.gen.yaml`. В этом файле должны быть ответы на три простых вопроса: какие плагины запускаются, куда попадают файлы и какую версию плагина использует каждый разработчик. Закрепление версий важнее, чем ожидает большинство команд. Один человек с более новым плагином может переписать десятки файлов и оставить остальных разгребать шум.

```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 lint`
- `buf 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, начните с того, что посмотрите, что сервис вообще отдаёт:

```bash
grpcurl -plaintext localhost:50051 list
```

Эта быстрая проверка ловит много скучных, но дорогих ошибок. Вы можете увидеть старое имя сервиса, неправильный пакет или контейнер, который так и не подхватил последнее изменение proto. Во многих случаях это первый знак того, что генерация в порядке, а проблема в деплое.

Затем посмотрите один метод, прежде чем писать клиентский код:

```bash
grpcurl -plaintext localhost:50051 describe acme.user.v1.UserService.GetUser
```

Внимательно прочитайте форму запроса и ответа. Если поле переехало, enum изменился или имя сообщения отличается от того, что ждёт клиент, вы увидите это сразу.

После этого отправьте очень маленький JSON-запрос:

```bash
grpcurl -plaintext -d '{\"id\":\"123\"}' localhost:50051 acme.user.v1.UserService/GetUser
```

Держите payload минимальным. Сейчас вы не проверяете все правила. Вы смотрите, совпадает ли JSON-форма с той proto-формой, которую, как вам кажется, вы сгенерировали.

Если reflection выключен, используйте Buf image, чтобы grpcurl и генераторы читали один и тот же контракт:

```bash
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. Для многих команд этого достаточно:

```bash
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, чтобы команда перестала бороться с генерацией кода.
