Бесплатные материалы

gRPC на Go: от внутреннего устройства до архитектуры

ИСТОЧНИК:
Исходный код
https://github.com/IgorWalther/videos/tree/master/0019_grpc/02_orders

Современная архитектура приложений

Современные системы уже давно вышли за рамки одного приложения. Теперь они представляют собой множество различных приложений (сервисов), количество которых иногда может достигать несколько сотен, которые должны общаться между собой, а иногда еще и дублироваться. Современную архитектуру можно рассмотреть на таком простом примере магазина:
У нас есть пользователи, которые пользуются нашим магазином. Их нужно подключить к нашей системе, чтобы они, собственно, могли использовать приложение. Обычно для этого используют gateway – сервис, который раскидывает запросы пользователей по системе, но так же может производить верификацию пользователя и балансировку нагрузки. Далее у нас есть сервисы самого магазина:
• Catalog — сервис для работы с каталогом товаров
• Ordering — сервис для работы с заказами (создание, оплата и т.д.)
• Antifraud — сервис для защиты от мошенничества
• ML Rec — рекомендации для основе ИИ
Каждый из этих сервисов имеет свою базу данных. Так, например, если упадет база данных заказов, каталог товаров все равно продолжит работу. Так же, каждый из этих сервисов может иметь копию. Например, если один дата-центр пострадает, благодаря копии сервиса на другом дата-центре приложение продолжит работу. Теперь мы хотим научить наши сервисы взаимодействовать между собой, например, чтобы при создании заказа, сервис заказов взял у сервиса каталога id товаров. Фреймворк gRPC является де-факто стандартным решением этой задачи во многих компаниях.

Что такое gRPC и как он появился

gRPC (Google Remote Procedure Calls) — высокопроизводительная система удаленного вызова процедур, разработанная в Google в 2015 году. Его идея как раз в том, чтобы научить общаться разные приложения, причем эти приложения могут быть написаны на разных стеках . gRPC использует HTTP/2 в качестве транспортного протокола и Protocol Buffers в качестве протокола сериализации.

История появления gRPC

Stubby RPC

В 2001 году Google делает для себя RPC фреймворк под названием Stubby. Он был изначально сделан как раз для того, чтобы позволить большому количеству микросервисов общаться между собой, причем еще и в разных дата-центрах. Stubby использовал кастомный транспортный протокол(?) и, предположительно, Protocol Buffers.

SPDY

В 2010 году тот же Google создает транспортный протокол SPDY, который должен был стать улучшением HTTP/1.x. Путем сжатия, мультиплексирования и приоритизации новому протоколу удалось снизить размер заголовков на ~85%, количество соединений на ~40% и ускорить загрузку на ~20-60%. Впоследствии SPDY станет основой для HTTP/2.

gRPC

В 2011 году Роб Пайк на конференции впервые упомянул Stubby RPC и сказал, что возможно его заопенсорсят, но этого так и не произошло. Когда Роба в очередной раз спросили про опенсорс Stubby, он сказал, мол, вам это не надо, лучше используйте RPC, который встроен в Go, так как он по функциональности такой же как Stubby, но не доступен на других языках. По слухам, Stubby не хотели выкладывать в свободный доступ в ожидании, пока он перейдет на более хороший транспортный протокол. И тут в 2014 году на базе SPDY появляется HTTP/2, что и нужно было Stubby. В 2015 году старый фреймворк адаптируют под новый транспортный протокол и выкладывают в свободный доступ под названием gPRC.

HTTP/2

Проблема HTTP/1

Рассмотрим стандартную модель взаимодействия в HTTP/1:
С одним запросом все понятно: мы его отправили и получили ответ. Но когда у нас есть несколько запросов появляется вариативность: мы можем либо отправить запрос, дождаться ответа, а потом отправить следующий запрос, либо отправить несколько запросов, а потом дожидаться ответов на них. Однако для обоих вариантов, отправляя сначала запрос 1 мы всегда первым получим ответ 1. Это происходит из-за того, что HTTP/1 использует протокол TCP, который гарантирует порядок.
Возникает вопрос: что если ответ на первый запрос очень большой и выгоднее было бы сначала получить ответ на второй запрос, так как он меньше и придет быстрее? Это можно решить установив несколько TCP соединений:
Но установить соединение не бесплатно и браузеры часто ограничивали количество TCP соединений.

Head-of-line Blocking

Проблемы начинаются, когда теряются пакеты. Допустим, у нас есть два запроса (зеленый и синий) и при передаче первого запроса у нас потерялся пакет:
Тогда наш сервер сможет получить оставшиеся пакеты, но отправить ответы, пока потерянный пакет не восстановится, не сможет, опять же из-за того, что TCP гарантирует порядок.
Даже если до потерянного пакета мы получили какие-то запросы, ответы на них все равно не получится отправить, пока этот пакет не восстановится.

Что делает HTTP/2

К сожалению, полностью решить проблему HTTP/2 не может, так как все еще использует TCP под капотом, но, используя эвристику, мы можем отправить ответы на запросы, которые мы получили до потери какого-то пакета.
Эвристика заключается в том, чтобы посылать пакет разных запросов вперемешку (мультиплексировать), а не последовательно пакеты одного запроса, а потом уже другого. При этом пакеты одного запроса будут идти по порядку, но не обязательно последовательно. Из-за этого появляется шанс, что когда какой-то пакет одного запроса потеряется, у нас будут пакеты другого запроса и мы сможем отправить ответ на этот второй запрос:
В этом примере, мы получили все пакеты синего запроса до того, как потеряли пакет зеленого запроса, а значит можем отправить ответ на синий, но чтобы отправить ответ на зеленый (и на возможные запросы после потерянного пакета), надо все еще дожидаться восстановления потерянного пакета.
Полностью эту проблему решили только в HTTP/3, использовав протокол UDP, который не гарантирует порядок.
Мультиплексирование реализовать приоритизацию и отмену (cancellation). Приоритизация позволяет отправлять пакеты не в совсем случайном порядке, а сказать, какие пакеты мы хотим отправить раньше. Отмена позволяет сказать, что, пакеты какого-то запроса нам больше не нужны и на него не надо присылать ответ.

Устройство HTTP/2

Запрос в HTTP/2 это поток из фреймов. У каждого фрейма есть заголовок, который представляет собой такую структуру:
type http2FrameHeader struct {
	valid bool // caller can access []byte fields in the Frame

	// Type is the 1 byte frame type. There are ten standard frame
	// types, but extension frame types may be written by WriteRawFrame
	// and will be returned by ReadFrame (as UnknownFrame).
	Type http2FrameType

	// Flags are the 1 byte of 8 potential bit flags per frame.
	// They are specific to the frame type.
	Flags http2Flags

	// Length is the length of the frame, not including the 9 byte header.
	// The maximum size is one byte less than 16MB (uint24), but only
	// frames up to 16KB are allowed without peer agreement.
	Length uint32

	// StreamID is which stream this frame is for. Certain frames
	// are not stream-specific, in which case this field is 0.
	StreamID uint32
}
StreamID — это номер запроса, к которому принадлежит фрейм (аналогично цветам из примеров выше: зеленый - 1, синий - 2).

Типы фреймов

Фреймы могут разных типов, например, данные, заголовки, настройки и т.д. За это отвечает поле Type:
type http2FrameType uint8

const (
	http2FrameData           http2FrameType = 0x0
	http2FrameHeaders        http2FrameType = 0x1
	http2FramePriority       http2FrameType = 0x2
	http2FrameRSTStream      http2FrameType = 0x3
	http2FrameSettings       http2FrameType = 0x4
	http2FramePushPromise    http2FrameType = 0x5
	http2FramePing           http2FrameType = 0x6
	http2FrameGoAway         http2FrameType = 0x7
	http2FrameWindowUpdate   http2FrameType = 0x8
	http2FrameContinuation   http2FrameType = 0x9
	http2FramePriorityUpdate http2FrameType = 0x10
)
Чаще всего используются фреймы с данными и заголовками. Фреймы с данными описываются такой структурой:
type http2DataFrame struct {
	http2FrameHeader // заголовок фрейма
	data []byte // сами данные
}
Фреймы с заголовками — такой:
type http2HeadersFrame struct {
	http2FrameHeader // заголовок фрейма

	// Priority is set if FlagHeadersPriority is set in the FrameHeader.
	Priority http2PriorityParam // параметры приоритета

	headerFragBuf []byte // not owned // буфер с фрагментом заголовков
}

Флаги

Во фрейм можно так же передать разные флаги, например, сказать, что этот фрейм последний в запросе. Флаги описываются так:
type http2Flags uint8

// Frame-specific FrameHeader flag bits.
const (
	// Data Frame
	http2FlagDataEndStream http2Flags = 0x1
	http2FlagDataPadded    http2Flags = 0x8

	// Headers Frame
	http2FlagHeadersEndStream  http2Flags = 0x1
	http2FlagHeadersEndHeaders http2Flags = 0x4
	http2FlagHeadersPadded     http2Flags = 0x8
	http2FlagHeadersPriority   http2Flags = 0x20

	// Settings Frame
	http2FlagSettingsAck http2Flags = 0x1

	// Ping Frame
	http2FlagPingAck http2Flags = 0x1

	// Continuation Frame
	http2FlagContinuationEndHeaders http2Flags = 0x4

	http2FlagPushPromiseEndHeaders http2Flags = 0x4
	http2FlagPushPromisePadded     http2Flags = 0x8
)
В отличие от текстового HTTP/1, HTTP/2 имеет бинарный формат, что позволяет быстрее и проще его парсить.

Пример HTTP/2 запроса

Пусть у нас есть такой HTTP/1 запрос:
POST /go HTTP/1
Content-Length: 24

{"hello": "@igorutine"}
В HTTP/2 он будет выглядеть как поток следующий фреймов: Сначала будет идти HeadersFrame, то есть фрейм с заголовком нашего запроса:
HeaderFrame{
 StreamId: 1
 Flags: FlagEndHeaders
 [
 :method: POST
 :path: /go
 Content-Length: 24
 ]
}
Так как фрейм с заголовком у нас один, можно сразу сказать серверу, что он последний, передав флаг FlagEndHeaders.
Далее идет фрейм с данными:
DataFrame{
	StreamId: 1,
	Flags: FlagEndStream,
	PayloadLength: 24,
	Data: {"hello": "..."},
}
Совпадающие StreamId говорят, что наши HeadersFrame и DataFrame относятся к одному запросу, и так как этот DataFrame последний в запросе, передаем флажок FlagEndStream.
Пусть ответ на наш запрос в HTTP/1 выглядит так:
200 OK
Content-Length: 0
Date: Sat, 14 Feb 2026 16:34:56 GMT
В HTTP/2 он будет выглядеть так:
HeadersFrame{
 StreamId: 1
 Flags: FlagEndHeaders | FlagEndStream
 [
 :status: 200
 Content-Length: 0
 Date: Sat, ...
 ]
}
Так как нам никакие данные не возвращают, ответ будет состоять только из HeadersFrame.

Сжатие

Пусть у нас есть два заголовка запросов:
HeadersFrame{
 StreamId: 1
 Flags: FlagEndHeaders | FlagEndStream
 [
 :method: POST
 :path: /go
 Content-Length: 0
 ]
}

HeadersFrame{
 StreamId: 3
 Flags: FlagEndHeaders | FlagEndStream
 [
 :method: POST
 :path: /go
 Content-Length: 0
 ]
}
И ответов на эти запросы:
HeadersFrame{
 StreamId: 1
 Flags: FlagEndHeaders | FlagEndStream
 [
 :status: 20
 Content-Length: 0
 Date: Sat, ...
 ]
}

HeadersFrame{
 StreamId: 3
 Flags: FlagEndHeaders | FlagEndStream
 [
 :status: 20
 Content-Length: 0
 Date: Sat, ...
 ]
}
Можно заметить, что заголовки этих разных запросов и заголовки ответов на эти запросы отличаются лишь номером самого запроса (StreamId). Постоянно гонять одни и те же данные не выгодно, поэтому в HTTP/2 существует сжатие данных.
Во-первых, существует статическая таблица. Вот небольшая ее часть:
Теперь можно вместо того, чтобы пересылать, например, :method: POST, мы можем переслать байт со значением 3.
Во-вторых, если чего-то нет в статической таблице, оно сжимается кодом Хаффмана по специальной таблице и добавляется в динамическую таблицу. Вот небольшая часть этой специальной таблицы:
Рассмотрим пример работы этого сжатия:
Некоторые данные заголовка есть в статической таблице, например, :method: GET и :scheme: https. Другие же, например, пусть /resource сначала сжимаются, а потом добавляются в динамическую таблицу. В итоге мы получаем намного более легкий заголовок, чем изначально.

Внутреннее устройство gRPC

Как было упомянуто ранее, gRPC использует HTTP/2 в качестве транспортного протокола и Protocol Buffers в качестве протокола сериализации. Про второй поговорим чуть позже, а пока посмотрим как выглядят gRPC запросы в protobuf:
package grpctest.v1

service HelloService {
    rpc SayHello(HelloRequest) returns (HelloResponse);
}
Здесь у нас есть имя нашего сервиса HelloWorld, в нем метод SayHello как раз описывает запрос: он принимает описание запроса HelloRequest и возвращает описание ответа HelloResponse.
В HTTP/2 этот запрос будет выглядеть так:
:method: POST
:path: /grpctest.v1.HelloService/SayHello
content-type: application/grpc
user-agent: grpc-go/1.52.0

body: [...]
В описаниях запросов и ответов на них мы как раз указываем, что пользователь отдает серверу, а что сервер ему возвращает:
message HelloRequest {
    string name = 1;
}

message HelloResponse {
    string message = 1;
}
У каждого поля помимо имени есть еще тип, так как protobuf типизуемый и индекс, который нужен во-первых, для обратной совместимости, а во-вторых, для парсинга байтов.

Protocol Buffers

Преимущества Protocol Buffers

В сравнении с другими транспортными протоколами, такими как JSON и XML, у Protocol Buffers есть ряд преимуществ.
Во-первых, размер. JSON и XML являются текстовыми, в то время как Protocol Buffers – бинарным, что делает его намного более легким. Вот сравнение размеров для этих транспортных протоколов:
Во-вторых, текстовые JSON и XML довольно сложно и долго парсить, а бинарный Protocol Buffers — наоборот, просто и быстро:
В-третьих, в JSON хоть и есть типизация, но не особо хорошая, а в XML есть уязвимости, например XML External Entity Attack. В то же время, в Protocol Buffers очень хорошая типизация и отсутствуют уязвимости, как в XML.
Помимо всего этого, Protocol Buffers совместим с многими языками программирования, то есть по одному описанию можно генерировать код на разных языках, а так же по умолчанию поддерживает средства обратной совместимости.

Типы в Protocol Buffers

Scalar Types

Это простые численные типы, строчки, булевы значения и наборы байтов. У каждого типа есть эквивалент в Go:
message ScalarValueTypesExample {
    double double_field = 1; // Go Type: float64
    float api_field = 2; // Go Type: float32
    int32 int32_field = 3; // Go Type: int32
    int64 int64_field = 4; // Go Type: int64
    uint32 uint32_field = 5; // Go Type: uint32
    uint64 uint64_field = 6; // Go Type: uint64
    sint32 sint32_field = 7; // Go Type: int32
    sint64 sint64_field = 8; // Go Type: int64
    fixed32 fixed32_field = 9; // Go Type: uint32
    fixed64 fixed64_field = 10; // Go Type: uint64
    sfixed32 sfixed32_field = 11; // Go Type: int32
    sfixed64 sfixed64_field = 12; // Go Type: int64
    bool bool_field = 13; // Go Type: bool
    string string_field = 14; // Go Type: string
    bytes bytes_field = 15; // Go Type: []byte
}

Well-Known Types

Есть много типов, которые сделали в Google, которые часто используются на практике. Помимо новых типов, здесь есть обертки над скалярными типами, чтобы поддерживать nil. Это нужно, чтобы отличить ситуации, когда нам передали 0 потому, что там действительно 0 или потому, что значения нет и нам возвращают default value:
message WellKnownTypesExample {
    google.protobuf.Any any_field = 1;
    google.protobuf.Api api_field = 2;
    google.protobuf.Duration duration_field = 3;
    google.protobuf.Empty empty_field = 4;
    google.protobuf.FieldMask field_mask_field = 5;
    google.protobuf.SourceContext source_context_field = 6;
    google.protobuf.Struct struct_field = 7;
    google.protobuf.Timestamp timestamp_field = 8;
    google.protobuf.Type type_field = 9;
    google.protobuf.DoubleValue double_field = 10;
    google.protobuf.FloatValue float_field = 11;
    google.protobuf.Int64Value int64_field = 12;
    google.protobuf.UInt64Value uint64_field = 13;
    google.protobuf.Int32Value int32_field = 14;
    google.protobuf.UInt32Value uint32_field = 15;
    google.protobuf.BoolValue bool_field = 16;
    google.protobuf.StringValue string_field = 17;
    google.protobuf.BytesValue bytes_field = 18;
    google.protobuf.Value value_field = 19;
}

Common Types

Здесь содержатся так же часто используемые типы, но уже более узконаправленные, например тип для денег или номера телефона:
message CommonTypes {
    google.type.Interval interval_field = 1;
    google.type.Date date_field = 2;
    google.type.DayOfWeek day_of_week_field = 3;
    google.type.TimeOfDay time_of_day_field = 4;
    google.type.LatLng lat_lng_field = 5;
    google.type.Money money_field = 6;
    google.type.PostalAddress postal_address_field = 7;
    google.type.Color color_field = 8;
    google.type.Month month_field = 9;
    google.type.CalendarPeriod calendar_period_field = 10;
    google.type.Decimal decimal_field = 11;
    google.type.Expr expr_field = 12;
    google.type.Fraction fraction_field = 13;
    google.type.LocalizedText localized_text_field = 14;
    google.type.PhoneNumber phone_number_field = 15;
}

Optional

Как и обертки над скалярными типами, optional тип поддерживает nil:
message OptionalExample {
    int32 required_field = 1; // required (if not provided we get default
value: 0) Go type: int32
    optional int32 optional_field = 2; // optional (if not provided we get
null) Go type: *int32
}

Перечисления

Первое поле перечисления должно быть всегда _UNSPECIFIED с индексом 0, чтобы пир генерациии язык смог вывести zero value:
enum Corpus {
    CORPUS_UNSPECIFIED = 0;
    CORPUS_UNIVERSAL = 1;
    CORPUS_WEB = 2;
    CORPUS_IMAGES = 3;
    CORPUS_LOCAL = 4;
    CORPUS_NEWS = 5;
    CORPUS_PRODUCTS = 6;
    CORPUS_VIDEO = 7;
}

Deprecated Fields

Поля можно помечать как deprеcated, тогда при генерации будет так же сгенерировано сообщение, что лучше не использовать это поле, так как оно deprecated. Это возможность как раз является одним из способов поддержки обратной совместимости:
message DeprecatedExample {
    // Use new_field instead old_field
    int32 old_field = 1 [deprecated = true];
    int64 new_field = 2;
}
Если сгенерировать код по этому protobuf, получим код с таким комментарием:
type DeprecatedExample struct {
    state protoimpl.MessageState
    sizeCache protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields
    // Use new_field instead old_field
    //
    // Deprecated: Do not use.
    OldField int32
`protobuf:"varint,1,opt,name=old_field,json=oldField,proto3"
json:"old_field,omitempty"`
    NewField int64
`protobuf:"varint,2,opt,name=new_field,json=newField,proto3"
json:"new_field,omitempty"`
}

Reserved Fields

Поля и индексы можно зарезервировать для реализации в будущем. Резервировать индексы можно сразу в каком-то диапазоне. Это нужно когда, например, у вас есть функционал, который вы хотите поддерживать, но имплементировать его сейчас не хотите. Если попытаться объявить зарезервированное поле, будет ошибка:
enum Foo {
    reserved 2, 15, 9 to 11, 40 to max;
    reserved "FOO", "BAR";
    FOO = 2;
}
api/examples/enum.proto: Enum value "FOO" uses reserved number 2.
api/examples/enum.proto:22:5: Enum value "FOO" is reserved.

Вложенные сообщения и массивы

Внутри одного сообщения можно объявить другой. Чтобы создать массив элементов, нужно использовать ключевое слово repeated:
message SearchResponse {
    message Result {
        string url = 1;
        string title = 2;
        repeated string snippets = 3;
    }
    repeated Result result = 1;
}

Oneof

oneof позволяет сказать, что поле может быть каким-то из типов:
message Stock {
    // Stock-specific data
}
message Currency {
    // Currency-specific data
}
message ChangeNotification {
    int32 id = 1;
    oneof instrument {
    Stock stock = 2;
    Currency currency = 3;
    }
}

Map

Стандартные и всем знакомые мапы:
message GetProjectResponse {
    message Project {
    // data
    }
    
    map<string, Project> projects = 1;
}

Сериализация

Рассмотрим пример такого описания и как именно protobuf превращает его в последовательность байтов:
Индекс поля и его тип объединяются в одно число и это первый байт. Например поле с индексом 1 (00001 в двоичном виде) и типом string (010 в двоичном формате) становятся байтом 0a. То же самое с остальными полями.
Далее кодируются данные: у строк сначала записывается длинна, а далее сама строка. С числами интереснее: они разбиваются на группы по 7 бит, к каждой группе в начала добавляется флаг — 1, если есть группы далее и 0, если эта группа последняя. Далее полученные байты записываются в Little-Endian. Такая схема позволяет числам занимать столько байтов, сколько им нужно: если в поле int64 будет записано 4, оно займет 1 байт, а не 8.

Protobuf и gRPC

Теперь зная, как работает HTTP/2, и что он позволяет отправлять данные потоками, можно реализовать следующие вещи:
// Один запрос, один ответ:
rpc Send(Message) returns (Result);

// Один запрос, поток ответов:
rpc Send(Message) returns (stream Result);

// Поток запросов, один ответ:
rpc Send(stream Message) returns (Result);

// Поток запросов, поток ответов:
rpc Send(stream Message) returns (stream Result);
Потоки в gRPC это те же потоки из HTTP/2.

gRPC workflow

Сначала мы пишем proto-декларацию: описываем сервисы, запросы, сообщения. Далее, генерируем protobuf реализацию и реализацию сервера и клиента на нужном нам языке. Нам сгенерировали большую часть кода, осталось самому дописать нужное нам в реализации сервера и клиента.

Прямая и обратная совместимость в protobuf

Допустим, у нас была какая-то версия контракта. Если не изменять его, а только добавлять в него что-то новое, то есть два сценария:
  • Прямая совместимость: если у клиента новая версия, а у сервера старая, ничего не ломается, потому клиент просто не получит новые поля.
  • Обратная совместимость: если у клиента старая версия, а сервера новая, так же ничего не ломается, потому что клиент просто не будет обрабатывать новые поля.
Самое главное правило для поддержки совместимости, это не менять уже существующее, а только добавлять.

Protobuf на практике

Для начала посмотрим, как сгенерировать protobuf реализацию и сервер с клиентом по proto-декларации.
Напишем, нашу proto-декларацию в файле .proto:
syntax = "proto3";

package user.v1;

option go_package = "example.com/project/gen/user/v1;userv1";

service UserService {
    rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

message GetUserRequest {
    string id = 1;
}

message GetUserResponse {
    string id = 1;
    string name = 2;
}
Сначала мы указываем версию protobuf, в нашем случае это proto3. Далее мы указываем пакет user.v1 на уровне protobuf, а не Go. Следующим мы указывает пакет на уровне Go, а потом идет наша привычная реализация сервиса, запросов и сообщений.
Чтобы сгенерировать по этому описанию реализацию и сервер с клиентом будет использовать утилиту protoc. Для начала нужно ее установить.
Protoc для генерации protobuf реализации:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
Protoc для генерации gRPC сервера и клиента:
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Команда для генерации кода довольно громоздкая, так что разберем ее по частям:
protoc \
 -I {{.PROTO_DIR}} \
 --go_out={{.GEN_DIR}} \
 --go_opt=paths=source_relative \
 --go-grpc_out={{.GEN_DIR}} \
 --go-grpc_opt=paths=source_relative \
 {{.PROTO_DIR}}/user/v1/user.proto
Флагом -I указываем директорию, в которой находятся наши .proto файлы.
Далее, флагом --go_out указываем путь до директории, в которй надо сгенерировать protobuf реализацию, а опцией --go_opt=paths=source_relative, что путь относительный.
Затем, флагом --go-grpc_out указываем путь до директории, в которой надо сгенерировать сервер и клиент, опция --go-grpc_opt=paths=source_relative так же говорит, что путь относительный.
Наконец, указываем входные .proto файлы.
В сгенерированной protobuf реализации можно найти и наши описания сообщений:
type GetUserRequest struct {
    state protoimpl.MessageState
    sizeCache protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields
 
    Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
}

type GetUserResponse struct {
    state protoimpl.MessageState
    sizeCache protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields
 
    Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
    Name string `protobuf:"bytes,2,opt,name=name,proto3"
json:"name,omitempty"`
}
Готовый сериализатор/десериализатор. Вот небольшая его часть:
var file_user_v1_user_proto_rawDesc = []byte{
 0x0a, 0x12, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x70,
 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x22, 0x20, 0x0a,
 0x0e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x12,
...
}
Полный файл можно либо сгенерировать самому, либо найти здесь. В соседнем файле находится реализация сервера и клиента.

Пишем полноценное приложение

Исходный код
https://github.com/IgorWalther/videos/tree/master/0019_grpc/02_orders
Продолжить чтение
https://t.me/igoroutine/89?comment=594