Back to blog

GraphQL với Spring for GraphQL: Hướng Dẫn Toàn Diện

javaspring-bootgraphqlapibackend
GraphQL với Spring for GraphQL: Hướng Dẫn Toàn Diện

Giới Thiệu

REST APIs rất tốt — cho đến khi chúng không còn phù hợp nữa. Ứng dụng mobile tải thông tin người dùng nhận về 40 trường trong khi chỉ cần 5. Dashboard thực hiện 12 API calls riêng biệt để xây dựng một trang. Đổi tên một trường làm hỏng mọi client. Đây là những vấn đề hàng ngày khiến Facebook xây dựng GraphQL năm 2012 và open-source năm 2015.

Spring for GraphQL (giới thiệu trong Spring Boot 2.7, chính thức ổn định từ 3.0) là tích hợp GraphQL chính thức cho Spring Boot. Nó được xây dựng trên GraphQL Java, tích hợp chặt chẽ với Spring Security và Spring Data, và cảm giác tự nhiên với mọi Spring developer.

Mới với GraphQL? Đọc GraphQL là gì? Hướng Dẫn Toàn Diện trước — bài đó đề cập schemas, queries, mutations và những điểm khác biệt chính với REST.

Bạn Sẽ Học Được Gì

✅ Thiết lập Spring for GraphQL với Spring Boot 3
✅ Định nghĩa GraphQL schemas với types, queries, mutations và subscriptions
✅ Xây dựng controllers @QueryMapping, @MutationMapping và @SubscriptionMapping
✅ Giải quyết vấn đề N+1 với DataLoader và @BatchMapping
✅ Bảo mật GraphQL endpoints với Spring Security
✅ Xử lý lỗi và validation đúng cách
✅ Viết GraphQL tests với GraphQlTester
✅ Áp dụng best practices production: query complexity limits, depth limits, persisted queries

Điều Kiện Tiên Quyết


1. Thiết Lập Dự Án

Dependencies

<!-- pom.xml -->
<dependencies>
    <!-- Spring for GraphQL -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-graphql</artifactId>
    </dependency>
 
    <!-- Web layer -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
 
    <!-- JPA cho database -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
 
    <!-- Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
 
    <!-- Driver PostgreSQL -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
 
    <!-- GraphQL testing -->
    <dependency>
        <groupId>org.springframework.graphql</groupId>
        <artifactId>spring-graphql-test</artifactId>
        <scope>test</scope>
    </dependency>
 
    <!-- WebSocket cho subscriptions (tùy chọn) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
</dependencies>

Cấu Hình Ứng Dụng

# application.yml
spring:
  graphql:
    graphiql:
      enabled: true           # Bật GraphiQL browser IDE
      path: /graphiql
    path: /graphql            # Đường dẫn GraphQL endpoint
    schema:
      locations: classpath:graphql/  # Vị trí schema files
      file-extensions: .graphqls, .gqls
    websocket:
      path: /graphql-ws       # WebSocket path cho subscriptions
 
  datasource:
    url: jdbc:postgresql://localhost:5432/blogdb
    username: postgres
    password: postgres
 
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false

Những Gì Chúng Ta Sẽ Xây Dựng

Một Blog API với:

  • Authors viết posts
  • Posts với comments và tags
  • Real-time comment subscriptions
  • Mutations được bảo vệ bởi xác thực

2. Định Nghĩa Schema

GraphQL schemas nằm trong src/main/resources/graphql/. Spring for GraphQL tự động tải tất cả file .graphqls từ thư mục này.

schema.graphqls

# src/main/resources/graphql/schema.graphqls
 
# Scalar types
scalar DateTime
 
# ── Types ──────────────────────────────────
 
type Author {
    id: ID!
    name: String!
    email: String!
    bio: String
    posts: [Post!]!
    postCount: Int!
    createdAt: DateTime!
}
 
type Post {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    author: Author!
    comments: [Comment!]!
    tags: [String!]!
    viewCount: Int!
    createdAt: DateTime!
    updatedAt: DateTime!
}
 
type Comment {
    id: ID!
    content: String!
    author: Author!
    post: Post!
    createdAt: DateTime!
}
 
# ── Input Types ────────────────────────────
 
input CreatePostInput {
    title: String!
    content: String!
    tags: [String!]
}
 
input UpdatePostInput {
    title: String
    content: String
    tags: [String!]
    published: Boolean
}
 
input CreateCommentInput {
    postId: ID!
    content: String!
}
 
# ── Pagination ─────────────────────────────
 
type PostConnection {
    edges: [PostEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
}
 
type PostEdge {
    node: Post!
    cursor: String!
}
 
type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
}
 
# ── Query ──────────────────────────────────
 
type Query {
    # Records đơn lẻ
    author(id: ID!): Author
    post(id: ID!): Post
    me: Author
 
    # Collections
    authors: [Author!]!
    posts(
        published: Boolean
        authorId: ID
        tag: String
        first: Int = 10
        after: String
    ): PostConnection!
    searchPosts(query: String!): [Post!]!
}
 
# ── Mutation ───────────────────────────────
 
type Mutation {
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
    publishPost(id: ID!): Post!
 
    createComment(input: CreateCommentInput!): Comment!
    deleteComment(id: ID!): Boolean!
}
 
# ── Subscription ───────────────────────────
 
type Subscription {
    commentAdded(postId: ID!): Comment!
    postPublished: Post!
}

3. Domain Model & Repositories

// Author.java
@Entity
@Table(name = "authors")
public class Author {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false)
    private String name;
 
    @Column(nullable = false, unique = true)
    private String email;
 
    private String bio;
 
    @OneToMany(mappedBy = "author",
        fetch = FetchType.LAZY)
    private List<Post> posts = new ArrayList<>();
 
    @Column(name = "created_at")
    private Instant createdAt = Instant.now();
 
    // Getters/setters
}
 
// Post.java
@Entity
@Table(name = "posts")
public class Post {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false)
    private String title;
 
    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;
 
    private boolean published = false;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;
 
    @OneToMany(mappedBy = "post",
        cascade = CascadeType.ALL,
        fetch = FetchType.LAZY)
    private List<Comment> comments = new ArrayList<>();
 
    @ElementCollection
    @CollectionTable(name = "post_tags",
        joinColumns = @JoinColumn(name = "post_id"))
    @Column(name = "tag")
    private List<String> tags = new ArrayList<>();
 
    @Column(name = "view_count")
    private int viewCount = 0;
 
    @Column(name = "created_at")
    private Instant createdAt = Instant.now();
 
    @Column(name = "updated_at")
    private Instant updatedAt = Instant.now();
 
    // Getters/setters
}
 
// Comment.java
@Entity
@Table(name = "comments")
public class Comment {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;
 
    @Column(name = "created_at")
    private Instant createdAt = Instant.now();
 
    // Getters/setters
}
// AuthorRepository.java
public interface AuthorRepository
        extends JpaRepository<Author, Long> {
 
    Optional<Author> findByEmail(String email);
 
    @Query("""
        SELECT a FROM Author a
        WHERE a.id IN :ids
        """)
    List<Author> findAllByIds(
        @Param("ids") Collection<Long> ids);
}
 
// PostRepository.java
public interface PostRepository
        extends JpaRepository<Post, Long> {
 
    Page<Post> findByPublished(
        boolean published, Pageable pageable);
 
    @Query("""
        SELECT p FROM Post p
        WHERE LOWER(p.title) LIKE LOWER(:query)
           OR LOWER(p.content) LIKE LOWER(:query)
        """)
    List<Post> searchByQuery(
        @Param("query") String query);
 
    @Query("""
        SELECT p FROM Post p
        WHERE :tag MEMBER OF p.tags
        """)
    List<Post> findByTag(
        @Param("tag") String tag);
 
    List<Post> findByAuthorId(Long authorId);
}
 
// CommentRepository.java
public interface CommentRepository
        extends JpaRepository<Comment, Long> {
 
    List<Comment> findByPostId(Long postId);
 
    @Query("""
        SELECT c FROM Comment c
        WHERE c.post.id IN :postIds
        """)
    List<Comment> findByPostIds(
        @Param("postIds") Collection<Long> postIds);
}

4. Query Controllers

Spring for GraphQL sử dụng @Controller với các annotations @QueryMapping, @MutationMapping và @SchemaMapping.

@Controller
public class AuthorController {
 
    @Autowired
    private AuthorService authorService;
 
    // Resolve Query.author
    @QueryMapping
    public Optional<Author> author(
            @Argument Long id) {
        return authorService.findById(id);
    }
 
    // Resolve Query.authors
    @QueryMapping
    public List<Author> authors() {
        return authorService.findAll();
    }
 
    // Resolve Query.me (yêu cầu xác thực)
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public Author me(
            @AuthenticationPrincipal
            UserDetails userDetails) {
        return authorService
            .findByEmail(userDetails.getUsername())
            .orElseThrow(() ->
                new GraphQLException("Author not found"));
    }
 
    // Resolve Author.postCount — computed field
    @SchemaMapping(typeName = "Author",
        field = "postCount")
    public int postCount(Author author) {
        return author.getPosts().size();
    }
}
@Controller
public class PostController {
 
    @Autowired
    private PostService postService;
 
    @QueryMapping
    public Optional<Post> post(@Argument Long id) {
        return postService.findById(id);
    }
 
    // Cursor-based pagination
    @QueryMapping
    public PostConnection posts(
            @Argument Boolean published,
            @Argument Long authorId,
            @Argument String tag,
            @Argument Integer first,
            @Argument String after) {
 
        return postService.findPosts(
            published, authorId, tag, first, after);
    }
 
    @QueryMapping
    public List<Post> searchPosts(
            @Argument String query) {
        return postService.search(query);
    }
}

5. Mutation Controllers

@Controller
public class PostMutationController {
 
    @Autowired
    private PostService postService;
 
    @MutationMapping
    @PreAuthorize("isAuthenticated()")
    public Post createPost(
            @Argument CreatePostInput input,
            @AuthenticationPrincipal
            UserDetails userDetails) {
 
        return postService.createPost(
            input, userDetails.getUsername());
    }
 
    @MutationMapping
    @PreAuthorize("isAuthenticated()")
    public Post updatePost(
            @Argument Long id,
            @Argument UpdatePostInput input,
            @AuthenticationPrincipal
            UserDetails userDetails) {
 
        return postService.updatePost(
            id, input, userDetails.getUsername());
    }
 
    @MutationMapping
    @PreAuthorize("isAuthenticated()")
    public boolean deletePost(
            @Argument Long id,
            @AuthenticationPrincipal
            UserDetails userDetails) {
 
        postService.deletePost(
            id, userDetails.getUsername());
        return true;
    }
 
    @MutationMapping
    @PreAuthorize("isAuthenticated()")
    public Post publishPost(
            @Argument Long id,
            @AuthenticationPrincipal
            UserDetails userDetails) {
 
        return postService.publishPost(
            id, userDetails.getUsername());
    }
}
 
@Controller
public class CommentMutationController {
 
    @Autowired
    private CommentService commentService;
 
    @Autowired
    private ApplicationEventPublisher eventPublisher;
 
    @MutationMapping
    @PreAuthorize("isAuthenticated()")
    public Comment createComment(
            @Argument CreateCommentInput input,
            @AuthenticationPrincipal
            UserDetails userDetails) {
 
        Comment comment = commentService.createComment(
            input, userDetails.getUsername());
 
        // Publish event cho subscription
        eventPublisher.publishEvent(
            new CommentAddedEvent(comment));
 
        return comment;
    }
 
    @MutationMapping
    @PreAuthorize("isAuthenticated()")
    public boolean deleteComment(
            @Argument Long id,
            @AuthenticationPrincipal
            UserDetails userDetails) {
 
        commentService.deleteComment(
            id, userDetails.getUsername());
        return true;
    }
}

Service Layer

@Service
@Transactional
public class PostService {
 
    @Autowired
    private PostRepository postRepository;
 
    @Autowired
    private AuthorRepository authorRepository;
 
    @Transactional(readOnly = true)
    public PostConnection findPosts(
            Boolean published, Long authorId,
            String tag, int first, String after) {
 
        // Decode cursor (base64-encoded ID)
        Long afterId = after != null
            ? decodeCursor(after) : null;
 
        List<Post> posts;
        if (authorId != null) {
            posts = postRepository
                .findByAuthorId(authorId);
        } else if (tag != null) {
            posts = postRepository.findByTag(tag);
        } else if (published != null) {
            posts = postRepository
                .findByPublished(published,
                    PageRequest.of(0, first + 1,
                        Sort.by("createdAt").descending()))
                .getContent();
        } else {
            posts = postRepository
                .findAll(PageRequest.of(0, first + 1,
                    Sort.by("createdAt").descending()))
                .getContent();
        }
 
        // Áp dụng cursor filtering
        if (afterId != null) {
            final Long id = afterId;
            posts = posts.stream()
                .filter(p -> p.getId() > id)
                .collect(Collectors.toList());
        }
 
        boolean hasNextPage = posts.size() > first;
        List<Post> pageItems = hasNextPage
            ? posts.subList(0, first) : posts;
 
        List<PostEdge> edges = pageItems.stream()
            .map(p -> new PostEdge(p, encodeCursor(p)))
            .collect(Collectors.toList());
 
        PageInfo pageInfo = new PageInfo(
            hasNextPage,
            afterId != null,
            edges.isEmpty() ? null
                : edges.get(0).cursor(),
            edges.isEmpty() ? null
                : edges.get(edges.size() - 1).cursor()
        );
 
        return new PostConnection(
            edges, pageInfo, posts.size());
    }
 
    public Post createPost(
            CreatePostInput input,
            String authorEmail) {
 
        Author author = authorRepository
            .findByEmail(authorEmail)
            .orElseThrow(() ->
                new IllegalArgumentException(
                    "Author not found"));
 
        Post post = new Post();
        post.setTitle(input.title());
        post.setContent(input.content());
        post.setAuthor(author);
        post.setTags(input.tags() != null
            ? input.tags() : List.of());
 
        return postRepository.save(post);
    }
 
    public Post publishPost(
            Long id, String authorEmail) {
 
        Post post = postRepository.findById(id)
            .orElseThrow(() ->
                new IllegalArgumentException(
                    "Post not found: " + id));
 
        if (!post.getAuthor().getEmail()
                .equals(authorEmail)) {
            throw new AccessDeniedException(
                "You don't own this post");
        }
 
        post.setPublished(true);
        post.setUpdatedAt(Instant.now());
        return postRepository.save(post);
    }
 
    private String encodeCursor(Post post) {
        return Base64.getEncoder().encodeToString(
            post.getId().toString().getBytes());
    }
 
    private Long decodeCursor(String cursor) {
        return Long.parseLong(new String(
            Base64.getDecoder().decode(cursor)));
    }
}

6. Giải Quyết N+1 với DataLoader & @BatchMapping

Đây là chủ đề hiệu suất quan trọng nhất trong GraphQL. Không có DataLoader, việc tải danh sách posts và authors của chúng sẽ kích hoạt một query cho mỗi post.

Vấn Đề N+1

# Query này kích hoạt N+1 nếu không có DataLoader:
query {
    posts(first: 20) {
        edges {
            node {
                title
                author {      # ← Một DB query cho mỗi post!
                    name
                }
            }
        }
    }
}

@BatchMapping — Giải Pháp Thanh Lịch của Spring

Spring for GraphQL cung cấp @BatchMapping tự động batch việc resolve fields:

@Controller
public class PostBatchController {
 
    @Autowired
    private AuthorRepository authorRepository;
 
    @Autowired
    private CommentRepository commentRepository;
 
    // Batch load Author cho tất cả posts cùng lúc
    @BatchMapping(typeName = "Post", field = "author")
    public Map<Post, Author> postAuthors(
            List<Post> posts) {
 
        // Thu thập tất cả author IDs
        Set<Long> authorIds = posts.stream()
            .map(p -> p.getAuthor().getId())
            .collect(Collectors.toSet());
 
        // Một query duy nhất cho tất cả authors
        Map<Long, Author> authorsById =
            authorRepository.findAllByIds(authorIds)
                .stream()
                .collect(Collectors.toMap(
                    Author::getId,
                    Function.identity()));
 
        // Map mỗi post với author của nó
        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                p -> authorsById.get(
                    p.getAuthor().getId())));
    }
 
    // Batch load Comments cho tất cả posts cùng lúc
    @BatchMapping(typeName = "Post", field = "comments")
    public Map<Post, List<Comment>> postComments(
            List<Post> posts) {
 
        List<Long> postIds = posts.stream()
            .map(Post::getId)
            .collect(Collectors.toList());
 
        // Một query duy nhất cho tất cả comments
        Map<Long, List<Comment>> commentsByPostId =
            commentRepository.findByPostIds(postIds)
                .stream()
                .collect(Collectors.groupingBy(
                    c -> c.getPost().getId()));
 
        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                p -> commentsByPostId.getOrDefault(
                    p.getId(), List.of())));
    }
}

Kết quả: 21 queries → 2 queries, bất kể bao nhiêu posts được tải.

Manual DataLoader (cho các trường hợp phức tạp)

Cho logic batching phức tạp hơn, dùng DataLoader API trực tiếp:

@Configuration
public class DataLoaderConfig {
 
    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorRepository authorRepository) {
 
        return registrar -> {
            registrar.forTypePair(
                Long.class, Author.class)
                .registerBatchLoader(
                    (authorIds, environment) ->
                        Mono.fromCallable(() ->
                            authorRepository
                                .findAllByIds(authorIds))
                            .map(authors -> {
                                Map<Long, Author> byId =
                                    authors.stream()
                                        .collect(
                                            Collectors.toMap(
                                                Author::getId,
                                                Function.identity()));
                                return authorIds.stream()
                                    .map(id -> byId.get(id))
                                    .collect(
                                        Collectors.toList());
                            }));
        };
    }
}
 
// Sử dụng DataLoader trong controller
@SchemaMapping(typeName = "Post", field = "author")
public CompletableFuture<Author> author(
        Post post,
        DataLoader<Long, Author> dataLoader) {
    return dataLoader.load(
        post.getAuthor().getId());
}

7. Subscriptions

Subscriptions cho phép cập nhật real-time qua WebSocket. Client subscribe vào events và nhận data khi nó xảy ra.

@Controller
public class SubscriptionController {
 
    // Publisher cho comment events
    private final Sinks.Many<Comment> commentSink =
        Sinks.many().multicast()
            .onBackpressureBuffer();
 
    // Publisher cho post events
    private final Sinks.Many<Post> postSink =
        Sinks.many().multicast()
            .onBackpressureBuffer();
 
    // Được gọi khi comment được tạo
    public void onCommentAdded(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
 
    // Được gọi khi post được publish
    public void onPostPublished(Post post) {
        postSink.tryEmitNext(post);
    }
 
    @SubscriptionMapping
    public Flux<Comment> commentAdded(
            @Argument Long postId) {
        return commentSink.asFlux()
            .filter(c -> c.getPost().getId()
                .equals(postId));
    }
 
    @SubscriptionMapping
    public Flux<Post> postPublished() {
        return postSink.asFlux()
            .filter(Post::isPublished);
    }
}
// Cách tiếp cận dựa trên events sử dụng Spring events
@Component
public class GraphQLEventBridge
        implements ApplicationListener<
            CommentAddedEvent> {
 
    @Autowired
    private SubscriptionController subscriptionController;
 
    @Override
    public void onApplicationEvent(
            CommentAddedEvent event) {
        subscriptionController
            .onCommentAdded(event.getComment());
    }
}

Ví Dụ Subscription Client

// Frontend WebSocket subscription (JavaScript)
import { createClient } from 'graphql-ws';
 
const client = createClient({
    url: 'ws://localhost:8080/graphql-ws',
});
 
// Subscribe vào comments mới trên post 1
const unsubscribe = client.subscribe(
    {
        query: `
            subscription OnCommentAdded($postId: ID!) {
                commentAdded(postId: $postId) {
                    id
                    content
                    author {
                        name
                    }
                    createdAt
                }
            }
        `,
        variables: { postId: '1' },
    },
    {
        next: (data) =>
            console.log('Comment mới:', data),
        error: (err) => console.error(err),
        complete: () =>
            console.log('Subscription kết thúc'),
    }
);

8. Xử Lý Lỗi

Spring for GraphQL cung cấp xử lý lỗi có cấu trúc với DataFetcherExceptionResolver.

@Component
public class GraphQLExceptionHandler
        implements DataFetcherExceptionResolver {
 
    @Override
    public Mono<List<GraphQLError>> resolveException(
            Throwable exception,
            DataFetchingEnvironment environment) {
 
        GraphQLError error = resolveError(exception);
        if (error != null) {
            return Mono.just(List.of(error));
        }
        return Mono.empty(); // Để handler mặc định xử lý
    }
 
    private GraphQLError resolveError(
            Throwable exception) {
 
        if (exception instanceof IllegalArgumentException) {
            return GraphqlErrorBuilder.newError()
                .errorType(ErrorType.BAD_REQUEST)
                .message(exception.getMessage())
                .build();
        }
 
        if (exception instanceof AccessDeniedException) {
            return GraphqlErrorBuilder.newError()
                .errorType(ErrorType.FORBIDDEN)
                .message("Access denied")
                .build();
        }
 
        if (exception instanceof EntityNotFoundException) {
            return GraphqlErrorBuilder.newError()
                .errorType(ErrorType.NOT_FOUND)
                .message(exception.getMessage())
                .build();
        }
 
        return null; // Không xử lý — để mặc định xử lý
    }
}

Validation với Custom Scalars

@Configuration
public class ScalarConfig
        implements RuntimeWiringConfigurer {
 
    @Override
    public void configure(
            RuntimeWiring.Builder builder) {
        builder.scalar(ExtendedScalars.DateTime);
    }
}

Lỗi Theo Field (Partial Results)

GraphQL cho phép trả về kết quả một phần khi chỉ một field thất bại:

# Query
query {
    post(id: 1) {
        title
        author {
            name
        }
    }
}
 
# Response với partial error
{
  "data": {
    "post": {
      "title": "My Post",
      "author": null        ← field thất bại
    }
  },
  "errors": [
    {
      "message": "Author not found",
      "path": ["post", "author"],
      "extensions": {
        "classification": "NOT_FOUND"
      }
    }
  ]
}

9. Bảo Mật

Cấu Hình Spring Security

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
 
    @Autowired
    private JwtAuthenticationFilter jwtFilter;
 
    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(
                    SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // GraphQL endpoint — cho phép tất cả
                // (auth theo field qua @PreAuthorize)
                .requestMatchers("/graphql").permitAll()
                .requestMatchers("/graphiql").permitAll()
                .requestMatchers("/graphql-ws").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter,
                UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

Phân Quyền Theo Field

@Controller
public class AuthorController {
 
    // Ai cũng có thể query public posts
    @QueryMapping
    public List<Post> posts() {
        return postService.findPublished();
    }
 
    // Chỉ người dùng đã xác thực mới thấy drafts của mình
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Post> myDrafts(
            @AuthenticationPrincipal
            UserDetails userDetails) {
        return postService.findDraftsByAuthor(
            userDetails.getUsername());
    }
 
    // Chỉ admins mới có thể xóa bất kỳ post nào
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean adminDeletePost(
            @Argument Long id) {
        postService.forceDelete(id);
        return true;
    }
}

Ẩn Fields Nhạy Cảm

@Controller
public class AuthorController {
 
    // Email chỉ hiển thị cho chính author đó
    @SchemaMapping(typeName = "Author", field = "email")
    public String email(
            Author author,
            @AuthenticationPrincipal
            UserDetails userDetails) {
 
        if (userDetails != null &&
                author.getEmail().equals(
                    userDetails.getUsername())) {
            return author.getEmail();
        }
        return "***@***.***"; // Ẩn với người khác
    }
}

10. Kiểm Thử

Spring for GraphQL cung cấp GraphQlTester cho GraphQL tests rõ ràng và dễ đọc.

Thiết Lập

@SpringBootTest
@AutoConfigureGraphQlTester
class PostControllerTest {
 
    @Autowired
    private GraphQlTester graphQlTester;
 
    @Autowired
    private PostRepository postRepository;
 
    @Autowired
    private AuthorRepository authorRepository;
 
    @BeforeEach
    void setUp() {
        Author author = new Author();
        author.setName("Alice");
        author.setEmail("[email protected]");
        authorRepository.save(author);
 
        Post post = new Post();
        post.setTitle("GraphQL trong Spring Boot");
        post.setContent("Nội dung hướng dẫn...");
        post.setAuthor(author);
        post.setPublished(true);
        postRepository.save(post);
    }
}

Query Tests

@Test
void shouldFetchPublishedPosts() {
    graphQlTester.document("""
            query {
                posts(published: true, first: 10) {
                    edges {
                        node {
                            title
                            published
                            author {
                                name
                            }
                        }
                    }
                    totalCount
                }
            }
            """)
        .execute()
        .path("posts.edges[0].node.title")
        .entity(String.class)
        .isEqualTo("GraphQL trong Spring Boot")
        .path("posts.edges[0].node.author.name")
        .entity(String.class)
        .isEqualTo("Alice")
        .path("posts.totalCount")
        .entity(Integer.class)
        .satisfies(count -> assertThat(count)
            .isGreaterThan(0));
}
 
@Test
void shouldFetchSinglePost() {
    Long postId = postRepository.findAll()
        .get(0).getId();
 
    graphQlTester.document("""
            query GetPost($id: ID!) {
                post(id: $id) {
                    title
                    content
                }
            }
            """)
        .variable("id", postId)
        .execute()
        .path("post.title")
        .entity(String.class)
        .isEqualTo("GraphQL trong Spring Boot");
}
 
@Test
void shouldReturnNullForMissingPost() {
    graphQlTester.document("""
            query {
                post(id: 99999) {
                    title
                }
            }
            """)
        .execute()
        .path("post")
        .valueIsNull();
}

Mutation Tests

@Test
@WithMockUser(username = "[email protected]")
void shouldCreatePost() {
    graphQlTester.document("""
            mutation CreatePost($input: CreatePostInput!) {
                createPost(input: $input) {
                    id
                    title
                    published
                    author {
                        name
                    }
                }
            }
            """)
        .variable("input", Map.of(
            "title", "Bài Viết Mới",
            "content", "Nội dung bài viết",
            "tags", List.of("java", "graphql")))
        .execute()
        .path("createPost.title")
        .entity(String.class)
        .isEqualTo("Bài Viết Mới")
        .path("createPost.published")
        .entity(Boolean.class)
        .isEqualTo(false)
        .path("createPost.author.name")
        .entity(String.class)
        .isEqualTo("Alice");
}
 
@Test
void shouldRejectUnauthenticatedMutation() {
    graphQlTester.document("""
            mutation {
                createPost(input: {
                    title: "Hack",
                    content: "Attempt"
                }) {
                    id
                }
            }
            """)
        .execute()
        .errors()
        .satisfy(errors ->
            assertThat(errors).isNotEmpty());
}

Error Tests

@Test
void shouldReturnErrorForInvalidInput() {
    graphQlTester.document("""
            mutation {
                createPost(input: {
                    title: "",
                    content: ""
                }) {
                    id
                }
            }
            """)
        .execute()
        .errors()
        .satisfy(errors ->
            assertThat(errors)
                .anyMatch(e -> e.getErrorType()
                    == ErrorType.BAD_REQUEST));
}

11. Best Practices Production

Query Complexity & Depth Limits

Không có giới hạn, clients độc hại có thể gửi queries lồng nhau sâu làm quá tải server:

# Query độc hại — lồng nhau sâu
query {
    posts {
        edges { node {
            comments {
                author {
                    posts {
                        edges { node {
                            comments {
                                author { name }
                            }
                        }}
                    }
                }
            }
        }}
    }
}

Cấu hình giới hạn trong Spring for GraphQL:

spring:
  graphql:
    schema:
      introspection:
        enabled: false      # Tắt trong production
@Configuration
public class GraphQLConfig implements
        GraphQlSourceBuilderCustomizer {
 
    @Override
    public void customize(
            GraphQlSource.SchemaResourceBuilder builder) {
        builder.configureRuntimeWiring(wiringBuilder ->
            wiringBuilder
                .instrumentations(List.of(
                    new MaxQueryDepthInstrumentation(10),
                    new MaxQueryComplexityInstrumentation(
                        200)))
        );
    }
}

Persisted Queries

Persisted queries ngăn thực thi query tùy ý trong production:

@Configuration
public class PersistedQueryConfig {
 
    @Bean
    public PersistedQueryCache persistedQueryCache() {
        // Cache trong bộ nhớ của các queries được phép
        Map<String, String> allowedQueries = Map.of(
            "getPosts",
                "query { posts(first: 10) { ... } }",
            "getPost",
                "query GetPost($id: ID!) { post(id: $id) { ... } }"
        );
        return new InMemoryPersistedQueryCache(
            allowedQueries);
    }
}

Query Logging & Monitoring

@Component
public class GraphQLLoggingInstrumentation
        extends SimpleInstrumentation {
 
    private static final Logger log =
        LoggerFactory.getLogger(
            GraphQLLoggingInstrumentation.class);
 
    @Override
    public InstrumentationContext<ExecutionResult>
            beginExecution(
                InstrumentationExecutionParameters params,
                InstrumentationState state) {
 
        long startTime = System.currentTimeMillis();
        String operationName =
            params.getOperation();
 
        return SimpleInstrumentationContext
            .whenCompleted((result, throwable) -> {
                long duration =
                    System.currentTimeMillis()
                        - startTime;
 
                if (throwable != null || !result
                        .getErrors().isEmpty()) {
                    log.warn("GraphQL {} thất bại trong {}ms: {}",
                        operationName, duration,
                        result.getErrors());
                } else {
                    log.debug("GraphQL {} hoàn thành trong {}ms",
                        operationName, duration);
                }
            });
    }
}

GraphQL vs REST: Khi Nào Dùng Cái Nào

Tình HuốngGraphQLREST
Ứng dụng mobile (nhạy cảm với băng thông)✅ Chỉ tải fields cần❌ Over-fetches
Nhiều clients với nhu cầu data khác nhau✅ Một endpoint, linh hoạt❌ Cần nhiều endpoints
Real-time data✅ Subscriptions tích hợp⚠️ Cần thiết lập WebSocket
APIs CRUD đơn giản⚠️ Phức tạp thêm✅ Đơn giản và quen thuộc
Upload file❌ Thiết lập phức tạp✅ Multipart native
HTTP caching❌ POST-based, khó cache✅ GET requests cache dễ
Public APIs (có tài liệu, ổn định)⚠️ Cần schema introspection✅ Tiêu chuẩn OpenAPI/Swagger
Microservices (nội bộ)✅ Federation có thể✅ Contracts đơn giản

Tóm Tắt và Điểm Chính

✅ Spring for GraphQL là tích hợp GraphQL chính thức, production-ready cho Spring Boot — xây trên GraphQL Java với hỗ trợ toàn bộ hệ sinh thái Spring
✅ Phát triển schema-first — định nghĩa GraphQL schema trong file .graphqls trước, sau đó implement resolvers
✅ @QueryMapping / @MutationMapping / @SubscriptionMapping map controller methods với GraphQL operations một cách rõ ràng
✅ @BatchMapping giải quyết N+1 tự động bằng cách batch việc resolve fields — tối ưu hiệu suất quan trọng nhất trong GraphQL
✅ DataLoader cho kiểm soát chi tiết về batching cho các tình huống phức tạp hơn @BatchMapping
✅ Subscriptions qua WebSocket cho phép real-time features sử dụng Reactor Flux — cùng pattern với Spring WebFlux
✅ @PreAuthorize trên GraphQL controllers cung cấp security theo field tích hợp với Spring Security
✅ GraphQlTester làm GraphQL testing rõ ràng và dễ đọc như MockMvc cho REST
✅ Query complexity và depth limits là biện pháp bảo vệ production thiết yếu chống queries độc hại hoặc vô tình tốn kém
✅ Dùng GraphQL khi clients có nhu cầu data đa dạng, bạn xây dựng cho mobile, hoặc bạn cần real-time subscriptions


Tiếp Theo Là Gì?

Bây giờ bạn có thể xây dựng GraphQL APIs trong Spring Boot, tiếp tục series:

Tiếp Tục Series Spring Boot

  • API Versioning & HATEOAS: Thiết kế REST APIs phát triển duyên dáng cùng GraphQL API của bạn
  • Structured Logging & Centralized Logging: Instrument hiệu suất và lỗi của GraphQL resolvers
  • Monitoring với Actuator, Prometheus & Grafana: Thêm metrics dành riêng cho GraphQL vào dashboards của bạn

Bài Viết Spring Boot Liên Quan

Bài Viết Nền Tảng


Thuộc series Spring Boot Learning Roadmap

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.