---
title: "Версионирование API"
description: "Версионирование gRPC: пакеты shop.v1 и shop.v2, эволюция proto-схем, deprecated-пометки, два сервиса на одном сервере, мост в клиенте и buf breaking."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/20-versionirovanie/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 18
---

# Версионирование API

*Курс «gRPC-Go: высокопроизводительный RPC», урок 20 из 25. ~18 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/20-versionirovanie/*

Девятнадцатый урок закончил на обещании: «эволюция схем» поднимется с уровня байтов на уровень организации кода. Выполняем. Сегодня — версионирование gRPC: как устроены пакеты proto-схем, почему последний компонент пакета — это версия (shop.v1 → shop.v2), как раскладывать файлы по каталогам вроде proto/shop/v1/order.proto и почему это не бюрократия, а контракт. Разберёте, какие изменения внутри major-версии безопасны, а что является настоящим breaking change — на языке трёх видов совместимости: source, wire и semantic; как помечать устаревшее опцией deprecated = true и сколько времени давать на миграцию; как выглядит каноничное решение Google — версия в конце пакета и /v1/ в начале пути REST-мостов; и как buf breaking превращает правила из девятнадцатого урока в красный билд в CI. Всё это вы соберёте руками: два сервиса разных версий на одном порту, клиент с мостом на переходный период и честный сценарий выключения v1 — с текстом ошибки unknown service shop.v1.OrderService, который ваши клиенты увидят, если вы поспешите.

### версионирование grpc: три вида совместимости

Контракт API — это обещание: клиенты, написанные сегодня, заработают и завтра. AIP-180, гайд Google по обратной совместимости, формулирует базовое правило: «Old clients must be able to work against newer servers (with the same major version number)» — старые клиенты обязаны работать с новыми серверами, пока у них одинаковый major-номер версии. Само понятие «сломал — не сломал» раскладывается на три вида совместимости. «Source compatibility: Code written against a previous version must compile against a newer version, and successfully run with a newer version of the client library» — код обязан компилироваться и работать против новой клиентской библиотеки. «Wire compatibility: Code written against a previous version must be able to communicate correctly with a newer server» — сериализация и десериализация обязаны совпадать. «Semantic compatibility: Code written against a previous version must continue to receive what most reasonable developers would expect» — самое коварное: разумный разработчик продолжает получать то, что ожидал. Любое изменение, роняющее хотя бы один из трёх видов, — это breaking change, и версионирование начинается с честного ответа: минорное улучшение или слом? Англоязычные гайды зовут эту дисциплину evolution proto — эволюция proto-схем по явным правилам, а не по догадкам. Байтовую механику — unknown fields, reserved, Last One Wins — вы уже знаете из девятнадцатого урока; сегодня достраивается второй этаж: организация кода и время жизни версий.

### Пакет оканчивается версией: package shop.v1

Раскладка версий начинается с пакета. Стиль-гайд Buf формулирует правила, которые потом подхватывают линтеры: «All files should have a package defined»; «All files of the same package should be in the same directory, and every file should live in a directory that matches its package name»; «The last component of a package should be a version». Пакеты пишутся в нижнем регистре с подчёркиваниями: «Packages should be in lower_snake_case format» — поэтому shop.v1, а не ShopV1 или shopV1. Для нашего магазина это пакет shop.v1 и файл proto/shop/v1/order.proto — путь дублирует пакет, и по имени файла уже видно, какая версия перед вами. Ещё одно правило связывает файлы одного пакета между собой: «All of the file options below should have the same value, or all be unset, across every file that shares a package» — go_package обязан быть одинаковым у всех файлов пакета. За этим следят правила линтера Buf: PACKAGE_SAME_GO_PACKAGE — совпадение go_package, PACKAGE_DIRECTORY_MATCH — соответствие каталога пакету, PACKAGE_VERSION_SUFFIX — версия на конце пакета. Это скучная гигиена ровно до того момента, когда вы захотите выкатить v2: без неё не было бы видно, где кончается одна версия и начинается другая.

### Раскладка: одна версия — один каталог

```go
# Модуль Buf: корень proto/, рядом buf.yaml
shop/
└── proto/
    └── shop/
        ├── v1/
        │   └── order.proto    # package shop.v1;
        └── v2/
            └── order.proto    # package shop.v2;

// proto/shop/v1/order.proto — живой контракт из второго урока
syntax = "proto3";

package shop.v1; // последний компонент пакета — версия

option go_package = "github.com/example/shop/gen/shopv1"; // одинаков для всех файлов пакета

// proto/shop/v2/order.proto — major-переезд: новая директория, новый пакет
syntax = "proto3";

package shop.v2;

option go_package = "github.com/example/shop/gen/shopv2"; // в v1 значение другое — это нормально
```

Обратите внимание: v2 — это копия адресации с новым номером, а не «высокая» версия того же файла. go_package меняется на gen/shopv2: сгенерированные пакеты Go живут рядом, но не пересекаются, и старый код продолжает собираться против shopv1 без единой правки. О версии приходится думать в трёх слоях сразу: proto-пакет, каталог и Go-пакет — и все три названы одинаково, чтобы не гадать при чтении diff.

### Проверка: где лежит файл

**Вопрос:** В модуле Buf схемы живут в каталоге proto/. Файл объявляет package shop.v1. Где он обязан лежать?

- ✅ proto/shop/v1/order.proto — файл живёт в каталоге, повторяющем пакет, а последний компонент пакета обязан быть версией
- proto/shop.v1/order.proto — точка разрешена в имени каталога, если она совпадает с пакетом
- proto/order.proto — путь ни на что не влияет, важен только import при генерации
- shop/v1/order.proto — но лишь при условии, что go_package объявлен без версии

Стиль-гайд Buf: «every file should live in a directory that matches its package name» — каталог повторяет компоненты пакета (правило PACKAGE_DIRECTORY_MATCH), а «The last component of a package should be a version» (PACKAGE_VERSION_SUFFIX). Вариант с точкой в имени каталога путает пакет с путём: компоненты разделяются слэшами, а не точками. Вариант без proto/ ломает согласованность модуля, а версия в go_package вообще не связана с расположением файла.

### Минорные изменения: растём, не ломая

Внутри одной major-версии растить API можно почти свободно — но с оговорками. AIP-180: «In general, new components (interfaces, methods, messages, fields, enums, or enum values) may be added to existing APIs in the same major version» — новые интерфейсы, методы, сообщения, поля, перечисления и значения перечислений добавлять можно. Ограничения следуют сразу. «New required fields must not be added to existing request messages or resources» — новые обязательные поля в старые запросы не добавляют: клиент, который о них не знает, отправит запрос без них и получит отказ — это ломает его молча. Любое поле, которое заполняют клиенты, обязано иметь дефолтное поведение, совпадающее с прежним. Дефолты сервера менять тоже нельзя: «Default values are the values set by servers for resources when they are not specified by the client», и эти значения «must not change across minor versions». С enum'ами отдельная осторожность: клиентский switch может не знать нового значения — свободно добавляйте значения только в перечисления для запросов, а для ответов документируйте расширение заранее. С байтовой точки зрения всё это — переформулировка девятнадцатого урока: добавленное поле старый парсер пропустит как unknown fields. Разница в том, что AIP смотрит шире провода — на компиляцию и на ожидания людей.

### Переименование: на байтах бесплатно, в коде — нет

Девятнадцатый урок показал: на проводе правит номер поля, поэтому переименование не меняет ни одного байта. Но AIP-180 смотрит и на остальное: «Renaming a component is semantically equivalent to 'remove and add'» — переименование компонента семантически эквивалентно удалению и добавлению. Уточнение важное: «a service may add the new component, but must not remove the existing one» — добавить новое можно, убирать старое нельзя. Имя поля живёт не только в байтах: JSON-кодирование ходит по именам (вспомните совет резервировать имя вместе с номером), исходники клиентов генерируются по именам, и переименованное поле в чужом языке перестаёт находиться компилятором. Поэтому Buf содержит отдельное правило FIELD_SAME_NAME в категории WIRE_JSON: переименование в живой схеме билд не пройдёт, хотя байты не изменились бы вовсе. Практика: новое поле добавляется, старое помечается deprecated и живёт до конца срока жизни версии; если поле решено держать видимым в схеме, ему дают префикс OBSOLETE_, как советовал девятнадцатый урок. Само удаление — через reserved номера и имени; это процедура, а не новый факт, повторять её механику здесь не будем.

### Проверка: переименование поля

**Вопрос:** Поле recipient_name = 12 переименовали в customer_name, номер не тронули. Что из этого верно?

- ✅ Байты на проводе не изменятся, но это всё равно breaking change: изменятся имена в исходниках и JSON-представление — buf breaking поймает это правилом FIELD_SAME_NAME
- Ничего не случится: раз номер тот же, совместимость полная и для исходников, и для JSON-клиентов
- protoc откажется компилировать: имя поля нельзя менять после публикации схемы
- Старые клиенты перестанут понимать сообщения: парсер упадёт на незнакомом имени

На проводе правят номера: LEN-запись поля 12 останется той же, старые бинарники прочитают её без вопросов — вариант про падение парсера путает бинарный формат с JSON-миром, где парсинг идёт по именам. Но «Renaming a component is semantically equivalent to 'remove and add'»: исходники перегенерируются с новым именем, JSON-клиенты теряют привычный ключ. Первый вариант игнорирует это, второй — просто выдумка: protoc спокойно компилирует переименование.

### Каким будет полный путь метода?

```go
// proto/shop/v1/order.proto
syntax = "proto3";

package shop.v1;

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
}
// клиент вызывает GetOrder
```

**Что выведет программа?**

- ✅ /shop.v1.OrderService/GetOrder — в путь входит полное имя службы: пакет, точка, имя сервиса
- /OrderService/GetOrder — gRPC отбрасывает пакет, в пути только имя сервиса
- /shop/v1/OrderService/GetOrder — путь строится по REST-правилам: слэш между компонентами пакета
- /GetOrder — версия и сервис видны только в метаданных

gRPC-вызов — это HTTP/2 POST на :path, собранный из полного имени: /{package}.{Service}/{Method}. Пакет — часть имени, поэтому две службы с одинаковым именем OrderService, но разными пакетами, различаются на проводе целиком: /shop.v1.OrderService/GetOrder против /shop.v2.OrderService/GetOrder. Именно это даёт маршрутизацию по пакету — ниже вы зарегистрируете оба варианта на одном сервере. REST-путь вида /v1/... появится отдельно — в мостах gRPC-JSON у Google, и там версия тоже первая в пути.

### Один сервис против двух: v1 и v2 рядом

Сервер gRPC хранит службы по их полным именам, а полное имя включает пакет. Отсюда главный разворот версионирования. Первый путь — один сервис: контракт shop.v1 эволюционирует совместимыми изменениями, служба остаётся одна, клиенты живут спокойно. Второй путь — два сервиса: рядом с shop.v1.OrderService регистрируется shop.v2.OrderService, полное имя у каждого своё, и маршрутизация по пакету раскладывает вызовы сама: /shop.v1.OrderService/GetOrder уходит в старый хендлер, /shop.v2.OrderService/GetOrder — в новый. Один порт, один процесс, один health-check — но два контракта с независимым циклом жизни. Два сервиса — это ещё и гигиена: новый контракт не тащит на себе деприкации старого, а хендлеры можно вести разными командами и выпускать по разным графикам. Цена — дублирование: конвертация сообщений между версиями, двойная телеметрия, два набора тестов. Отдельный триггер для major — смена типа поля: AIP-180 требует «Existing fields and messages must not have their type changed, even if the new type is wire-compatible, because type changes alter generated code in a breaking way» — даже проводно-совместимая смена типа ломает сгенерированный код, а значит source-совместимость. Правило практиков простое: пока изменения совместимы — живите в одной версии и растите её по правилам AIP-180; два сервиса заводите, когда совместимость больше не выдерживается — сменой типа поля, сменой семантики ответа или сменой формата данных.

### Сервер с двумя версиями: регистрируем оба пакета

```go
// main.go — один сервер обслуживает обе версии контракта
package main

import (
        "context"
        "log"
        "net"

        "google.golang.org/grpc"

        shopv1 "github.com/example/shop/gen/shopv1" // сгенерировано из proto/shop/v1
        shopv2 "github.com/example/shop/gen/shopv2" // сгенерировано из proto/shop/v2
)

// v1Handler реализует shop.v1.OrderService — старый контракт.
type v1Handler struct {
        shopv1.UnimplementedOrderServiceServer
}

func (h *v1Handler) GetOrder(ctx context.Context, req *shopv1.GetOrderRequest) (*shopv1.GetOrderResponse, error) {
        order := loadOrder(ctx, req.GetId()) // доменная логика общая для обеих версий
        return &shopv1.GetOrderResponse{Order: toV1(order)}, nil
}

// v2Handler реализует shop.v2.OrderService — новый контракт.
type v2Handler struct {
        shopv2.UnimplementedOrderServiceServer
}

func (h *v2Handler) GetOrder(ctx context.Context, req *shopv2.GetOrderRequest) (*shopv2.GetOrderResponse, error) {
        order := loadOrder(ctx, req.GetId())
        return &shopv2.GetOrderResponse{Order: toV2(order)}, nil
}

func main() {
        lis, err := net.Listen("tcp", ":50051")
        if err != nil {
                log.Fatalf("порт занят: %v", err)
        }
        srv := grpc.NewServer()
        shopv1.RegisterOrderServiceServer(srv, &v1Handler{}) // путь /shop.v1.OrderService/...
        shopv2.RegisterOrderServiceServer(srv, &v2Handler{}) // путь /shop.v2.OrderService/...
        log.Println("слушаем :50051 — v1 и v2 рядом")
        if err := srv.Serve(lis); err != nil {
                log.Fatalf("сервер остановлен: %v", err)
        }
}
```

Смотрите, что здесь разного и что общего: хендлеры не знают друг о друге, но доменная функция loadOrder одна — бизнес-логика не вилится, вилки остаются только в сгенерированных типах. shopv1.Order и shopv2.Order — два независимых Go-типа без общего интерфейса, и конвертеры toV1/toV2 пишутся руками. Подсмотреть службы удобно grpcurl из восемнадцатого урока: describe покажет и shop.v1.OrderService, и shop.v2.OrderService; reserved-номера, как вы помните, в describe не печатаются — это честное ограничение инструмента, а не исчезновение защиты.

### Стратегии выката версий

| Стратегия | Механика | Когда выбирать |
| --- | --- | --- |
| Эволюция внутри v1 | Только совместимые изменения: новые поля и методы; ничего не удаляется и не переименовывается | Всегда первый выбор: изменения описываются в пределах AIP-180 |
| Два сервиса v1 и v2 | Оба пакета генерируются, оба хендлера регистрируются на одном сервере; маршрутизация по пакету | Контракт сломан принципиально: смена типа, семантики или формата |
| Разные инстансы | v1 и v2 живут на отдельных деплоях; трафик делит балансировщик по методу | Радикально разные нагрузки или графики выключения версий |
| Каналы v1alpha и v1beta | Экспериментальные пакеты рядом со стабильной v1 | Публичные API с чужими клиентами и долгими циклами обновления |
| Deprecate и выключение | deprecated = true, объявленный срок, затем снятие регистрации | Финал любой версии: последняя фаза обоих путей |
| Тихая замена схемы | Поменять поле под тем же номером «на месте» | Никогда: breaking change для всех клиентов одновременно |

### Проверка: что меняет deprecated

**Вопрос:** Поле помечено как map<string, string> metadata = 3 [deprecated = true];. Что изменится на проводе?

- ✅ Ничего: номер, тип и кодировка прежние — меняются только метаданные дескриптора, а генератор Go добавит комментарий «Deprecated: Do not use.»
- Поле перестанет кодироваться, и сообщения автоматически похудеют
- Сервер начнёт отвечать Unimplemented на сообщения, где поле заполнено
- protoc потребует немедленно удалить поле и занять его номер в reserved

deprecated — метка для людей и инструментов, а не операция над данными: дескриптор хранит флаг, protoc-gen-go добавляет комментарий «Deprecated: Do not use.», IDE зачёркивает вызовы, документация печатает пометку. На байтах не меняется ничего: старые и новые клиенты продолжают обмениваться теми же записями. Гайд Google формулирует цель прямо: «API elements (fields, messages, RPCs) may be marked deprecated in any channel to indicate that they should no longer be used» — should no longer, а не can no longer.

### Deprecation: пометь, объяви срок, выключи

Пометка deprecated — не украшение, а начало процедуры. Гайд Google по версионированию: «API elements (fields, messages, RPCs) may be marked deprecated in any channel to indicate that they should no longer be used» — пометить можно поле, сообщение или RPC в любом канале. Дальше — время. Для бета-канала гайд называет цифру: функциональность «may be removed after it has been deprecated for a sufficient period; we recommend 180 days» — полгода на миграцию. Есть и правило гигиены в обе стороны: «Deprecated API functionality must not graduate from alpha to beta, nor beta to stable» — помеченное устаревшим не должно мигрировать между каналами, иначе в стабильный выпуск попадает «заранее устаревшее». Для целых версий логика та же, но масштабнее: «An older version must go through a reasonable, well-communicated deprecation period before being shut down» — версия уходит через объявленный, заранее оговорённый период. Отдельно зафиксируйте для себя: помеченное deprecated поле продолжает работать. Это приглашение к переезду с датой отселения, а не выселение с момента подписи.

### Deprecation в схеме и что видит Go-клиент

```go
// proto/shop/v1/order.proto — схема v1 после пары лет жизни
message Order {
  int64 id = 1;
  repeated OrderItem items = 2;

  // metadata оказался неудобен: помечаем, но не удаляем —
  // номер, тип и формат на проводе не меняются.
  map<string, string> metadata = 3 [deprecated = true];

  optional string promo_code = 4;
  OrderStatus status = 5;
  google.protobuf.Timestamp created_at = 6;

  reserved 7;               // номер купонного поля выведен из оборота
  reserved "coupon_code";   // и имя тоже — JSON должен перестать парситься

  int64 total_cents = 8;
}

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);

  // Метод дублировал GetOrder по другому ключу — объявляем устаревшим.
  rpc GetOrderByCode(GetOrderByCodeRequest) returns (GetOrderResponse) {
    option deprecated = true;
  }
}
```

Пометка меняет сгенерированный код: protoc-gen-go добавляет комментарий «Deprecated: Do not use.» над устаревшими декларациями — IDE зачёркивает обращения, статический анализ начинает ругаться. При этом wire-формат идентичен: клиенты обеих версий кода говорят с сервером без изменений. Так выглядит деприкация без выката: контракт не сдвинулся, изменились только рекомендации по использованию.

### Соберите заголовок схемы v2

Заполните пропуски: пакет второй версии, блокировка унаследованного номера и имени, новое поле заметки.

```go
syntax = "proto3";

// Пакет обязан оканчиваться версией — это его последний компонент.
package ___;

option go_package = "github.com/example/shop/gen/shopv2";

import "google/protobuf/timestamp.proto";

// Заказ второй версии: канон v1 переезжает без сюрпризов.
message Order {
  int64 id = 1;
  repeated OrderItem items = 2;
  map<string, string> metadata = 3;
  optional string promo_code = 4;
  OrderStatus status = 5;
  google.protobuf.Timestamp created_at = 6;

  // Купонное поле выведено из оборота ещё в v1 — блокируем и здесь:
  // счёт номеров в каждой версии ведётся заново, история переезжает вручную.
  reserved ___;
  reserved "___";

  int64 total_cents = 8;

  // Новое поле второй версии: короткая заметка покупателя к заказу.
  string ___ = 9;
}
```

Подсказка: Последний компонент пакета — версия; в v1 под номером 7 жили reserved 7; и reserved "coupon_code";, в v2 блокировка повторяется, а заметка получает следующий свободный номер.

Правильные ответы в пропусках: `shop.v2`, `7`, `coupon_code`, `customer_note`

### Клиент с двумя версиями: мост на переходный период

Гайд Google фиксирует право клиента, а не только обязанность сервера: «Different versions of the same API must be able to work at the same time within a single client application for a reasonable transition period» — обе версии живут в одном приложении одновременно. Это значит, что клиентская сторона версионирования — не «переключили импорт и перекомпилировали», а мост. Внутри приложения заводится доменная структура, которая не знает о proto-версиях; над ней — конвертеры в обе стороны и клиент, умеющий говорить и с v1, и с v2. Переключение версий прячется за флаг миграции: включили v2 для одного процента трафика, посмотрели метрики, расширили охват. Мост — не вечная постройка: его судьба — умереть в конце переходного периода, когда последний вызов v1 уйдёт и импорт shopv1 удалят. Пока он жив, он единственное место в коде, где оба пакета импортированы одновременно, — и это осознанная изоляция: если где-то и придётся касаться обоих контрактов, то лучше в одном файле, чем по всему репозиторию.

### Мост: домен посередине, версии по краям

```go
// bridge.go — клиент на переходный период миграции
package shopclient

import (
        "context"

        shopv1 "github.com/example/shop/gen/shopv1"
        shopv2 "github.com/example/shop/gen/shopv2"
)

// Order — доменная структура приложения: она не знает о proto-версиях.
type Order struct {
        ID        int64
        PromoCode string
        Total     int64
}

// Client умеет говорить на двух версиях сразу — так требует переходный период.
type Client struct {
        v1     shopv1.OrderServiceClient
        v2     shopv2.OrderServiceClient
        useV2  bool // флаг миграции: катают по проценту трафика
}

func New(v1 shopv1.OrderServiceClient, v2 shopv2.OrderServiceClient, useV2 bool) *Client {
        return &Client{v1: v1, v2: v2, useV2: useV2}
}

// GetOrder идёт через выбранную версию и возвращает доменный тип.
func (c *Client) GetOrder(ctx context.Context, id int64) (*Order, error) {
        if c.useV2 {
                resp, err := c.v2.GetOrder(ctx, &shopv2.GetOrderRequest{Id: id})
                if err != nil {
                        return nil, err
                }
                return fromV2(resp.GetOrder()), nil
        }
        resp, err := c.v1.GetOrder(ctx, &shopv1.GetOrderRequest{Id: id})
        if err != nil {
                return nil, err
        }
        return fromV1(resp.GetOrder()), nil
}

// fromV2 превращает proto-сообщение v2 в доменную структуру.
func fromV2(o *shopv2.Order) *Order {
        return &Order{ID: o.GetId(), PromoCode: o.GetPromoCode(), Total: o.GetTotalCents()}
}

// fromV1 делает то же для v1 — конвертеры пишутся руками, типов общего нет.
func fromV1(o *shopv1.Order) *Order {
        return &Order{ID: o.GetId(), PromoCode: o.GetPromoCode(), Total: o.GetTotalCents()}
}
```

Ключевая деталь: доменный тип Order один, proto-типов два. shopv1.Order и shopv2.Order — разные структуры: между ними нет ни наследования, ни общего интерфейса, и сгенерированные геттеры получают разные типы. Конвертеры — та подушка, на которой приложение переживает миграцию; добавьте сюда лог-событие при каждом вызове v1 и метрику доли трафика — и получится production-паттерн переходного периода.

### Клиент v1 стучится к серверу без v1

```go
// сервер: зарегистрирован только v2 — регистрацию shop.v1 сняли
shopv2.RegisterOrderServiceServer(srv, &v2Handler{})

// клиент собран по схеме v1:
conn, err := grpc.NewClient("localhost:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()))
client := shopv1.NewOrderServiceClient(conn)

resp, err := client.GetOrder(ctx, &shopv1.GetOrderRequest{Id: 42})
// что в err?
```

**Что выведет программа?**

- ✅ Unimplemented с текстом unknown service shop.v1.OrderService: службы с таким полным именем на сервере нет
- NotFound: сервер честно скажет, что заказа 42 не существует
- Unavailable: соединение разорвётся — клиент не достучится до порта
- Запрос пройдёт и вернёт пустой Order: сервер ответит нулевыми значениями

Внутри grpc-go в server.go стоит ветка с комментарием «Unknown service, or known server unknown method.»: если службы нет — «unknown service %v», если служба есть, а метода нет — «unknown method %v for service %v». Полное имя v1-службы после снятия регистрации не совпадает ни с чем, поэтому клиент получает Unimplemented и «unknown service shop.v1.OrderService». Именно этот текст увидят ваши забытые клиенты в день выключения — планируйте его появление по календарю, а не по факту инцидента.

### Канон Google: версия в пакете и /v1/ в пути

Как это выглядит у крупнейшего оператора gRPC API. Гайд Google по версионированию: «All Google API interfaces must provide a major version number, which is encoded at the end of the protobuf package, and included as the first part of the URI path for REST APIs» — major-версия живёт в конце пакета и в первой части пути REST-моста: пакет google.firestore.v1, а запрос выглядит как GET /v1/projects/my-project/topics. Миноров и патчей на поверхности нет: «Google APIs use v1, not v1.0, v1.1, or v1.4.2» — для пользователя версия обновляется на месте, новые возможности приезжают без миграции. Для экспериментов — каналы: к версии приписывается уровень стабильности, v1beta и v1alpha легальны, но «v1 is an acceptable version for the stable channel, but v1beta or v1alpha are not» — стабильный канал всегда чистый v1, а бета обязана быть надмножеством стабильного. И архитектурное правило с зубами: «A new major version of an API must not depend on a previous major version of the same API» — новая major-версия не должна зависеть от предыдущей: копируйте контракты, а не связывайте версии в цепочку зависимостей.

### buf breaking: правила становятся красным билдом

Все правила эволюции не стоят ничего, пока проверяются памятью и надеждой. Buf превращает их в шаг CI. Документация: «buf breaking compares a Protobuf schema against an earlier version and reports any rules that the newer version breaks» — каждая проверка принимает две схемы: текущую и базовую, переданную через --against. Типовая команда: buf breaking --against '.git#branch=main' — схему в вашей ветке сверяют с main: сломаете контракт, билд падает до ревью, а не после выката. Правила сгруппированы по категориям: WIRE проверяет проводную совместимость, WIRE_JSON добавляет сверху JSON-слой. Внутри — имена, которые вы теперь читаете как словарь: FIELD_NO_DELETE, FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED (удаление с reserved номером — легально), FIELD_SAME_NAME, FIELD_SAME_TYPE, FILE_NO_DELETE. Безопасная эволюция из девятнадцатого урока и пакетная гигиена из сегодняшнего — это один шаг пайплайна: сломал — увидел имя правила и номер файла, а не тикет от клиента через месяц.

### Тесты на совместимость

Последний слой — тесты, которые держат матрицу: клиент v1 против сервера v1, клиент v1 против сервера v2, клиент v2 против сервера v1, клиент v2 против сервера v2. Все четыре ячейки обязаны работать в течение переходного периода — это исполняемая версия правила AIP-180 про старых клиентов на новых серверах. Добавьте к этому golden-тесты сериализации: зафиксированные байты сообщений из девятнадцатого урока не дадут случайно переехать номеру поля. Как запускать сервер в тесте без порта и сети — отдельная наука: в gRPC для этого есть bufconn, поднимающий сервер на in-memory соединении. Это тема следующего урока — 21-testirovanie «Тестирование gRPC-сервисов»: bufconn, тесты хендлеров и стримов; сегодняшняя матрица версий станет там набором тестовых случаев. Версионирование без тестов на совместимость — конституция без суда: красиво, но не исполняется.

> **Пакет — не место для рефакторинга.** Захотелось переименовать пакет shop.v1 в shop.orders.v1 «для красоты»? Это breaking change: изменится полное имя каждой службы, сервер перестанет узнавать старые пути, и все клиенты начнут ловить Unimplemented с текстом «unknown service shop.v1.OrderService» — хотя ни один байт сообщений не изменился. Внутри версии пакет не переименовывают никогда; не нравится имя — это разговор о новой версии, со всем её церемониалом. Заодно помните гигиену: go_package у файлов одного пакета обязан совпадать — правило PACKAGE_SAME_GO_PACKAGE не даст разъехаться и ему.

### Чек-лист: версионирование без вилок

- Последний компонент пакета — версия: shop.v1, shop.v2; каталог повторяет пакет: proto/shop/v1/order.proto.
- go_package одинаков у всех файлов пакета — PACKAGE_SAME_GO_PACKAGE; в v1 и v2 значения разные, и это нормально.
- Три вида совместимости: source, wire, semantic — ломает любой из них, а не только wire.
- Внутри major-версии добавляйте поля и методы, но не требуйте новые обязательные поля в старых запросах и не меняйте дефолты.
- Переименование: на байтах бесплатно, по AIP-180 — remove и add; FIELD_SAME_NAME в buf breaking его поймает.
- Удаление — только через reserved номера и имени и только по процедуре: пометка, срок, удаление.
- Два сервиса (v1 и v2) на одном сервере разводит трафик по пакету — полное имя метода начинается с пакета.
- Клиент на переходный период: доменная структура, конвертеры, флаг миграции — обе версии в одном приложении.
- deprecated = true ничего не меняет на проводе: это таймер и пометка «Deprecated: Do not use.» в сгенерированном коде.
- buf breaking --against '.git#branch=main' в CI — правила эволюции проверяет машина, а не память.

### Workflow: выкатываем v2 и выключаем v1

Соберите полный цикл major-перехода: от решения до дня выключения старой версии.

Правильный порядок строк:

1. `пока изменения совместимы, v1 растёт в пределах AIP-180: новые поля и методы, ничего не удаляется`
2. `требования ломают контракт принципиально — принимается решение о major и заводится пакет shop.v2 с каталогом proto/shop/v2/`
3. `shopv2 генерируется рядом с shopv1: старый код собирается против прежнего пакета без правок`
4. `сервер регистрирует оба сервиса: полный путь каждого метода различается пакетом`
5. `клиенты переходят через мост: обе версии работают в одном приложении, трафик катают по флагу`
6. `телеметрия показывает падение доли v1; оставшимся клиентам объявляют дату выключения`
7. `в v1 методы и поля помечаются deprecated = true — пометка не меняет провод, только таймер`
8. `в объявленный день регистрацию shop.v1 снимают: старые клиенты видят Unimplemented «unknown service shop.v1.OrderService»`

### Карточки: версионирование gRPC

- **package shop.v1** — Соглашение о версиях в пакете: «The last component of a package should be a version»; каталог повторяет пакет (proto/shop/v1/order.proto); файлы одного пакета живут в одной директории. Линтеры Buf: PACKAGE_DIRECTORY_MATCH, PACKAGE_VERSION_SUFFIX, PACKAGE_SAME_GO_PACKAGE. Полное имя службы включает пакет — версия видна даже в пути вызова.
- **Три вида совместимости** — AIP-180: «Source compatibility: Code written against a previous version must compile against a newer version»; «Wire compatibility: Code written against a previous version must be able to communicate correctly with a newer server»; «Semantic compatibility: Code written against a previous version must continue to receive what most reasonable developers would expect». Один major-номер — обязанность держать все три.
- **deprecated** — Опция поля, сообщения или RPC: [deprecated = true]. «API elements (fields, messages, RPCs) may be marked deprecated in any channel to indicate that they should no longer be used». На проводе ничего не меняет; protoc-gen-go добавляет «Deprecated: Do not use.». Бета-функциональность удаляют после периода — гайд Google рекомендует 180 дней.
- **Полное имя метода** — /{package}.{Service}/{Method}: /shop.v1.OrderService/GetOrder. Пакет — часть маршрута, поэтому два сервиса с одинаковым именем в разных пакетах живут на одном сервере без конфликтов, а трафик раскладывается по пакету. Снятие регистрации v1 даёт клиентам «unknown service shop.v1.OrderService» — так устроен server.go в grpc-go.
- **buf breaking** — «buf breaking compares a Protobuf schema against an earlier version and reports any rules that the newer version breaks». База сравнения: buf breaking --against '.git#branch=main'. Категории WIRE и WIRE_JSON; правила FIELD_NO_DELETE, FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED, FIELD_SAME_NAME, FIELD_SAME_TYPE, FILE_NO_DELETE.
- **Мост в клиенте** — Паттерн переходного периода: доменная структура без proto-типов, конвертеры v1 и v2, флаг миграции. Право клиента: «Different versions of the same API must be able to work at the same time within a single client application for a reasonable transition period». Мост умирает вместе с v1: импорт shopv1 удаляют последним.

### Что дальше по курсу

Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — protoc, gRPC-сервер и Buf в браузере не запустить, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет; каждое упражнение делается локально за несколько минут. Три эксперимента. Первый: соберите сервер из этого урока, зарегистрируйте только v2 и достучитесь клиентом v1 — получите дословный «unknown service shop.v1.OrderService», затем верните регистрацию v1 и убедитесь, что оба пути отвечают. Второй: пометьте metadata как deprecated = true, перегенерируйте код и найдите комментарий «Deprecated: Do not use.» в pb-файле; сверьте, что вывод grpcurl describe не изменился. Третий: поставьте Buf, запустите buf lint на модуль с v1 и v2, затем buf breaking --against против ветки без v2-каталога и разберите каждую строку отчёта. Дальше по маршруту: двадцать первый урок — тестирование gRPC-сервисов: bufconn, тесты хендлеров и стримов — там сегодняшняя матрица совместимости станет набором тестов. А перед этим загляните в [сериализацию и оптимизацию proto](/courses/grpc-go/lessons/19-proto-opt/) — правила, которые buf breaking проверяет за вас, вы выводили руками, и в [proto3](/courses/grpc-go/lessons/02-proto3/) — там началась схема Order. Полный маршрут — в [курсе gRPC на Go](/courses/grpc-go/).

## Коротко: что запомнить

- Версия живёт в конце пакета: shop.v1 → shop.v2; каталог повторяет пакет, файлы одной версии — в одной директории, go_package у файлов пакета одинаков.
- Три вида совместимости — source, wire и semantic; обязанность сервера: «Old clients must be able to work against newer servers (with the same major version number)».
- Внутри major-версии добавлять можно, ломать нельзя: новые обязательные поля в старых запросах, смена дефолтов и удаление компонентов запрещены.
- Переименование — wire-безопасно, но по AIP-180 это remove и add; buf breaking ловит его правилом FIELD_SAME_NAME в категории WIRE_JSON.
- deprecated = true ничего не меняет на проводе — это таймер: Google рекомендует 180 дней перед удалением бета-функциональности; в Go появляется «Deprecated: Do not use.».
- Два сервиса на одном сервере: /shop.v1.OrderService/GetOrder и /shop.v2.OrderService/GetOrder — маршрутизация по пакету; домен общий, конвертация явная.
- Клиент переходного периода: доменная структура, конвертеры v1 и v2, флаг миграции; обе версии работают в одном приложении, мост умирает последним.
- Канон Google: major-версия в конце пакета и /v1/ в начале REST-пути, без миноров и патчей; v1beta и v1alpha — каналы, а стабильный v1 — всегда чистый.

## Домашнее задание

Разверните версионирование на схеме Order. Первое: раскладите модуль Buf — proto/shop/v1/order.proto и proto/shop/v2/order.proto, запустите buf lint и добейтесь нуля замечаний по пакетным правилам. Второе: добавьте в v2 поле customer_note = 9 и метод ListOrders с серверным стримингом; в v1 не должно измениться ни одного байта — прогоните buf breaking --against '.git#branch=main' до и после и сравните отчёты. Третье: зарегистрируйте оба сервиса на одном сервере и соберите матрицу из четырёх комбинаций клиент-сервер; отдельно запишите текст ошибки для ячейки «клиент v1 против сервера без v1» и объясните, почему это Unimplemented, а не NotFound. Четвёртое: пометьте metadata в v1 как deprecated, перегенерируйте код и найдите «Deprecated: Do not use.» в сгенерированном файле; объясните, что изменилось бы на проводе, если бы вы удалили поле без reserved. Пятое: напишите мост с конвертерами в обе стороны и флагом миграции, а затем удалите импорт shopv1 и опишите, что осталось чинить и где.

## Вопросы и ответы по уроку

### Чем package shop.v1 отличается от одного пакета shop с версионированием в имени сервиса?

Полным именем службы: оно собирается из пакета и имени — shop.v1.OrderService — и видно на проводе в пути каждого вызова (/shop.v1.OrderService/GetOrder). Версия в пакете даёт отдельные каталоги и go_package (независимую генерацию), правило PACKAGE_VERSION_SUFFIX у линтера, а главное — два сервиса с одинаковым именем OrderService в разных пакетах сосуществуют на одном сервере без конфликтов. «Версии по названию» (OrderServiceV2 внутри общего пакета) контракты оставляют делить пакет, генерацию и общие сообщения, и переезд дорожает с каждым месяцем. Стиль-гайд Buf формулирует прямо: последний компонент пакета обязан быть версией.

### Можно ли менять номер поля при переходе на новую major-версию?

Внутри одной версии — никогда: это правило девятнадцатого урока, и оно не имеет исключений. При major-переезде формально вы начинаете новый пакет, где ничто не обязано наследовать номера, но практика другая: канон Order (id = 1 … total_cents = 8) переезжает в v2 с теми же номерами — так проще писать мосты и конвертеры, а diff схем читается как «добавилось поле 9», а не «всё изменилось». Менять номера имеет смысл только там, где меняется и смысл поля. Помните, что смена номера — это удаление плюс добавление одновременно, и в v2 тоже нужен reserved для освободившихся номеров, если по старой схеме их кто-то мог заполнить.

### Сколько версий держать и когда выключать старую?

Гайд Google: «Different versions of the same API must be able to work at the same time within a single client application for a reasonable transition period» — переходный период существует, он ограничен и объявляется заранее: «An older version must go through a reasonable, well-communicated deprecation period before being shut down». Для бета-элементов гайд называет ориентир 180 дней после деприкации; для стабильных версий срок больше и зависит от контракта с клиентами. Практический сигнальный набор: доля трафика на v1, количество тикетов, наличие крупного клиента, который так и не переехал. Выключение всегда проходит через снятие регистрации — клиенты увидят Unimplemented с текстом «unknown service shop.v1.OrderService», и это должно случиться в обещанную дату, а не раньше.
