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
- Nền tảng Spring Boot (Bắt Đầu)
- Tích hợp JPA & database (Tích Hợp Database)
- Khái niệm GraphQL (GraphQL là gì?)
- Xác thực JWT (Security & JWT)
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: falseNhữ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ống | GraphQL | REST |
|---|---|---|
| Ứ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
- API Documentation với OpenAPI/Swagger — tài liệu hóa REST API của bạn cùng với GraphQL
- JWT Authentication & Authorization — thiết lập JWT dùng cho GraphQL security
- Advanced JPA Optimization — bổ sung
@BatchMappingvới tối ưu cấp JPA - Async Processing & Scheduled Tasks — async patterns hữu ích với GraphQL subscriptions
Bài Viết Nền Tảng
- GraphQL là gì? Hướng Dẫn Toàn Diện — khái niệm GraphQL cho mọi stack
- REST API là gì? — hiểu REST để biết khi nào chọn GraphQL thay thế
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.