Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~18 мин +50 XP

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

Девятнадцатый урок закончил на обещании: «эволюция схем» поднимется с уровня байтов на уровень организации кода. Выполняем. Сегодня — версионирование 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, который ваши клиенты увидят, если вы поспешите.

Содержание урока · 16 разделов

версионирование 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

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

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; }

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

Гайд 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.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

Термин

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. Полное имя службы включает пакет — версия видна даже в пути вызова.

1 / 6

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

Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором 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 и опишите, что осталось чинить и где.

Личные заметкиПусто
Хранятся только в вашем браузере (gRPC · Версионирование API)

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

Чем 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», и это должно случиться в обещанную дату, а не раньше.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Connect25 уроков~12 чпродвинутый25 уроков уже доступны

Connect: современный RPC от Buf

Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.

  • Строить сервисы на connectrpc.com/connect
  • Понимать три протокола и выбирать правильный
  • Настраивать buf: lint, breaking changes, generate
  • Писать interceptors и обрабатывать ошибки