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

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

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

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

Версионирование схем

версионирование protobuf — это правила, по которым схема меняется, пока старые клиенты продолжают работать: свежие номера полей, reserved для выбывших, никогда не переиспользуемые значения enum. Собираем полный контур: безопасные и ломающие изменения, сосуществование notes.v1 и notes.v2 на одном mux, buf breaking против опубликованной версии и депрекация с наблюдением через метрики.

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

Ваш notes-сервис живёт: завтра продукту понадобится поле dueDate, послезавтра — переименовать body в content, через месяц — выкинуть устаревшее поле целиком. Каждое из этих желаний может как пройти незаметно, так и положить всех клиентов сразу — разница только в знании правил эволюции схемы — версионирование protobuf держится на одном факте из введения в proto: по сети передаётся не имя поля, а его номер. Номер — контракт навсегда; имя — украшение, которое можно менять с оговорками; содержимое — то, что вы добавляете и убираете по правилам. Версии схем в buf-мире проверяются машинами, которые вы уже построили: buf breaking из десятого урока сравнивает локальную схему с опубликованной и ловит каждое нарушение до релиза, а BSR из двенадцатого хранит опубликованные версии с метками — эталон, против которого сравнивать. А connect версионирование не добавляет ничего своего: ваши хендлеры — обычные http.Handler, и два поколения схемы могут жить на одном mux бок о бок. План урока: карта безопасных и ломающих изменений, резервы и депрекация, пакетные версии как честный план Б, операционный контур с buf breaking и метриками из семнадцатого урока. Даже запрос grpc версия api в поисковике приводит к тем же рецептам — новый пакет, депрекация, совместимость по номерам полей, — потому что wire-формат общий.

Номер поля — контракт: как устроена совместимость#

Прежде чем правил, механика. Сообщение по wire-формату — это последовательность пар номер-значение: получатель читает номер, смотрит в своей версии схемы, что за поле под этим номером, и декодирует. Отсюда два вида совместимости, и их полезно различать. Wire-совместимость: старый бинарник читает новое сообщение без ошибок — незнакомые номера он пропускает (сохранив при желании в unknown fields), знакомые декодирует; отсутствующие поля получают нулевые значения. Source-совместимость: перегенерированный код старой схемой компилируется — имена полей и типов важны здесь. Клиенты не перегенерируются одновременно с сервером — браузер из девятого урока шлёт JS-код месячной давности, мобильное приложение — годичной, — поэтому по умолчанию вы проектируете под wire-совместимость и бережёте source-совместимость по возможности. Из механики следуют все правила урока. Добавить поле со свежим номером — безопасно: старые клиенты его пропустят. Убрать поле — безопасно для чтения (старые клиенты пришлют его, сервер молча проигнорирует), но опасно для будущего: номер выбывшего поля нельзя отдать другому полю, пока где-то жив клиент, который помнит под этим номером старый тип. Изменить тип поля — почти всегда поломка: получатель декодирует байты по старой схеме и получает мусор, который не заметен как ошибка. Это худший жанр поломки — не отказ, а тихая порча данных.

Исходная схема notes.v1

GO
syntax = "proto3";

package notes.v1;

message Note {
        string id = 1;
        string title = 2;
        string body = 3;
}

message GetNoteRequest {
        string id = 1;
}

Базовая схема из первых уроков курса: пакет notes.v1, три поля с номерами 1-3. Всё дальнейшее — история изменений этого файла. Обратите внимание на стиль имени пакета: notes.v1 — домен и версия; к пакетной версии мы вернёмся, когда эволюции внутри одной схемы станет мало.

Безопасные изменения: что можно спокойно делать#

Каталог разрешённого — короткий и стоит его выучить наизусть. Добавление нового поля со свежим номером — базовая операция эволюции: клиенты старой версии не увидят поле, новые прочитают; число в protobuf3 всегда имеет нулевое значение по умолчанию, поэтому отсутствие поля неотличимо от пустого — это свойство и есть фундамент совместимости. Переименование идентификатора поля — безопасно на wire-уровне, потому что имя по сети не ездит; но с оговоркой: JSON-кодек из четвёртого урока сериализует поля именем (нижнекэмельным по умолчанию), и браузерные клиенты, читающие JSON, заметят переименование. Если меняете имя, меняйте для JSON-потребителей через явную опцию json_name, сохранив старое. Добавление значения в enum — безопасно с оговоркой: старый клиент, получив незнакомое числовое значение, увидит его как число (в proto3 незнакомые enum-значения сохраняются), и ваш код должен переживать незнакомца без паники — проверяйте ветку default. Помечать поле опцией deprecated = true — безопасность в чистом виде: метка для людей и генераторов, wire-формат не трогается. И резервирование выбывшего — не изменение, а гигиена после удаления, следующий блок. Отдельно — что безопасно на чтении: сервер может начать игнорировать поле, которое раньше использовал; клиенты переживают это без труда. Общий знаменатель каталога: вы только расширяете словарь, ничего не переиначивая в существующем.

Эволюционировавшая схема: резерв и депрекация

GO
message Note {
        string id = 1;
        string title = 2;
        // body удалён в v1.3: контент переехал в content
        reserved 3;
        reserved "body", "old_body";

        string content = 6;      // свежий номер, не 3!
        repeated string tags = 4;
        bool pinned = 5 [deprecated = true];

        // появилось в v1.2; старые клиенты поля не увидят
        optional google.protobuf.Timestamp due_date = 7;
}

Три приёма урока в одном сообщении: reserved блокирует и номер 3, и имена body/old_body от повторного использования — компилятор откажется собирать схему с нарушением; pinned помечен deprecated — компиляторы сгенерируют предупреждение в коде клиентов; due_date добавлен со свежим номером 7 и optional — старые клиенты живут как жили.

Какое изменение схемы гарантированно ломает старых клиентов?

Что увидит старый клиент?

Прочитайте код и выберите, что он выведет:

// Сервер собран со схемой, где body удалён и номер 3 зарезервирован,
// а контент живёт в content = 6.
// Клиент перегенерирован со СТАРОЙ схемой и вызывает GetNote.

// Запрос: GetNote(id: "1")
// Сервер отвечает Note{id: "1", title: "План", content: "Привет"}

Резервы: гигиена после удаления#

Удаление поля создаёт самую коварную угрозу курса: номер освобождается, и через полгода кто-то добавляет новое поле, взяв красивый освободившийся номер. Пока жив хоть один клиент со старой схемой, он под этим номером ждёт старого типа — и получает новый: поломка без единой ошибки на проводе. Защита — reserved: директива, после которой компилятор запрещает использовать зарезервированные номера и имена. Резервируйте всегда оба измерения: reserved 3; блокирует номер, reserved «body»; блокирует имя (чтобы никто не вернул поле с тем же именем под другим номером — риск другой поломки на JSON-уровне). Можно списком: reserved 2, 3, 5; и reserved «a», «b». Та же дисциплина для enum: удалённое значение резервируется и номером, и именем, потому что переиспользование enum-номера ломает так же тихо. Правило срока резерва: навсегда. Резерв бесплатен, не влияет на wire-формат, не мешает ничему — а история про «точно никто не помнит старый номер» всегда оказывается ложью: старые клиенты живут годами, билды откапываются из архивов, фичи откатываются. В buf-линте из десятого урока есть готовые правила, которые требуют резервов при удалении — включённый lint сделает гигиену автоматической, и собеседование по схеме перестанет быть нужным.

Зачем reserved и номеру, и имени удалённого поля?

Enum и oneof: те же правила, своя специфика#

У enum-значений номера те же контракты, что у полей, с одним усилением: значение по умолчанию — ноль — фиксировано. Первое значение enum обязано быть нулевым, и оно должно быть осмысленным «неизвестно/не задано» — NAME_UNSPECIFIED; менять номер нулевого значения нельзя никогда, это ломает всех сразу. Добавление значения — безопасная операция при одном условии: клиентский код обязан переживать незнакомые значения — в Go перегенерированный enum примет число, в TypeScript из девятого урока вы получите значение, которого нет в union-типе, и switch без default упадёт не сразу, а в рантайме. Удаление значения — через резерв пары номер-имя, как у полей; при живых клиентах вместо удаления — депрекация. Oneof эволюционирует как набор полей: добавлять ветки можно со свежими номерами, удалять с резервом, менять тип существующей ветки — нельзя. Отдельная ловушка oneof: код с type switch (в Go — switch msg.Which...Case) при добавлении ветки не сломается компиляцией, а молча уйдёт в ветку отсутствия значения; при добавлении ветки в oneof просматривайте все type switch в кодовой базе — это источник тихих багов, который buf breaking не видит, потому что схема формально совместима. Механика завершена: дальше — когда эволюцией одной схемы уже не обойтись.

Пакетные версии: notes.v1 и notes.v2 на одном сервере#

Правила выше закрывают подавляющее большинство изменений, но есть класс, который совместимым сделать нельзя: смена типа поля, изменение смысла существующего поля, изменение семантики процедуры (метод был синхронным — стал асинхронным), удаление метода. Для них честный инструмент — новая пакетная версия: notes.v2 со своей схемой, своим генерируемым кодом и своим хендлером. Версии схем в этом жанре живут параллельно, и это не позор, а дизайн: пакет — единица совместимости, вы объявляете, что v2 не обязана быть совместимой с v1 внутри пакета, а между пакетами совместимость не требуется вовсе. Что важно решить честно. Первое: v2 — копия с эволюцией, а не «схема мечты»: неубиваемые номера полей, те же уроки резервов, свой список депрекаций с первого дня. Второе: обмен сообщениями между версиями — явный слой конвертации в коде сервера; хендлер v2 не должен импортировать сообщения v1 напрямую, мост из схемы в схему пишется руками и покрывается тестами из шестнадцатого урока. Третье: не плодите версии — v3 возможна только когда v1 похоронена; каждая живая версия — это два SDK, две документации, два контура тестов. В gRPC-мире запрос grpc версия api решается точно так же — новый пакет в пути процедуры: смена пакета меняет путь вызова целиком, и маршрутизация разделяет версии без единой настройки.

Два поколения на одном mux

GO
mux := http.NewServeMux()

// v1: старые клиенты; путь /notes.v1.NoteService/GetNote
pathV1, handlerV1 := notesv1connect.NewNoteServiceHandler(
        &notesV1Server{svc: svc},
        connect.WithInterceptors(&metricsInterceptor{}),
)
mux.Handle(pathV1, handlerV1)

// v2: новые клиенты; путь /notes.v2.NoteService/GetNote
pathV2, handlerV2 := notesv2connect.NewNoteServiceHandler(
        &notesV2Server{svc: svc, bridge: notesBridge{}},
        connect.WithInterceptors(&metricsInterceptor{}),
)
mux.Handle(pathV2, handlerV2)

// Business logic одна: оба хендлера ходят в общий svc через конвертацию.

Маршрутизация бесплатна: путь процедуры содержит имя пакета, поэтому mux сам раздаёт вызовы по версиям — никакой конфигурации, кроме второй пары Handle. Метрики из семнадцатого урока размечают версии автоматически: procedure содержит notes.v1 или notes.v2, и вы видите долю трафика каждой версии на живом дашборде. Общая бизнес-логика одна — версии отличаются слоем конвертации сообщений.

Клиент v2 стучится в сервер без v2 — что вернётся?

Прочитайте код и выберите, что он выведет:

// Сервер монтирует только notesv1connect.NewNoteServiceHandler.
// Клиент собран из пакета notesv2connect и вызывает GetNote:
// путь запроса /notes.v2.NoteService/GetNote

client := notesv2connect.NewNoteServiceClient(httpClient, "http://localhost:8080")
_, err := client.GetNote(ctx, connect.NewRequest(&notesv2.GetNoteRequest{Id: "1"}))

Операционный контур: buf breaking, BSR и депрекация#

Правила работают, когда их проверяет машина на каждом коммите — контур вы собирали в десятом и двенадцатом уроках, здесь он соединяется в цикл. Первый рубеж — buf breaking в CI: сравнение локальной схемы с опубликованной (buf.build/acme/notes из BSR или коммитом git) ловит смену номера, снятый резерв, изменение типа, удаление метода — каждое нарушение до того, как схема уехала в релиз; красный пайплайн означает «это изменение требует новой версии, а не правки текущей». Второй рубеж — публикация: buf push с меткой; релизные метки v1.3.0, v2.0.0 из двенадцатого урока — точки, против которых будут сравниваться следующие изменения, и координаты, по которым клиенты закрепляют зависимость. Третий рубеж — депрекация как процесс, а не метка: deprecated = true в схеме — начало отсчёта, дальше — письмо владельцам, срок в доках и наблюдение. Наблюдение — это мост к семнадцатому уроку: метки procedure в счётчике calls показывают живой трафик по каждому пути, и решение «v1 пуста, можно выключать» принимается по данным, а не по настроению: доля v1-трафика падает неделями, последние клиенты упираются — их владельцы известны по списку рассылки. Четвёртый рубеж — выключение: когда трафик нулевой на протяжении согласованного окна, v1-хендлер снимается с mux, схема v1 получает пометку deprecated в репозитории, а попытки вызвать несуществующий путь честно вернут unimplemented из прошлого блока. Полный цикл занимает месяцы — и это нормально: версии схем — медленная дисциплина.

Карта изменений схемы#

ИзменениеWire-эффектЧто делатьКто ловит
добавить поле со свежим номеромбезопасно: старые клиенты пропускают незнакомоепросто добавить; optional для явного отсутствияbuf lint проверяет стиль
удалить полестарые клиенты читают нулевые значенияreserved номер и имя навсегдаlint + компилятор против возврата номера
переименовать поле (идентификатор)wire не трогается: имя по сети не ездитдля JSON-клиентов сохранить json_nameбилды браузерных клиентов
сменить номер или тип поляполомка: мусор или пустота без ошибкине делать; новая пакетная версияbuf breaking до релиза
enum: добавить значениебезопасно при default-ветке у клиентоврезерв при удалении парой номер-имякод-ревью type switch
сменить семантику процедурыконтракт сломан при том же путиновая версия пакета, v1 через депрекациюдокументация + метрики трафика версий

Соберите версию v2 с резервами

Восстановите заголовок пакета и дисциплину резервов в схеме v2.

notes.v2; message Note { string id = 1; string title = 2; // поле content удалено во время подготовки v2 3; "content"; string body = 5; } enum Status { STATUS_UNSPECIFIED = 0; STATUS_ACTIVE = 1; // STATUS_DRAFT удалён — номер не переиспользовать 2; }

Жизненный цикл ломающего изменения

Расставьте шаги от идеи до выключения старой версии.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: версионирование схем

Термин

Номер поля

нажмите, чтобы перевернуть
Определение

Единственный контракт wire-формата: получатель находит поле по номеру. Смена номера или типа — поломка без ошибки на проводе; имя — украшение, его менять можно с оговоркой про JSON.

1 / 6

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

Схема отныне эволюционирует по правилам: расширение со свежими номерами, резервы на выбывших, депрекация с данными, пакетные версии для несовместимого — и всё под машиной buf breaking и метриками трафика версий. Следующий урок — интеграция с фреймворками: хендлер Connect — обычный http.Handler, и мы встанем в чужие mux, цепочки middleware chi и Echo, разберём h2c и graceful shutdown: девятнадцатый. Потом — миграция с gRPC на Connect: двадцатый урок соберёт весь материал курса в пошаговый план переезда. Для повторения: buf breaking и правила линта — в десятом уроке, метки и публикация BSR — в двенадцатом, коды и HTTP-маппинг, включая unimplemented, — в шестом, генерация кода из схем — в одиннадцатом. Программа всех двадцати пяти уроков — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Номер поля — контракт wire-формата навсегда; имя можно менять (с оговоркой про JSON), номер и тип — никогда.
  • Безопасные изменения: добавить поле со свежим номером, пометить deprecated, добавить значение enum с default-веткой у клиентов.
  • Удаление — всегда через reserved номера и имени, навсегда; переиспользование номера — тихая порча данных без ошибок на проводе.
  • Несовместимые изменения — новая пакетная версия notes.v2: своя схема и хендлер, оба монтируются на один mux, маршрутизация автоматична.
  • Клиент v2 на сервере без v2 получает HTTP 404 и код unimplemented — диагностический шаблон «не ту версию сгенерировали».
  • Операционный контур: buf breaking в CI до релиза, метки BSR как точки сравнения, депрекация с наблюдением доли трафика по метке procedure.
  • JSON-кодек сериализует поля именами: переименование идентификатора требует сохранения json_name для браузерных клиентов.
  • Метрики из семнадцатого урока размечают версии сами: procedure содержит notes.v1 или notes.v2 — доля трафика каждой версии видна на дашборде.

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

Проведите notes.v1 через полный цикл эволюции и попробуйте сломать его нарочно. Шаг первый: добавьте в Note поля tags = 4 и due_date = 7 (optional, Timestamp), перегенерируйте код из одиннадцатого урока и убедитесь, что старый клиент (собранный до изменения) читает новые ответы без ошибок и видит пустые значения. Шаг второй: удалите body, поставьте reserved 3 и reserved "body", попытайтесь добавить поле с номером 3 — зафиксируйте отказ компилятора как доказательство работы резерва. Шаг третий: соберите notes.v2 с изменённым типом одного поля, смонтируйте оба хендлера на один mux и проверьте curl-ом оба пути — /notes.v1.NoteService/GetNote и /notes.v2.NoteService/GetNote. Шаг четвёртый: соберите клиент из пакета v2 и постучитесь в сервер, где v2 не смонтирована, — убедитесь, что приедет unimplemented, а не таймаут. Шаг пятый: прогоните buf breaking против опубликованной в двенадцатом уроке метки и против локального git-тега; зафиксируйте в заметках таблицу «изменение — ловится ли buf breaking — что делать». По дорожке наблюдаемости посмотрите на метки procedure в /metrics: обе версии должны быть видны там без единой строки вашего кода сверх интерсептора из семнадцатого урока.

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

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

Нужно ли менять имя пакета при каждом релизе?

Нет: пакет меняется только при несовместимом изменении. Совместимые релизы — это та же notes.v1 с новыми полями; имя пакета — объявление «внутри совместимость гарантирована». Плодить версии — антипаттерн: каждая живущая версия удваивает SDK, документацию и тесты. Правило: совместимое — эволюция текущего пакета, несовместимое — новый пакет и депрекация старого.

Переименование поля сломает браузерных клиентов?

Wire-формат — нет: по сети ездят номера, и Go-клиенты заметят только перегенерацию. JSON-кодек сериализует поля именами (нижнекэмельными по умолчанию), поэтому TypeScript-клиенты из девятого урока читают именно имена. Правило: меняя идентификатор, фиксируйте JSON-имя опцией json_name со старым значением — тогда оба жанра клиентов переживут переименование без изменений.

Можно ли сменить тип поля на совместимый, например с int32 на int64?

Только между типами с одинаковым wire-кодированием: int32 и int64, а также uint32/uint64 взаимозаменяемы — получатель расширит число корректно. string и bytes взаимозаменяемы при валидном UTF-8. А вот числовое в строковое, message в скалярное, repeated в скалярное — поломка без ошибки. Если сомневаетесь — прогоните buf breaking: он поймает несовместимую смену типов до релиза, а не в проде.

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

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

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

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

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

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding