Співбесіда Spring GraphQL: Резолвери, DataLoader та Розв'язання проблеми N+1

Підготовка до співбесід Spring GraphQL з цим повним посібником. Резолвери, DataLoader, обробка проблеми N+1, мутації та найкращі практики для технічних запитань.

Технічна співбесіда Spring GraphQL з резолверами та DataLoader

Spring for GraphQL спрощує інтеграцію GraphQL у застосунках Spring Boot. Ця технологія стала незамінною для сучасних API, а запитання на співбесідах щодо неї трапляються все частіше. Цей посібник охоплює основні концепції: резолвери, DataLoader, проблему N+1 та просунуті патерни.

Фокус співбесіди

Рекрутери особливо перевіряють розуміння проблеми N+1 та використання DataLoader. Ці дві теми становлять 60% запитань на співбесідах Spring GraphQL.

Що таке Spring for GraphQL?

Spring for GraphQL — це офіційний наступник GraphQL Java Spring, нативно інтегрований у фреймворк Spring починаючи з версії 2.7. Ця інтеграція дає кілька переваг: підтримку анотацій Spring, інтеграцію зі Spring Security та прозоре використання WebFlux або MVC.

Базова конфігурація потребує лише залежності spring-boot-starter-graphql та схеми GraphQL.

build.gradle.ktsjava
dependencies {
    // Spring GraphQL starter зі Spring MVC
    implementation("org.springframework.boot:spring-boot-starter-graphql")
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Для тестів GraphQL
    testImplementation("org.springframework.graphql:spring-graphql-test")
}

Схема GraphQL знаходиться в src/main/resources/graphql/ з розширенням .graphqls.

graphql
# schema.graphqls
type Query {
    # Отримати статтю за її ідентифікатором
    article(id: ID!): Article

    # Сторінкований список статей
    articles(page: Int = 0, size: Int = 10): [Article!]!
}

type Article {
    id: ID!
    title: String!
    content: String!
    # Зв'язок з автором
    author: Author!
    # Список коментарів
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    # Статті, написані цим автором
    articles: [Article!]!
}

type Comment {
    id: ID!
    content: String!
    author: Author!
    createdAt: String!
}

Ця архітектура декларує типи та їхні зв'язки. GraphQL автоматично генерує документацію та валідовує запити на стороні сервера.

Як працюють резолвери Spring GraphQL?

Резолвери становлять серце виконання GraphQL. Кожне поле схеми може мати окремий резолвер. Spring використовує анотацію @QueryMapping для кореневих запитів та @SchemaMapping для зв'язків.

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;

    public ArticleController(ArticleRepository articleRepository,
                            AuthorRepository authorRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
    }

    // Резолвер для Query.article(id)
    @QueryMapping
    public Article article(@Argument Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ArticleNotFoundException(id));
    }

    // Резолвер для Query.articles(page, size)
    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // Резолвер для Article.author - викликається для кожної статті
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return authorRepository.findById(article.getAuthorId())
            .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId()));
    }
}

Резолвер author виконується для кожної повернутої статті. Ця гнучка архітектура завантажує дані за потреби, але вводить проблему N+1.

Класична пастка співбесіди

Кандидат, який реалізує резолвер @SchemaMapping, не згадуючи проблему N+1, демонструє неповне розуміння GraphQL. Рекрутери систематично очікують цей аналіз.

Що таке проблема N+1 у GraphQL?

Проблема N+1 виникає, коли запит GraphQL ініціює N додаткових запитів для завантаження зв'язків. Цей деструктивний патерн систематично з'являється з базовими резолверами.

Розгляньте запит, який отримує 50 статей з їхніми авторами:

graphql
# GraphQL запит
query {
    articles(size: 50) {
        id
        title
        author {
            name
        }
    }
}

З попереднім резолвером цей запит виконує:

  • 1 запит для 50 статей
  • 50 запитів для завантаження кожного автора окремо
sql
-- Запит 1: отримання статей
SELECT * FROM articles LIMIT 50

-- Запити 2-51: кожен автор окремо
SELECT * FROM authors WHERE id = 1
SELECT * FROM authors WHERE id = 2
SELECT * FROM authors WHERE id = 3
-- ... 47 додаткових запитів

Це помноження запитів різко погіршує продуктивність. Endpoint, що відповідає за 50мс, може зрости до 2 секунд з N+1.

Як працюють DataLoader?

DataLoader групують окремі запити у пакетні запити. Замість того, щоб завантажувати кожного автора окремо, DataLoader збирає всі запитувані ID та виконує єдиний запит.

AuthorBatchLoader.javajava
@Component
public class AuthorBatchLoader {

    private final AuthorRepository authorRepository;

    public AuthorBatchLoader(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    // Метод пакетного завантаження
    public Mono<Map<Long, Author>> loadAuthors(Set<Long> authorIds) {
        // Єдиний запит для всіх авторів
        List<Author> authors = authorRepository.findAllById(authorIds);

        // Перетворення в Map для швидкого пошуку
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

Реєстрація DataLoader відбувається через BatchLoaderRegistry у виділеній конфігурації.

GraphQLConfig.javajava
@Configuration
public class GraphQLConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorBatchLoader authorBatchLoader) {

        return registry -> {
            // Реєстрація DataLoader для авторів
            registry.forTypePair(Long.class, Author.class)
                .registerMappedBatchLoader((authorIds, env) ->
                    authorBatchLoader.loadAuthors(authorIds));
        };
    }
}

Змінений резолвер тепер використовує DataLoader замість прямого доступу:

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;

    public ArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // Оптимізований резолвер з DataLoader
    @SchemaMapping(typeName = "Article", field = "author")
    public CompletableFuture<Author> author(
            Article article,
            DataLoader<Long, Author> authorDataLoader) {

        // DataLoader автоматично групує виклики
        return authorDataLoader.load(article.getAuthorId());
    }
}

З цим підходом той самий запит для 50 статей виконує лише 2 SQL-запити:

sql
-- Запит 1: отримання статей
SELECT * FROM articles LIMIT 50

-- Запит 2: всі автори за один раз
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50)

Готовий до співбесід з Spring Boot?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Як реалізувати DataLoader з контекстом?

Просунуті DataLoader іноді потребують додаткового контексту, наприклад ідентифікатора tenant для мульти-tenant застосунків або фільтрів безпеки.

SecuredAuthorBatchLoader.javajava
@Component
public class SecuredAuthorBatchLoader {

    private final AuthorRepository authorRepository;
    private final SecurityContextHolder securityContext;

    public SecuredAuthorBatchLoader(AuthorRepository authorRepository,
                                    SecurityContextHolder securityContext) {
        this.authorRepository = authorRepository;
        this.securityContext = securityContext;
    }

    public Mono<Map<Long, Author>> loadAuthorsForTenant(
            Set<Long> authorIds,
            BatchLoaderEnvironment env) {

        // Отримати контекст GraphQL
        GraphQLContext context = env.getContext();
        String tenantId = context.get("tenantId");

        // Запит, відфільтрований за tenant
        List<Author> authors = authorRepository
            .findAllByIdInAndTenantId(authorIds, tenantId);

        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

Конфігурація контексту відбувається через WebGraphQL interceptor:

TenantInterceptor.javajava
@Component
public class TenantInterceptor implements WebGraphQlInterceptor {

    @Override
    public Mono<WebGraphQlResponse> intercept(
            WebGraphQlRequest request,
            Chain chain) {

        // Витягти tenant з заголовків
        String tenantId = request.getHeaders()
            .getFirst("X-Tenant-ID");

        // Додати до контексту GraphQL
        request.configureExecutionInput((input, builder) ->
            builder.graphQLContext(ctx ->
                ctx.put("tenantId", tenantId)
            ).build()
        );

        return chain.next(request);
    }
}

Які відмінності між @QueryMapping та @SchemaMapping?

Це класичне запитання співбесіди перевіряє розуміння ієрархії резолверів.

| Анотація | Використання | Еквівалент | |----------|--------------|------------| | @QueryMapping | Кореневі поля типу Query | @SchemaMapping(typeName = "Query") | | @MutationMapping | Кореневі поля типу Mutation | @SchemaMapping(typeName = "Mutation") | | @SubscriptionMapping | Підписки в реальному часі | @SchemaMapping(typeName = "Subscription") | | @SchemaMapping | Усі поля будь-якого типу | Загальна форма |

EquivalenceDemo.javajava
@Controller
public class EquivalenceDemo {

    // Ці дві декларації еквівалентні
    @QueryMapping
    public Article article(@Argument Long id) {
        return findArticle(id);
    }

    @SchemaMapping(typeName = "Query", field = "article")
    public Article articleEquivalent(@Argument Long id) {
        return findArticle(id);
    }

    // Для вкладених полів працює лише @SchemaMapping
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return findAuthor(article.getAuthorId());
    }

    // Альтернативний синтаксис з типом як параметром
    @SchemaMapping
    public Author author(Article article) {
        // Spring виводить typeName = "Article" з параметра
        return findAuthor(article.getAuthorId());
    }
}

Як обробляти мутації з валідацією?

Мутації GraphQL змінюють дані. Spring GraphQL інтегрується з Bean Validation для валідації входів.

graphql
# schema.graphqls
type Mutation {
    createArticle(input: CreateArticleInput!): Article!
    updateArticle(id: ID!, input: UpdateArticleInput!): Article!
    deleteArticle(id: ID!): Boolean!
}

input CreateArticleInput {
    title: String!
    content: String!
    authorId: ID!
}

input UpdateArticleInput {
    title: String
    content: String
}

Контролер використовує @Valid для запуску валідації:

ArticleMutationController.javajava
@Controller
public class ArticleMutationController {

    private final ArticleService articleService;

    public ArticleMutationController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @MutationMapping
    public Article createArticle(@Argument @Valid CreateArticleInput input) {
        // Валідація виконується автоматично
        return articleService.create(input);
    }

    @MutationMapping
    public Article updateArticle(@Argument Long id,
                                 @Argument @Valid UpdateArticleInput input) {
        return articleService.update(id, input);
    }

    @MutationMapping
    public boolean deleteArticle(@Argument Long id) {
        articleService.delete(id);
        return true;
    }
}
CreateArticleInput.javajava
public record CreateArticleInput(
    @NotBlank(message = "Назва обов'язкова")
    @Size(min = 5, max = 200, message = "Назва повинна мати від 5 до 200 символів")
    String title,

    @NotBlank(message = "Вміст обов'язковий")
    @Size(min = 100, message = "Вміст повинен мати щонайменше 100 символів")
    String content,

    @NotNull(message = "Автор обов'язковий")
    Long authorId
) {}
Обробка помилок

Помилки валідації автоматично повертають структуровану помилку GraphQL зі шляхом недійсного поля. Spring GraphQL форматує ці помилки відповідно до специфікації GraphQL.

Як оптимізувати запити з @BatchMapping?

Анотація @BatchMapping спрощує створення DataLoader безпосередньо в контролерах. Цей підхід уникає явної конфігурації BatchLoaderRegistry.

OptimizedArticleController.javajava
@Controller
public class OptimizedArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;
    private final CommentRepository commentRepository;

    public OptimizedArticleController(ArticleRepository articleRepository,
                                      AuthorRepository authorRepository,
                                      CommentRepository commentRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
        this.commentRepository = commentRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // BatchMapping для авторів - обробляє всі статті одразу
    @BatchMapping
    public Map<Article, Author> author(List<Article> articles) {
        // Зібрати унікальні ID авторів
        Set<Long> authorIds = articles.stream()
            .map(Article::getAuthorId)
            .collect(Collectors.toSet());

        // Єдиний запит для всіх авторів
        Map<Long, Author> authorsById = authorRepository
            .findAllById(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // Зв'язування стаття -> автор
        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> authorsById.get(article.getAuthorId())
            ));
    }

    // BatchMapping для коментарів
    @BatchMapping
    public Map<Article, List<Comment>> comments(List<Article> articles) {
        List<Long> articleIds = articles.stream()
            .map(Article::getId)
            .toList();

        // Пакетне отримання коментарів
        List<Comment> allComments = commentRepository
            .findByArticleIdIn(articleIds);

        // Групування за статтею
        Map<Long, List<Comment>> commentsByArticle = allComments.stream()
            .collect(Collectors.groupingBy(Comment::getArticleId));

        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> commentsByArticle
                    .getOrDefault(article.getId(), List.of())
            ));
    }
}

@BatchMapping пропонує більш стислий синтаксис, ніж ручна конфігурація DataLoader, зберігаючи ті самі гарантії продуктивності.

Як тестувати резолвери GraphQL?

Spring GraphQL надає GraphQlTester для написання виразних та читабельних тестів.

ArticleControllerTest.javajava
@SpringBootTest
@AutoConfigureGraphQlTester
class ArticleControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldReturnArticleById() {
        // Given: документ GraphQL
        String query = """
            query {
                article(id: 1) {
                    id
                    title
                    author {
                        name
                    }
                }
            }
            """;

        // When & Then
        graphQlTester.document(query)
            .execute()
            .path("article.id").entity(String.class).isEqualTo("1")
            .path("article.title").entity(String.class).isNotEmpty()
            .path("article.author.name").entity(String.class).isNotEmpty();
    }

    @Test
    void shouldCreateArticle() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "Нова назва",
                    content: "Тестовий вміст, достатньо довгий, щоб задовольнити обмеження валідації",
                    authorId: 1
                }) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .path("createArticle.id").entity(String.class).isNotEmpty()
            .path("createArticle.title").entity(String.class)
                .isEqualTo("Нова назва");
    }

    @Test
    void shouldReturnErrorForInvalidInput() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "",
                    content: "коротко",
                    authorId: 1
                }) {
                    id
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .errors()
            .expect(error -> error.getMessage()
                .contains("Назва обов'язкова"));
    }

    @Test
    void shouldHandleVariables() {
        String query = """
            query GetArticle($id: ID!) {
                article(id: $id) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(query)
            .variable("id", 42)
            .execute()
            .path("article").valueIsNull();
    }
}

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Як обробляти підписки в реальному часі?

Підписки дозволяють надсилати дані в реальному часі клієнтам через WebSocket.

graphql
# schema.graphqls
type Subscription {
    articleCreated: Article!
    commentAdded(articleId: ID!): Comment!
}
SubscriptionController.javajava
@Controller
public class SubscriptionController {

    private final Sinks.Many<Article> articleSink =
        Sinks.many().multicast().onBackpressureBuffer();

    private final Sinks.Many<Comment> commentSink =
        Sinks.many().multicast().onBackpressureBuffer();

    @SubscriptionMapping
    public Flux<Article> articleCreated() {
        return articleSink.asFlux();
    }

    @SubscriptionMapping
    public Flux<Comment> commentAdded(@Argument Long articleId) {
        return commentSink.asFlux()
            .filter(comment -> comment.getArticleId().equals(articleId));
    }

    // Метод, що викликається при створенні статті
    public void publishArticle(Article article) {
        articleSink.tryEmitNext(article);
    }

    // Метод, що викликається при додаванні коментаря
    public void publishComment(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

Конфігурація WebSocket потребує додаткової залежності:

build.gradle.ktsjava
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-websocket")
}
WebSocketConfig.javajava
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

Які найкращі практики безпеки GraphQL?

Безпека GraphQL потребує кількох шарів захисту від атак відмови в обслуговуванні та несанкціонованого доступу.

SecurityConfig.javajava
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // Захищений GraphQL endpoint
                .requestMatchers("/graphql").authenticated()
                // GraphiQL доступний лише в dev
                .requestMatchers("/graphiql").permitAll()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }
}
SecuredArticleController.javajava
@Controller
public class SecuredArticleController {

    private final ArticleRepository articleRepository;

    public SecuredArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    // Доступ обмежений автентифікованими користувачами
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // Лише адміністратори можуть видаляти
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleRepository.deleteById(id);
        return true;
    }

    // Дрібнозерниста перевірка: лише автор може змінювати
    @MutationMapping
    @PreAuthorize("@articleSecurity.isAuthor(#id, authentication)")
    public Article updateArticle(@Argument Long id,
                                 @Argument UpdateArticleInput input) {
        return articleRepository.findById(id)
            .map(article -> {
                article.setTitle(input.title());
                article.setContent(input.content());
                return articleRepository.save(article);
            })
            .orElseThrow();
    }
}

Захист від складних запитів:

yaml
# application.yml
spring:
  graphql:
    schema:
      introspection:
        # Вимкнути introspection в продакшні
        enabled: false
QueryComplexityInstrumentation.javajava
@Component
public class QueryComplexityInstrumentation extends SimplePerformantInstrumentation {

    private static final int MAX_COMPLEXITY = 100;

    @Override
    public InstrumentationState createState(InstrumentationCreateStateParameters parameters) {
        return new ComplexityState();
    }

    @Override
    public InstrumentationContext<ExecutionResult> beginExecution(
            InstrumentationExecutionParameters parameters,
            InstrumentationState state) {

        int complexity = calculateComplexity(parameters.getDocument());

        if (complexity > MAX_COMPLEXITY) {
            throw new GraphQLException(
                "Запит надто складний: " + complexity + " (max: " + MAX_COMPLEXITY + ")"
            );
        }

        return super.beginExecution(parameters, state);
    }
}

Висновок

Spring for GraphQL пропонує елегантну інтеграцію GraphQL в екосистему Spring. Володіння резолверами, DataLoader та обробкою проблеми N+1 становить базу знань, яку очікують на співбесідах.

Чек-лист співбесіди Spring GraphQL:

  • ✅ Пояснити роль анотацій @QueryMapping, @MutationMapping та @SchemaMapping
  • ✅ Описати проблему N+1 та її вплив на продуктивність GraphQL
  • ✅ Реалізувати DataLoader з @BatchMapping або BatchLoaderRegistry
  • ✅ Валідувати входи мутацій за допомогою Bean Validation
  • ✅ Захистити резолвери за допомогою @PreAuthorize та Spring Security
  • ✅ Тестувати резолвери за допомогою GraphQlTester
  • ✅ Налаштувати підписки для функцій реального часу
  • ✅ Захистити API від надто складних запитів

Теги

#spring graphql
#dataloaders
#n+1 problem
#interview
#graphql java

Поділитися

Пов'язані статті