Phỏng vấn Spring GraphQL: Resolver, DataLoader và Giải pháp cho Vấn đề N+1

Chuẩn bị cho phỏng vấn Spring GraphQL với hướng dẫn đầy đủ này. Resolver, DataLoader, xử lý vấn đề N+1, mutation và các thực hành tốt nhất cho câu hỏi kỹ thuật.

Phỏng vấn kỹ thuật Spring GraphQL với resolver và DataLoader

Spring for GraphQL đơn giản hóa việc tích hợp GraphQL trong các ứng dụng Spring Boot. Công nghệ này đã trở nên thiết yếu cho các API hiện đại, và các câu hỏi phỏng vấn về chủ đề này ngày càng phổ biến. Hướng dẫn này bao gồm các khái niệm cốt lõi: resolver, DataLoader, vấn đề N+1 và các pattern nâng cao.

Trọng tâm phỏng vấn

Nhà tuyển dụng đặc biệt kiểm tra hiểu biết về vấn đề N+1 và cách sử dụng DataLoader. Hai chủ đề này chiếm 60% câu hỏi trong các buổi phỏng vấn Spring GraphQL.

Spring for GraphQL là gì?

Spring for GraphQL là người kế nhiệm chính thức của GraphQL Java Spring, được tích hợp tự nhiên vào framework Spring từ phiên bản 2.7. Sự tích hợp này mang lại nhiều lợi thế: hỗ trợ annotation Spring, tích hợp với Spring Security và sử dụng minh bạch WebFlux hoặc MVC.

Cấu hình cơ bản chỉ yêu cầu phụ thuộc spring-boot-starter-graphql và một schema GraphQL.

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

    // Cho các test GraphQL
    testImplementation("org.springframework.graphql:spring-graphql-test")
}

Schema GraphQL nằm trong src/main/resources/graphql/ với phần mở rộng .graphqls.

graphql
# schema.graphqls
type Query {
    # Lấy bài viết theo định danh
    article(id: ID!): Article

    # Danh sách bài viết phân trang
    articles(page: Int = 0, size: Int = 10): [Article!]!
}

type Article {
    id: ID!
    title: String!
    content: String!
    # Quan hệ với tác giả
    author: Author!
    # Danh sách bình luận
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    # Bài viết do tác giả này viết
    articles: [Article!]!
}

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

Kiến trúc này khai báo các kiểu và mối quan hệ của chúng. GraphQL tự động tạo tài liệu và xác thực các truy vấn ở phía máy chủ.

Resolver Spring GraphQL hoạt động như thế nào?

Resolver là trái tim của việc thực thi GraphQL. Mỗi trường schema có thể có một resolver riêng. Spring sử dụng annotation @QueryMapping cho các truy vấn gốc và @SchemaMapping cho các quan hệ.

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;
    }

    // Resolver cho Query.article(id)
    @QueryMapping
    public Article article(@Argument Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ArticleNotFoundException(id));
    }

    // Resolver cho 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();
    }

    // Resolver cho Article.author - được gọi cho mỗi bài viết
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return authorRepository.findById(article.getAuthorId())
            .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId()));
    }
}

Resolver author được thực thi cho mỗi bài viết được trả về. Kiến trúc linh hoạt này tải dữ liệu theo yêu cầu nhưng giới thiệu vấn đề N+1.

Bẫy phỏng vấn cổ điển

Ứng viên triển khai resolver @SchemaMapping mà không đề cập đến vấn đề N+1 thể hiện sự hiểu biết không đầy đủ về GraphQL. Nhà tuyển dụng luôn mong đợi phân tích này.

Vấn đề N+1 trong GraphQL là gì?

Vấn đề N+1 xảy ra khi một truy vấn GraphQL kích hoạt N truy vấn bổ sung để tải các quan hệ. Pattern phá hoại này xảy ra một cách có hệ thống với các resolver cơ bản.

Xem xét một truy vấn lấy 50 bài viết với tác giả của chúng:

graphql
# Truy vấn GraphQL
query {
    articles(size: 50) {
        id
        title
        author {
            name
        }
    }
}

Với resolver trước đó, truy vấn này thực thi:

  • 1 truy vấn cho 50 bài viết
  • 50 truy vấn để tải từng tác giả riêng lẻ
sql
-- Truy vấn 1: lấy bài viết
SELECT * FROM articles LIMIT 50

-- Truy vấn 2-51: từng tác giả riêng lẻ
SELECT * FROM authors WHERE id = 1
SELECT * FROM authors WHERE id = 2
SELECT * FROM authors WHERE id = 3
-- ... 47 truy vấn bổ sung

Việc nhân số truy vấn này làm giảm hiệu suất đáng kể. Một endpoint phản hồi trong 50ms có thể nhảy lên 2 giây với N+1.

DataLoader hoạt động như thế nào?

DataLoader nhóm các truy vấn riêng lẻ thành các yêu cầu hàng loạt. Thay vì tải từng tác giả riêng biệt, DataLoader thu thập tất cả các ID được yêu cầu và thực hiện một truy vấn duy nhất.

AuthorBatchLoader.javajava
@Component
public class AuthorBatchLoader {

    private final AuthorRepository authorRepository;

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

    // Phương thức tải hàng loạt
    public Mono<Map<Long, Author>> loadAuthors(Set<Long> authorIds) {
        // Truy vấn duy nhất cho tất cả tác giả
        List<Author> authors = authorRepository.findAllById(authorIds);

        // Chuyển đổi thành Map để tra cứu nhanh
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

Việc đăng ký DataLoader được thực hiện thông qua BatchLoaderRegistry trong một cấu hình chuyên dụng.

GraphQLConfig.javajava
@Configuration
public class GraphQLConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorBatchLoader authorBatchLoader) {

        return registry -> {
            // Đăng ký DataLoader cho tác giả
            registry.forTypePair(Long.class, Author.class)
                .registerMappedBatchLoader((authorIds, env) ->
                    authorBatchLoader.loadAuthors(authorIds));
        };
    }
}

Resolver được sửa đổi giờ đây sử dụng DataLoader thay vì truy cập trực tiếp:

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();
    }

    // Resolver được tối ưu với DataLoader
    @SchemaMapping(typeName = "Article", field = "author")
    public CompletableFuture<Author> author(
            Article article,
            DataLoader<Long, Author> authorDataLoader) {

        // DataLoader tự động nhóm các cuộc gọi
        return authorDataLoader.load(article.getAuthorId());
    }
}

Với cách tiếp cận này, cùng truy vấn cho 50 bài viết chỉ thực thi 2 truy vấn SQL:

sql
-- Truy vấn 1: lấy bài viết
SELECT * FROM articles LIMIT 50

-- Truy vấn 2: tất cả tác giả cùng một lúc
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50)

Sẵn sàng chinh phục phỏng vấn Spring Boot?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Cách triển khai DataLoader với context?

DataLoader nâng cao đôi khi yêu cầu context bổ sung, chẳng hạn như định danh tenant cho ứng dụng đa tenant hoặc bộ lọc bảo mật.

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) {

        // Lấy context GraphQL
        GraphQLContext context = env.getContext();
        String tenantId = context.get("tenantId");

        // Truy vấn được lọc theo 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);
    }
}

Cấu hình context được thực hiện thông qua interceptor WebGraphQL:

TenantInterceptor.javajava
@Component
public class TenantInterceptor implements WebGraphQlInterceptor {

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

        // Trích xuất tenant từ headers
        String tenantId = request.getHeaders()
            .getFirst("X-Tenant-ID");

        // Thêm vào context GraphQL
        request.configureExecutionInput((input, builder) ->
            builder.graphQLContext(ctx ->
                ctx.put("tenantId", tenantId)
            ).build()
        );

        return chain.next(request);
    }
}

Sự khác biệt giữa @QueryMapping và @SchemaMapping là gì?

Câu hỏi phỏng vấn cổ điển này xác minh sự hiểu biết về phân cấp resolver.

| Annotation | Sử dụng | Tương đương | |------------|---------|-------------| | @QueryMapping | Trường gốc của kiểu Query | @SchemaMapping(typeName = "Query") | | @MutationMapping | Trường gốc của kiểu Mutation | @SchemaMapping(typeName = "Mutation") | | @SubscriptionMapping | Subscription thời gian thực | @SchemaMapping(typeName = "Subscription") | | @SchemaMapping | Tất cả trường của bất kỳ kiểu nào | Dạng tổng quát |

EquivalenceDemo.javajava
@Controller
public class EquivalenceDemo {

    // Hai khai báo này tương đương
    @QueryMapping
    public Article article(@Argument Long id) {
        return findArticle(id);
    }

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

    // Đối với các trường lồng nhau, chỉ @SchemaMapping hoạt động
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return findAuthor(article.getAuthorId());
    }

    // Cú pháp thay thế với kiểu làm tham số
    @SchemaMapping
    public Author author(Article article) {
        // Spring suy luận typeName = "Article" từ tham số
        return findAuthor(article.getAuthorId());
    }
}

Cách quản lý mutation với xác thực?

Mutation GraphQL sửa đổi dữ liệu. Spring GraphQL tích hợp với Bean Validation để xác thực đầu vào.

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
}

Controller sử dụng @Valid để kích hoạt xác thực:

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) {
        // Xác thực được thực hiện tự động
        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 = "Tiêu đề là bắt buộc")
    @Size(min = 5, max = 200, message = "Tiêu đề phải từ 5 đến 200 ký tự")
    String title,

    @NotBlank(message = "Nội dung là bắt buộc")
    @Size(min = 100, message = "Nội dung phải có ít nhất 100 ký tự")
    String content,

    @NotNull(message = "Tác giả là bắt buộc")
    Long authorId
) {}
Xử lý lỗi

Lỗi xác thực tự động trả về một lỗi GraphQL có cấu trúc với đường dẫn của trường không hợp lệ. Spring GraphQL định dạng các lỗi này theo đặc tả GraphQL.

Cách tối ưu hóa truy vấn với @BatchMapping?

Annotation @BatchMapping đơn giản hóa việc tạo DataLoader trực tiếp trong các controller. Cách tiếp cận này tránh cấu hình BatchLoaderRegistry rõ ràng.

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 cho tác giả - xử lý tất cả bài viết cùng một lúc
    @BatchMapping
    public Map<Article, Author> author(List<Article> articles) {
        // Thu thập ID tác giả duy nhất
        Set<Long> authorIds = articles.stream()
            .map(Article::getAuthorId)
            .collect(Collectors.toSet());

        // Truy vấn duy nhất cho tất cả tác giả
        Map<Long, Author> authorsById = authorRepository
            .findAllById(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // Liên kết bài viết -> tác giả
        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> authorsById.get(article.getAuthorId())
            ));
    }

    // BatchMapping cho bình luận
    @BatchMapping
    public Map<Article, List<Comment>> comments(List<Article> articles) {
        List<Long> articleIds = articles.stream()
            .map(Article::getId)
            .toList();

        // Lấy hàng loạt bình luận
        List<Comment> allComments = commentRepository
            .findByArticleIdIn(articleIds);

        // Nhóm theo bài viết
        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 cung cấp cú pháp ngắn gọn hơn so với cấu hình DataLoader thủ công trong khi vẫn đảm bảo các đảm bảo hiệu suất tương tự.

Cách kiểm tra Resolver GraphQL?

Spring GraphQL cung cấp GraphQlTester để viết các test biểu cảm và dễ đọc.

ArticleControllerTest.javajava
@SpringBootTest
@AutoConfigureGraphQlTester
class ArticleControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldReturnArticleById() {
        // Given: tài liệu 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: "Tiêu đề mới",
                    content: "Nội dung kiểm tra đủ dài để đáp ứng các ràng buộc xác thực",
                    authorId: 1
                }) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .path("createArticle.id").entity(String.class).isNotEmpty()
            .path("createArticle.title").entity(String.class)
                .isEqualTo("Tiêu đề mới");
    }

    @Test
    void shouldReturnErrorForInvalidInput() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "",
                    content: "ngắn",
                    authorId: 1
                }) {
                    id
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .errors()
            .expect(error -> error.getMessage()
                .contains("Tiêu đề là bắt buộc"));
    }

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

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

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Cách quản lý subscription thời gian thực?

Subscription cho phép đẩy dữ liệu thời gian thực đến các client thông qua 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));
    }

    // Phương thức được gọi khi tạo bài viết
    public void publishArticle(Article article) {
        articleSink.tryEmitNext(article);
    }

    // Phương thức được gọi khi thêm bình luận
    public void publishComment(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

Cấu hình WebSocket yêu cầu một phụ thuộc bổ sung:

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();
    }
}

Các thực hành tốt nhất về bảo mật GraphQL là gì?

Bảo mật GraphQL yêu cầu nhiều lớp bảo vệ chống lại các cuộc tấn công từ chối dịch vụ và truy cập trái phép.

SecurityConfig.javajava
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // Endpoint GraphQL được bảo vệ
                .requestMatchers("/graphql").authenticated()
                // GraphiQL chỉ có thể truy cập trong 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;
    }

    // Truy cập bị giới hạn cho người dùng đã xác thực
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // Chỉ quản trị viên mới có thể xóa
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleRepository.deleteById(id);
        return true;
    }

    // Kiểm tra chi tiết: chỉ tác giả mới có thể sửa đổi
    @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();
    }
}

Bảo vệ chống lại các truy vấn phức tạp:

yaml
# application.yml
spring:
  graphql:
    schema:
      introspection:
        # Tắt introspection trong môi trường production
        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(
                "Truy vấn quá phức tạp: " + complexity + " (max: " + MAX_COMPLEXITY + ")"
            );
        }

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

Kết luận

Spring for GraphQL cung cấp tích hợp GraphQL thanh lịch vào hệ sinh thái Spring. Việc thành thạo resolver, DataLoader và quản lý vấn đề N+1 tạo nên nền tảng kiến thức được mong đợi trong các buổi phỏng vấn.

Danh sách kiểm tra phỏng vấn Spring GraphQL:

  • ✅ Giải thích vai trò của các annotation @QueryMapping, @MutationMapping@SchemaMapping
  • ✅ Mô tả vấn đề N+1 và tác động của nó đối với hiệu suất GraphQL
  • ✅ Triển khai DataLoader với @BatchMapping hoặc BatchLoaderRegistry
  • ✅ Xác thực đầu vào mutation với Bean Validation
  • ✅ Bảo mật resolver với @PreAuthorize và Spring Security
  • ✅ Kiểm tra resolver với GraphQlTester
  • ✅ Cấu hình subscription cho các tính năng thời gian thực
  • ✅ Bảo vệ API khỏi các truy vấn quá phức tạp

Thẻ

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

Chia sẻ

Bài viết liên quan