Содержание урока · 11 разделов
- Номер поля — контракт: как устроена совместимость
- Безопасные изменения: что можно спокойно делать
- Резервы: гигиена после удаления
- Enum и oneof: те же правила, своя специфика
- Пакетные версии: notes.v1 и notes.v2 на одном сервере
- Операционный контур: buf breaking, BSR и депрекация
- Карта изменений схемы
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Ваш 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
GOsyntax = "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-формат не трогается. И резервирование выбывшего — не изменение, а гигиена после удаления, следующий блок. Отдельно — что безопасно на чтении: сервер может начать игнорировать поле, которое раньше использовал; клиенты переживают это без труда. Общий знаменатель каталога: вы только расширяете словарь, ничего не переиначивая в существующем.
Эволюционировавшая схема: резерв и депрекация
GOmessage 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
GOmux := http.NewServeMux()
// v1: старые клиенты; путь /notes.v1.NoteService/GetNote
pathV1, handlerV1 := notesv1connect.NewNoteServiceHandler(
¬esV1Server{svc: svc},
connect.WithInterceptors(&metricsInterceptor{}),
)
mux.Handle(pathV1, handlerV1)
// v2: новые клиенты; путь /notes.v2.NoteService/GetNote
pathV2, handlerV2 := notesv2connect.NewNoteServiceHandler(
¬esV2Server{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(¬esv2.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.
Жизненный цикл ломающего изменения
Расставьте шаги от идеи до выключения старой версии.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Никогда не переиспользуйте номер поля и значение enum — даже если кажется, что старых клиентов больше нет. Клиенты живут дольше, чем помнит команда: архивные билды, откаты релизов, ветки поддержки. Переиспользование номера — поломка без ошибок: декодер отдаст мусор как обычные данные. Резерв бесплатен и работает вечно — ставьте его при каждом удалении и не снимайте никогда.
План выключения v1 пишите до публикации v2: срок поддержки в документации, письмо владельцам, метрика доли трафика v1 из семнадцатого урока на дашборде. Выключение — только после согласованного окна с нулевым или ничтожным трафиком, и с честным unimplemented для опоздавших. Такая дисциплина превращает страшное «сломаем всех» в рутину с чек-листом.
Схемы урока — настоящие proto-файлы, а хендлеры и мосты — настоящий Go: скопируйте их в проект из третьего урока и прогоните buf breaking из десятого против старой схемы. Браузерная песочница сайта proto-компилятор и сетевые вызовы не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP.
Карточки: версионирование схем
Что дальше по курсу#
Схема отныне эволюционирует по правилам: расширение со свежими номерами, резервы на выбывших, депрекация с данными, пакетные версии для несовместимого — и всё под машиной 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: обе версии должны быть видны там без единой строки вашего кода сверх интерсептора из семнадцатого урока.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужно ли менять имя пакета при каждом релизе?
Нет: пакет меняется только при несовместимом изменении. Совместимые релизы — это та же 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: он поймает несовместимую смену типов до релиза, а не в проде.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding