REST умер? Почему Java-разработчики уходят в GraphQL

от автора

Один экран в приложении, а на бэкенде несколько REST-вызовов, куча эндпоинтов и ответы, где 90% полей не используются. Теряем в скорости, усложняется фронтенд и приходится версионировать контракт, когда меняется формат данных.

GraphQL предлагает другой подход: один API-эндпоинт и запрос, в котором клиент сам указывает, какие поля ему нужны. Это снижает overfetching, уменьшает количество сетевых затрат и упрощает договоренности между фронтом и бэком за счет схемы как явного контракта и живой документации.

В новом переводе от команды Spring АйО разберем, где GraphQL реально помогает: как уйти от разрастания эндпоинтов, как держать контракт синхронизированным и что делать с типичными проблемами производительности и наблюдаемости, когда данные собираются из разных источников.

Почему GraphQL?

Сначала давайте обсудим слона в комнате: не стоит учить что-то только ради строки в резюме. Избегайте Resume Driven Development! Однако GraphQL решает несколько очень конкретных болевых точек, с которыми вы могли сталкиваться при построении традиционных REST API:

  • Больше никакой избыточной выборки. REST-эндпоинты часто возвращают огромный объём данных, когда вам нужны всего пара полей. GraphQL позволяет клиенту запрашивать ровно то, что ему нужно.

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

  • Устранение разрастания эндпоинтов. Попрощайтесь с /users/{id}, /users/{id}/posts и сложными стратегиями версионирования (/v1, /v2). GraphQL даёт один-единственный эндпоинт, который обрабатывает все запросы к данным.

  • Встроенная документация. Поскольку GraphQL опирается на строго типизированную схему, вы получаете встроенную «живую» документацию. Инструменты вроде обозревателя GraphiQL позволяют интроспектировать и тестировать API прямо из коробки.

Проект: GraphQL Books

На протяжении статьи мы создаём приложение, которое управляет библиотекой книг, авторов и отзывов. Чтобы быстро начать работу без ручной установки внешних баз данных, мы используем Docker Compose — он поднимает базу PostgreSQL и экземпляр Zipkin для трассировки.

Вот compose.yaml, который используется для локальной разработки:

services:  postgres:    image: 'postgres:latest'    environment:      - 'POSTGRES_DB=books'      - 'POSTGRES_PASSWORD=password'      - 'POSTGRES_USER=admin'    ports:      - '5432:5432'  zipkin:    image: 'openzipkin/zipkin:latest'    ports:      - '9411:9411'

Модуль Docker Compose в Spring Boot автоматически запускает эти контейнеры, когда вы стартуете приложение. Никакой ручной настройки не требуется.

1. Разработка по схеме (Schema-First)

В отличие от REST API, где контракт часто остаётся «на потом», GraphQL продвигает подход schema-first. Мы заранее определяем контракт API с помощью Schema Definition Language (SDL). Это гарантирует, что фронтенд- и бэкенд-команды согласованы ещё до начала реализации.

Вот часть схемы из проекта:

type Query {  books: [Book!]!  book(id: Int!): Book!  authors: [Author!]!  search(text: String) : [SearchItem!]!}type Mutation {  addBook(bookInput: BookInput): Book!}type Book {  id: ID!  title: String!  author: Author!}type Author {  id: ID!  name: String!  books: [Book!]!}input BookInput {  title: String!  authorId: Int!}union SearchItem = Author | Book

Ключевые моменты: типы операций Query и Mutation определяют, что именно могут запрашивать клиенты; input-типы используются для передачи структурированных аргументов; а union-тип — для union типов 🙂

Spring for GraphQL также выводит на старте отличный отчёт Schema Mapping Inspection Report. Он проверяет соответствие вашего Java-кода схеме, убеждаясь, что нет неразмеченных полей или отсутствующих fetcher’ов данных. Если что-то рассинхронизировано, вы увидите это прямо в выводе консоли ещё до того, как первый запрос попадёт на сервер.

2. Data Fetchers (контроллеры)

Чтобы связать GraphQL-схему с Java-кодом, мы создаём контроллеры. С помощью аннотаций вроде @QueryMapping и @MutationMapping мы сопоставляем бэкенд-логику операциям, определённым в схеме. Вот BookController:

@Controllerpublic class BookController {    private final BookRepository bookRepository;    private final AuthorRepository authorRepository;    public BookController(BookRepository bookRepository, AuthorRepository authorRepository) {        this.bookRepository = bookRepository;        this.authorRepository = authorRepository;    }    @QueryMapping    public List<Book> books() {        return bookRepository.findAll();    }    @QueryMapping    public Optional<Book> book(@Argument Long id) {        return bookRepository.findById(id);    }    @MutationMapping    public Book addBook(@Argument BookInput bookInput) {        var author = authorRepository.findById(bookInput.authorId());        var book = new Book();        book.setTitle(bookInput.title());        book.setAuthor(author.orElseThrow());        return bookRepository.save(book);    }}

Аннотация @QueryMapping — это сокращение для @SchemaMapping(typeName = "Query"). Spring автоматически сопоставляет имя метода с соответствующим полем в схеме. Аннотация @Argument привязывает входящие аргументы GraphQL к параметрам метода.

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

public record BookInput(String title, Long authorId) {}

Records идеально подходят для GraphQL input-типов: они неизменяемые, лаконичные, а Spring for GraphQL может автоматически связывать входящие аргументы с конструктором record’а.

С включённым интерфейсом GraphiQL вы можете интерактивно тестировать эти операции:

# найти все книги вместе с их авторамиquery {  books {    id    title    author {      name    }  }}# найти конкретную книгу, используя переменныеquery findBookById($id: Int!) {  book(id: $id) {    id    title    author {      id      name    }  }}# добавить новую книгуmutation {  addBook(bookInput: {title: "new book", authorId: 1}) {    id    title  }}

3. Решение проблемы N+1 с пакетной загрузкой

Производительность критически важна. Если мы запрашиваем список авторов, а затем отдельно получаем книги для каждого автора, мы сталкиваемся с классической проблемой N+1 запросов:

@SchemaMappingpublic List<Book> books(Author author) throws InterruptedException {  log.info("Retrieving books for author " + author.getName());  return bookRepository.findByAuthor(author);}

Если у вас 6 авторов, этот метод выполняет 6 отдельных запросов. Вы можете наблюдать, как SQL-операторы накапливаются в консоли, с spring.jpa.show-sql=true. Исправление — аннотация @BatchMapping, которая группирует все эти обращения в один вызов к базе данных:

@BatchMappingpublic List<List<Book>> books(List<Author> authors) {    log.info("Batch loading books for {} authors", authors.size());    List<Long> authorIds = authors.stream()        .map(Author::getId)        .toList();    List<Book> allBooks = bookRepository.findByAuthorIdIn(authorIds);    Map<Long, List<Book>> booksByAuthorId = allBooks.stream()        .collect(Collectors.groupingBy(book -> book.getAuthor().getId()));    return authors.stream()        .map(author -> booksByAuthorId.getOrDefault(author.getId(), Collections.emptyList()))        .toList();}

Сигнатура метода говорит сама за себя. Spring передаёт полный список объектов Author, для которых нужно разрешить поле books, а вы возвращаете List<List>, где порядок соответствует входному списку. Один запрос — и готово.

Чтобы сделать это ещё более масштабируемым, можно включить виртуальные потоки (Virtual Threads), чтобы выборка данных выполнялась параллельно, а не последовательно в рамках одного потока Tomcat. Это одна строка в конфигурации:

spring:  threads:    virtual:      enabled: true

4. Продвинутый поиск с Unions

Что если пользователь ищет по ключевому слову, а вы хотите возвращать либо Book, либо Author? Объединения GraphQL (Unions) позволяют это сделать. В схеме мы определяем:

union SearchItem = Author | Book

А затем реализуем SearchController, который возвращает полиморфные результаты:

@Controllerpublic class SearchController {    private static final Logger log = LoggerFactory.getLogger(SearchController.class);    private final BookRepository bookRepository;    private final AuthorRepository authorRepository;    public SearchController(BookRepository bookRepository, AuthorRepository authorRepository) {        this.bookRepository = bookRepository;        this.authorRepository = authorRepository;    }    @QueryMapping    public List<Object> search(@Argument String text) {        log.debug("Searching for '{}'", text);        List<Object> results = new ArrayList<>();        results.addAll(authorRepository.findAllByNameContainsIgnoreCase(text));        results.addAll(bookRepository.findAllByTitleContainsIgnoreCase(text));        return results;    }}

Тип возвращаемого значения — List, потому что результаты могут быть экземплярами Author или Book. Spring for GraphQL автоматически выполняет разрешение типа, поскольку оба класса находятся в том же пакете, что и типы схемы, которые они представляют. Клиенты затем могут использовать inline fragments, чтобы запрашивать поля, специфичные для конкретного типа:

query {  search(text: "Spring") {    ... on Book {      title    }    ... on Author {      name    }  }}

5. Query By Example и @GraphQlRepository

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

Spring for GraphQL решает эту задачу с помощью @GraphQlRepository и Query by Example:

@GraphQlRepositorypublic interface ReviewRepository extends JpaRepository<Review, Long>, QueryByExampleExecutor<Review> {}

Это и есть весь репозиторий. Аннотация @GraphQlRepository автоматически создаёт data fetcher’ы для запросов в вашей схеме, которые соответствуют этому типу. В сочетании с QueryByExampleExecutor клиенты могут динамически фильтровать данные без того, чтобы вы писали какой-либо дополнительный код контроллеров.

На стороне схемы мы определяем input-тип ReviewFilter:

input ReviewFilter {  rating: Int  verified: Boolean  reviewerName: String}

И соответствующий Java record:

public record ReviewFilter(  Integer rating,  Boolean verified,  String reviewerName) {}

Теперь клиенты могут отправлять такие запросы — и фильтрация будет работать «из коробки»:

# найти только верифицированные отзывы{  reviews(filter: { verified: true }) {    reviewerName    rating    comment  }}# найти отзывы конкретного рецензента{  reviews(filter: { reviewerName: "Sarah Chen" }) {    book {      title    }    rating    comment  }}

Поля в фильтре все допускают null, поэтому клиенты могут включать или опускать любую комбинацию, которая им нужна.

6. Репозитории Spring Data AOT

Spring Boot 4 вводит Spring Data AOT (Ahead-of-Time) компиляцию. Добавив цель process-aot в Maven-плагин, мы переносим обработку запросов репозиториев из runtime в build time. Вместо того чтобы на каждом старте разбирать имена методов производных запросов, AOT-процессор заранее генерирует SQL-выражения и реализации репозиториев во время сборки.

В проекте это уже настроено в pom.xml:

<plugin>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-maven-plugin</artifactId>    <executions>        <execution>            <id>process-aot</id>            <goals>                <goal>process-aot</goal>            </goals>        </execution>    </executions></plugin>

Это даёт более быстрый старт (команда Spring упоминает улучшение на 50–70%), обнаружение ошибок на этапе сборки — например, опечаток в именах методов вроде findByNamme, — меньший расход памяти и возможность просматривать сгенерированные SQL-реализации в target/. Репозитории в этом проекте, такие как BookRepository и AuthorRepository, используют производные методы запросов, которые выигрывают от AOT-обработки:

public interface BookRepository extends JpaRepository<Book, Long> {    @Override    @EntityGraph(attributePaths = "author")    List<Book> findAll();    List<Book> findAllByTitleContainsIgnoreCase(String title);    List<Book> findByAuthorIdIn(List<Long> authorIds);}

7. Интеграция клиентского приложения

Мы не ограничиваемся сервером. Мы также рассматриваем, как потреблять GraphQL API из Java-приложения. ClientApp — это отдельное приложение Spring Boot (с WebApplicationType.NONE), которое демонстрирует нативный HttpSyncGraphQlClient от Spring:

@Import(RestClientAutoConfiguration.class)public class ClientApp implements ApplicationRunner {    private static final Logger log = LoggerFactory.getLogger(ClientApp.class);    private final HttpSyncGraphQlClient client;    public ClientApp(RestClient.Builder builder) {        RestClient restClient = builder.baseUrl("http://localhost:8080/graphql").build();        this.client = HttpSyncGraphQlClient.builder(restClient).build();    }    public static void main(String[] args) {        new SpringApplicationBuilder(ClientApp.class).web(WebApplicationType.NONE).run(args);    }    @Override    public void run(ApplicationArguments args) throws Exception {        var document = """                query findBookById($id: Int!) {                    book(id: $id) {                        id                        title                        author {                            id                            name                        }                    }                }                """;        var book = client.document(document)                .variable("id", 1L)                .retrieveSync("book")                .toEntity(Book.class);        log.info("Book: {}", book);    }}

Клиент построен поверх RestClient, поэтому реактивные зависимости не требуются. Вы описываете GraphQL-запрос строкой, привязываете переменные типобезопасными методами и десериализуете ответ напрямую в ваш entity-класс. Вызов .toEntity(Book.class) автоматически обрабатывает вложенное поле author.

8. Наблюдаемость

Поскольку GraphQL API может «распараллеливать» выборку данных между базами, кэшами и внешними сервисами, наблюдаемость становится крайне важной. В этот проект добавлены зависимости Micrometer tracing bridge и Zipkin reporter, а в конфигурации приложения вероятность сэмплирования выставлена на 100%:

management:  tracing:    sampling:      probability: 1.0

Spring for GraphQL имеет встроенную инструментацию на базе Micrometer Observation API. Это означает, что каждый GraphQL-запрос и каждый нетривиальный data fetcher автоматически трассируется. Вы можете открыть Grafana по адресу http://localhost:3000 и увидеть, как именно GraphQL-запрос раскладывается по вашим data fetcher’ам, какие из них медленные и где находятся узкие места.

9. Тестирование

Проект также включает полный набор тестов с использованием GraphQlTester из Spring for GraphQL. Вот пример, который тестирует мутацию добавления новой книги:

@SpringBootTest@Transactionalclass BookControllerTests {    private final GraphQlTester graphQlTester;    @Autowired    BookControllerTests(ExecutionGraphQlService graphQlService) {        this.graphQlTester = ExecutionGraphQlServiceTester.builder(graphQlService).build();    }    @Test    void shouldAddNewBook() {        var document = """        mutation($input: BookInput!) {            addBook(bookInput: $input) {                id                title                author {                    id                    name                }            }        }        """;        Map<String, Object> input = Map.of(                "title", "New Book",                "authorId", 1        );        graphQlTester.document(document)                .variable("input", input)                .execute()                .path("addBook")                .entity(Book.class)                .satisfies(book -> {                    assertThat(book.getTitle()).isEqualTo("New Book");                    assertThat(book.getAuthor()).isNotNull();                });    }}

ExecutionGraphQlServiceTester выполняет запросы к полноценному GraphQL-движку без запуска HTTP-сервера, что делает тесты быстрыми и сосредоточенными. В проекте во время тестов используются Testcontainers для PostgreSQL, поэтому ваши тестовые данные изолированы и воспроизводимы.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

ссылка на оригинал статьи https://habr.com/ru/articles/1026130/