golang

GraphQL и Go — gqlgen после года в проде: опыт, советы и выводы

  • вторник, 17 февраля 2026 г. в 00:00:06
https://habr.com/ru/companies/ru_mts/articles/994594/

Привет! На связи Петр Коробейников, я лидирую разработку бэкенда в одной из ключевых финтех-команд и отвечаю за то, чтобы пользователи приложения «Мой МТС» всегда видели актуальные данные своего счета. Если коротко, у нас в проекте Go на бэке, а для общения с приложением GraphQL — выбор продиктован платформой, и мы фактически предоставляем сабграф, к которому и обращается наша часть приложения.

В этой заметке я не буду сравнивать протоколы, холиварить на тему REST vs gRPC vs GraphQL или давать пошаговую инструкцию по GraphQL. Поделюсь опытом применения gqlgen в реальном проекте, а еще подсвечу, что сделал бы иначе полтора года назад (спойлер: не так уж и много).

Содержание

Раскладка файлов и кодогенерация

Быстро собрать и запустить приложение можно и по официальному туториалу, но лично мне не нравится, какую структуру размещения файлов в нем предлагают.

У нас весь сгенерированный код (выхлоп mockgen, protoc, gqlgen и прочих инструментов) попадает в папочку internal/generated. Описания схем — в папке contract, а в ней подпапки server — для схем, предоставленных сервером, и client — для схем, по которым генерируются клиенты для внешних сервисов.

Вот как это выглядит:

contract
├── client
│   ├── grpc
│   │   ├── client1
│   │   └── client2
│   └── openapi
│       ├── client1
│       └── client2
└── server
    ├── graphql
    │   └── <**our-app-graphql-schema-here**>
...
internal
├── generated
│   ├── contract
│   │   ├── client
│   │   └── server
│   │       └── graphql/
│   │           └── <**our-app-name**>/
│   │               ├── model/
│   │               │   └── generated_model.go
│   │               ├── generated_server.go
│   │               ├── <**our-app-name**>.resolvers.go
│   │               └── resolver.go
│   └── mockgen
...
├── repository
...
├── service
...

В целом все вписывалось в общую схему размещения, но было одно но: файлы resolver.go и *.resolvers.go на самом деле редактируемые, в них придется писать код. Что неприятненько, ведь мы хотели добиться идемпотентности генерации и никогда не трогать сгенерированный код.

Надо признать, что gqlgen очень грамотно добавляет и удаляет код в этих файлах. Если из схемы пропал резолвер, код его обработки переедет в самый конец файла и будет закомментирован — случайно ничего не потеряется. Но в первый раз я бы рекомендовал немного потренироваться и понаблюдать, как ведет себя генератор при изменении схемы.

В качестве примера — фрагмент конфигурации gqlgen.yml, в котором переопределены пути для соответствия нашему лейауту:

schema:
  - contract/server/graphql/our-app-name/*.graphqls
exec:
  filename: internal/generated/contract/server/graphql/our-app-name/generated_server.go
  package: graphql
model:
  filename: internal/generated/contract/server/graphql/our-app-name/model/generated_model.go
  package: model
resolver:
  layout: follow-schema
  dir: internal/generated/contract/server/graphql/our-app-name
  package: graphql
  filename_template: "{name}.resolvers.go"

Описание схемы

В случае с gqlgen мы используем подход Schema First: описываем схему, а затем генерируем по ней код. Вот примитивный пример схемы:

type Query {
}

type Mutation {
}

Мы применяем в коде расширение .graphqls, что соответствует нашей схеме (смотрите в конфиге выше — секция shema). Заметил, что многие используют расширение .graphql без «s» на конце. Не то чтобы это ошибка, но все же не очень правильно.

Ну и подписки (Subscription) в схеме не описаны, потому что мы их не используем.

Соглашения по именованию

Нейминг — всегда самая холиварная часть соглашений. Опишу, как именуем резолверы и типы данных мы, и заодно покритикую сам себя.

Во-первых, мы не используем snake_case. При этом в официальной документации для полей всегда применяют camelCase.

К сожалению, я не нашел адекватного линтера, который подсвечивает проблемы, чтобы повесить его на pre-commit (напишите в комментариях, если знаете такой). Поэтому ограничился утилитой format-graphql, чтобы расставить пропущенные пробелы и отсортировать описания.

Но выбор между snake_case и camelCase — не самая существенная проблема. Я размышлял, как именовать объекты и операции над ними, и пришел к такому варианту:

type Query {
  item: ItemResult
}

type Mutation {
  # Сначала объект, над которым совершается действие,
  # затем глагол.
  # Это сужает область поиска операции в автокомплите того же Graphiql.
  itemCreate(input: ItemCreateInput) ItemCreateResult
  itemUpdate(input: ItemUpdateInput) ItemUpdateResult
  itemDelete(input: ItemDeleteInput) ItemDeleteResult
}

type ItemResult {
  id: ID!
  name: String!
  # ...
}

input ItemCreateInput {
  name: String
  # ...
}

input ItemUpdateInput {
  name: String
  # ...
}

input ItemDeleteInput {
  id: ID!
}

Оглядываясь назад, я бы немного упростил эту схему:

  • Отказался бы от Item*Result и возвращал ID!(да, пришлось бы запрашивать данные из API, которые уже есть на стороне приложения).

  • Упростил аргумент для запросов item*Delete до ID!.

  • Возможно, отказался бы от суффикса *Result в Query — тут дело вкуса.

Так выглядела бы наша схема, проектируй я ее сейчас, а не тогда:

type Query {
  item: Item
}

type Mutation {
  itemCreate(input: ItemCreateInput) ID!
  itemUpdate(input: ItemUpdateInput) ID!
  itemDelete(id: ID!) ID!
}

type Item {
  id: ID!
  name: String!
  # ...
}

input ItemCreateInput {
  name: String
  # ...
}

input ItemUpdateInput {
  name: String
  # ...
}

Наверное, так выглядела бы наша схема, проектируй я ее сейчас. Но тогда я ориентировался на наши ограничения. Тогда имело смысл возвращать из мутации весь объект полностью в приложение, вместо того, чтобы делать на него отдельный запрос. Ограничение ушло, а схема осталась.

И основная мысль, которую я всегда держу в голове, — именование типов и методов (запросов и мутаций) должно оставаться единообразным для всей схемы.

А теперь поделюсь нашими подходами или лайфхаками, если угодно.

Объявили свой тип для передачи дат вместо String

В случае объявления собственного типа всю работу по преобразованию возьмет на себя сгенерированный сервер: не придется каждый раз вручную парсить дату при запросе или собирать ее в нужный формат при ответе.

Объявите собственный тип скаляра в ваше схеме и можете его использовать:

scalar DateTime

type Item {
  createdAt: DateTime!
  updatedAt: DateTime
}

Для поддержки определенного формата вам в любом случае потребуется написать код сериализации и десериализации — но только один раз для всего проекта. Чтобы не перегружать статью, вот ссылка на официальную документацию, там все подобно расписано.

Этот вариант подходит не только для дат со строгим форматом, но и для любых специфических типов вашей предметной области. А также для более специфичных скалярных типов, например UUID.

Кстати, поддержка UUID есть и из коробки, просто добавьте в секцию models вашего gqlgen.yml эти строки:

models:
  UUID:
    model:
      - github.com/99designs/gqlgen/graphql.UUID

Сделали резолверы пригодными для тестов

Когда я сгенерировал код по схеме, руки сами потянулись добавить механику обработки.

Примерно такие заглушки вы получите после генерации в файле your-app-name.resolvers.go:

// ...

func (r *mutationResolver) ItemCreate(ctx context.Context, input *model.ItemCreateInput) (*model.ItemCreateResult, error) {
	// Здесь по умолчанию строчка с panic("Not implemented")
}

// ...

Я же вынес код обработки отдельно, ведь в сгенерированных заглушках мы только обогащаем данные параметрами запроса, перехваченными в middleware выше по стеку:

// ...

func (r *mutationResolver) ItemCreate(ctx context.Context, input *model.ItemCreateInput) (*model.ItemCreateResult, error) {
	personID := PersonIDFromRequestContext(ctx)

	return r.itemCreateProcessor.Process(ctx, personID, input)
}

// ...

В таком виде покрыть модульными тестами код метода Process() остается делом техники. Не придется крафтить запрос с контекстом, где будет правильно лежать ID пользователя. У нас есть аргументы, которые мы подаем на вход, и результаты — на выходе.

Разобрались с «тяжелыми» резолверами внутри объектов

Из коробки gqlgen генерирует код, который позволяет вернуть весь объект целиком при его запросе. Даже если вы перечислили поля, которые хотите получить, вы все равно будете заполнять все поля этого объекта на бэке. Ненужные будут отрезаны при сериализации ответа.

Покажу на примере. Дана схема:

Query {
  item {
    id: ID!
    name: String!
    expensiveField: YourDomainTypeOrMayBeJustTooLongString!
  }
}

Так при запросе item вам придется заполнить еще и поле expensiveField:

{
  item {
    id
    name
  }
}

В официальной документации есть решение, но, как мне показалось, оно неочевидно новичкам, только что приступившим к работе с GraphQL. И вот ответ: выделить отдельный резолвер, который будет вызываться только тогда, когда поле запрашивается явно.

Как сконфигурировать такое поведение:

models:
  Item:
    fields:
      expensiveField:
        resolver: true

Теперь, если в запросе не указано поле expensiveField, то и код не будет выполняться:

{
  item {
    id
    name
    expensiveField # <-- будет вызвано два резолвера:
                   # для item и для expensiveField,
                   # результат смержится в объект item
  }
}

В отдельные резолверы стоит выделять действительно тяжелые поля, для возврата которых, например, нужно сходить в соседний сервис. Если у вас скалярные данные (скажем, ФИО) и хранятся они в одной таблице рядом, дешевле доставать их все, чем объявлять свой резолвер на каждое поле.

Настроили использование директив

Директивы в GraphQL помогают влиять на рантайм и/или кодогенерацию. В документации есть пример проверки роли пользователя в действии. Я же покажу наш реальный пример.

Мы используем в схеме директивы, чтобы добавлять теги структур в сгенерированный код. На эти теги смотрит механизм валидации. Вот пример:

directive @goTag(key: String!, value: String) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION

input ItemCreateInput {
  field1: String! @goTag(key: "validate", value: "validator_name1,validator_name2")
  field2: String! @goTag(key: "validate", value: "validator_name3")
  field3: String @goTag(key: "validate", value: "omitempty,validator_name4")
}

Директива @goTag поддерживается из коробки. Мы просто добавить ее в схему и применили к полю.

Теперь провалидировать пришедшие от пользователя данные можно одной строкой:

err := s.validator.StructCtx(ctx, input)
if err != nil {
	return nil, someWrappingAndProcessing(err)
}

Положили в модель дополнительные поля

Иногда случается, что в каком-то пользовательском вводе могут потребоваться дополнительные поля, например id пользователя. В явном виде это поле не передается — просто неудобно и бессмысленно для авторизованного пользователя в каждом пейлоаде прокидывать его id. Но на бэке мы знаем, кому принадлежит этот ввод.

Вот реальный пример. В рамках своей учетной записи пользователь не может создать более N-связанных с ней объектов (заметок, напоминаний, счетов, карточек, фоток профиля — ну вы понимаете).

Мы знаем id учетки, знаем, что пользователь пытается добавить. И чтобы не таскать дополнительный параметр id в каждую функцию, удобно положить его в ту же структуру с пользовательским вводом. Вот пример конфигурации в gqlgen.yml:

  ItemCreateInput:
    extraFields:
      PersonID:
        description: "Идентификатор пользователя, не присваивается автоматически."
        type: "github.com/google/uuid.UUID"

Теперь в коде мы можем задать это поле и передать на валидацию объект целиком:

input.PersonID = personID

err := s.validator.StructCtx(ctx, input)
if err != nil { /* ... */ }

Ну и, к слову, валидатор проще описать, когда вы работаете со структурой целиком, а не с отдельными полями.

Интегрировали OTel

Для трейсинга я не стал ничего придумывать, взял готовую библиотеку, которая полностью меня устроила: github.com/zhevron/gqlgen-opentelemetry/v2 — ее легко интегрировать, и она автоматически показывает запрос и параметры.

Для конфигурации один простой вызов:

server.Use(gqlgen_opentelemetry.Tracer{
	IncludeFieldSpans: true,
	IncludeVariables:  true,
})

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

Ну, и наконец, я решил поделиться тем, что мы не применяем у себя, но может быть полезно вам.

Отклонение сложных запросов

Когда это ограничение имеет значение? Если вы предоставляете свой граф наружу внешним клиентам — например, как это делает GitHub. Можно запретить выполнение тяжелых запросов, которые используют несколько резолверов и вытягивают большой объем данных за один присест.

Как мог бы выглядеть такой запрос? Например, так:

{
  item1: item {
    id
    name
    subitems {
      id
      name
      subitems {
        # ...
      }
    }
  }
  item2: item {
    id
    name
    subitems {
      id
      name
      subitems {
        # ...
      }
    }
  }
  # itemN ...
}

В этом синтетическом примере мы вызвали один и тот же резолвер несколько раз с разными алиасами и прошли вглубь по вложенным объектам.

Поскольку мы не предоставляем API наружу, то и не ограничиваем сложные запросы — знаем все их заранее. Но я решил упомянуть о механизме ограничения Query Complexity — наверняка кому-то будет актуально.

Реализовать его несложно. Подсчитывать сложность (complexity) каждого узла для ограничения «тяжести» запроса — это общая практика для GraphQL. В других языках и библиотеках механизм будет схожим. Поэтому мы всегда знаем цену в попугаях работы каждого из резолверов — понимаем, какова сложность всего запроса, и можем не пустить еще до выполнения:

server.Use(extension.FixedComplexityLimit(5))

Что еще я сделал бы иначе

Если бы у меня была возможность выбрать язык, фреймворк и библиотеку для поддержки GraphQL на старте, я отдал бы предпочтение двум вариантам:

  • Kotlin / SpringBoot / graphql starter

  • C# / Стандартный шаблон ASP.NET приложения / HotChocolate

Реализацию нашего сабграфа я повторил для себя в обоих вариантах. На мой взгляд, получилось существенно компактнее и выразительнее.

В случае со SpringBoot мы можем использовать привычный нам подход Schema First, связывая резолверы с кодом с помощью аннотаций. Это полностью убирает этап ручной кодогенерации.

В случае с HotChocolate удобно применять подход Code First. И поскольку мы предоставляем сервер, то всегда можем получить актуальную схему. И здесь, возможно, прожженные дотнетчики спросят: почему же HotChocolate, а не GraphQL? Я действительно сравнил оба фреймворка, а не только почитал, что пишут на Reddit. Интеграция и описание схемы на HotChocolate показалась мне плавнее, а документация дружелюбнее. Если заблуждаюсь, то поправьте в комментариях.

Послесловие

В этой заметке я подсветил те моменты, с которыми лично столкнулся в работе по предоставлению GraphQL API. Буду рад обсудить в комментариях ваши предложения и размышления.