Содержание урока · 16 разделов
- версионирование grpc: три вида совместимости
- Пакет оканчивается версией: package shop.v1
- Минорные изменения: растём, не ломая
- Переименование: на байтах бесплатно, в коде — нет
- Один сервис против двух: v1 и v2 рядом
- Стратегии выката версий
- Deprecation: пометь, объяви срок, выключи
- Клиент с двумя версиями: мост на переходный период
- Канон Google: версия в пакете и /v1/ в пути
- buf breaking: правила становятся красным билдом
- Тесты на совместимость
- Чек-лист: версионирование без вилок
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
версионирование 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. Где он обязан лежать?
Минорные изменения: растём, не ломая#
Внутри одной 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, номер не тронули. Что из этого верно?
Каким будет полный путь метода?
Прочитайте код и выберите, что он выведет:
// proto/shop/v1/order.proto
syntax = "proto3";
package shop.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
}
// клиент вызывает GetOrderОдин сервис против двух: 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];. Что изменится на проводе?
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
Заполните пропуски: пакет второй версии, блокировка унаследованного номера и имени, новое поле заметки.
Клиент с двумя версиями: мост на переходный период#
Гайд 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
Прочитайте код и выберите, что он выведет:
// сервер: зарегистрирован только 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?Канон 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-перехода: от решения до дня выключения старой версии.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: версионирование gRPC
Что дальше по курсу#
Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором 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 — правила, которые buf breaking проверяет за вас, вы выводили руками, и в proto3 — там началась схема Order. Полный маршрут — в курсе gRPC на Go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Версия живёт в конце пакета: 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», и это должно случиться в обещанную дату, а не раньше.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки