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 вызовы могут висеть бесконечно - обязательно ставьте.
Что значит «пробрасывается»
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. Это бесплатное «убийство» висящих запросов - не нужно вручную закрывать соединения.
gRPC vs REST: trade-offs
Аспект 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 на стороне клиента и проверь, что сервер реагирует на отмену.