Add sem2 summary (#2)

Reviewed-on: #2
Co-authored-by: Sergey Yakovlev <3gnees@gmail.com>
This commit was merged in pull request #2.
This commit is contained in:
2026-09-24 19:04:43 +03:00
committed by osukhoroslov
parent a5011dd94b
commit 25fd4ed555
7 changed files with 348 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+347
View File
@@ -0,0 +1,347 @@
# Семинар 2. TCP/UDP, Protobuf и gRPC
## Задача: положить число в Storage
Допустим, у нас есть простой сервис **Storage**. Он хранит одно число, умеет обновлять его через `PutValue` и возвращать через `GetValue`. Клиент хочет положить туда `42`. Казалось бы, очень простая задача. Что может пойти не так?
![Клиент отправляет PutValue(42) сервису Storage и ждёт ответ](images/storage-task.png)
Первый вариант — сервер вообще недоступен: процесс упал, машина выключена или ещё не начала принимать соединения. Второй — сервер жив, но запрос потерялся по дороге.
![Два исхода: сервер недоступен или запрос потерялся в сети](images/storage-no-request.png)
Но есть более неприятные случаи. Сервер получил запрос и начал его выполнять, а потом сломался. Успел он записать число или нет? Клиент этого не знает. Или сервер всё выполнил и отправил ответ, но уже ответ потерялся в сети. Число записано, а клиент об этом не узнает.
![Сервер упал при выполнении либо выполнил запрос, но ответ не дошёл](images/storage-no-response.png)
**Клиент не может отличить «сервер ничего не сделал» от «сервер всё сделал, но не ответил».** Таймаут сообщает только, что мы не дождались ответа – он не дает понять состояние сервера.
Можно попробовать ещё раз положить `42`: с точки зрения самого числа повторное присваивание даст тот же результат, если между попытками его никто не менял. Это идея идемпотентности. А вот с инкрементом такой приём уже не сработает: повтор увеличит число ещё раз. Можно спросить сервер, что в нём сейчас лежит, но и на этот запрос мы можем не получить ответа.
Как с этим жить? Для начала, хотелось бы вообще не думать о том, что пакеты могут теряться, дублироваться, фрагментироваться и изменяться. Посмотрим, какие абстракции для этого дают UDP и TCP, и какие проблемы всё равно останутся.
## Что дают UDP и TCP
### Откуда берутся проблемы с пакетами
Сеть ненадёжна по вполне конкретным причинам:
- **Переполнение буферов.** В маршрутизатор приходит 10 Гбит/с, а передать дальше он может только 1 Гбит/с. Очередь маршрутизатора конечна: когда она заполнится, новые пакеты придётся отбрасывать. Очередь может переполниться и на принимающем компьютере, если он не успевает обрабатывать данные.
- **Отказ линии или устройства.** Кабель повредили при ремонте, оборудование выключилось, а маршрутизатор ещё отправляет пакеты по старому маршруту. Отправитель при этом не обязательно сразу узнает, что случилось.
- **Повреждение данных.** Помеха изменила биты. Если проверка контрольной суммы обнаружит повреждение, пакет будет отброшен.
- **Повторы и разные задержки.** Например, при потере подтверждения на одном из нижних уровней возможна повторная передача уже доставленных данных. Очереди и смена маршрута могут привести к тому, что более поздний пакет обгонит ранний. Сам IP не обещает ни порядка, ни защиты от дублей.
- **Фрагментация.** Если IP-пакет больше допустимого размера на пути — MTU, — может потребоваться разбиение на фрагменты. Если потерялся один фрагмент, исходную датаграмму собрать уже нельзя.
Приложению неудобно разбираться с каждой такой ситуацией. Поэтому поверх IP используются транспортные протоколы. UDP сохраняет целостность отдельных сообщений, а TCP гарантирует порядок и надежность передачи.
### UDP
**UDP — User Datagram Protocol.** Мы отправляем датаграмму — отдельный массив байтов. На принимающей стороне сохраняется граница сообщений, даже вопреки фрагментации IP пакетов.
А вот **доставку, порядок и отсутствие дублей UDP не гарантирует**. Сам протокол не ждёт подтверждений и не повторяет потерянные датаграммы. Поэтому для нашей операции `PutValue` пришлось бы отдельно решать, как понять, дошёл ли запрос и что делать при потере.
Для других задач такие свойства подходят. Например, в конференции Zoom не обязательно, чтобы дошёл каждый кусочек голоса: небольшой пропуск наш мозг часто может восстановить по смыслу.
### TCP
**TCP — Transmission Control Protocol** — даёт абстракцию надёжного упорядоченного потока байтов. Можно представить канал: мы пишем в него байты, а другая сторона читает их в том же порядке, без повторов, дублей и потерь. Ниже этой абстракции все еще остается фрагментация пакетов, потери и повторы – но TCP все это скрывает от пользователя.
Сначала стороны устанавливают TCP-соединение. Каждая сторона должна сообщить ISN и другие свои параметры и получить подтверждение. Логически это четыре действия, но ответ сервера и его собственное начало обмена объединяются в одно сообщение. Получается три пакета: **SYN → SYN + ACK → ACK**.
![Установление TCP-соединения: четыре логических действия в трёх сегментах](images/tcp-handshake.png)
После этого обе стороны хранят состояние: какие байты уже отправлены, какие подтверждены, какие получены и чего ещё не хватает. Соединение двунаправленное: клиент и сервер могут одновременно передавать данные, у каждого направления свой независимый поток байтов.
- **Sequence number (`seq`)** — номер первого байта в сегменте.
- **Acknowledgment number (`ack`)** — номер следующего ожидаемого байта. Он подтверждает весь непрерывный префикс перед ним.
- **Окно приёма** показывает, сколько данных получатель готов принять. Это позволяет отправлять несколько сегментов, не ожидая подтверждения каждого отдельно, но не переполнять буфер получателя. Дополнительно отправитель ограничивает объём данных в пути с учётом перегрузки сети.
Возьмём условную нумерацию с единицы. Пришли байты 4–6, а 1–3 потерялись. Получатель может сохранить 4–6 в буфере, но продолжает подтверждать, что ждёт байт 1. Отправитель обнаружит потерю по таймеру или повторным подтверждениям и повторит передачу. Когда появятся 1–3, образуется непрерывная последовательность 1–6: её можно отдать приложению, а в `ack` указать 7.
Если потерялось само подтверждение, отправитель тоже может повторить данные. Получатель узнает дубликат по номерам байтов и не отдаст его приложению второй раз. На схеме также упомянут **SACK**: он позволяет дополнительно сообщить, какие участки уже получены после пропуска.
![Буфер получателя: байты 4–6 ждут пропущенные 1–3; после их прихода ACK меняется с 1 на 7](images/tcp-window.png)
За порядок приходится платить ожиданием: байты 4–6 уже у нас, но приложение не увидит их, пока не придут 1–3. Это **head-of-line blocking**.
## RPC
Теперь хочется избавиться от ручной работы: не собирать сообщения `PutValue`, не разбирать ответы и не писать всю сериализацию/десериализацию сообщений самостоятельно.
Посмотрим на Storage как на обычный объект. У него есть методы `PutValue` и `GetValue`: мы передаём запрос и получаем результат. Можно сделать так, чтобы клиентский код выглядел именно как вызов метода, а отправка сообщения на сервер происходила внутри, практически прозрачно для пользователя.
Такую астракцию дает **RPC — Remote Procedure Call, удалённый вызов процедуры**. Она находится на уровень выше TCP и UDP. Пока всё работает, нам почти не приходится думать о сети. Но при ошибке нужно вспомнить, что вызов шел через сеть, и сервер мог не ответить по разным причинам.
Операцию и её параметры можно записать в JSON. Но каждый раз передавать названия полей довольно неэффективно. В нашем примере используются **Protobuf** для описания и сериализации сообщений и **gRPC** — широко используемый фреймворк удалённых вызовов. Показанный gRPC-сервис работает поверх HTTP/2 и TCP. Protobuf определяет, как представить наши данные, а gRPC связывает вызов клиентского метода с обработчиком на сервере.
## Описываем Storage в `.proto`
Откроем [storage.proto](grpc-practice/go-server/proto/storage.proto). В начале указаны версия `proto3`, пакет `storage` и настройка `go_package` для генерации Go-кода. Дальше описан сам сервис:
```protobuf
syntax = "proto3";
package storage;
option go_package = "hsegrpc/;storagepb";
import "google/protobuf/timestamp.proto";
service Storage {
rpc PutValue(PutRequest) returns (PutResponse);
rpc GetValue(GetRequest) returns (GetResponse);
}
message Value {
uint64 payload = 1;
optional google.protobuf.Timestamp updated_at = 2;
}
message PutRequest {
Value value = 1;
}
message PutResponse {
uint64 value = 1;
}
message GetRequest {
}
message GetResponse {
Value value = 1;
}
```
Здесь `rpc` объявляет метод. `PutValue` принимает `PutRequest` и возвращает `PutResponse`; с `GetValue` всё аналогично.
`PutRequest` содержит `Value` — значение, которое хотим положить. У `Value` есть само число `payload` и время обновления `updated_at`. `GetRequest` пустой, а `GetResponse` содержит `Value`. В `PutResponse` возвращается записанное число.
**Что означают единички и двойки после полей?** Это номера, по которым поля узнаются при сериализации. Вместо строки `payload` можно передавать номер `1`, а получатель по своей схеме знает, что это за поле. Номер относится к конкретному типу сообщения: `value = 1` в `PutRequest` и `payload = 1` в `Value` друг другу не мешают. Это не значение поля и не его позиция в исходном файле.
Эти номера важны для совместимости. Допустим, мы решили убрать `updated_at` и добавить другое поле. Нельзя просто отдать новому полю номер `2`: у пользователей старой версии под этим номером всё ещё описан `Timestamp`. Новому полю нужно дать новый номер, например `3`. Тогда это будет именно новое поле, а не другое значение на месте старого.
## Из `.proto` получаем код
Из одного `.proto` можно сгенерировать код для Go, Python, C++ и других языков. На семинаре мы смотрели Go; инструкции для разных языков есть на [grpc.io](https://grpc.io/docs/languages/).
В первой строке [main.go](grpc-practice/go-server/main.go) приведена команда `protoc` для генерации. Из каталога `grpc-practice/go-server` она выглядит так:
```bash
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/storage.proto
```
Для неё нужны `protoc` и Go-плагины `protoc-gen-go`, `protoc-gen-go-grpc`. Команда создаёт два файла:
- [`storage.pb.go`](grpc-practice/go-server/proto/storage.pb.go) — структуры `Value`, `PutRequest`, `PutResponse`, `GetRequest`, `GetResponse` и вспомогательные методы;
- [`storage_grpc.pb.go`](grpc-practice/go-server/proto/storage_grpc.pb.go) — клиентский и серверный интерфейсы, а также код для работы с gRPC.
### Сообщения становятся структурами
В `storage.pb.go` каждому `message` соответствует Go-структура. Вот все типы из нашего примера; для наглядности здесь опущены служебные поля генератора, теги и вспомогательные методы:
```go
type Value struct {
Payload uint64
UpdatedAt *timestamppb.Timestamp
}
type PutRequest struct {
Value *Value
}
type PutResponse struct {
Value uint64
}
type GetRequest struct{}
type GetResponse struct {
Value *Value
}
```
Можно прямо сопоставить их с `.proto`: `payload` превратился в `Payload`, `updated_at` — в `UpdatedAt`, а вложенное сообщение `Value` — в поле типа `*Value`. Поэтому запрос «положить 42» мы собираем как обычную структуру:
```go
request := &storagepb.PutRequest{
Value: &storagepb.Value{
Payload: 42,
},
}
```
### Клиент
Наверное, самый сложный момент: **`StorageClient`**. Имея его экземпляр, пользователь может вызвать методы `PutValue` и `GetValue` сервиса `Storage`. Его интерфейс сгенерирован в `storage_grpc.pb.go` (упрощенно):
```go
type StorageClient interface {
PutValue(in *PutRequest) (*PutResponse, error)
GetValue(in *GetRequest) (*GetResponse, error)
}
```
Получить такой клиент позволяет сгенерированный `NewStorageClient`. Если `conn` — уже созданное gRPC-подключение, клиентский код выглядит так:
```go
client := storagepb.NewStorageClient(conn)
response, err := client.PutValue(request)
if err != nil {
log.Fatal(err)
}
fmt.Println(response.GetValue()) // 42
```
### Сервер
**`StorageServer`** задаёт интерфейс, который серверсу предстоит реализовать. Генератор не знает, как именно наш сервис `Storage` хранит число. Есть заготовка `UnimplementedStorageServer`: её методы отвечают, что операция не реализована. Дальше мы пишем собственные `PutValue` и `GetValue`. Здесь проходит важная граница: описание сервиса и сетевой обмен можно сгенерировать, а решение, что делать с пришедшим значением, остаётся за нами.
Вот интерфейс и один из методов заготовки из `storage_grpc.pb.go` (упрощенно):
```go
type StorageServer interface {
PutValue(*PutRequest) (*PutResponse, error)
GetValue(*GetRequest) (*GetResponse, error)
mustEmbedUnimplementedStorageServer()
}
type UnimplementedStorageServer struct{}
func (UnimplementedStorageServer) PutValue(
_ *PutRequest,
) (*PutResponse, error) {
return nil, status.Errorf(
codes.Unimplemented, "method PutValue not implemented",
)
}
```
Свою реализацию мы пишем в [storage/server.go](grpc-practice/go-server/storage/server.go). Наш `Server` включает `UnimplementedStorageServer`, хранит значение и mutex. Клиенты могут обращаться одновременно, поэтому доступ к значению защищён блокировкой:
```go
type Value struct {
payload uint64
updatedAt time.Time
}
type Server struct {
storagepb.UnimplementedStorageServer
valueLocker sync.RWMutex
value Value
}
func NewServer() *Server {
return &Server{}
}
```
Здесь `Value` — наша внутренняя структура для хранения числа; `storagepb.Value` из предыдущих листингов — сообщение для передачи по сети. Встраивание `UnimplementedStorageServer` даёт заготовки методов и служебный метод из интерфейса, а нужные `PutValue` и `GetValue` мы определяем у своего `Server`.
В `PutValue` приходит сгенерированный `PutRequest`. Сначала проверяем, передал ли клиент `value`: он может его не указать, и тогда `request.GetValue()` вернёт `nil`. Если поле нужно для операции, эту проверку делаем сами. Иначе записываем число, выставляем время обновления и возвращаем ответ:
```go
func (s *Server) PutValue(
request *storagepb.PutRequest,
) (*storagepb.PutResponse, error) {
s.valueLocker.Lock()
defer s.valueLocker.Unlock()
if request.GetValue() == nil {
return nil, errors.New("missed value")
}
s.value = Value{
payload: request.GetValue().GetPayload(),
updatedAt: time.Now(),
}
return &storagepb.PutResponse{
Value: s.value.payload,
}, nil
}
```
`GetValue` берёт блокировку на чтение и возвращает текущее значение. Наш вспомогательный метод `toProto` перекладывает его в сгенерированное Protobuf-сообщение:
```go
func (s *Server) GetValue(
_ *storagepb.GetRequest,
) (*storagepb.GetResponse, error) {
s.valueLocker.RLock()
defer s.valueLocker.RUnlock()
return &storagepb.GetResponse{
Value: s.value.toProto(),
}, nil
}
func (v *Value) toProto() *storagepb.Value {
return &storagepb.Value{
Payload: v.payload,
UpdatedAt: timestamppb.New(v.updatedAt),
}
}
```
### Соединяем реализацию с gRPC-сервером
Остаётся запустить сервер, используя `main.go`. Ниже основной фрагмент запуска; `addr` — адрес, который в нашем примере по умолчанию равен `0.0.0.0:51000`:
```go
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
reflection.Register(grpcServer)
storageService := storage.NewServer()
storagepb.RegisterStorageServer(grpcServer, storageService)
err = grpcServer.Serve(lis)
if err != nil {
log.Fatalf("server failed")
}
```
`net.Listen("tcp", addr)` открывает TCP-listener. `grpc.NewServer()` создаёт сервер gRPC, а `storage.NewServer()` — нашу реализацию Storage. Вызов `RegisterStorageServer(grpcServer, storageService)` связывает их: теперь gRPC знает, какому объекту передавать вызовы `PutValue` и `GetValue`. `reflection.Register` позволит инструментам вроде `grpcurl` узнать описание сервиса. Наконец, `grpcServer.Serve(lis)` начинает принимать соединения.
Получается полный путь одного запроса: клиентский метод получает `PutRequest` → сообщение сериализуется и передаётся по сети → сервер gRPC разбирает его → вызывает наш `Server.PutValue` → тот меняет значение и возвращает `PutResponse` → ответ уходит клиенту. Сериализацию и передачу обеспечивает готовый код, а изменение значения написано нами в `server.go`.
## Проверяем через grpcurl и Postman
Для этого примера нужны Go и установленный [`grpcurl`](https://github.com/fullstorydev/grpcurl). В каталоге `grpc-practice/go-server` запускаем сервер:
```bash
go run main.go
```
В другом терминале смотрим список сервисов и описание Storage:
```bash
grpcurl -plaintext localhost:51000 list
grpcurl -plaintext localhost:51000 describe storage.Storage
```
`-plaintext` отключает TLS: в этом примере работаем без шифрования. `list` показывает доступные сервисы, а `describe` — методы Storage и типы их запросов и ответов. Узнать это у работающего сервера позволяет включённый в `main.go` сервис reflection: `grpcurl` получает описание интерфейса, хотя мы не передавали ему `.proto` отдельным файлом.
Теперь положим `100500`, как в демонстрации, и прочитаем значение обратно:
```bash
grpcurl -plaintext -d '{"value":{"payload":100500}}' \
localhost:51000 storage.Storage/PutValue
grpcurl -plaintext -d '{}' localhost:51000 storage.Storage/GetValue
```
В запросе повторяется структура из `.proto`: внутри `value` находится `payload`. `PutValue` возвращает записанное число, `GetValue` — текущее значение и время обновления. В терминале мы вводим JSON, потому что это удобно человеку; `grpcurl` по описанию сервиса преобразует его в Protobuf-сообщение.
У `PutValue` ожидается ответ `{"value":"100500"}`, а у `GetValue` — вложенный объект `value` с полями `payload` и `updatedAt`. Время выставляет наш сервер. Если к нему одновременно обращается другой пишущий клиент, чтение может уже показать его значение.
Если не хочется писать команды в консоли, можно использовать **Postman**, как в конце семинара: импортировать `storage.proto`, указать адрес `localhost:51000`, выбрать `PutValue`, заполнить сообщение и нажать **Invoke**. Затем вызвать `GetValue` и увидеть записанное число.
Получилось, что мы описали сервис в `.proto`, сгенерировали код и дописали небольшую реализацию — и у нас уже есть работающий сервис. Все файлы и команды находятся в [материалах практики](grpc-practice/go-server/README.md).
+1
View File
@@ -8,6 +8,7 @@
### Семинар
- [Презентация](02-communication-seminar.pptx)
- [Конспект: TCP/UDP, gRPC и Protobuf](notes.md)
- [Практика с gRPC](grpc-practice)
- [Пример gRPC Streaming](grpc-streaming)