diff --git a/materials/02-communication/images/storage-no-request.png b/materials/02-communication/images/storage-no-request.png new file mode 100644 index 0000000..9359693 Binary files /dev/null and b/materials/02-communication/images/storage-no-request.png differ diff --git a/materials/02-communication/images/storage-no-response.png b/materials/02-communication/images/storage-no-response.png new file mode 100644 index 0000000..e347d08 Binary files /dev/null and b/materials/02-communication/images/storage-no-response.png differ diff --git a/materials/02-communication/images/storage-task.png b/materials/02-communication/images/storage-task.png new file mode 100644 index 0000000..49a569a Binary files /dev/null and b/materials/02-communication/images/storage-task.png differ diff --git a/materials/02-communication/images/tcp-handshake.png b/materials/02-communication/images/tcp-handshake.png new file mode 100644 index 0000000..9025d4a Binary files /dev/null and b/materials/02-communication/images/tcp-handshake.png differ diff --git a/materials/02-communication/images/tcp-window.png b/materials/02-communication/images/tcp-window.png new file mode 100644 index 0000000..2a9b886 Binary files /dev/null and b/materials/02-communication/images/tcp-window.png differ diff --git a/materials/02-communication/notes.md b/materials/02-communication/notes.md new file mode 100644 index 0000000..0bd6d2b --- /dev/null +++ b/materials/02-communication/notes.md @@ -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). diff --git a/materials/02-communication/readme.md b/materials/02-communication/readme.md index fba4ea4..117d460 100644 --- a/materials/02-communication/readme.md +++ b/materials/02-communication/readme.md @@ -8,6 +8,7 @@ ### Семинар - [Презентация](02-communication-seminar.pptx) +- [Конспект: TCP/UDP, gRPC и Protobuf](notes.md) - [Практика с gRPC](grpc-practice) - [Пример gRPC Streaming](grpc-streaming)