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

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.
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.
# 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 для зв'язків.
@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 запит
query {
articles(size: 50) {
id
title
author {
name
}
}
}З попереднім резолвером цей запит виконує:
- 1 запит для 50 статей
- 50 запитів для завантаження кожного автора окремо
-- Запит 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 та виконує єдиний запит.
@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 у виділеній конфігурації.
@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 замість прямого доступу:
@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-запити:
-- Запит 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 застосунків або фільтрів безпеки.
@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:
@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 | Усі поля будь-якого типу | Загальна форма |
@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 для валідації входів.
# 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 для запуску валідації:
@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;
}
}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.
@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 для написання виразних та читабельних тестів.
@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.
# schema.graphqls
type Subscription {
articleCreated: Article!
commentAdded(articleId: ID!): Comment!
}@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 потребує додаткової залежності:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-websocket")
}@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}Які найкращі практики безпеки GraphQL?
Безпека GraphQL потребує кількох шарів захисту від атак відмови в обслуговуванні та несанкціонованого доступу.
@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();
}
}@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();
}
}Захист від складних запитів:
# application.yml
spring:
graphql:
schema:
introspection:
# Вимкнути introspection в продакшні
enabled: false@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 Boot: Поширення Транзакцій
Опануйте поширення транзакцій у Spring Boot: REQUIRED, REQUIRES_NEW, NESTED тощо. 12 питань зі співбесід з кодом та поширеними пастками.

Рішення проблеми N+1 у Spring Data JPA у 2026: Fetch Join і EntityGraph
Повний посібник з виявлення та усунення проблеми N+1 у Spring Data JPA. Fetch join, @EntityGraph, batch fetching та стратегії продуктивності запитів.

30 Питань на Співбесіді зі Spring Boot: Повний Гід для Java-розробників
Підготуйтеся до співбесід зі Spring Boot із 30 ключовими питаннями про авто-конфігурацію, стартери, Spring Data JPA, безпеку й тестування.