Команда проектирует сервис заказов: две команды, два репозитория, и в каждом своя копия структуры Order на Go, которые дрейфуют врозь с каждым спринтом. Это ровно та проблема, для которой придуман .proto-файл: структура описывается один раз, лежит в одном месте, а код для обеих сторон генерируется. В этом уроке пройдём весь синтаксис proto3, который встречается в боевых схемах: анатомию файла, поля и их номера, скалярные типы и их соответствие Go, повторяющиеся поля, enum, oneof и объявление сервиса с методами. Файлы получаются короткими — обычно сотня-две строк на сервис, — но в них умещается несколько неочевидных правил, и почти все они связаны с одним: .proto описывает не структуру данных в памяти, а способ её передачи по сети. Именно поэтому у полей есть номера, у enum обязателен ноль, а у схемы есть правила эволюции, о которых в конце урока.
Анатомия .proto-файла: syntax, package, go_package#
Каждый файл начинается с объявления версии: syntax = "proto3"; — это должна быть первая непустая строка, иначе protoc примет файл за старый proto2 и начнёт жаловаться. Дальше package — пространство имён: одинаковые имена сообщений в разных пакетах не конфликтуют, а главное, package попадает в путь HTTP-вызова: метод CreateOrder сервиса OrderService из пакета shop.v1 будет доступен по пути /shop.v1.OrderService/CreateOrder. Затем опция go_package — она задаёт import-путь пакета, в который ляжет сгенерированный Go-код; после точки с запятой можно указать алиас имени пакета, если путь заканчивается неудобно. Схемы импортируют друг друга через import, чаще всего — встроенные google/protobuf/timestamp.proto или google/protobuf/empty.proto. Имя файла — lower_snake_case: order_service.proto, а не OrderService.proto. Один файл держите одной темой: заказы в одном, платежи в другом — конфликты слияний будут благодарны.
message: поля, типы и номера#
message — это тип данных, грубый аналог struct в Go: у каждого поля есть тип, имя и номер. Номера — не украшение, а основа формата: в бинарном виде по проводам едут пары «номер поля + значение», а имена — нигде. Именно поэтому правило номер один звучит так: номер поля после релиза менять нельзя. Поле с номером от 1 до 15 кодируется одним байтом, где номер и тип поля упакованы вместе; номера от 16 до 2047 требуют уже двух байтов. На маленьких сообщениях, которых миллионы, это заметно, поэтому сложившаяся практика — отдавать первые номера самым горячим полям: идентификаторам, статусам, суммам. Гнаться за этим с линейкой не стоит, но знать правило полезно. Типы полей — скалярные из таблицы ниже, другие message или enum; вложенные message объявляются прямо внутри родителя, если тип больше нигде не нужен.
Скалярные типы proto3 и их типы в Go#
| Тип в proto3 | Тип в Go | Когда брать |
|---|---|---|
| string | string | текст, идентификаторы, UTF-8 |
| bytes | []byte | двоичные данные, сырые файлы |
| int32 / int64 | int32 / int64 | целые; 64 бита для сумм и времён в секундах |
| uint32 / uint64 | uint32 / uint64 | счётчики, размеры |
| sint32 / sint64 | int32 / int64 | частые отрицательные числа: кодируется компактнее int |
| bool | bool | флаги |
| float / double | float32 / float64 | научные значения; для денег не подходит |
| enum | именованный тип int32 | статусы, категории |
| message | указатель на структуру | вложенные объекты, timestamp, duration |
repeated, optional и map#
Три модификатора покрывают почти всё остальное. repeated перед типом означает список: repeated OrderItem items = 2; превращается в Go-поле []OrderItem. map<K, V> даёт словарь map[K]V, но с ограничениями: ключи — только целые или string, повторяющиеся карты запрещены, а порядок элементов не гарантирован. Самое тонкое — третий случай: по умолчанию proto3 не различает «поле равно нулю» и «поле не задано» — для скаляров оба состояния выглядят одинаково, и в Go поле имеет тип значения, а не указателя. Если различие важно (промокод «пустая строка» versus «промокода нет»), ставьте слово optional перед типом: поддержка появилась в protoc 3.15, и такие поля генерируются в Go как указатели (*string), с методами has-проверки. В моих схемах optional встречается редко — чаще я считаю отсутствие и ноль одним и тем же, но для полей, где семантика «не задано» существует по бизнес-логике, это правильный инструмент.
Схема заказа целиком
GOsyntax = "proto3";
package shop.v1;
option go_package = "github.com/example/shop/gen/shopv1";
import "google/protobuf/timestamp.proto";
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;
}
message OrderItem {
string sku = 1;
int32 quantity = 2;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_NEW = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_CANCELLED = 4;
}В Go это превратится в структуры Order с полями Id int64, Items []OrderItem, Metadata map[string]string, PromoCode string, Status OrderStatus и CreatedAt timestamppb.Timestamp. Обратите внимание на Timestamp: это импортированный message из стандартной библиотеки protobuf, а не скалярный тип — и он же станет указателем в Go.
enum: почему ноль обязателен#
enum объявляет перечисление именованных значений, и в proto3 у него жёсткое правило: первое значение обязано быть нулём. Причина практическая, а не эстетика: ноль — значение по умолчанию для каждого нового сообщения, и если сервер пришлёт поле, которого в старой версии схемы не было, клиент увидит именно его. Поэтому конвенцией стало первое имя вида ORDER_STATUS_UNSPECIFIED — «статус не указан» — на нуле, а осмысленные статусы начиная с единицы. В Go enum становится именованным типом на базе int32 с константами вида OrderStatus_ORDER_STATUS_NEW: префикс имени типа повторяется, к этому стоит привыкнуть. Ещё один нюанс, который ловит новичков: если новая версия сервера пришлёт значение, которого нет в вашей старой схеме, Go вернёт исходное число без паники — клиентские свитчи по enum должны иметь ветку default.
oneof: ровно одно из многих
GOmessage Payment {
oneof method {
CardPayment card = 1;
BankTransfer bank = 2;
CashOnDelivery cash = 3;
}
}
message CardPayment {
string last4 = 1;
}
message BankTransfer {
string account = 1;
}
message CashOnDelivery {}oneof — tagged union: одновременно задано не больше одного поля из перечисленных. В Go появится интерфейс Payment_Method с методами-маркерами isPayment_Method, а чтение удобно делать через GetCard(), GetBank(), GetCash() — невыбранное поле вернёт nil. Присвоение нового значения автоматически стирает предыдущее.
oneof и service: методы в схеме#
Зачем нужен oneof, если можно завести три optional-поля? Семантика: три optional-поля допускают состояние «заданы все три сразу», oneof — нет, и это проверяется на уровне библиотеки, а не код-ревью. Типовые сценарии — способы оплаты, фильтры поиска (ровно один критерий), варианты событий в стриме. Вторая половина урока — service: объявление сервиса собирает методы, и каждому rpc нужен тип сообщения и на входе, и на выходе: передать голый скаляр нельзя, оборачивайте в message, даже если метод ничего не принимает. Слово stream перед типом делает соответствующую сторону стриминговой: его нет — unary, слева — клиентский стрим, справа — серверный, с обеих сторон — двунаправленный. Имя сервиса вместе с package формирует путь вызова, поэтому переименовать сервис после релиза — то же самое, что сломать все клиенты разом.
Сервис заказов: методы и стриминг
GOservice OrderService {
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderReply);
rpc GetOrder (GetOrderRequest) returns (Order);
rpc WatchOrders (WatchRequest) returns (stream OrderEvent);
}
message CreateOrderRequest {
repeated OrderItem items = 1;
}
message CreateOrderReply {
int64 id = 1;
}
message GetOrderRequest {
int64 id = 1;
}
message WatchRequest {
OrderStatus status = 1;
}
message OrderEvent {
Order order = 1;
string kind = 2;
}CreateOrder и GetOrder — unary: по одному сообщению в обе стороны. WatchOrders возвращает stream OrderEvent — это server streaming: один запрос, поток событий в ответ. Просмотрите суффиксы Request/Reply: оборачивать даже одиночные аргументы в message — обязательное правило proto3.
После релиза: не менять номера существующих полей, не переиспользовать номера удалённых, не менять тип поля. Удаляйте поле осторожно — номер и имя объявите reserved, иначе следующий автор схемы займет номер заново, и старые данные распарсятся в мусор. Полный чек-лист изменений — в уроке про версионирование.
Имена файлов — lower_snake_case, message — UpperCamelCase, поля — lower_snake_case, значения enum — CAPS_SNAKE. Комментарии // над полями protoc переносит в сгенерированный Go-код, поэтому пояснения к неочевидным полям пишут прямо в схеме. Стиль-гайд Google на это и есть первоисточник — не выдумывайте свой.
Проверка: номера полей
Какие номера полей protobuf кодируются самым коротким — однобайтовым — тегом?
Проверка: enum
Почему первое значение enum в proto3 обязано быть нулём?
Что напечатает код со свежим сообщением?
Прочитайте код и выберите, что он выведет:
// orderpb — сгенерированный пакет из Order выше
order := &orderpb.Order{}
fmt.Println(order.Id, order.Status, order.PromoCode, len(order.Items))Восстановите схему
Впишите пропущенные ключевые слова proto3: модификаторы полей, объявление перечисления и стриминг.
Соберите скелет .proto-файла
Расставьте элементы в том порядке, в котором их читает protoc — от версии синтаксиса до методов.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше по курсу#
Схема готова — теперь её нужно превратить в Go-код, и для этого нужен protoc: установка плагинов, флаги и анатомия сгенерированных файлов — тема следующего урока. Сразу за ним — первый gRPC-сервер, где эта же схема заказов заработает вживую. Когда базовый синтаксис осядет, пригодятся два урока на продвинутом уровне: оптимизация сериализации и версионирование схем — оба показывают, как менять .proto, не ломая работающих клиентов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каркас файла: syntax = "proto3", package (часть пути вызова), option go_package (куда генерировать Go-код), import для внешних схем.
- Поля кодируются номерами: 1–15 — однобайтовый тег; после релиза номер менять нельзя.
- repeated — срез, map — словарь без гарантии порядка, optional — указатель в Go и различие «не задано» от нуля.
- Enum обязан начинаться с нуля — это значение по умолчанию и защита совместимости; первый элемент называют ..._UNSPECIFIED.
- oneof — tagged union: одно значение из группы, невыбранные геттеры возвращают nil.
- Методы service принимают и возвращают только message; слово stream слева/справа задаёт вид стриминга.
- Правила эволюции: не менять номера и типы, удалённые поля объявлять reserved.
Домашнее задание
Возьмите схему заметок из домашнего задания первого урока и доведите её до полного синтаксиса: message Note с полем created_at через google/protobuf/timestamp.proto, enum NoteStatus с UNSPECIFIED-нулем, список тегов через repeated, словарь метаданных через map и oneof для фильтра поиска (по статусу или по тегу). Проверьте себя: у каждого message поля есть номер, у enum первый элемент — ноль, у каждого rpc — типы сообщений на вход и выход.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем proto3 отличается от proto2?
proto3 убрал обязательные поля (required), добавил map и first-class стриминг, а скаляры по умолчанию не различают «нуль» и «не задано» — presence вернули позже словом optional. Для новых gRPC-сервисов используйте proto3.
Что означает UNSPECIFIED в enum?
Конвенцию ввёл Google style guide: первое нулевое значение называют ..._UNSPECIFIED и трактуют как «поле не заполнено». Это делает нулевое значение осмысленным вместо случайного статуса NEW или PAID.
Можно ли передавать деньги как float?
Нет: бинарная арифметика с плавающей точкой теряет копейки. Для денег храните сумму в int64 в минимальной единице (копейках) либо используйте тип google.type.Money из стандартной библиотеки protobuf.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки