gRPC-Gateway и OpenAPI

gRPC-Gateway и OpenAPI

gRPC отлично работает между микросервисами, но браузеры и легаси-клиенты не умеют гонять HTTP/2-streams и protobuf-payload-ы. gRPC-Gateway решает это: генерирует REST-прокси перед gRPC-сервисом, читая аннотации в .proto. Один и тот же сервис доступен и как gRPC (для микросервисов), и как REST (для фронтенда, мобильных, curl).

Установка

go install \
    github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest \
    github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest

# В go.mod
go get github.com/grpc-ecosystem/grpc-gateway/v2

Аннотации в .proto

import "google/api/annotations.proto";
import "google/api/field_behavior.proto";

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
    option (google.api.http) = {
      post: "/v1/users"
      body: "*"
    };
  }

  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option (google.api.http) = {
      get: "/v1/users/{id}"
    };
  }

  rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) {
    option (google.api.http) = {
      patch: "/v1/users/{id}"
      body: "user"
    };
  }

  rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty) {
    option (google.api.http) = {
      delete: "/v1/users/{id}"
    };
  }
}

message GetUserRequest {
  string id = 1 [(google.api.field_behavior) = REQUIRED];
}

{id} в URL автоматически биндится в поле id request-message. body: "*" означает «всё тело запроса в request-сообщение». body: "user" - только конкретное поле.

Генерация прокси-кода

protoc -I . \
 --go_out . --go_opt paths=source_relative \
 --go-grpc_out . --go-grpc_opt paths=source_relative \
 --grpc-gateway_out . --grpc-gateway_opt paths=source_relative \
 --openapiv2_out . --openapiv2_opt logtostderr=true \
    proto/user.proto

Создаются: user.pb.go, user_grpc.pb.go, user.pb.gw.go (REST-прокси), user.swagger.json (OpenAPI спецификация).

Запуск Gateway-сервера

gRPC-Gateway: браузер по REST и микросервис по gRPC обращаются к одному backend через прокси

func main() {
    ctx := context.Background()

    // 1. Запускаем gRPC-сервер на :50051
    go runGRPCServer()

    // 2. Запускаем REST gateway на :8080
    mux := runtime.NewServeMux()
    opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}

    if err := pb.RegisterUserServiceHandlerFromEndpoint(ctx, mux, "localhost:50051", opts); err != nil {
        log.Fatal(err)
    }

    log.Println("REST gateway on :8080, gRPC on :50051")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
<?php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use User\CreateUserRequest;
use User\GetUserRequest;
use User\UserServiceClient;

#[Route('/v1/users')]
final readonly class UserController
{
    public function __construct(
        private UserServiceClient $client,
    ) {}

    #[Route('', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);

        $req = (new CreateUserRequest())->setName($payload['name'] ?? '');
        [$response, $status] = $this->client->CreateUser($req)->wait();

        if ($status->code !== \Grpc\STATUS_OK) {
            return new JsonResponse(['error' => $status->details], 400);
        }
        return new JsonResponse(['id' => $response->getUser()->getId()], 201);
    }

    #[Route('/{id}', methods: ['GET'])]
    public function get(string $id): JsonResponse
    {
        $req = (new GetUserRequest())->setId($id);
        [$response, $status] = $this->client->GetUser($req)->wait();

        return match ($status->code) {
            \Grpc\STATUS_OK        => new JsonResponse([
                'id'   => $response->getUser()->getId(),
                'name' => $response->getUser()->getName(),
            ]),
            \Grpc\STATUS_NOT_FOUND => new JsonResponse(['error' => 'not found'], 404),
            default                => new JsonResponse(['error' => $status->details], 500),
        };
    }
}

В мире PHP плагина «grpc-gateway» нет - REST-фасад пишут руками. Стандартная стратегия: Symfony (или Slim) обслуживает HTTP, внутри контроллеров вызывает gRPC-stub. Это даёт полный контроль над форматом ошибок, аутентификацией и rate limiting. Symfony-контроллер выглядит так.

Теперь работает и gRPC, и REST одновременно:

curl http://localhost:8080/v1/users -d '{"name":"Alice"}'
grpcurl -plaintext -d '{"name":"Alice"}' localhost:50051 user.UserService/CreateUser

REST ↔ gRPC mapping

HTTPgRPCГде поля
GET /v1/users/{id}GetUser(GetUserRequest){id} → req.id, query → остальные
POST /v1/users body {...}CreateUser(CreateUserRequest)body → req
PATCH /v1/users/{id} body {...}UpdateUser(UpdateUserRequest)path + body
DELETE /v1/users/{id}DeleteUser(DeleteUserRequest)path → req

Status code mapping

gRPC-Gateway автоматически конвертирует gRPC-коды в HTTP-коды:

  • codes.NotFound404
  • codes.InvalidArgument400
  • codes.PermissionDenied403
  • codes.Unauthenticated401
  • codes.AlreadyExists409
  • codes.Internal500
  • codes.Unavailable503

Можно настроить custom error handler через runtime.WithErrorHandler, чтобы возвращать клиентам JSON-формат вашей ошибки.

OpenAPI генерация

user.swagger.json - стандартная OpenAPI v2 (Swagger) спецификация. Её можно открыть в Swagger UI:

# docker-compose.yml
services:
  swagger:
    image: swaggerapi/swagger-ui
    ports: ["8081:8080"]
    environment:
 - SWAGGER_JSON=/spec/user.swagger.json
    volumes:
 - ./gen:/spec

Получаете живую документацию с возможностью попробовать API из браузера. Поддерживает аннотации описаний прямо в .proto через google.api.field_behavior, google.api.field_info.

Versioning

URL-versioning через префикс - стандарт:

rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
  option (google.api.http) = {
    post: "/v1/users"
    body: "*"
  };
}

При появлении v2 создаются новые .proto и сервис UserServiceV2 с путями /v2/users. Старые клиенты на v1 продолжают работать. Через год-два v1 deprecate, потом удаляют. Это семантическое versioning на уровне API.

API-first vs gRPC-first

Альтернативный подход - начать с OpenAPI YAML и сгенерировать Go-код через oapi-codegen:

oapi-codegen -generate types -package api openapi.yaml > api/types.gen.go
oapi-codegen -generate chi-server -package api openapi.yaml > api/server.gen.go
**gRPC-first** (gateway): основные клиенты - микросервисы, REST вторичен. **API-first** (oapi-codegen): основные клиенты - фронтенд и мобильные, REST первичен. Иногда в одном проекте используют оба подхода для разных частей.

Production-ready Gateway

mux := runtime.NewServeMux(
    runtime.WithErrorHandler(customErrorHandler),
    runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{}),
    runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
        // Какие HTTP headers пробрасываем в gRPC metadata
        return runtime.DefaultHeaderMatcher(key)
    }),
)
<?php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;

#[AsEventListener]
final readonly class GrpcErrorListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        $event->setResponse(new JsonResponse(
            ['error' => $exception->getMessage(), 'code' => $exception->getCode()],
            $this->mapHttpStatus($exception->getCode()),
        ));
    }

    private function mapHttpStatus(int $grpcCode): int
    {
        return match ($grpcCode) {
            \Grpc\STATUS_INVALID_ARGUMENT  => 400,
            \Grpc\STATUS_UNAUTHENTICATED   => 401,
            \Grpc\STATUS_PERMISSION_DENIED => 403,
            \Grpc\STATUS_NOT_FOUND         => 404,
            \Grpc\STATUS_ALREADY_EXISTS    => 409,
            \Grpc\STATUS_UNAVAILABLE       => 503,
            default                         => 500,
        };
    }
}

В Symfony-гейте те же концерны решает стандартная инфраструктура: ExceptionListener под кастомный формат ошибок, nelmio/cors-bundle для CORS, symfony/rate-limiter для rate limit, monolog для логов. Прокидывание HTTP headers в gRPC metadata - явный код в контроллере или middleware.

OpenAPI-документация в Symfony-гейте генерируется через zircote/swagger-php с аттрибутами на контроллерах. Это даёт ту же выдачу, что и --openapiv2_out в gRPC-Gateway, но описание ведётся вручную:

<?php
declare(strict_types=1);

namespace App\Controller;

use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

#[OA\Info(version: '1.0.0', title: 'UserService Gateway')]
#[Route('/v1/users')]
final readonly class UserApiController
{
    #[OA\Post(
        path: '/v1/users',
        summary: 'Create user',
        requestBody: new OA\RequestBody(
            required: true,
            content: new OA\JsonContent(properties: [
                new OA\Property(property: 'name', type: 'string'),
            ]),
        ),
        responses: [
            new OA\Response(response: 201, description: 'created'),
            new OA\Response(response: 400, description: 'invalid'),
        ],
    )]
    #[Route('', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        // ...
        return new JsonResponse(['ok' => true], 201);
    }
}

Запуск генератора: vendor/bin/openapi src/Controller --output public/openapi.json. Файл подхватывает любой Swagger UI - получаете живую документацию.

В проде также стоит включить CORS (для браузеров), TLS, rate limiting, логирование на уровне HTTP перед gRPC.

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

Добавь gRPC-Gateway к TaskService с REST-эндпоинтами под /v1/tasks. Сгенерируй OpenAPI v2 spec, открой в Swagger UI и проверь все методы. Реализуй custom error handler, который возвращает JSON {"error": "...", "code": "..."} вместо стандартного формата. Убедись, что REST и gRPC возвращают одинаковые status-коды для одних и тех же ситуаций.

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