gRPC: первый сервер и клиент

gRPC: первый сервер и клиент

gRPC - фреймворк для вызова удалённых процедур поверх HTTP/2 и Protocol Buffers. Сильные стороны: типизированный контракт, мультиплексирование запросов в одном TCP-соединении, поддержка стримов, готовая инфраструктура (deadlines, metadata, interceptors).

Определение сервиса

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}

Каждый RPC имеет ровно один request-message и один response-message. Это сознательное ограничение - оно делает интерфейс предсказуемым и легко эволюционируемым через добавление полей.

Сервер

type userServer struct {
    pb.UnimplementedUserServiceServer
    users map[string]*pb.User
    mu    sync.RWMutex
}

func (s *userServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
    if req.Name == "" {
        return nil, status.Error(codes.InvalidArgument, "name is required")
    }

    user := &pb.User{
        Id:   uuid.New().String(),
        Name: req.Name,
    }

    s.mu.Lock()
    s.users[user.Id] = user
    s.mu.Unlock()

    return &pb.CreateUserResponse{User: user}, nil
}

func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    s.mu.RLock()
    user, ok := s.users[req.Id]
    s.mu.RUnlock()

    if !ok {
        return nil, status.Error(codes.NotFound, "user not found")
    }
    return &pb.GetUserResponse{User: user}, nil
}

func main() {
    lis, _ := net.Listen("tcp", ":50051")
    grpcServer := grpc.NewServer()
    pb.RegisterUserServiceServer(grpcServer, &userServer{users: make(map[string]*pb.User)})
    log.Fatal(grpcServer.Serve(lis))
}
<?php
declare(strict_types=1);

namespace App\Grpc;

use Spiral\RoadRunner\GRPC\ContextInterface;
use Spiral\RoadRunner\GRPC\Exception\GRPCException;
use Spiral\RoadRunner\GRPC\StatusCode;
use User\CreateUserRequest;
use User\CreateUserResponse;
use User\GetUserRequest;
use User\GetUserResponse;
use User\User;
use User\UserServiceInterface;

final class UserService implements UserServiceInterface
{
    /** @var array<string, User> */
    private array $users = [];

    public function CreateUser(ContextInterface $ctx, CreateUserRequest $in): CreateUserResponse
    {
        if ($in->getName() === '') {
            throw new GRPCException('name is required', StatusCode::INVALID_ARGUMENT);
        }

        $user = (new User())
            ->setId(\Symfony\Component\Uid\Uuid::v4()->toRfc4122())
            ->setName($in->getName());

        $this->users[$user->getId()] = $user;

        return (new CreateUserResponse())->setUser($user);
    }

    public function GetUser(ContextInterface $ctx, GetUserRequest $in): GetUserResponse
    {
        $user = $this->users[$in->getId()] ?? null;
        if ($user === null) {
            throw new GRPCException('user not found', StatusCode::NOT_FOUND);
        }
        return (new GetUserResponse())->setUser($user);
    }
}

В PHP полноценный gRPC-сервер обычно поднимается через Spiral RoadRunner (spiral/roadrunner-grpc) - это production-grade рантайм на Go, который держит долгоживущие соединения (PHP-FPM на это не способен по архитектуре). Сгенерированный из .proto интерфейс UserServiceInterface реализуется как обычный сервис.

В worker.php (entrypoint RoadRunner) регистрируем сервис и стартуем:

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Spiral\RoadRunner\GRPC\Server;
use Spiral\RoadRunner\Worker;
use User\UserServiceInterface;

$server = new Server();
$server->registerService(UserServiceInterface::class, new App\Grpc\UserService());
$server->serve(Worker::create());

Запуск: ./rr serve -c .rr.yaml, где .rr.yaml описывает grpc.listen: tcp://0.0.0.0:50051. Альтернативный путь - Swoole (swoole/grpc), но RoadRunner более развит в PHP-экосистеме.

Клиент

Клиент создаётся один раз и переиспользуется - он thread-safe и держит постоянное соединение с пулом HTTP/2 streams. Открывать новый conn на каждый запрос - антипаттерн.

conn, _ := grpc.Dial("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)
defer conn.Close()

client := pb.NewUserServiceClient(conn)

resp, err := client.CreateUser(ctx, &pb.CreateUserRequest{Name: "Alice"})
if err != nil {
    st, ok := status.FromError(err)
    if ok {
        log.Printf("gRPC error: code=%s msg=%s", st.Code(), st.Message())
    }
}
<?php
declare(strict_types=1);

use Grpc\ChannelCredentials;
use User\CreateUserRequest;
use User\UserServiceClient;

final class UserApi
{
    public function __construct(
        private readonly UserServiceClient $client,
    ) {}

    public static function create(string $host): self
    {
        return new self(new UserServiceClient($host, [
            'credentials' => ChannelCredentials::createInsecure(),
        ]));
    }

    public function createUser(string $name): string
    {
        $req = (new CreateUserRequest())->setName($name);

        // Метод возвращает [$response, $status]
        [$response, $status] = $this->client->CreateUser($req)->wait();

        if ($status->code !== \Grpc\STATUS_OK) {
            throw new \RuntimeException(
                sprintf('gRPC error: code=%d msg=%s', $status->code, $status->details),
            );
        }
        return $response->getUser()->getId();
    }
}

$api = UserApi::create('localhost:50051');
$id = $api->createUser('Alice');

PHP-клиент - это сгенерированный класс, унаследованный от \Grpc\BaseStub. Подключается через ext-grpc.

BaseStub thread-safe только в рамках одного процесса PHP; внутри FPM-воркера держим один экземпляр на запрос (или per-worker через DI Symfony). Закрывать соединение вручную не нужно - Channel живёт пока есть ссылка.

gRPC коды ошибок

<ComparisonTable data={{ headers: ["gRPC Code", "HTTP аналог", "Когда"], rows: [ ["InvalidArgument", "400", "Невалидные данные"], ["NotFound", "404", "Не найден"], ["AlreadyExists", "409", "Дубликат"], ["PermissionDenied", "403", "Нет прав"], ["Unauthenticated", "401", "Не авторизован"], ["DeadlineExceeded", "504", "Таймаут"], ["ResourceExhausted", "429", "Rate limit"], ["Internal", "500", "Внутренняя ошибка"], ["Unavailable", "503", "Недоступен - retry"] ] }} />

Unavailable важен: gRPC-клиенты автоматически делают retry при этом коде. Используйте его, когда сбой временный (БД не отвечает, lock contention) и есть смысл повторить.

Status с details

Стандартные коды лаконичные. Для дополнительной структурированной информации есть status.Details:

st := status.New(codes.InvalidArgument, "validation failed")
st, _ = st.WithDetails(&errdetails.BadRequest{
    FieldViolations: []*errdetails.BadRequest_FieldViolation{
        {Field: "email", Description: "must be valid email"},
    },
})
return nil, st.Err()

Клиент извлекает детали через st.Details() и обрабатывает программно - например, подсвечивает невалидные поля в форме.

<?php
declare(strict_types=1);

use Google\Rpc\BadRequest;
use Google\Rpc\BadRequest\FieldViolation;
use Spiral\RoadRunner\GRPC\Exception\GRPCException;
use Spiral\RoadRunner\GRPC\StatusCode;

$violations = new BadRequest();
$violations->setFieldViolations([
    (new FieldViolation())->setField('email')->setDescription('must be valid email'),
]);

throw new GRPCException(
    'validation failed',
    StatusCode::INVALID_ARGUMENT,
    ['grpc-status-details-bin' => $violations->serializeToString()],
);

В PHP детали прикручиваются через grpc-google-cloud-php (Google\Rpc\BadRequest). В RoadRunner это упаковывается в GRPCException с trailers.

Metadata - заголовки gRPC

// Клиент
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer token123")
ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", uuid.New().String())

// Сервер
md, ok := metadata.FromIncomingContext(ctx)
if ok {
    tokens := md.Get("authorization")
}
<?php
declare(strict_types=1);

use Spiral\RoadRunner\GRPC\ContextInterface;

// Клиент: metadata третьим аргументом
$metadata = [
    'authorization' => ['Bearer token123'],
    'x-request-id'  => [\Symfony\Component\Uid\Uuid::v4()->toRfc4122()],
];
[$response, $status] = $client->CreateUser($req, $metadata)->wait();

// Сервер: ContextInterface даёт getValue() для metadata
public function CreateUser(ContextInterface $ctx, CreateUserRequest $in): CreateUserResponse
{
    $tokens = $ctx->getValue('authorization') ?? [];
    $token = $tokens[0] ?? null;
    // ...
}

В PHP-клиенте metadata - это третий аргумент вызова метода stub-а. На сервере (RoadRunner) - объект ContextInterface.

Используется для аутентификации, correlation ID, A/B-флагов. Имена case-insensitive, значения - список строк (один заголовок может иметь несколько значений).

Deadlines и cancellation propagation

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

resp, err := client.CreateUser(ctx, req)
<?php
declare(strict_types=1);

// Клиент: 2 секунды на запрос
$options = ['timeout' => 2_000_000]; // microseconds
[$response, $status] = $client->CreateUser($req, [], $options)->wait();

if ($status->code === \Grpc\STATUS_DEADLINE_EXCEEDED) {
    throw new \RuntimeException('CreateUser timed out');
}

В PHP deadline передаётся через timeout (в микросекундах) в опциях вызова. RoadRunner на сервере читает его из контекста:

<?php
declare(strict_types=1);

use Spiral\RoadRunner\GRPC\ContextInterface;

public function Get(ContextInterface $ctx, GetRequest $in): GetResponse
{
    // RoadRunner кладёт оставшийся deadline в значения контекста
    $timeout = $ctx->getValue('timeout');
    if ($timeout !== null && (int)$timeout[0] < 100_000) {
        // меньше 100ms - не имеет смысла лезть в downstream
        throw new GRPCException('not enough time', StatusCode::DEADLINE_EXCEEDED);
    }
    // ...
}

Deadline пробрасывается через wire - сервер видит его и может отменить долгие операции. Если сервер вызывает другие сервисы - deadline пробрасывается дальше, образуя цепочку. Без deadline вызовы могут висеть бесконечно - обязательно ставьте.

Что значит «пробрасывается»

Deadline уменьшается на каждом hop: Frontend -> Gateway -> user svc -> account svc -> DB

Frontend → API Gateway → UserService → AccountService → DB
   3s             ~3s           ~2.8s          ~2.5s         ~2.3s
   ↑ deadline уменьшается на каждом hop на сетевой round-trip

gRPC-runtime автоматически сериализует remaining deadline в metadata (grpc-timeout). Сервер при получении запроса создаёт child-context с этим deadline. Если в сервере вы делаете client.SomethingElse(ctx, ...) - ctx уже содержит deadline, и он улетит дальше.

// Сервер видит deadline и УЖЕ ИМЕЕТ ctx с ним:
func (s *server) Get(ctx context.Context, req *pb.GetRequest) (*pb.GetResponse, error) {
    if dl, ok := ctx.Deadline(); ok {
        slog.Info("incoming deadline", "remaining", time.Until(dl))
    }
    // Этот вызов не превысит remaining deadline:
    user, err := s.accountClient.Get(ctx, &pb.AccountReq{Id: req.Id})
    return &pb.GetResponse{...}, err
}
<?php
declare(strict_types=1);

use Spiral\RoadRunner\GRPC\ContextInterface;

final readonly class AccountFacade
{
    public function __construct(
        private AccountServiceClient $accountClient,
        private LoggerInterface $logger,
    ) {}

    public function get(ContextInterface $ctx, string $id): Account
    {
        $remaining = (int)($ctx->getValue('timeout')[0] ?? 0);
        $this->logger->info('incoming deadline', ['remaining_us' => $remaining]);

        $req = (new AccountReq())->setId($id);
        [$resp, $status] = $this->accountClient
            ->Get($req, [], ['timeout' => $remaining])
            ->wait();

        if ($status->code !== \Grpc\STATUS_OK) {
            throw new \RuntimeException('downstream failed: ' . $status->details);
        }
        return $resp->getAccount();
    }
}

В PHP при обращении к downstream-сервису берём остаток таймаута и прокидываем дальше. В Symfony-проектах удобно делегировать это Symfony\Contracts\HttpClient или сделать декоратор над gRPC-клиентом.

Cancellation: клиент отменил - все остановились

// Клиент:
ctx, cancel := context.WithCancel(parentCtx)
go func() { <-someEvent; cancel() }()      // отмена по сигналу
resp, err := client.LongOperation(ctx, req)

// Сервер видит отмену через ctx.Done():
func (s *server) LongOperation(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
    select {
    case <-ctx.Done():
        return nil, status.FromContextError(ctx.Err()).Err()
    case result := <-s.compute(ctx, req):
        return result, nil
    }
}
<?php
declare(strict_types=1);

use Spiral\RoadRunner\GRPC\ContextInterface;

public function LongOperation(ContextInterface $ctx, Req $in): Resp
{
    $deadlineUs = (int)($ctx->getValue('timeout')[0] ?? PHP_INT_MAX);
    $startedAt = hrtime(true);

    while (!$this->isDone()) {
        // cooperative cancellation: каждые N шагов проверяем deadline
        $elapsedUs = (int)((hrtime(true) - $startedAt) / 1000);
        if ($elapsedUs >= $deadlineUs) {
            throw new GRPCException('cancelled', StatusCode::CANCELLED);
        }
        $this->step();
    }
    return (new Resp())->setResult($this->result());
}

В PHP отмены «по событию» нет (нет горутин и каналов), но deadline-cancellation работает аналогично - истёкший timeout прерывает вызов. Для долгих операций на сервере периодически проверяем флаг через cooperative cancellation.

Отмена пробрасывается транзитивно: если ваш сервер делает 5 даунстрим-вызовов, отмена клиента остановит все 5. Это бесплатное «убийство» висящих запросов - не нужно вручную закрывать соединения.

`sql.QueryContext(ctx, ...)` отменит запрос в PostgreSQL при отмене context - драйвер пошлёт `CancelRequest`. Если же вы делаете `sql.Query()` без ctx - даже после возврата ошибки в gRPC, БД будет дожимать тяжёлый JOIN. Всегда передавайте ctx до самого низа.

gRPC vs REST: trade-offs

Структура gRPC и REST: разные транспорт, payload и контракт

Аспект                     gRPC                           REST/JSON
─────────────────────      ───────────────────────────    ───────────────────────────
Транспорт                  HTTP/2 (multiplexing)          HTTP/1.1 (обычно)
Payload                    Protobuf (binary, типизир.)    JSON (текст, нетипизир.)
Контракт                   .proto + кодген                OpenAPI (опционально)
Streaming                  4 типа из коробки              SSE/WebSocket - отдельно
Браузер                    нужен grpc-web/gateway         работает напрямую
Размер payload             3-10× меньше JSON              базовый
Скорость сериализации      5-100× быстрее                 базовый
Читаемость в логах         бинарный (нужен protoc)        видно глазами
Curl-friendly              нет (нужен grpcurl)            да
Эволюция API               через номера полей             через версионирование/опц. поля
Ошибки                     gRPC codes (16 значений)       HTTP status (400-599)
Зрелость инструментов      service mesh, tracing - норма  REST везде, есть всё

Когда gRPC выигрывает: микросервисы внутри одного периметра (типизация, скорость, streaming), мобильные клиенты на медленных сетях (бинарный payload), бэкенд-к-бэкенд интеграции с высокой нагрузкой.

Когда REST лучше: публичный API для веба (браузеры, curl, debug), интеграции с внешними партнёрами (JSON - lingua franca), простые CRUD-сервисы (накладные расходы gRPC не оправданы).

В реальных проектах часто комбинируют: gRPC между сервисами + gRPC-Gateway наружу для REST-доступа (см. урок 5).

Health Checking

Стандартизированный API проверки здоровья сервиса:

import "google.golang.org/grpc/health"
import healthpb "google.golang.org/grpc/health/grpc_health_v1"

healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("user.UserService", healthpb.HealthCheckResponse_SERVING)
<?php
declare(strict_types=1);

namespace App\Grpc;

use Grpc\Health\V1\HealthCheckRequest;
use Grpc\Health\V1\HealthCheckResponse;
use Grpc\Health\V1\HealthCheckResponse\ServingStatus;
use Grpc\Health\V1\HealthInterface;
use Spiral\RoadRunner\GRPC\ContextInterface;

final class HealthService implements HealthInterface
{
    /** @var array<string, int> */
    private array $statuses = ['' => ServingStatus::SERVING];

    public function setStatus(string $service, int $status): void
    {
        $this->statuses[$service] = $status;
    }

    public function Check(ContextInterface $ctx, HealthCheckRequest $in): HealthCheckResponse
    {
        $status = $this->statuses[$in->getService()] ?? ServingStatus::SERVICE_UNKNOWN;
        return (new HealthCheckResponse())->setStatus($status);
    }
}

В RoadRunner-приложении health-сервис реализуется тем же способом - стандартный proto grpc.health.v1.Health с методами Check и Watch.

Используется Kubernetes liveness/readiness probes, load balancer-ами для исключения unhealthy instances. Стандартный API - везде один и тот же.

Reflection и grpcurl

import "google.golang.org/grpc/reflection"
reflection.Register(grpcServer)

RoadRunner поддерживает reflection через флаг конфигурации - не нужно отдельно регистрировать сервис в коде:

# .rr.yaml
grpc:
    listen: tcp://0.0.0.0:50051
    proto:
        - proto/user.proto
    pool:
        num_workers: 4
    # Включаем server reflection API
    enable_reflection: true

С reflection можно вызывать сервис без .proto файла на руках. grpcurl использует это:

grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe user.UserService
grpcurl -plaintext -d '{"name":"Bob"}' localhost:50051 user.UserService/CreateUser

В проде reflection обычно отключают (security: раскрывает API). В dev/staging - оставляют для удобства.

Мини-практика

Реализуй gRPC сервис TaskService с методами Create, Get, List, Delete. Добавь reflection для grpcurl, health-check, status.Details при ошибке валидации. Передай в metadata correlation_id и пробрось его в логах сервера. Установи deadline на стороне клиента и проверь, что сервер реагирует на отмену.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.