---
title: "Protocol Buffers: синтаксис proto3"
description: "Синтаксис proto3 на примере схемы заказов: message и номера полей, protobuf-типы в Go, repeated, optional, map, enum, oneof и service с rpc-методами."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/02-proto3/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 22
---

# Protocol Buffers: синтаксис proto3

*Курс «gRPC-Go: высокопроизводительный RPC», урок 2 из 25. ~22 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/02-proto3/*

Контракт gRPC-сервиса начинается с .proto-файла, и proto3 синтаксис — это вся грамматика, которая вам понадобится: message, номера полей, enum, oneof, map и service. Разберём каждый элемент на схеме сервиса заказов и посмотрим, во что превращается каждый тип в Go.

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

### Схема заказа целиком

```go
syntax = "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: ровно одно из многих

```go
message 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 формирует путь вызова, поэтому переименовать сервис после релиза — то же самое, что сломать все клиенты разом.

### Сервис заказов: методы и стриминг

```go
service 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 кодируются самым коротким — однобайтовым — тегом?

- Любые номера до 2047
- ✅ От 1 до 15
- Только номер 1
- Все номера кодируются одинаково

Тег содержит номер и тип поля и упаковывается в varint: для номеров 1–15 хватает одного байта, с 16 начинается второй. Поэтому горячие поля принято ставить в начало диапазона.

### Проверка: enum

**Вопрос:** Почему первое значение enum в proto3 обязано быть нулём?

- Ноль быстрее кодируется в varint
- ✅ Ноль — значение по умолчанию: это гарантирует совместимость при появлении новых значений
- Требование ASCII-кодировки строк
- Ноль резервируется под коды ошибок

Свежесозданное сообщение во всех полях держит нулевые значения. Ноль как первое значение enum делает «пустой» статус валидным и обратимо совместимым, поэтому конвенцией стал первый элемент вида ..._UNSPECIFIED.

### Что напечатает код со свежим сообщением?

```go
// orderpb — сгенерированный пакет из Order выше
order := &orderpb.Order{}

fmt.Println(order.Id, order.Status, order.PromoCode, len(order.Items))
```

**Что выведет программа?**

- Паника: nil pointer dereference
- ✅ 0 0 <nil> 0
- Ошибку компиляции: поля не инициализированы
- Пустые строки вместо чисел

Все поля proto3-сообщения получают значения по умолчанию: Id — 0, Status — нулевое значение enum, PromoCode из-за optional — nil-указатель (<nil> при печати), а nil-срез Items честно даёт len 0. Чтение любого поля свежего сообщения безопасно.

### Восстановите схему

Впишите пропущенные ключевые слова proto3: модификаторы полей, объявление перечисления и стриминг.

```go
message Order {
  int64 id = 1;
  ___ OrderItem items = 2;
  ___<string, string> labels = 3;
  ___ string promo_code = 4;
  OrderStatus status = 5;
}

___ OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_NEW = 1;
}

service OrderService {
  rpc CreateOrder (CreateOrderRequest) returns (CreateOrderReply);
  rpc WatchOrders (WatchRequest) returns (___ OrderEvent);
}
```

Подсказка: Список — repeated, словарь — map, различать «не задано» и ноль помогает optional, перечисление начинается с enum, поток в rpc помечается словом stream.

Правильные ответы в пропусках: `repeated`, `map`, `optional`, `enum`, `stream`

### Соберите скелет .proto-файла

Расставьте элементы в том порядке, в котором их читает protoc — от версии синтаксиса до методов.

Правильный порядок строк:

1. `syntax = "proto3";`
2. `package shop.v1;`
3. `option go_package = "github.com/example/shop/gen/shopv1";`
4. `import "google/protobuf/timestamp.proto";`
5. `message Order { int64 id = 1; OrderStatus status = 2; }`
6. `enum OrderStatus { ORDER_STATUS_UNSPECIFIED = 0; }`
7. `service OrderService { rpc CreateOrder (CreateOrderRequest) returns (CreateOrderReply); }`

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

Схема готова — теперь её нужно превратить в Go-код, и для этого нужен protoc: установка плагинов, флаги и анатомия сгенерированных файлов — тема [следующего урока](/courses/grpc-go/lessons/03-generaciya/). Сразу за ним — [первый gRPC-сервер](/courses/grpc-go/lessons/04-server/), где эта же схема заказов заработает вживую. Когда базовый синтаксис осядет, пригодятся два урока на продвинутом уровне: [оптимизация сериализации](/courses/grpc-go/lessons/19-proto-opt/) и [версионирование схем](/courses/grpc-go/lessons/20-versionirovanie/) — оба показывают, как менять .proto, не ломая работающих клиентов.

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

- Каркас файла: 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.
