# Співбесіда Spring GraphQL: Резолвери, DataLoader та Розв'язання проблеми N+1 > Підготовка до співбесід Spring GraphQL з цим повним посібником. Резолвери, DataLoader, обробка проблеми N+1, мутації та найкращі практики для технічних запитань. - Published: 2026-03-25 - Updated: 2026-05-04 - Author: SharpSkill - Tags: spring graphql, dataloaders, n+1 problem, interview, graphql java - Reading time: 14 min --- 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. ```java // build.gradle.kts 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` для зв'язків. ```java // ArticleController.java @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
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 та виконує єдиний запит. ```java // AuthorBatchLoader.java @Component public class AuthorBatchLoader { private final AuthorRepository authorRepository; public AuthorBatchLoader(AuthorRepository authorRepository) { this.authorRepository = authorRepository; } // Метод пакетного завантаження public Mono> loadAuthors(Set authorIds) { // Єдиний запит для всіх авторів List authors = authorRepository.findAllById(authorIds); // Перетворення в Map для швидкого пошуку Map authorMap = authors.stream() .collect(Collectors.toMap(Author::getId, Function.identity())); return Mono.just(authorMap); } } ``` Реєстрація DataLoader відбувається через `BatchLoaderRegistry` у виділеній конфігурації. ```java // GraphQLConfig.java @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 замість прямого доступу: ```java // ArticleController.java @Controller public class ArticleController { private final ArticleRepository articleRepository; public ArticleController(ArticleRepository articleRepository) { this.articleRepository = articleRepository; } @QueryMapping public List
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( Article article, DataLoader 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) ``` ## Як реалізувати DataLoader з контекстом? Просунуті DataLoader іноді потребують додаткового контексту, наприклад ідентифікатора tenant для мульти-tenant застосунків або фільтрів безпеки. ```java // SecuredAuthorBatchLoader.java @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> loadAuthorsForTenant( Set authorIds, BatchLoaderEnvironment env) { // Отримати контекст GraphQL GraphQLContext context = env.getContext(); String tenantId = context.get("tenantId"); // Запит, відфільтрований за tenant List authors = authorRepository .findAllByIdInAndTenantId(authorIds, tenantId); Map authorMap = authors.stream() .collect(Collectors.toMap(Author::getId, Function.identity())); return Mono.just(authorMap); } } ``` Конфігурація контексту відбувається через WebGraphQL interceptor: ```java // TenantInterceptor.java @Component public class TenantInterceptor implements WebGraphQlInterceptor { @Override public Mono 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` | Усі поля будь-якого типу | Загальна форма | ```java // EquivalenceDemo.java @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` для запуску валідації: ```java // ArticleMutationController.java @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; } } ``` ```java // CreateArticleInput.java 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. ```java // OptimizedArticleController.java @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
articles(@Argument int page, @Argument int size) { return articleRepository.findAll(PageRequest.of(page, size)) .getContent(); } // BatchMapping для авторів - обробляє всі статті одразу @BatchMapping public Map author(List
articles) { // Зібрати унікальні ID авторів Set authorIds = articles.stream() .map(Article::getAuthorId) .collect(Collectors.toSet()); // Єдиний запит для всіх авторів Map 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> comments(List
articles) { List articleIds = articles.stream() .map(Article::getId) .toList(); // Пакетне отримання коментарів List allComments = commentRepository .findByArticleIdIn(articleIds); // Групування за статтею Map> 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` для написання виразних та читабельних тестів. ```java // ArticleControllerTest.java @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! } ``` ```java // SubscriptionController.java @Controller public class SubscriptionController { private final Sinks.Many
articleSink = Sinks.many().multicast().onBackpressureBuffer(); private final Sinks.Many commentSink = Sinks.many().multicast().onBackpressureBuffer(); @SubscriptionMapping public Flux
articleCreated() { return articleSink.asFlux(); } @SubscriptionMapping public Flux 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 потребує додаткової залежності: ```java // build.gradle.kts dependencies { implementation("org.springframework.boot:spring-boot-starter-websocket") } ``` ```java // WebSocketConfig.java @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } ``` ## Які найкращі практики безпеки GraphQL? Безпека GraphQL потребує кількох шарів захисту від атак відмови в обслуговуванні та несанкціонованого доступу. ```java // SecurityConfig.java @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(); } } ``` ```java // SecuredArticleController.java @Controller public class SecuredArticleController { private final ArticleRepository articleRepository; public SecuredArticleController(ArticleRepository articleRepository) { this.articleRepository = articleRepository; } // Доступ обмежений автентифікованими користувачами @QueryMapping @PreAuthorize("isAuthenticated()") public List
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 ``` ```java // QueryComplexityInstrumentation.java @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 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 від надто складних запитів --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/uk/blog/spring-boot/spring-graphql-interview-resolvers-dataloaders-n1