В системах управления контентом (или CMS) часто приходится работать с огромными и постоянно меняющимися массивами данных. Так что оптимизация производительности уже не роскошь, а необходимость.

Привет! Я Олег, Python-разработчик в Kokoc Group, и сегодня расскажу, как ускорить работу с данными в CMS Wagtail и сделать разработку проще и приятнее с помощью GraphQL и Graphene. В статье разберу реальные примеры и покажу процесс настройки конкретной системы.

Теория. Когда и почему пора переходить на GraphQL: реальный пример с динамическими данными

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

Особенно для современных приложений, которым необходима гибкость в управлении данными, будь то интернет-магазины, системы бронирования или Telegram-боты. 

Покажу на примере, бота, которого я делал для одного из проектов

Бот делался для спортивных онлайн-марафонов. Он давал пользователям всю необходимую информацию по похудению, планы тренировок (при наличии подписки), возможность смотреть рецепты, сохранять свой прогресс: фото до и после, вес, анализы — и рассчитывать КБЖУ, причем внутри бота за счет интеграции с онлайн-анализатором.

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

Если бы я использовал REST API, управление этими запросами могло бы стать сложной задачей. Пришлось бы справляться с множеством эндпоинтов и сложной логикой, несмотря на возможность указать параметры для получения только нужных полей. И как следствие, усложнять архитектуру и увеличивать нагрузку на сервер.

Представьте, что у нас есть 1000 пользователей, которые настраивают меню и запрашивают рецепты. Если мы используем REST API, каждый запрос возвращает фиксированный набор данных, скажем, в 100 КБ, из которых пользователю нужно только 10 КБ. Это значит, что 90% данных передаются в никуда, и сервер, по сути, работает вхолостую. 

GraphQL же позволяет выбирать только действительно нужные данные, даже если они сложны и вложены. Хотите получить только название рецепта? Легко. Нужен полный комплект данных с фото, описанием и видео? Проще говоря, это — «шведский стол», на котором выбираешь только нужное.

А здесь собраны все различия между REST API и GraphQL.
А здесь собраны все различия между REST API и GraphQL.

Конечно, не каждый проект нужно срочно переводить на GraphQL. Если ваше приложение работает стабильно, запросов немного и данные не слишком динамичные, REST API вполне справится. Но если сервис растет, запросы становятся сложнее, и хочется оптимизировать работу с данными — самое время пересесть с «велосипеда» REST на GraphQL, чтобы быстро и легко «маневрировать» в мире динамически меняющихся данных.

Практика. Создание приложения и работа с GraphQL

Я обещал показать весь процесс настройки системы. Вот что мы сделаем:

  1. Создадим простое приложение на Wagtail, где будем работать с персонажами, локациями и эпизодами. Это поможет увидеть, как GraphQL упрощает работу с данными.

  2. Настроим GraphQL в Django-проекте: установим необходимые инструменты, чтобы GraphQL мог работать с нашим приложением.

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

  4. Научимся использовать интерфейс GraphQL для тестирования и отладки GraphQL-запросов. Это поможет проверить, что все работает правильно.

В результате вы получите практический опыт работы с GraphQL и сможете перенести его на свои проекты.

Разбираемся с настройкой

Для начала определимся со структурой моделей в нашем приложении

Character — данные по персонажам.Location — данные по локациям.Episode — данные по эпизодам.
Character — данные по персонажам.
Location — данные по локациям.
Episode — данные по эпизодам.

Если у вас еще нет приложения на Wagtail, то вот подробная инструкция по его созданию.

Определим модели в подпапке models в нашем проекте:

# modesl/location.py

class LocationTags(TaggedItemBase):
   content_object = ParentalKey(
       to='Location', on_delete=models.CASCADE,
       related_name='tagged_items'
   )


class Location(ClusterableModel):
   name = models.CharField(verbose_name=_("Location name"), unique=True)
   type = models.CharField(verbose_name=_("Location type"))
   dimension = models.CharField(verbose_name=_("Location dimension"))
   tags = TaggableManager(through='LocationTags', blank=True, related_name='location_tags')


   created = models.DateTimeField(auto_created=True, auto_now=True)
   modified = models.DateTimeField(auto_now=True)
# modesl/episode.py

class EpisodeTags(TaggedItemBase):
   content_object = ParentalKey(
       to='Episode', on_delete=models.CASCADE,
       related_name='tagged_items'
   )




class Episode(ClusterableModel):
   name = models.CharField(verbose_name=_("Episode name"))
   air_date = models.DateField(verbose_name=_("Episode air date"))
   code = models.CharField(verbose_name=_("Episode code"), unique=True)
   tags = TaggableManager(through='EpisodeTags', blank=True, related_name='episode_tags')


   created = models.DateTimeField(auto_created=True, auto_now=True)
   modified = models.DateTimeField(auto_now=True)
# models/character.py

class CharacterTags(TaggedItemBase):
   content_object = ParentalKey(
       to='Character', on_delete=models.CASCADE,
       related_name='tagged_items'
   )




class EpisodesStreamBlock(StreamBlock):
   episode = SnippetChooserBlock("project.Episode")




class Character(ClusterableModel):
   name = models.CharField(verbose_name=_("Character name"))
   status = models.CharField(verbose_name=_("Character status"))
   species = models.CharField(verbose_name=_("Character species"))
   gender = models.CharField(verbose_name=_("Character gender"))
   image = models.ForeignKey(
       "wagtailimages.Image",
       on_delete=models.CASCADE, verbose_name=_("Character image"), null=True
   )
   location = models.ForeignKey(
       "project.Location",
       on_delete=models.SET_NULL, verbose_name=_("Character current location"), null=True
   )
   episodes = StreamField(
       EpisodesStreamBlock(), use_json_field=True, verbose_name=_("Episodes in which the character appeared")
   )
   tags = TaggableManager(through='CharacterTags', blank=True, related_name='character_tags')


   created = models.DateTimeField(auto_created=True, auto_now=True)
   modified = models.DateTimeField(auto_now=True)
# wagtail_hooks.py

@register_snippet
class LocationSnippetViewSet(SnippetViewSet):
   model = Location
   menu_label = _('Locations')
   add_to_settings_menu = False
   add_to_admin_menu = True
   exclude_from_explorer = False




@register_snippet
class EpisodeSnippetViewSet(SnippetViewSet):
   model = Episode
   menu_label = _('Episodes')
   add_to_settings_menu = False
   add_to_admin_menu = True
   exclude_from_explorer = False




@register_snippet
class CharacterSnippetViewSet(SnippetViewSet):
   model = Character
   menu_label = _('Characters')
   add_to_settings_menu = False
   add_to_admin_menu = True
   exclude_from_explorer = False

Мы определили модели для нашего приложения и настроили их для работы в админке Wagtail. В следующем разделе перейдем к интеграции Wagtail с GraphQL через Graphene, чтобы обеспечить гибкое взаимодействие с данными и оптимизировать запросы. А также рассмотрим, как настроить GraphQL-схему и запросы для наших моделей, чтобы максимально эффективно использовать возможности GraphQL.

Устанавливаем пакеты

Установка graphene-django

Для начала нужно установить пакет graphene-django, который позволяет интегрировать GraphQL в Django-проект. Это можно сделать с помощью pip:

pip install graphene-django

Пакет содержит все необходимые инструменты для работы с GraphQL в рамках Django.

Определяем модуль API и добавляем его в URLs

Далее нужно создать модуль для вашего API, который будет обрабатывать GraphQL-запросы, и подключить его к маршрутам (urls.py). 

Структура модуля:

mutations — хранит все мутации схемы.nodes — хранит все описания моделей схемы.queries — хранит все запросы схемы.
mutations хранит все мутации схемы.
nodes хранит все описания моделей схемы.
queries — хранит все запросы схемы.

В urls.py модуля необходимо определить два основных эндпоинта:

# api/urls.py

# Определяем пространство имен для этого набора маршрутов
app_name = "api"


# Определяем список URL-путей для работы с GraphQL
urlpatterns = [
   # Путь для API GraphQL без интерфейса GraphiQL (используется для выполнения запросов)
   path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=False))),


   # Путь для интерфейса GraphiQL, который позволяет интерактивно тестировать запросы
   path('graphiql/', csrf_exempt(GraphQLView.as_view(graphiql=True, pretty=True))),
]

csrf_exempt в примере необходим для отключения проверки CSRF-токена. В реальных проектах следует учитывать меры безопасности, такие как аутентификация и защита от CSRF-атак.

В urls.py проекта подключаем наш модуль:

# core/urls.py

urlpatterns = [
    ...
    path('api/', include(api_urls, namespace='api')),
    ...
]

Настраиваем схемы для Graphene

Схема GraphQL — это основной элемент, который описывает структуру данных и запросов. Чтобы ее создать, нужно определить файл schema.py в модуле API. В этом файле создаются классы для обработки запросов (Query) и мутаций (Mutation).

# api/schema.py

# Определяем класс мутаций, который объединяет все мутации для персонажей, эпизодов и локаций
class Mutation(graphene.ObjectType, CharacterMutation, EpisodeMutation, LocationMutation):
   class Meta:
       # Описание для основной мутации
       description = "Main Mutation"




# Определяем класс запросов, который объединяет все запросы для персонажей, эпизодов и локаций
class Query(graphene.ObjectType, CharacterQuery, EpisodeQuery, LocationQuery):
   class Meta:
       # Описание для основного запроса
       description = "Main Query"



# Создаем схему GraphQL, которая включает в себя как запросы, так и мутации
schema = graphene.Schema(query=Query, mutation=Mutation)

Добавляем Graphene-Django в настройки проекта

Теперь нужно интегрировать Graphene-Django в проект, добавив его в список установленных приложений и указав путь к созданной схеме в файле settings.py:

INSTALLED_APPS = [
   ...
   "graphene_django",
   ...
]

...

GRAPHENE = {
   'SCHEMA': 'project.api.schema.schema',
}

Эта конфигурация позволит Django находить и использовать вашу GraphQL-схему.

Определяем Nodes, Queries и Mutations 

Nodes:

В GraphQL, Node(Type) — тип данных, который моделирует определенную сущность или объект в системе. Вот основные аспекты, которые помогут вам понять, что такое Node и как он работает:

  1. Типизация и моделирование. Node — это GraphQL-тип, который определяет, какие поля и типы данных доступны для запросов. Например, если у вас есть CharacterNode, это значит, что он представляет данные о персонаже и описывает, какие поля доступны для запроса, такие как имя, статус и изображение.

  2. Связь с Django. Node связывает модель Django с GraphQL-схемой. Он преобразует данные модели Django в формат, понятный GraphQL. Это позволяет GraphQL-запросам обращаться к данным Django-моделей без необходимости вручную обрабатывать их.

  3. Разрешители (Resolvers). Каждый Node может иметь методы, называемые разрешителями, которые определяют, как получать данные для каждого поля. Эти методы отвечают за выполнение запросов и возврат нужных данных. Например, resolve_episodes в CharacterNode возвращает список эпизодов, связанных с персонажем.

  4. Связи и Поля. Node может включать различные поля, которые определяют связи с другими типами данных. Например, поле episodes в CharacterNode может указывать на связанные эпизоды, а location — на местоположение персонажа. Это помогает строить сложные запросы и получать связанные данные.

Определим наши «Ноды»:

# api/nodes/base.py

# Определяем интерфейс TaggableInterface для объектов, которые могут иметь теги
class TaggableInterface(graphene.Interface):
   # Поле 'tags', которое представляет список тегов в виде строки
   tags = graphene.List(graphene.String)


   # Метод для разрешения значения поля 'tags'
   def resolve_tags(self, info):
       # Возвращаем все теги, связанные с объектом
       return self.tags.all()




class WagtailImageNode(DjangoObjectType):
   # Определяем GraphQL-тип для модели Image из Wagtail
   class Meta:
       model = Image
       # Указываем интерфейс для работы с тегами
       interfaces = (TaggableInterface,)
       # Указываем, что поле 'tags' должно быть исключено из GraphQL-типов
       exclude = ['tags']




# Регистрируем конвертер для поля типа Image
@convert_django_field.register(Image)
def convert_image(field, registry=None):
   # Возвращаем WagtailImageNode как GraphQL тип для модели Image
   return WagtailImageNode(
       description=field.help_text,  # Добавляем описание поля, если оно есть
       required=not field.null  # Указываем, обязательно ли это поле
   )

Подробнее про интерфейсы в GraphQL можно прочитать тут.

# api/nodes/character.py

# Определяем GraphQL-тип CharacterNode на основе Django-модели Character
class CharacterNode(DjangoObjectType):
   # Поле episodes для получения списка эпизодов, связанных с персонажем
   episodes = graphene.List("project.api.EpisodeNode")
   # Поле location для получения информации о локации персонажа
   location = graphene.Field("project.api.LocationNode")
   # Поле image для получения данных об изображении
   image = graphene.Field("project.api.WagtailImageNode")




   class Meta:
       # Связываем GraphQL-тип с моделью Character
       model = Character
       # Указываем интерфейс для работы с тегами
       interfaces = (TaggableInterface,)
       # Указываем поля, которые должны быть доступны в запросах
       only_fields = ('name', 'status', 'species', 'gender', 'image', 'created', 'modified')
       # Исключаем поле 'tags', чтобы оно не было доступно в запросах
       exclude = ('tags',)


   # Метод для разрешения запроса на получение эпизодов, связанных с персонажем
   def resolve_episodes(self: Character, info):
       result = []
       # Проходим по каждому эпизоду, связанному с персонажем
       for episode in self.episodes:
           # Добавляем идентификатор эпизода в результат
           result.append(episode.value)
       # Возвращаем список идентификаторов эпизодов
       return result


   # Метод для разрешения запроса на получение локации, связанной с персонажем
   def resolve_location(self: Character, info):
       # Возвращаем объект локации, связанный с персонажем
       return self.location
# api/nodes/episode.py

# Определяем GraphQL-тип EpisodeNode на основе Django-модели Episode
class EpisodeNode(DjangoObjectType):
   class Meta:
       # Связываем GraphQL-тип с моделью Episode
       model = Episode
       # Указываем интерфейс, который должен использоваться (в данном случае интерфейс для работы с тегами)
       interfaces = (TaggableInterface,)
       # Исключаем поле 'tags' из схемы GraphQL, чтобы оно не было доступно в запросах
       exclude = ('tags',)


   # Определяем поле character_set для получения списка персонажей, связанных с эпизодом
   character_set = graphene.List("project.api.CharacterNode")


   # Метод для разрешения запроса на получение списка персонажей, связанных с текущим эпизодом
   def resolve_character_set(self: Episode, info):
       # Фильтруем персонажей, которые связаны с эпизодом по его идентификатору
       return Character.objects.filter(
           episodes__contains=[{"value": self.id}]  # Ищем персонажей, у которых эпизод присутствует в поле episodes
       )
# api/nodes/location.py

# Определяем GraphQL-тип LocationNode на основе Django-модели Location
class LocationNode(DjangoObjectType):
   class Meta:
       # Связываем GraphQL-тип с моделью Location
       model = Location
       # Указываем интерфейс, который должен использоваться (в данном случае интерфейс для работы с тегами)
       interfaces = (TaggableInterface,)
       # Исключаем поле 'tags' из схемы GraphQL, чтобы оно не было доступно в запросах
       exclude = ('tags',)

Queries:

Queries — это запросы, которые позволяют клиентам получать данные от сервера. Вот основные аспекты, которые помогут вам понять, что такое Queries и как они работают:

  1. Определение запросов. Queries позволяют определить, какие данные можно запросить у сервера и в каком формате они будут возвращены. Каждый запрос в GraphQL представляет собой операцию, которая возвращает данные в виде структуры, соответствующей запросу. Например, запрос может быть использован для получения списка персонажей или конкретного персонажа по его ID.

  2. Функция разрешения (Resolver). Для каждого запроса в GraphQL определен метод разрешения (resolver), который отвечает за извлечение данных из базы данных или другой системы и возвращение их в ответ на запрос. Метод разрешения выполняет логику, необходимую для выполнения запроса, например, фильтрацию данных или получение информации по заданным критериям.

  3. Структура запросов. Запросы могут быть простыми и сложными, включать параметры для фильтрации данных или запрашивать связанные данные. А еще содержать поля, которые определяют, какие именно данные нужно вернуть.

Определим наши запросы:

# api/queries/character.py

# Класс CharacterQuery будет содержать GraphQL запросы для работы с персонажами
class CharacterQuery:
   # Запрос для получения списка персонажей с необязательным фильтром по статусу
   characters = graphene.List("project.api.CharacterNode", status=graphene.String(required=False))


   # Запрос для получения конкретного персонажа по его ID
   character = graphene.Field("project.api.CharacterNode", character_id=graphene.Int(required=True))


   # Метод для разрешения запроса на получение конкретного персонажа по его ID
   def resolve_character(self, info, character_id):
       try:
           # Пытаемся получить персонажа по ID
           return Character.objects.get(id=character_id)
       except Character.DoesNotExist:
           # Если персонаж с данным ID не найден, выбрасываем исключение
           raise Exception("Ошибка. Нет такого персонажа")


   # Метод для разрешения запроса на получение списка персонажей с фильтрацией по переданным аргументам
   def resolve_characters(self, info, **kwargs):
       # Создаем фильтр на основе переданных аргументов
       filters = Q(**kwargs)
       # Фильтруем список персонажей по условиям фильтра
       character = Character.objects.filter(filters)
       # Возвращаем отфильтрованный список персонажей
       return character
# api/queries/episode.py


# Класс для выполнения запросов по эпизодам
class EpisodeQuery:
   # Запрос для получения конкретного эпизода по его ID
   episode = graphene.Field("project.api.EpisodeNode", episode_id=graphene.Int(required=True))


   # Запрос для получения списка эпизодов с возможностью фильтрации по датам
   episodes = graphene.List(
       "project.api.EpisodeNode",
       date_from=graphene.Date(required=False),  # Дата начала диапазона
       date_to=graphene.Date(required=False)  # Дата окончания диапазона
   )


   # Метод для разрешения запроса на получение одного эпизода по его ID
   def resolve_episode(self, info, episode_id):
       try:
           # Пытаемся найти эпизод по его ID
           return Episode.objects.get(id=episode_id)
       except Episode.DoesNotExist:
           # Если эпизод не найден, выбрасываем исключение
           raise Exception("Ошибка. Нет такого эпизода")


   # Метод для разрешения запроса на получение списка эпизодов с фильтрацией по дате выпуска
   def resolve_episodes(self, info, date_from=None, date_to=None):
       # Создаем фильтр для фильтрации эпизодов по дате выпуска (air_date)
       filters = Q()


       # Если указана дата начала диапазона, добавляем фильтр на air_date ">= date_from"
       if date_from:
           filters &= Q(air_date__gte=date_from)


       # Если указана дата окончания диапазона, добавляем фильтр на air_date "<= date_to"
       if date_to:
           filters &= Q(air_date__lte=date_to)


       # Возвращаем отфильтрованный список эпизодов
       return Episode.objects.filter(filters)
# api/queries/locations.py


# Класс для выполнения GraphQL-запросов по локациям
class LocationQuery:
   # Запрос для получения конкретной локации по её ID
   location = graphene.Field("project.api.LocationNode", location_id=graphene.Int(required=True))


   # Запрос для получения списка локаций с возможностью фильтрации по типу
   locations = graphene.List("project.api.LocationNode", type=graphene.String(required=False))


   # Метод для разрешения запроса на получение одной локации по её ID
   def resolve_location(self, info, location_id):
       try:
           # Пытаемся найти локацию по её ID
           return Location.objects.get(id=location_id)
       except Location.DoesNotExist:
           # Если локация не найдена, выбрасываем исключение
           raise Exception("Ошибка. Нет такой локации")


   # Метод для разрешения запроса на получение списка локаций с возможностью фильтрации
   def resolve_locations(self, info, **kwargs):
       # Создаем фильтр на основе переданных аргументов (kwargs)
       filters = Q(**kwargs)
       # Применяем фильтр и возвращаем отфильтрованный список локаций
       return Location.objects.filter(filters)

Mutations:

Mutations — это операции, которые позволяют менять данные на сервере. В отличие от запросов (Queries), которые только получают данные, мутации их создают, обновляют и удаляют. Вот основные аспекты, которые помогут понять, что такое Mutations и как они работают:

  1. Изменение данных. Mutations позволяют выполнять операции, которые меняют состояние данных на сервере. Например, вы можете использовать их для добавления нового персонажа, обновления информации о существующем или удаления персонажа из базы данных.

  2. Определение мутаций. Каждая мутация определяет, какие данные должны изменить и вернуть после выполнения операции. 

  3. Методы разрешения (Resolvers). Для каждой мутации определен метод разрешения (resolver), который выполняет логику изменения данных. Этот метод обрабатывает входные данные, вносит изменения в базу и возвращает результат выполнения операции.

Определим наши мутации

# api/mutations/character.py


class CharacterInput(graphene.InputObjectType):
   """
   Входные данные для создания персонажа в GraphQL.
   """
   name = graphene.String(
       required=True,
       description="Имя персонажа (обязательно)."  # Описание для поля 'name', указывающее, что это обязательное поле
   )
   status = graphene.String(
       required=True,
       description="Текущий статус персонажа (обязательно)."  # Описание для поля 'status', обязательное
   )
   species = graphene.String(
       required=True,
       description="Вид персонажа (обязательно)."  # Описание для поля 'species', обязательное
   )
   gender = graphene.String(
       required=True,
       description="Пол персонажа (обязательно)."  # Описание для поля 'gender', обязательное
   )
   tags = graphene.List(
       graphene.String,
       required=False,
       description="Дополнительные теги, связанные с персонажем (необязательно)."  # Список тегов, необязательное поле
   )
   location = graphene.InputField(
       LocationInput,
       required=True,
       description="Локация, связанная с персонажем (обязательно)."  # Поле с локацией, обязательное
   )
   episodes = graphene.List(
       EpisodeInput,
       required=True,
       description="Список эпизодов, в которых персонаж появлялся (обязательно)."  # Обязательный список эпизодов
   )




class CreateCharacter(graphene.Mutation):
   """
   Мутация для создания нового персонажа.
   """


   class Arguments:
       character_input = CharacterInput(
           required=True,
           description="Данные о персонаже (обязательно)."  # Аргумент мутации с данными о персонаже
       )


   create = graphene.Field(
       graphene.Boolean,
       description="Указывает, был ли успешно создан персонаж."  # Поле, которое показывает статус создания персонажа
   )
   error_message = graphene.String(
       description="Сообщение об ошибке, если создание персонажа не удалось."  # Поле для сообщения об ошибке
   )
   character_data = graphene.Field(
       "project.api.CharacterNode",
       description="Данные о созданном персонаже."  # Поле с данными о созданном персонаже
   )
   location_data = graphene.Field(
       "project.api.LocationNode",
       description="Данные о локации, связанной с персонажем."  # Поле с данными о локации персонажа
   )
   episode_data = graphene.List(
       "project.api.EpisodeNode",
       description="Список эпизодов, связанных с персонажем."  # Поле для списка связанных эпизодов
   )


   # Основной метод для выполнения мутации создания персонажа
   @classmethod
   def mutate(cls, root, info, character_input: CharacterInput):
       """
       Выполняет мутацию для создания персонажа.


       Args:
           root: Корневой резолвер.
           info: Контекст GraphQL.
           character_input (CharacterInput): Входные данные для персонажа.


       Returns:
           CreateCharacter: Результат мутации, включая статус успешности и данные.
       """


       def _get_obj(model, get_field: dict = None, defaults: dict = None, tags: list = None):
           """
           Вспомогательная функция для получения или создания объекта модели.


           Args:
               model: Модель для поиска или создания.
               get_field: Поля для поиска объекта.
               defaults: Значения по умолчанию, если объект нужно создать.
               tags: Теги для объекта, если они есть.


           Returns:
               obj: Найденный или созданный объект.
           """
           obj, created = model.objects.get_or_create(
               **get_field,
               defaults=defaults
           )
           if created and tags:
               obj.tags.add(*tags)  # Добавляем теги, если объект был создан и теги присутствуют
           return obj


       try:
           # Получаем или создаем объект локации, основываясь на данных
           location_input: LocationInput = character_input.location
           location_obj: Location
           location_obj = _get_obj(
               Location,
               {"id": location_input.id}  # Используем ID для поиска объекта
           ) if location_input.id else _get_obj(
               Location,
               {"name": location_input.name},  # Используем имя, если ID отсутствует
               {
                   "type": location_input.type,
                   "dimension": location_input.dimension,
               }, location_input.tags)  # Заполняем поля для создания нового объекта


           # Создаем объект персонажа
           character = Character(
               name=character_input.name,
               status=character_input.status,
               species=character_input.species,
               gender=character_input.gender,
               location=location_obj  # Привязываем локацию
           )
           character.save()  # Сохраняем персонажа в базе данных


           # Обработка списка эпизодов, в которых персонаж появлялся
           episodes_stream_data = []
           episodes = []
           for episode_input in character_input.episodes:
               # Получаем или создаем эпизоды
               episode = _get_obj(
                   Episode,
                   {'id': episode_input.id},  # Поиск эпизода по ID
               ) if episode_input.id else _get_obj(
                   Episode,
                   {"code": episode_input.code},  # Поиск эпизода по коду
                   {
                       "name": episode_input.name,
                       'air_date': episode_input.air_date
                   },
                   episode_input.tags
               )
               episodes_stream_data.append(('episode', episode))  # Добавляем эпизод в StreamField данные
               episodes.append(episode)  # Добавляем эпизод в список


           # Преобразуем список эпизодов в StreamField
           episodes_stream_value = StreamValue(character.episodes.stream_block, episodes_stream_data)
           character.episodes = episodes_stream_value  # Сохраняем эпизоды в объекте персонажа
           character.save()  # Сохраняем персонажа после добавления эпизодов


           # Если указаны теги, добавляем их
           if character_input.tags:
               character.tags.add(*character_input.tags)  # Добавляем теги к персонажу


           # Возвращаем успешный результат мутации
           create = True
           return CreateCharacter(create=create, character_data=character, location_data=location_obj,
                                  episode_data=episodes)
       except Exception as err:
           # Ловим любые другие ошибки и возвращаем их
           return CreateCharacter(create=False, error_message=str(err))  # Возвращаем ошибку в случае исключения




# Объединяем мутации, связанные с персонажами
class CharacterMutation:
   create_character = CreateCharacter.Field()  # Определяем поле для создания персонажа в мутациях
# api/mutations/episode.py


class EpisodeInput(graphene.InputObjectType):
   """
   Входные данные для создания эпизода в GraphQL.
   """
   id = graphene.Int(
       required=False,
       description="Идентификатор эпизода (необязательно)."
   )
   name = graphene.String(
       required=False,
       description="Название эпизода (необязательно)."
   )
   air_date = graphene.Date(
       required=False,
       description="Дата выхода эпизода (необязательно)."
   )
   code = graphene.String(
       required=False,
       description="Код эпизода (необязательно)."
   )
   tags = graphene.List(
       graphene.String,
       required=False,
       description="Дополнительные теги для эпизода (необязательно)."
   )




class CreateEpisode(graphene.Mutation):
   """
   Мутация для создания нового эпизода.
   """


   class Arguments:
       episode_data = EpisodeInput(
           required=True,
           description="Данные об эпизоде (обязательно)."
       )


   create = graphene.Field(
       graphene.Boolean,
       description="Указывает, был ли успешно создан эпизод."
   )
   error_message = graphene.String(
       description="Сообщение об ошибке, если создание эпизода не удалось."
   )
   episode_data = graphene.Field(
       "project.api.EpisodeNode",
       description="Данные о созданном эпизоде."
   )


   # Основной метод для выполнения мутации создания эпизода
   @classmethod
   def mutate(cls, root, info, episode_data):
       """
       Выполняет мутацию для создания эпизода.


       Args:
           root: Корневой резолвер.
           info: Контекст GraphQL.
           episode_data (EpisodeInput): Входные данные для эпизода.


       Returns:
           CreateEpisode: Результат мутации, включая статус успешности и данные.
       """
       try:
           # Создаем объект эпизода
           episode = Episode.objects.create(
               id=episode_data.id,
               name=episode_data.name,
               air_date=episode_data.air_date,
               code=episode_data.code
           )
           # Если указаны теги, добавляем их
           if episode_data.tags:
               episode.tags.add(*episode_data.tags)


           # Возвращаем успешный результат мутации
           create = True
           return CreateEpisode(create=create, episode_data=episode)
       except IntegrityError:
           # Если эпизод с таким идентификатором уже существует, возвращаем ошибку
           return CreateEpisode(create=False, error_message="Такой эпизод уже существует в системе")
       except Exception as err:
           # Ловим любые другие ошибки и возвращаем их
           return CreateEpisode(create=False, error_message=str(err))




# Объединяем мутации, связанные с эпизодами
class EpisodeMutation:
   create_episode = CreateEpisode.Field()
# api/mutations/location.py


class LocationInput(graphene.InputObjectType):
   """
   Входные данные для создания локации в GraphQL.
   """
   id = graphene.Int(
       required=False,
       description="Идентификатор локации (необязательно)."
   )
   name = graphene.String(
       required=False,
       description="Название локации (необязательно)."
   )
   type = graphene.String(
       required=False,
       description="Тип локации (необязательно)."
   )
   dimension = graphene.String(
       required=False,
       description="Размерность локации (необязательно)."
   )
   tags = graphene.List(
       graphene.String,
       required=False,
       description="Дополнительные теги для локации (необязательно)."
   )




class CreateLocation(graphene.Mutation):
   """
   Мутация для создания новой локации.
   """


   class Arguments:
       location_data = LocationInput(
           required=True,
           description="Данные о локации (обязательно)."
       )


   create = graphene.Field(
       graphene.Boolean,
       description="Указывает, была ли успешно создана локация."
   )
   error_message = graphene.String(
       description="Сообщение об ошибке, если создание локации не удалось."
   )
   location_data = graphene.Field(
       "project.api.LocationNode",
       description="Данные о созданной локации."
   )


   # Основной метод для выполнения мутации создания локации
   @classmethod
   def mutate(cls, root, info, location_data):
       """
       Выполняет мутацию для создания локации.


       Args:
           root: Корневой резолвер.
           info: Контекст GraphQL.
           location_data (LocationInput): Входные данные для локации.


       Returns:
           CreateLocation: Результат мутации, включая статус успешности и данные.
       """
       try:
           # Создаем объект локации
           location = Location.objects.create(
               id=location_data.id,
               name=location_data.name,
               type=location_data.type,
               dimension=location_data.dimension
           )
           # Если указаны теги, добавляем их
           if location_data.tags:
               location.tags.add(*location_data.tags)


           # Возвращаем успешный результат мутации
           create = True
           return CreateLocation(create=create, location_data=location)
       except IntegrityError:
           # Если локация с таким идентификатором уже существует, возвращаем ошибку
           return CreateLocation(create=False, error_message="Такая локация уже существует")
       except Exception as err:
           # Ловим любые другие ошибки и возвращаем их
           return CreateLocation(create=False, error_message=str(err))




# Объединяем мутации, связанные с локациями
class LocationMutation:
   create_location = CreateLocation.Field()

Теперь посмотрим на результаты

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

Посмотрим на такую мутацию:

// наша мутация

mutation createCharacter($character_input: CharacterInput!) {
  createCharacter(characterInput: $character_input) {
    create
    errorMessage
    locationData {
      name
      type
      dimension
    }
    episodeData {
      name
      code
      airDate
    }
    characterData {
      name
      status
      species
      gender
      location {
        name
        type
        dimension
      }
      episodes {
        name
        code
        airDate
      }
    }
  }
}
// передаваемые значения - vaiables

{
  "character_input": {
    "name": "Rick Sanchez",
    "status": "Alive",
    "species": "Human",
    "gender": "Male",
    "tags": [
      "Scientist",
      "Adventurer"
    ],
    "location": {
      "name": "Earth",
      "type": "Planet",
      "dimension": "Dimension C-137",
      "tags": [
        "Planet"
      ]
    },
    "episodes": [
      {
        "name": "Pilot",
        "airDate": "2013-12-02",
        "code": "S01E01",
        "tags": [
          "First episode",
          "Season one"
        ]
      },
      {
        "name": "Ricksy Business",
        "airDate": "2014-04-06",
        "code": "S01E11",
        "tags": [
          "Eleven episode",
          "Season one"
        ]
      }
    ]
  }
}
// ответ от сервера
{
  "data": {
    "createCharacter": {
      "characterData": {
        "episodes": [
          {
            "airDate": "2013-12-02",
            "code": "S01E01",
            "name": "Pilot"
          },
          {
            "airDate": "2014-04-06",
            "code": "S01E11",
            "name": "Ricksy Business"
          }
        ],
        "gender": "Male",
        "location": {
          "dimension": "Dimension C-137",
          "name": "Earth",
          "type": "Planet"
        },
        "name": "Rick Sanchez",
        "species": "Human",
        "status": "Alive"
      },
      "create": true,
      "episodeData": [
        {
          "airDate": "2013-12-02",
          "code": "S01E01",
          "name": "Pilot"
        },
        {
          "airDate": "2014-04-06",
          "code": "S01E11",
          "name": "Ricksy Business"
        }
      ],
      "errorMessage": null,
      "locationData": {
        "dimension": "Dimension C-137",
        "name": "Earth",
        "type": "Planet"
      }
    }
  }
}

Теперь если мы запросим всю информацию по персонажам:

// запрос всех персонажей

query allCharacters {
  characters {
    episodes {
      airDate
      code
      name
      tags
    }
    gender
    image {
      file
      fileSize
      title
    }
    location {
      dimension
      name
      tags
      type
    }
    name
    species
    status
    tags
  }
}
// ответ с сервера

{
  "data": {
    "characters": [
      {
        "episodes": [
          {
            "airDate": "2013-12-02",
            "code": "S01E01",
            "name": "Pilot",
            "tags": [
              "First episode",
              "Season one"
            ]
          },
          {
            "airDate": "2014-04-06",
            "code": "S01E11",
            "name": "Ricksy Business",
            "tags": [
              "Season one",
              "Eleven episode"
            ]
          }
        ],
        "gender": "Male",
        "image": null,
        "location": {
          "dimension": "Dimension C-137",
          "name": "Earth",
          "tags": [
            "Planet"
          ],
          "type": "Planet"
        },
        "name": "Rick Sanchez",
        "species": "Human",
        "status": "Alive",
        "tags": [
          "Scientist",
          "Adventurer"
        ]
      }
    ]
  }
}

Заключение

Итак, если вы смотрите на свой Wagtail и его обилие запросов с усталостью в глазах, возможно, вам пора познакомиться с GraphQL. Это как отправиться на фитнес для серверов: меньше тяжестей, больше гибкости. Вместо того, чтобы «тащить» избыточные данные, GraphQL позволяет взять только то, что в списке. Еще и сделает это изящно!

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

И да, не забудьте оставить дверь открытой для REST, он все так же полезен и пригождается в огромном множестве проектов!

Комментарии (1)


  1. ggo
    25.09.2024 08:25

    Вся преимущество GraphQL в федерации.

    Когда у вас микросервисы, и хочется иметь одну точку управления схемами со всех микросервисов. Вот тогда, да, GraphQL проявляется во всей красе. Правда не достается бесплатно, есть свои ограничения.

    Если у вас один сервис, то особого преимущества REST vs GQL нет.