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-сервера
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
| HTTP | gRPC | Где поля |
|---|---|---|
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.NotFound→404codes.InvalidArgument→400codes.PermissionDenied→403codes.Unauthenticated→401codes.AlreadyExists→409codes.Internal→500codes.Unavailable→503
Можно настроить 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
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-коды для одних и тех же ситуаций.