Архитектура

Построение GraphQL-шлюза на Go поверх существующих REST API: архитектура и практика

Ruslan Ismailov Опубликовано 12 мин чтения
П

Введение: зачем GraphQL-шлюз поверх REST и когда это оправдано

Микросервисная архитектура решает проблему масштабируемости и независимости команд, но создаёт новую: клиент вынужден делать множество HTTP-запросов к разным REST API, склеивать данные на стороне фронтенда и страдать от over-fetching и under-fetching. Мобильное приложение запрашивает пользователя, его заказы и статус доставки — три разных сервиса, три разных запроса, три разных контракта.

GraphQL-шлюз решает эту проблему элегантно: он предоставляет единую точку входа с типизированным графом данных, позволяя клиенту запрашивать ровно те поля, которые нужны. При этом сами микросервисы остаются на REST — никакого переписывания, никаких рисков регрессий.

Подход оправдан в следующих случаях:

  • У вас несколько клиентов (web, mobile, B2B API) с разными потребностями в данных.
  • REST-сервисы написаны разными командами и менять их контракты дорого.
  • Существует проблема N+1 запросов на клиенте или избыточной передачи данных.
  • Требуется централизованная аутентификация и мониторинг.

Go — идеальный выбор для шлюза: низкие накладные расходы, богатая стандартная библиотека для HTTP, горутины для параллельных запросов к downstream-сервисам. В этой статье мы пройдём путь от схемы до деплоя в Kubernetes.

Архитектурная схема

Общая топология выглядит следующим образом. Клиент (браузер, мобильное приложение, другой сервис) отправляет один GraphQL-запрос на шлюз. Шлюз разбирает запрос, вызывает резолверы, каждый из которых обращается к соответствующему REST API микросервису по HTTP. Результаты агрегируются и возвращаются клиенту в виде единого JSON-ответа.

  • Client → GraphQL Gateway (Go, порт 8080)
  • GraphQL Gateway → User Service (REST, порт 3001)
  • GraphQL Gateway → Order Service (REST, порт 3002)
  • GraphQL Gateway → Delivery Service (REST, порт 3003)
  • GraphQL Gateway ↔ Redis (кэш, порт 6379)

Шлюз не хранит бизнес-логику — он только транслирует, агрегирует и кэширует. Это принципиально: шлюз должен оставаться тонким слоем, иначе вы получите распределённый монолит.

Выбор библиотеки: gqlgen vs graph-gophers/graphql-go

В экосистеме Go существуют две основные библиотеки для построения GraphQL-серверов.

graph-gophers/graphql-go — зрелая библиотека с reflection-based подходом. Резолверы привязываются через соглашения об именах методов, что упрощает старт, но затрудняет рефакторинг в больших проектах: компилятор не поможет найти несоответствие схемы и кода.

gqlgen — кодогенерирующий подход: вы определяете схему в SDL, запускаете go generate, и библиотека генерирует типобезопасные интерфейсы резолверов. Реализуете интерфейсы — получаете полную проверку на этапе компиляции. Именно поэтому gqlgen предпочтителен для production-шлюза уровня Middle/Senior:

  • Схема является единственным источником истины (Schema-First).
  • Сгенерированный код избавляет от boilerplate и ошибок маппинга.
  • Встроенная поддержка DataLoader, middleware, complexity limits.
  • Активно развивается: версия 2025–2026 поддерживает Go 1.22+ generics.

Определение схемы GraphQL (SDL)

Создаём файл schema.graphqls. Схема описывает граф данных, доступный клиенту, независимо от внутреннего устройства REST-сервисов:

type Query {
  user(id: ID!): User
  orders(userId: ID!): [Order!]!
}

type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!
}

type Order {
  id: ID!
  status: OrderStatus!
  total: Float!
  delivery: Delivery
}

type Delivery {
  trackingNumber: String!
  estimatedDate: String
  courier: String!
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

После описания схемы запускаем кодогенерацию:

go run github.com/99designs/gqlgen generate

gqlgen создаст файлы graph/generated/generated.go и graph/model/models_gen.go, а также заглушки резолверов в graph/resolver.go.

Реализация резолверов: HTTP-клиент и маппинг ответов

Каждый резолвер — это метод Go-структуры, реализующий сгенерированный интерфейс. Резолвер делает HTTP-запрос к REST API и маппит ответ на GraphQL-тип.

Определяем зависимости шлюза через структуру Resolver:

package graph

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "time"

    "github.com/yourorg/gateway/graph/model"
)

type Resolver struct {
    HTTPClient      *http.Client
    UserServiceURL  string
    OrderServiceURL string
    DeliveryServiceURL string
}

func NewResolver() *Resolver {
    return &Resolver{
        HTTPClient: &http.Client{
            Timeout: 5 * time.Second,
        },
        UserServiceURL:     "http://user-service:3001",
        OrderServiceURL:    "http://order-service:3002",
        DeliveryServiceURL: "http://delivery-service:3003",
    }
}

Реализуем резолвер запроса пользователя:

func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
    url := fmt.Sprintf("%s/users/%s", r.Resolver.UserServiceURL, id)
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("creating request: %w", err)
    }

    // Проброс JWT токена
    if token := ctx.Value("jwt"); token != nil {
        req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
    }

    resp, err := r.Resolver.HTTPClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("user service request: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode == http.StatusNotFound {
        return nil, nil
    }
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("user service returned %d", resp.StatusCode)
    }

    var restUser struct {
        ID    string `json:"id"`
        Name  string `json:"full_name"` // REST поле может отличаться
        Email string `json:"email"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&restUser); err != nil {
        return nil, fmt.Errorf("decoding user: %w", err)
    }

    return &model.User{
        ID:    restUser.ID,
        Name:  restUser.Name,
        Email: restUser.Email,
    }, nil
}

Обратите внимание на маппинг: REST API возвращает full_name, а GraphQL-схема использует name. Шлюз берёт на себя нормализацию контрактов.

Проблема N+1 запросов: DataLoader на Go

Если клиент запрашивает список заказов и для каждого — информацию о доставке, наивная реализация сделает 1 запрос за заказами и N запросов за доставками. DataLoader решает это батчингом: вместо N отдельных HTTP-запросов — один батч-запрос.

Используем библиотеку github.com/graph-gophers/dataloader/v7:

package loader

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "strings"

    dl "github.com/graph-gophers/dataloader/v7"
    "github.com/yourorg/gateway/graph/model"
)

type DeliveryLoader struct {
    *dl.Loader[string, *model.Delivery]
}

func NewDeliveryLoader(deliveryServiceURL string, client *http.Client) *DeliveryLoader {
    batchFn := func(ctx context.Context, orderIDs []string) []*dl.Result[*model.Delivery] {
        // Один батч-запрос к REST API
        url := fmt.Sprintf("%s/deliveries?order_ids=%s",
            deliveryServiceURL, strings.Join(orderIDs, ","))
        resp, err := client.Get(url)
        results := make([]*dl.Result[*model.Delivery], len(orderIDs))

        if err != nil {
            for i := range results {
                results[i] = &dl.Result[*model.Delivery]{Error: err}
            }
            return results
        }
        defer resp.Body.Close()

        var deliveries map[string]*model.Delivery
        json.NewDecoder(resp.Body).Decode(&deliveries)

        for i, id := range orderIDs {
            if d, ok := deliveries[id]; ok {
                results[i] = &dl.Result[*model.Delivery]{Data: d}
            } else {
                results[i] = &dl.Result[*model.Delivery]{Data: nil}
            }
        }
        return results
    }

    return &DeliveryLoader{
        Loader: dl.NewBatchedLoader(batchFn),
    }
}

DataLoader инжектируется в контекст запроса через middleware и используется в резолвере поля delivery у типа Order. Это снижает количество HTTP-запросов с O(N) до O(1) для батча.

Кэширование ответов с Redis

Redis позволяет кэшировать ответы REST-сервисов на уровне шлюза, снижая нагрузку на downstream. Для справочных данных (пользователи, каталог товаров) TTL в 60–300 секунд даёт значительный выигрыш.

package cache

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

type RedisCache struct {
    client *redis.Client
}

func NewRedisCache(addr string) *RedisCache {
    return &RedisCache{
        client: redis.NewClient(&redis.Options{Addr: addr}),
    }
}

func (c *RedisCache) GetUser(ctx context.Context, id string) (*UserDTO, error) {
    key := fmt.Sprintf("user:%s", id)
    data, err := c.client.Get(ctx, key).Bytes()
    if err == redis.Nil {
        return nil, nil // cache miss
    }
    if err != nil {
        return nil, err
    }
    var user UserDTO
    return &user, json.Unmarshal(data, &user)
}

func (c *RedisCache) SetUser(ctx context.Context, id string, user *UserDTO, ttl time.Duration) error {
    key := fmt.Sprintf("user:%s", id)
    data, err := json.Marshal(user)
    if err != nil {
        return err
    }
    return c.client.Set(ctx, key, data, ttl).Err()
}

В резолвере сначала проверяем кэш, при промахе — идём в REST API и пишем результат в Redis. Важно инвалидировать кэш при мутациях: если шлюз поддерживает GraphQL Mutations, мутация updateUser должна удалять ключ user:{id} из Redis.

Аутентификация и авторизация: проброс JWT

Шлюз валидирует JWT-токен один раз и пробрасывает его в downstream-сервисы. Это централизует логику аутентификации и не требует дублирования в каждом микросервисе.

func JWTMiddleware(jwtSecret string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                next.ServeHTTP(w, r)
                return
            }

            tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
            claims, err := validateJWT(tokenStr, jwtSecret)
            if err != nil {
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
                return
            }

            ctx := context.WithValue(r.Context(), "jwt", tokenStr)
            ctx = context.WithValue(ctx, "userID", claims.Subject)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

В резолверах токен извлекается из контекста и добавляется в заголовок исходящего HTTP-запроса к REST API. Downstream-сервисы остаются ответственными за авторизацию конкретных ресурсов — шлюз лишь обеспечивает идентификацию.

Деплой: Docker и Kubernetes

Многоступенчатый Dockerfile минимизирует размер образа:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o gateway ./cmd/gateway

FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/gateway .
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s \
  CMD wget -qO- http://localhost:8080/healthz || exit 1
ENTRYPOINT ["./gateway"]

Kubernetes Deployment с health checks и resource limits:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: graphql-gateway
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: graphql-gateway
  template:
    metadata:
      labels:
        app: graphql-gateway
    spec:
      containers:
      - name: gateway
        image: yourorg/graphql-gateway:1.0.0
        ports:
        - containerPort: 8080
        env:
        - name: USER_SERVICE_URL
          value: "http://user-service.production.svc.cluster.local:3001"
        - name: ORDER_SERVICE_URL
          value: "http://order-service.production.svc.cluster.local:3002"
        - name: REDIS_ADDR
          valueFrom:
            secretKeyRef:
              name: redis-secret
              key: addr
        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"
          limits:
            cpu: "500m"
            memory: "256Mi"
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /readyz
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 5

Endpoint /healthz реализуется тривиально — возвращает 200 OK. /readyz дополнительно проверяет доступность Redis и хотя бы одного downstream-сервиса.

Мониторинг: трейсинг и метрики

Для GraphQL-шлюза важны две категории наблюдаемости: трейсинг запросов через сервисы и метрики на уровне операций GraphQL.

Трейсинг реализуется через OpenTelemetry. gqlgen предоставляет хук AroundOperations, позволяющий создать span на каждую GraphQL-операцию:

func NewTracingExtension(tracer trace.Tracer) graphql.HandlerExtension {
    return &tracingExtension{tracer: tracer}
}

func (t *tracingExtension) InterceptOperation(
    ctx context.Context,
    next graphql.OperationHandler,
) graphql.ResponseHandler {
    opCtx := graphql.GetOperationContext(ctx)
    ctx, span := t.tracer.Start(ctx, opCtx.OperationName,
        trace.WithAttributes(
            attribute.String("graphql.operation.type", string(opCtx.Operation.Operation)),
        ),
    )
    defer span.End()
    return next(ctx)
}

Метрики Prometheus: считайте количество запросов по операциям, латентность (гистограмма p50/p95/p99), количество ошибок и cache hit rate Redis. Grafana-дашборд с этими метриками даёт полную картину здоровья шлюза.

Дополнительно стоит настроить complexity limits в gqlgen, чтобы защититься от злоумышленных запросов с глубокой вложенностью:

srv := handler.NewDefaultServer(generated.NewExecutableSchema(cfg))
srv.Use(extension.FixedComplexityLimit(100))

Заключение: когда GraphQL-шлюз — правильный выбор

GraphQL-шлюз на Go поверх REST API — это не серебряная пуля, но мощный инструмент в правильном контексте. Он оправдан, когда у вас несколько клиентов с разными потребностями, зрелые REST-сервисы, которые нецелесообразно переписывать, и команда, готовая поддерживать дополнительный слой инфраструктуры.

Ключевые решения, принятые в этой статье:

  • gqlgen — за кодогенерацию и типобезопасность схемы.
  • DataLoader — обязателен для любого шлюза с реляционными данными; без него N+1 убьёт производительность.
  • Redis — кэширование на уровне шлюза снижает нагрузку на downstream и улучшает p99-латентность.
  • Kubernetes с health checks и resource limits — производственный минимум для надёжного деплоя.
  • OpenTelemetry + Prometheus — без наблюдаемости шлюз превращается в чёрный ящик.

Шлюз должен оставаться тонким: никакой бизнес-логики, только агрегация, трансляция и сквозные аспекты (аутентификация, кэш, мониторинг). Если логика начинает накапливаться — это сигнал вынести её в отдельный микросервис или пересмотреть границы существующих.

Хорошо спроектированный GraphQL-шлюз невидим для конечного разработчика: он просто работает быстро, предсказуемо и безопасно, не раскрывая сложности распределённой системы за ним.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →