โหมดมืด
บทที่ 14 — GraphQL (ภาษา query API ที่ client เลือก field เองได้) with Spring — Spring for GraphQL
← บทที่ 13: Batch + Scheduling | สารบัญ | บทที่ 15: Native Image →
REST มีปัญหา:
- Over-fetching —
/users/1คืน 30 field, frontend ใช้แค่ 3 - Under-fetching — ต้องเรียกหลาย endpoint สำหรับ 1 หน้า (
/users/1+/users/1/orders+/orders/.../items) - Versioning hell (ปัญหา version ที่จัดการยาก) — เพิ่ม field → break consumer เก่า
GraphQL แก้:
- Client บอกว่า "อยากได้ field อะไร" → server คืนแค่นั้น
- 1 endpoint = 1 round trip
- Schema ระบุ type ชัดเจน + introspection (ดูโครงสร้าง API ได้เลยโดยไม่ต้องอ่าน doc)
ใช้เวลา 3-4 ชั่วโมง
🚧 โซนขั้นสูง — ข้ามได้
บทนี้เป็น บทอ้างอิงระดับลึก มือใหม่ ข้ามไปก่อนได้ — REST API (บทที่ 1) เพียงพอและเรียนง่ายกว่าสำหรับเริ่มต้น GraphQL เหมาะกับงานที่ frontend ต้องการดึงข้อมูลหลากหลายรูปแบบ กลับมาอ่านเมื่อทีม frontend ขอ หรือเจอปัญหา over/under-fetching จริง
📋 ก่อนเริ่ม + ศัพท์ปูพื้น
- ✅ ต้องผ่าน: Spring Boot บทที่ 1–2 (REST + JPA) และเข้าใจ N+1 problem (บทที่ 2 ข้อ 10)
- ⚠️ หัวข้อ Subscription (3.3) ต้องเข้าใจ Flux จากบท 10 — ข้ามได้ถ้ายังไม่ผ่าน
- ⚠️ Part 6 Security ต้องผ่านบท Security ก่อน — ข้ามได้ถ้ายังไม่ผ่าน
🔑 คอนเซ็ปต์หลัก:
- GraphQL (กราฟ-คิวแอล) = ภาษา query API ที่ client เลือกได้เองว่าจะเอา field ไหน
- schema (สคี-มา) = "พิมพ์เขียว" บอกว่ามีข้อมูลอะไร หน้าตาแบบไหน
🔑 ปัญหาที่ GraphQL แก้:
- over-fetching = ได้ข้อมูลเกินที่ใช้ (REST
/users/1คืน 30 field แต่ใช้ 3)- under-fetching = ได้ไม่พอ ต้องยิงซ้ำหลายรอบ
🔑 ขั้นสูง (เจอตอนเขียน resolver):
- resolver (รี-โซลฟ์-เวอร์) = ฟังก์ชันที่ดึงค่าของแต่ละ field
- DataLoader = ตัวช่วยรวม query หลายอันเป็นก้อนเดียว แก้ N+1
Part 1: GraphQL คืออะไร
1.1 ภาษาสำหรับ Query API
GraphQL ต่างจาก REST ตรงที่ client เป็นคนบอกว่าต้องการ field อะไรบ้างในคำขอเดียว วิธีนี้แก้ปัญหา over-fetching (ได้ field เกิน) และ under-fetching (ต้องยิงหลาย request) ของ REST server มี schema เดียวเท่านั้น แต่ client query ได้ยืดหยุ่น:
graphql
# Client ส่ง query
query {
user(id: 1) {
name
email
orders(limit: 5) {
id
total
items {
product { name }
quantity
}
}
}
}Server คืน JSON ที่ตรง shape ของ query:
json
{
"data": {
"user": {
"name": "Alice",
"email": "a@x.com",
"orders": [
{"id": 1, "total": 99.50, "items": [...]}
]
}
}
}1.2 Operation 3 ชนิด
| Operation | ใช้ทำอะไร |
|---|---|
| query | อ่านข้อมูล (เหมือน GET) |
| mutation | เปลี่ยนแปลง (POST/PUT/DELETE) |
| subscription | streaming (WebSocket) |
1.3 ความต่างกับ REST
| REST | GraphQL | |
|---|---|---|
| Endpoint | หลาย | 1 (/graphql) |
| HTTP method | GET/POST/PUT/DELETE | POST เกือบทั้งหมด |
| Response shape | กำหนดโดย server | กำหนดโดย client |
| Caching | HTTP cache ช่วย | ต้องทำเอง (persisted query) |
| Type system | ผ่าน OpenAPI | มี type system ในตัว (schema บังคับ) |
| Versioning | URL versioning, header | Schema evolution (deprecate field) |
| Learning curve | ต่ำ | สูง |
🔑 ศัพท์ในตาราง:
- persisted query (เพอร์-ซิส-ทิด-เคียวรี่) = query ที่ register ไว้ก่อน server เก็บ → client ส่ง hash แทน query เต็ม (cache ได้ + ลด payload)
- introspection (อิน-โทร-สเป็ก-ชั่น) = การส่อง schema — client query schema ของ server ได้เอง
- Schema evolution = พัฒนา/เปลี่ยน schema ทีละน้อย (ไม่ break client เก่า)
- deprecate field = มาร์ค field ว่าจะเลิกใช้ — ยังคืนค่าได้แต่บอก client ให้เลิกขอ
Part 2: Setup Spring for GraphQL
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>yaml
# application-dev.yml
# GraphiQL (สังเกตตัว i) = หน้าเว็บ playground สำหรับลองพิมพ์ query ต่างจาก GraphQL (ตัว L)
# เปิดได้ที่ http://localhost:8080/graphiql หลัง start แอป — ใช้ทดสอบ query ได้เลย
spring:
graphql:
graphiql:
enabled: true # ← เปิด GraphiQL UI (dev only)
path: /graphiql
schema:
printer.enabled: true # /graphql/schema
introspection:
enabled: true # dev เปิดเพื่อให้ GraphiQL ส่อง schema ได้
cors:
allowed-origins:
- "http://localhost:3000" # ระบุ origin ชัด ๆ — ห้ามใช้ "*"
- "http://localhost:5173"yaml
# application-prod.yml — production lock down
spring:
graphql:
graphiql:
enabled: false # ปิด GraphiQL UI ใน prod
schema:
introspection:
enabled: false # ⭐ ปิด introspection ใน prod (ดู security note ข้างล่าง)
cors:
allowed-origins:
- "https://app.example.com" # production origin ที่ trust จริง ๆ เท่านั้น🔴 CORS wildcard เป็นบั๊กความปลอดภัย:
allowed-origins: "*"กับ GraphQL endpoint ที่ถือ auth token = ใครก็ยิงจาก origin ไหนก็ได้ และถ้าวันหนึ่งเปิดallowCredentials: truepreflight จะ fail ทันทีตาม CORS spec — ระบุ origin เป็น list ตรง ๆ เหมือนตัวอย่าง prod ข้างบน🔴 ปิด introspection ใน production: introspection เปิดอยู่ = attacker query schema ทั้ง API ได้ทันที (ทุก type, ทุก mutation, ทุก field) → map attack surface ได้ง่ายมาก เปิดใน dev เพื่อใช้ GraphiQL ค้น schema สะดวก แต่ใน prod ต้องปิด
src/main/resources/graphql/schema.graphqls:
graphql
type Query {
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0): [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
orderCreated: Order!
}
type User {
id: ID!
name: String!
email: String!
age: Int
orders(limit: Int = 5): [Order!]!
}
type Order {
id: ID!
user: User!
items: [OrderItem!]!
total: Float!
status: OrderStatus!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
}
input CreateUserInput {
name: String!
email: String!
age: Int
}
input UpdateUserInput {
name: String
email: String
age: Int
}Part 3: Controllers
3.1 @QueryMapping / @MutationMapping
Spring for GraphQL ผูก resolver กับ schema ผ่าน annotation คล้าย Spring MVC — @QueryMapping สำหรับ query (อ่าน), @MutationMapping สำหรับ mutation (เปลี่ยนข้อมูล), @Argument ดึง argument จาก query เขียนเหมือน controller ปกติแต่ map กับ field ใน GraphQL schema Spring จับคู่ argument ให้เองโดยดูจาก ชื่อ parameter — เช่น id ใน schema.graphqls (user(id: ID!)) จะจับคู่กับ parameter ชื่อ id ใน method อัตโนมัติ ต้องตั้งชื่อให้ตรงกัน:
java
@Controller
public class UserGraphQLController {
private final UserService userService;
public UserGraphQLController(UserService userService) {
this.userService = userService;
}
@QueryMapping
public User user(@Argument Long id) { // GraphQL ID! → Spring for GraphQL coerce (แปลงชนิดข้อมูลให้ตรงกันอัตโนมัติ) เป็น Long ให้
// id: ID! เป็น non-null ใน schema — GraphQL engine ปฏิเสธ request ก่อนถึง resolver นี้ถ้า id ไม่มา ไม่ต้องเช็ค null เอง
// ⚠️ ถ้า id จริง ๆ เป็น UUID/string (ไม่ใช่ตัวเลข) ให้ใช้ @Argument String id แทน Long — ID! ไม่ได้การันตีว่าเป็นตัวเลขเสมอไป
return userService.findById(id);
}
@QueryMapping
public List<User> users(
@Argument Integer limit,
@Argument Integer offset) {
return userService.findAll(limit, offset);
}
@MutationMapping
public User createUser(@Argument @Valid CreateUserInput input) { // CreateUserInput/UpdateUserInput เป็น record — ดูนิยามใน Part 5.4
return userService.create(input);
}
@MutationMapping
public User updateUser(@Argument Long id, @Argument UpdateUserInput input) { // UpdateUserInput เป็น record เช่นกัน — ดูนิยามใน Part 5.4; Spring map GraphQL input → record ให้อัตโนมัติ
return userService.update(id, input);
}
@MutationMapping
public Boolean deleteUser(@Argument Long id) {
userService.delete(id);
return true;
}
}3.2 @SchemaMapping (nested resolver)
field ที่ซ้อนกัน (เช่น user.orders) ไม่ควรดึงมาทุกครั้ง @SchemaMapping กำหนด resolver แยกสำหรับ nested field ที่จะถูกเรียก "เฉพาะเมื่อ client ขอ field นั้น" ทำให้ไม่ join/query สิ่งที่ไม่ได้ใช้ แต่ระวัง — วิธีนี้เปิดช่องให้เกิดปัญหา N+1 ได้ (ดูหัวข้อ DataLoader ถัดไป):
graphql
type User {
orders(limit: Int = 5): [Order!]!
}java
// หมายเหตุ: นี่เป็น controller คนละตัว/คนละ snippet จากข้อ 3.1 (เพื่อโฟกัสแค่ nested resolver)
// ในโค้ดจริงต้องมี constructor ฉีด OrderService เข้ามาเหมือนกับ UserService ในข้อ 3.1
@Controller
public class UserGraphQLController {
private final OrderService orderService;
public UserGraphQLController(OrderService orderService) {
this.orderService = orderService;
}
@SchemaMapping(typeName = "User", field = "orders")
public List<Order> orders(User user, @Argument Integer limit) {
// user.getId() — ถ้า User เป็น class ปกติ
// user.id() — ถ้า User เป็น Java record (accessor ของ record ไม่มี get prefix)
return orderService.findByUserId(user.getId(), limit);
}
}→ ตอน client query user { orders { ... } } → Spring เรียก method นี้
3.3 Subscription
นอกจาก query/mutation GraphQL มี subscription สำหรับ real-time — client subscribe แล้ว server push ข้อมูลผ่าน WebSocket เมื่อมี event (เช่น order ใหม่) ใน Spring ใช้ @SubscriptionMapping ที่คืน Flux (สายข้อมูลแบบ reactive ที่ส่งค่าได้หลายตัวต่อเนื่อง — ดูรายละเอียดบท 10) ข้ามหัวข้อนี้ได้ถ้ายังไม่ผ่านบท 10:
java
@Controller
public class OrderSubscriptionController {
// Sinks.Many = ตัวช่วยสร้าง Flux ที่เราสั่ง emit ค่าเองได้จากโค้ดปกติ (ต่างจาก Flux ที่มาจาก DB query)
private final Sinks.Many<Order> sink = Sinks.many().multicast().onBackpressureBuffer();
public void emit(Order o) { sink.tryEmitNext(o); }
@SubscriptionMapping
public Flux<Order> orderCreated() {
return sink.asFlux();
}
}Client ผ่าน WebSocket:
graphql
subscription { orderCreated { id total } }ตั้ง:
yaml
spring.graphql.websocket.path: /graphql⚠️ dependency เพิ่มเติม (ถ้าใช้ Spring MVC +
starter-web): ต้องเพิ่มspring-boot-starter-websocketด้วย ไม่งั้น subscription จะไม่ทำงาน — ถ้าใช้ WebFlux (starter-webflux) ไม่ต้องเพิ่ม
Part 4: ปัญหา N+1 + DataLoader
4.1 ปัญหา
N+1 คือปัญหาที่ขึ้นชื่อที่สุดของ GraphQL เกิดเพราะ nested resolver ถูกเรียกแยกต่อ parent แต่ละตัว ตัวอย่าง: ถ้าดึง 100 user แล้วขอ orders ของแต่ละคน จะกลายเป็น 1 + 100 query ต้องแก้ด้วย DataLoader (หัวข้อถัดไป):
graphql
query {
users {
name
orders { id } # ← จะเรียก findByUserId ทีละ user
}
}ถ้ามี 100 user → query DB 101 ครั้ง (1 ค้น users + 100 ค้น orders)
4.2 DataLoader Pattern
DataLoader แก้ N+1 ด้วยการ "รวบ" request — แทนที่จะ query ทีละ user มันสะสม user id ทั้งหมดในรอบเดียวแล้วยิง query เดียว (WHERE user_id IN (...)) resolver คืน CompletableFuture (object ที่แทนผลลัพธ์ที่จะมาทีหลัง — async result) จาก loader แล้ว Spring batch ให้ ทำให้ 100 user เหลือ 2 query:
💡
@BatchMappingคืออะไร: เวอร์ชันที่ Spring ทำ DataLoader registration ให้อัตโนมัติ — ไม่ต้องยุ่งกับDataLoader<K,V>เอง รับList<User>ทั้งก้อนมาครั้งเดียว แล้วคืนMapที่ key คือ user แต่ละคน, value คือ orders ของเขา
java
@Controller
public class UserGraphQLController {
@QueryMapping
public List<User> users() {
return userService.findAll();
}
// วิธี idiomatic (แนะนำ): @BatchMapping ทำ DataLoader registration ให้อัตโนมัติ
// Spring for GraphQL รวบ user ทั้งหมดจาก request เดียวแล้วส่งเป็น List มาให้ครั้งเดียว
// ตัวอย่างนี้ใช้ Map (blocking) เพราะ orderService.findByUsers เรียก JPA ธรรมดา (ไม่ reactive)
// Mono<Map<K,V>> ควรใช้เฉพาะตอนต่อ WebFlux/R2DBC แบบ reactive จริง ๆ เท่านั้น
@BatchMapping(typeName = "User", field = "orders")
public Map<User, List<Order>> orders(List<User> users) {
return orderService.findByUsers(users); // Spring จัดการ batch ให้
}
// ⚠️ ถ้า User เป็น JPA @Entity (ไม่ใช่ record) ต้อง override equals()/hashCode() ตาม id เอง
// ไม่งั้นการใช้ User เป็น key ใน Map ข้างบนอาจจับคู่ผิด เพราะ equals() default เทียบ object reference
}→ DataLoader batch request — ส่ง user_ids เป็น set แล้ว query เดียว → 100 user = 2 query (users + orders WHERE user_id IN (...))
💡 วิธีที่ manual กว่า (ถ้าต้องการควบคุม DataLoader โดยตรง): ใช้
DataLoader<K,V>ใน@SchemaMapping+ ลงทะเบียน BatchLoader ผ่านBatchLoaderRegistryใน@Configuration:java// ใน controller: inject DataLoader ผ่าน parameter @SchemaMapping(typeName = "User", field = "orders") public CompletableFuture<List<Order>> orders(User user, DataLoader<Long, List<Order>> ordersByUserLoader) { return ordersByUserLoader.load(user.getId()); // user.getId() หรือ user.id() ขึ้นอยู่กับว่า User เป็น class หรือ record } // ใน @Configuration: ลงทะเบียน batch loader @Configuration public class DataLoaderConfig { @Bean public BatchLoaderRegistry ordersBatchLoader(OrderService orderService, BatchLoaderRegistry registry) { registry.<Long, List<Order>>forName("ordersByUserLoader") .registerMappedBatchLoader((userIds, env) -> { Map<Long, List<Order>> byUser = orderService.findByUserIds(userIds); return Mono.just(byUser); }); return registry; } }หมายเหตุ: pattern แบบ manual มีประโยชน์เมื่อ batch loader ต้องการ context พิเศษ (เช่น DataFetchingEnvironment) หรือต้อง share ข้าม controller หลายตัว แต่สำหรับกรณีทั่วไป
@BatchMappingสั้นกว่าและอ่านง่ายกว่า
4.3 ใน Hibernate
ฝั่ง DB ที่ DataLoader เรียกต้องเป็น batch query จริง — เขียน repository method ที่รับ list ของ id (WHERE userId IN :ids) แล้ว group ผลตาม userId เป็น Map ให้ loader กระจายกลับไปแต่ละ user นี่คือชิ้นที่ทำให้ batch loading ทำงานได้:
java
// OrderRepository (interface)
@Query("SELECT o FROM Order o WHERE o.userId IN :userIds")
List<Order> findByUserIds(@Param("userIds") List<Long> userIds);
// OrderService
public Map<Long, List<Order>> findByUserIds(Set<Long> ids) {
// DataLoader ส่ง Set<Long> มา → แปลงเป็น List เพื่อใช้กับ JPA query ข้างบน
return orderRepo.findByUserIds(new ArrayList<>(ids))
.stream()
.collect(Collectors.groupingBy(Order::getUserId));
}Part 5: Error Handling
5.1 Default
GraphQL ต่างจาก REST ตรงที่ error ไม่ใช้ HTTP status (รหัสสถานะ เช่น 200 = สำเร็จ, 404 = ไม่พบ, 500 = server error) response มักเป็น 200 เสมอ แต่ใส่ error ไว้ใน array errors (พร้อม path + classification) เคียงข้าง data ที่อาจเป็น null บางส่วน ต้องเข้าใจ format นี้ก่อนค่อยปรับแต่ง:
GraphQL spec ระบุ:
json
{
"data": { "user": null },
"errors": [{
"message": "...",
"path": ["user"],
"extensions": { "classification": "INTERNAL_ERROR" }
}]
}5.2 Custom Exception
วิธีหนึ่งในการคืน error ที่มีความหมาย — ให้ exception implement GraphQLError พร้อมระบุ classification (NOT_FOUND, FORBIDDEN) เอง จากนั้น GraphQL จะแปลงเป็น error format ให้ client รู้ประเภทของ error ไม่ใช่แค่ message ดิบ:
java
public class UserNotFoundException extends RuntimeException
implements GraphQLError {
public UserNotFoundException(Long id) {
super("User not found: " + id);
}
@Override
public ErrorClassification getErrorType() {
return ErrorType.NOT_FOUND;
}
@Override
public List<SourceLocation> getLocations() { return null; }
}5.3 ExceptionResolver
วิธีที่ดีกว่าคือจัดการ error รวมศูนย์ด้วย DataFetcherExceptionResolverAdapter — map exception ทุกชนิด (รวมที่ไม่ได้ implement GraphQLError) เป็น GraphQL error ที่ classification ถูก คล้าย @RestControllerAdvice ของ MVC ทำให้ business code ไม่ต้องรู้เรื่อง GraphQL error format:
java
@Component
public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (ex instanceof UserNotFoundException) {
return GraphqlErrorBuilder.newError(env)
.message(ex.getMessage())
.errorType(ErrorType.NOT_FOUND)
.build();
}
if (ex instanceof AccessDeniedException) {
return GraphqlErrorBuilder.newError(env)
.message("Access denied")
.errorType(ErrorType.FORBIDDEN)
.build();
}
return null; // fallback default
}
}5.4 Validation
input ของ mutation ก็ validate ได้เหมือน REST — ใส่ @Valid ที่ @Argument แล้วใช้ Bean Validation annotation (@NotBlank, @Email) บน input record validation error จะถูกแปลงเป็น GraphQL error format ให้อัตโนมัติ:
⚠️ dependency ที่ต้องเพิ่ม:
@Validต้องมีspring-boot-starter-validationใน pom.xml ก่อน ถ้าไม่มี annotation จะเป็น no-op เงียบ ๆ (ไม่ validate, ไม่ error):xml<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
java
@MutationMapping
public User createUser(@Argument @Valid CreateUserInput input) {
...
}
public record CreateUserInput(
@NotBlank String name,
@Email String email,
@Min(0) @Max(150) Integer age
) {}→ validation error คืนเป็น GraphQL error format
Part 6: Security
⚠️ prerequisite: หัวข้อนี้ใช้
SecurityFilterChain, JWT,@PreAuthorizeจากบท Security — ถ้ายังไม่ผ่านบท Security ข้ามไปก่อนได้
java
@Bean
public SecurityFilterChain graphql(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(a -> a
// dev: permitAll; production: /graphql ควรเป็น authenticated() และ /graphiql ควรปิดไปเลย
.requestMatchers("/graphql").authenticated()
.requestMatchers("/graphiql").permitAll() // เปิดแค่ dev — prod ปิดด้วย application-prod.yml
)
// ⚠️ ปิด CSRF ได้เฉพาะ stateless authentication (JWT/API key via Authorization header) เท่านั้น
// ถ้าแอปใช้ session cookie ห้ามปิด CSRF หรือต้องตั้ง CsrfTokenRepository ให้ถูกต้อง
.csrf(c -> c.disable())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}🔴 ปิด CSRF เมื่อไหร่ได้?:
.csrf(c -> c.disable())ปลอดภัยก็ต่อเมื่อ API ใช้ stateless authentication เท่านั้น (JWT Bearer token ผ่านAuthorizationheader) — ถ้าแอปมี session cookie อยู่ด้วย การปิด CSRF เปิดช่องโหว่ CSRF attack ทันที อย่าปิดถ้าไม่แน่ใจ
Field-level security:
java
@Controller
public class UserGraphQLController {
@QueryMapping
@PreAuthorize("hasRole('ADMIN')")
public List<User> users() { ... }
@SchemaMapping
@PreAuthorize("#user.id == authentication.principal.id or hasRole('ADMIN')")
public String email(User user) {
return user.getEmail();
}
}Part 7: Performance + Production
7.1 Query Complexity
ความยืดหยุ่นของ GraphQL มาพร้อมความเสี่ยง — client เขียน query ที่ซ้อนลึกหรือกว้างมากจน server ทำงานหนัก จนกลายเป็น DoS (Denial of Service: ทำให้ server รับ request อื่นไม่ได้ โดยไม่ตั้งใจหรือตั้งใจ) ต้องจำกัดด้วย max query depth/complexity เพื่อปฏิเสธ query ที่แพงเกินก่อนรัน:
GraphQL อันตราย — client query deep ได้:
graphql
query {
user(id: 1) {
orders {
user {
orders {
user { ... } # วนซ้ำไม่สิ้นสุด!
}
}
}
}
}→ DoS attack (การโจมตีให้ server ล้มเหลวด้วยการยิง query ที่แพงมาก) potential
แก้:
java
// imports ที่ต้องวางไว้บนสุดของไฟล์ (มาจาก graphql-java ที่อยู่ใน classpath อยู่แล้วผ่าน spring-boot-starter-graphql):
import graphql.analysis.MaxQueryDepthInstrumentation;
import graphql.analysis.MaxQueryComplexityInstrumentation;
import graphql.execution.instrumentation.Instrumentation;
@Bean
public Instrumentation maxDepthInstrumentation() {
return new MaxQueryDepthInstrumentation(5);
}
@Bean
public Instrumentation maxComplexityInstrumentation() {
return new MaxQueryComplexityInstrumentation(100);
}⚠️ ตรวจ constructor ตาม version:
graphql-javaเปลี่ยน constructor ของ instrumentation พวกนี้ไปตาม version — บาง version ต้องการFieldComplexityCalculatorเพิ่มเติม ตรวจ version ของgraphql-javaที่ Spring Boot ของคุณดึงมาก่อนใช้ตัวอย่างนี้ตรง ๆ (ถ้ามี Instrumentation bean อื่นด้วย เช่น tracing/metrics, Spring Boot auto-configuration จะ chain ให้ผ่านChainedInstrumentationอัตโนมัติ)
7.2 Persisted Queries (APQ) — ลด network + whitelist
แทนที่ client จะส่ง query string เต็มทุกครั้ง — ส่ง SHA-256 hash (รหัสสรุปขนาดคงที่ 64 ตัวอักษร ที่แทน query string ยาว ๆ ได้) ของ query แทน extensions คือ field พิเศษใน GraphQL request body (นอกเหนือจาก query/variables) ที่ client library (เช่น Apollo Client) เติมให้อัตโนมัติ ไม่ต้องเขียนเอง:
text
[ครั้งแรก] Client → POST /graphql { query, extensions: { persistedQuery: { sha256Hash: "abc..." } } }
Server → cache query string ภายใต้ hash → execute
[ครั้งถัดไป] Client → POST /graphql { extensions: { persistedQuery: { sha256Hash: "abc..." } } }
Server → lookup → execute (query ไม่ต้องส่งซ้ำ)แนวคิด APQ (Spring for GraphQL ไม่มี built-in class สำเร็จรูป — ต้อง implement เอง):
⚠️ หมายเหตุ: Spring for GraphQL 1.x/2.x ไม่มี class
PersistedQuerySupport,InMemoryPersistedQuerySupport, หรือApqInterceptorใน public API — ต้อง implementWebGraphQlInterceptorเอง หรือใช้ Apollo Router ที่ handle APQ ฝั่ง gateway แทน
java
// แนวทาง: implement WebGraphQlInterceptor เองเพื่อทำ APQ
@Component
public class ApqInterceptor implements WebGraphQlInterceptor {
private final Map<String, String> queryCache = new ConcurrentHashMap<>(); // หรือ Redis
@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
// 1. ดึง hash จาก extensions.persistedQuery.sha256Hash
// 2. ถ้ามี hash → lookup ใน cache → inject query กลับเข้า request
// 3. ถ้าไม่มีใน cache → รับ query เต็ม + cache ไว้
// (implement logic ตาม APQ spec: https://www.apollographql.com/docs/apollo-server/performance/apq/)
return chain.next(request);
}
}ข้อดี:
- Network payload เล็กลง 5-10 เท่า (query ยาวๆ ไม่ส่งซ้ำ)
- Server cache execution plan ได้
- Whitelist mode — ใน production ปิดรับ query ใหม่ → block ad-hoc query → security ดีขึ้น (ต้อง implement check ใน interceptor เอง)
Build pipeline (กระบวนการ build ของ frontend project ที่มี npm/TypeScript): npm run extract-queries → push hash map ไป Redis → server โหลดเข้า cache ตอน startup
7.3 Caching
REST cache ด้วย HTTP cache. GraphQL ทำได้ยาก (POST + body ต่างกันทุกครั้ง)
ใช้ผสม:
- CDN persisted query (key = query hash + variables) — Cloudflare/Fastly cache ได้
- DataLoader cache (per request) — แก้ N+1
- Application-level cache (Caffeine/Redis) — field-level (เช่น
@Cacheableบน DataLoader's batch function)
7.4 Apollo Federation / Subgraph — graph ข้าม service
ปัญหาปี 2026: บริษัทใหญ่มี 50+ microservices แต่ละบริการมี GraphQL ของตัวเอง → frontend ต้องเรียก 5 graph แยกกัน
Apollo Federation v2 = standard สำหรับรวม subgraph หลาย service เป็น supergraph เดียวให้ frontend
แต่ละ subgraph own type ของตัวเอง แต่ extend type ของ subgraph อื่นได้:
graphql
# user-service schema
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
# order-service schema — extend User
type User @key(fields: "id") {
id: ID! @external
orders: [Order!]! # field นี้ resolved โดย order-service
}
type Order {
id: ID!
total: Float!
}Frontend query ผ่าน Apollo Router → router วาง plan → เรียก user-service สำหรับ name/email + เรียก order-service สำหรับ orders → รวม response
Spring for GraphQL + Federation
Spring มี spring-graphql-federation (จาก Apollo) ที่เพิ่ม @key directive + _entities resolver:
xml
<dependency>
<groupId>com.apollographql.federation</groupId>
<artifactId>federation-graphql-java-support</artifactId>
<!-- ตรวจ version ล่าสุดที่ https://github.com/apollographql/federation-jvm/releases
ตัวอย่าง: 4.1.0 (ตรวจสอบ compatibility กับ graphql-java version ของ Spring Boot ที่ใช้ก่อน) -->
<version>4.1.0</version>
</dependency>⚠️
federation-jvmversion ต้องตรงกับgraphql-javaversion ที่ Spring Boot ใช้อยู่ ตรวจ compatibility matrix ก่อนเสมอ
java
// Entity ที่ federation gateway จะมา resolve ผ่าน _entities
@Controller
public class UserSubgraphController {
@QueryMapping
public User user(@Argument Long id) { ... }
}
// federation-jvm 5.x: Entity resolver ลงทะเบียนผ่าน Federation.transform(runtimeWiring)
// TypeResolutionEnvironment ไม่ใช่ interface ที่ implement ได้ — มันเป็น object argument ที่ส่งเข้า TypeResolver
// ดูตัวอย่างที่ถูกต้องที่: https://github.com/apollographql/federation-jvm/tree/main/samples⚠️ ระวัง: federation-graphql-java-support ไม่มี annotation ชื่อ
@EntityMapping— ตัวอย่างที่อ้างถึง pattern นี้บนอินเทอร์เน็ตหลายแห่งเข้าใจผิดกับ Spring for GraphQL annotation อื่น Federation resolver ต้องลงทะเบียนผ่านFederation.transform(schema)หรือ implementEntityResolver/TypeResolverตาม Apollo spec อ่านโครงเต็มที่ federation-jvm README
📚 อ่านต่อ: Apollo Federation v2 spec · Spring Boot + Federation tutorial · ทางเลือก: GraphQL Mesh, Hot Chocolate Fusion (.NET — GraphQL framework สำหรับ .NET)
💡 ไม่ต้องรีบใช้ federation — ถ้ามี 2-3 service ใช้ schema stitching หรือ BFF (Backend For Frontend) ที่ aggregate ใน Spring ยังพอเอาอยู่ Federation จะคุ้มเมื่อมี 5+ subgraph และทีมหลายทีม
Part 8: Testing
java
@SpringBootTest
@AutoConfigureGraphQlTester
class UserGraphQLTest {
@Autowired GraphQlTester tester;
// @MockitoBean ใช้ได้เฉพาะ Spring Boot 3.4+ — ถ้าใช้เวอร์ชันต่ำกว่าให้ใช้ @MockBean แทน
// ตรวจ version ของคุณได้ใน pom.xml: ดูที่ <parent><version>X.Y.Z</version></parent>
// ถ้า Y (ตัวเลขหลักกลาง) น้อยกว่า 4 → ใช้ @MockBean แทน (@MockBean จะถูก deprecated ในอนาคต)
@MockitoBean UserService userService;
@Test
void getUser() {
// User เป็น record เช่น: record User(Long id, String name, String email) { ... }
when(userService.findById(1L)).thenReturn(new User(1L, "Alice", "a@x.com"));
tester.document("""
query {
user(id: 1) {
name
email
}
}
""")
.execute()
.path("user.name").entity(String.class).isEqualTo("Alice");
}
}Part 9: GraphQL vs REST — เลือกเมื่อไหร่
✅ GraphQL ดีเมื่อ:
- Frontend ต่าง platform ดึงข้อมูลต่างกัน (web ต้องการเยอะ, mobile ต้องการน้อย)
- Multiple data source ที่อยาก compose
- Frequent UI change ที่ปรับ query ได้โดยไม่ต้อง backend
- BFF (Backend For Frontend — server กลางที่รวมข้อมูลจากหลาย service มาให้ frontend โดยเฉพาะ) layer
❌ GraphQL ไม่ดีเมื่อ:
- Simple CRUD
- File upload (มี complication)
- ต้องการ HTTP cache เต็มที่
- Team ใหม่ไม่เคยใช้
- Public API (client query ลึก = อันตราย)
Part 10: Checkpoint
- ความต่างหลักของ GraphQL vs REST?
- Operation 3 ชนิดคือ?
- N+1 ใน GraphQL เกิดยังไง? DataLoader แก้ยังไง?
- Subscription ใช้ protocol อะไร?
- Max query depth ใช้ทำอะไร?
- Persisted query แก้ปัญหาอะไร?
- SchemaMapping ต่างกับ QueryMapping?
- Federation คือ?
- GraphQL caching ทำไมยาก?
- ตัดสินใจใช้ GraphQL เมื่อไหร่?
Part 11: สรุปบทนี้
- GraphQL = client บอก shape ของ response — แก้ over/under-fetching
- Schema-first ใน Spring for GraphQL —
schema.graphqls+ Controller @QueryMapping,@MutationMapping,@SubscriptionMapping,@SchemaMapping- DataLoader = แก้ N+1 ด้วย batch
- Security: max depth/complexity, persisted queries, field-level auth
- Not silver bullet — เลือกตาม use case
บทถัดไป (สุดท้ายของหมวด) — Native Image (GraalVM + Spring AOT)