Привет! Мы – Екатерина и Виктория, разработчик и старший разработчик в БФТ-Холдинге. В статье кратко расскажем об основах DGS фреймворка, его преимуществах, проблемах, с которыми мы столкнулись при работе с ним, а также покажем создание простого сервиса с поддержкой WebFlux.

DGS (Domain Graph Service) – open source проект компании Netflix. Сначала он был внутренним проектом компании, однако в 2020 году было решено сделать его открытым для сообщества. Фреймворк развивается с 2019 года и использовался Netflix еще до вывода в open source. По словам создателей, DGS фреймворк является production-ready решением.

DGS один из нескольких фреймворков для работы с GraphQL в Java. Он построен на основе библиотеки graphql-java и упрощает работу с ней.

Причины выбора DGS Framework

DGS предоставляет удобные готовые инструменты для настройки проекта. Фреймворк предоставляет возможность настраивать шаблонный обработчик запросов через DataFetcher<T> и собирать схему с помощью аннотаций @DgsCodeRegistry и @DgsTypeDefinitionRegistry, что позволяет хранить метаданные схемы в любой структуре и строить их во время старта приложения. В том числе DGS фреймворк умеет загружать уже готовые схемы GraphQLSchema, где они будут объединены с другими схемами (уже готовыми или из аннотаций). В своем примере для простоты мы рассматриваем именно загрузку уже готовой схемы, но на практике проект чаще всего выглядит не так просто.

Технические требования

DGS фреймворк использует Spring Boot. Для 6.x и более поздних версий фреймворка требуется Spring Boot 3 и JDK 17. Однако, можно использовать фреймворк и с более ранними версиями Spring Boot, тогда нужно будет использовать более старые версии DGS фреймворка. Для проектов со Spring Boot 2.7 подойдут релизы DGS 5.5.x. Если же в проекте используется Spring Boot 2.6, тогда потребуется версия 5.4.x.

Реализация GraphQL сервиса

Здесь будут кратко описаны основные моменты создания приложения с использованием DGS фреймворка.

Начальная настройка

Начнем с создания Spring Boot приложения. В проекте использовались JDK 17, Spring Boot 3.0.11, WebFlux и Maven. Напомним, что старые версии Spring Boot не поддерживаются в новых версиях DGS (начиная с 6.x).

Структура проекта будет выглядеть таким образом:

Структура проекта

Перейдем в сгенерированный проект. Добавим необходимые зависимости в pom.xml:

 <dependencyManagement>
      <dependencies>
          <dependency>
              <groupId>com.netflix.graphql.dgs</groupId>
              <artifactId>graphql-dgs-platform-dependencies</artifactId>
              <!-- The DGS BOM/platform dependency. This is the only place you set version of DGS -->
              <version>6.0.5</version>
              <type>pom</type>
              <scope>import</scope>
          </dependency>
					<!-- fix bug Could not initialize class com.netflix.graphql.dgs.DgsExecutionResult$Builder -->
					<dependency>
              <groupId>com.graphql-java</groupId>
              <artifactId></artifactId>
              <version>20.3</version>
          </dependency>
      </dependencies>
  </dependencyManagement>
<dependency>
    <groupId>com.netflix.graphql.dgs</groupId>
    <artifactId>graphql-dgs-webflux-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.28</version>
</dependency>

В тестовом проекте мы рассматриваем именно graphql-dgs-webflux-starter, так как по большей части мы стремимся к использованию реактивного программирования, и нам необходимо было выяснить возможность поддержки работы неблокирующего подхода со стороны DGS Framework. Если проект основан на обычном Spring Boot, то можно использовать graphql-dgs-spring-boot-starter.

Блок com.graphql-java в <dependencyManagement> требуется для исправления ошибки Could not initialize class com.netflix.graphql.dgs.DgsExecutionResult$Builder. Причина этой ошибки состоит в том, что по умолчанию тянется неподходящая версия зависимости com.graphql-java. Поэтому нужно указать версию вручную в dependencyManagement (подробнее о проблеме и решении можно почитать здесь).

Кодогенерация

Если есть необходимость генерировать java-модели и data fetcher, можно добавить использование кодогенератора. У фреймворка есть такой плагин (для Gradle, для Maven есть только плагин от комьюнити). Мы использовали только базовые возможности и настройки плагина: указали путь к схеме <schemaPaths> и пакет для сгенерированных классов <packageName>.

Плагин достаточно гибкий и позволяет настроить многие свои параметры. Подробнее о настройках генерации и списке настраиваемых параметров можно прочитать на странице плагина в GitHub.

 <dependency>
    <groupId>com.netflix.graphql.dgs.codegen</groupId>
    <artifactId>graphql-dgs-codegen-client-core</artifactId>
    <version>5.1.17</version>
</dependency>
<plugin>
    <groupId>io.github.deweyjose</groupId>
    <artifactId>graphqlcodegen-maven-plugin</artifactId>
    <version>1.24</version>
    <executions>
        <execution>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <schemaPaths>
            <param>src/main/resources/schema/blog.graphqls</param>
        </schemaPaths>
        <packageName>com.example.blogdemo.generated</packageName>
    </configuration>
</plugin>
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>build-helper-maven-plugin</artifactId>
    <executions>
        <execution>
            <phase>generate-sources</phase>
            <goals>
                <goal>add-source</goal>
            </goals>
            <configuration>
                <sources>
                    <source>${project.build.directory}/generated-sources</source>
                </sources>
            </configuration>
        </execution>
    </executions>
</plugin>

Схема

Далее, следуя schema-first подходу, добавим GraphQL схему в src/resources/schema/blog.graphqls. В данном проекте в качестве примера будет описана структура простого блога с постами, комментариями и их авторами:

type Query {
    posts(titleFilter: String): [Post]
    post(idFilter: Int!): Post
}

type Post {
    id: Int!
    title: String
    text: String
    likes: Int
    author: User!
    comments: [Comment]
}

type Comment {
    id: Int!
    text: String!
    user: User!
    post: Post!
}

type User {
    id: Int!
    name: String!
    email: String
}

Эта схема описывает два запроса: списка постов (posts) и одного поста по его id (post). В запросе posts фильтр по заголовку titleFilter является необязательным, то есть можно будет выполнять запрос с фильтром или без него. Также в схеме содержится описание объектных типов данных, которые используются в этих запросах (Post, Comment и User).

Модели

Затем потребуются аналогичные java-модели, соответствующие каждому описанному в схеме объектному типу. Их можно либо сгенерировать на основе схемы с помощью плагина, о котором говорилось выше, либо написать самостоятельно. Они представляют собой обычные POJO классы и отражают структуру данных, описанную в blog.graphqls. Например, класс данных о посте будет выглядеть таким образом:

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Post {
  private Integer id;
  private String title;
  private String text;
  private Integer likes;
  private Integer authorId;
}

Для упрощения структуры класса использую Lombok.

Data Fetcher

Data fetcher отвечают за обработку запроса и возвращают результат его выполнения. Они так же могут быть сгенерированы или написаны вручную. Эти классы должны быть отмечены аннотацией @DgsComponent. Например, data fetcher для постов будет выглядеть так:

@DgsComponent
@AllArgsConstructor
public class PostDataFetcher {
    private final PostService postService;

    @DgsQuery
    public Flux<Post> posts(@InputArgument String titleFilter) {
        if (titleFilter == null) {
            return postService.getPosts();
        }
        return postService.getPostsByTitle(titleFilter);
    }

    @DgsQuery
    public Mono<Post> post(@InputArgument Integer idFilter) {
        return postService.getPostById(idFilter);
    }
}

Методы нужно отметить аннотацией @DgsQuery и они соответствует запросам, описанным в схеме blog.graphqls . Кроме @DgsQuery в DGS фреймворке есть специализированные аннотации, которые указывают на другие GraphQL операции: @DgsMutation и @DgsSubscription.

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

Data Loader и проблема N+1

Представим, что требуется получить список постов с информацией об авторе каждого поста. И допустим, что посты и авторов мы будем получать из двух разных сервисов. В простой реализации, для получения информации о N постах потребуется 1 раз обратиться к сервису постов для получения списка постов и N раз обращаться к сервису с авторами, по одному разу для каждого поста. Значит, в сумме потребуется выполнить N+1 запрос. Очевидно, что это не очень оптимальное решение.

Проблема N+1
Проблема N+1

Описанная ситуация известна как проблема N+1. Она не является уникальной для GraphQL, и, в зависимости от инструмента, может решаться различными способами. При использовании DGS эту проблему можно решить с помощью использования data loader и пакетной загрузки данных.

Процесс получения данных изменится таким образом: после получения списка постов будет подготавливаться список требующихся id авторов. Далее с полученным набором id будет выполнен запрос сразу всего списка авторов.

Решение проблемы N+1
Решение проблемы N+1

Реализация такого подхода возможна при выполнении двух условий. Во-первых, сервис авторов должен предоставлять возможность загрузить список пользователей по списку id. И, во-вторых, data fetcher должен быть способен выполнять пакетную загрузку из сервиса пользователей.

Создадим data loader. Этот класс будет реализовывать org.dataloader.BatchLoader или org.dataloader.MappedBatchLoader. Эти классы являются дженериками, поэтому потребуется указать типы для ключа и объекта результата. В данном примере мы будем искать пользователей с типом User по их id типа Integer, поэтому будем использовать org.dataloader.BatchLoader<Integer, User>.

@DgsDataLoader(name = "users")
@AllArgsConstructor
public class UserLoader implements BatchLoader<Integer, User> {
    private final UserService userService;
    @Override
    public CompletionStage<List<User>> load(List<Integer> list) {
        return CompletableFuture.supplyAsync(()->userService.getUserListByIds(list));
    }
}

В созданном классе потребуется реализовать только один метод: CompletionStage<List> load(List keys). Созданный класс необходимо пометить аннотацией @DgsDataLoader, чтобы фреймворк распознал его как data loader. Однако, хоть data loader и будет зарегистрирован благодаря аннотации, он не будет использован, пока не будет описано его использование в data fetcher.

При получении списка комментариев для списка постов, описанный подход останется применимым, но с некоторыми изменениями. Изменятся тип результата и типы данных, которые получает и возвращает переопределяемый метод:

@DgsDataLoader
@AllArgsConstructor
public class CommentLoader implements MappedBatchLoader<Integer, List<Comment>> {
    private final CommentService commentService;
    @Override
    public CompletionStage<Map<Integer, List<Comment>>> load(Set<Integer> list) {
        return CompletableFuture.supplyAsync(()->commentService.getCommentListByPostIds(new ArrayList<>(list)));
    }
}

Использование Data Loader

Как было упомянуто выше, описанный data loader необходимо использовать в каком-нибудь data fetcher. Создадим такой data fetcher. Так как пользователь будет нужен и в посте, и в комментарии, метода в классе будет тоже два.

@DgsComponent
public class UserDataFetcher {
    @DgsData(parentType = "Post", field = "author")
    public CompletableFuture<User> author(DgsDataFetchingEnvironment dfe) {
        DataLoader<Integer, User> dataLoader = dfe.getDataLoader(UserLoader.class);
        Post post = dfe.getSource();
        Integer id = post.getAuthorId();
        return dataLoader.load(id);
    }

    @DgsData(parentType = "Comment", field = "user")
    public CompletableFuture<User> commentUser(DataFetchingEnvironment dfe) {
        DataLoader<Integer, User> dataLoader = dfe.getDataLoader("users");
        Comment comment = dfe.getSource();
        Integer id = comment.getUserId();
        return dataLoader.load(id);
    }
}

В @DgsData указываем родительский объектный тип и поле, для которого этот метод будет выполняться. Тип возвращаемого значения у методов будет CompletableFuture, это требуется для пакетной загрузки данных. Самое же главное отличие этого data fetcher будет в том, что данные будут подгружаться не напрямую из сервиса с данными, а через data loader.

Получить нужный data loader можно двумя способами, в зависимости от того, что указано в получаемых параметрах метода: DataFetchingEnvironment или DgsDataFetchingEnvironment. В случае работы с DgsDataFetchingEnvironment искать нужный data loader можно по имени класса. Этот способ является более типо-безопасным. Если же использовать DataFetchingEnvironment, то поиск data loader будет осуществляться по его имени. Поэтому потребуется задать это имя с помощью параметра name в аннотации @DgsDataLoader.

В примере выше первый метод использует DgsDataFetchingEnvironment, второй – DataFetchingEnvironment.

RxJava в Data Fetcher

Небольшое замечание о том, почему в data fetcher, использующем data loader, возвращаемым типом является CompletableFuture, а не Flux, как ожидалось. Как уже упоминалось в начале статьи, DGS фреймворк использует внутри библиотеку graphql-java. А graphql-java, в свою очередь, не поддерживает RxJava / WebFlux. И DGS сам конвертирует CompletableFuture в Mono/Flux.

Запуск

DGS фреймворк по умолчанию добавляет в проект возможность использования инструмента GraphiQL. Получить к нему доступ можно по адресу http://localhost:8080/graphiql после стандартного запуска проекта.

Вывод

DGS Framework предоставляет простой и довольно привлекательный способ использования GraphQL. Платформа DGS делает быстрой и доступной обработку выполнения запросов, упрощая процесс разработки проекта. Исходный код демонстрационного проекта можно найти в моем репозитории.

В этой статье рассматривается лишь малая часть того, что умеет DGS Framework, поэтому если у вас остались вопросы по фреймворку, мы с удовольствием ответим на них.

Надеюсь, статья была интересной и полезной. Спасибо за внимание!

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


  1. JajaComp
    15.04.2024 10:38

    Лично мне graphql-kotlin показался более гибким, поэтому выбрал его.