Spring REST API & Data JPA
11/46
Bài 11 / 46~12 phútRequest & ResponseLộ trình · chặng 15/21Miễn phí… lượt xem

Response Nâng Cao: Streaming, Advice, HATEOAS và Security

Làm chủ kỹ thuật streaming dữ liệu chống OOM, ResponseBodyAdvice bọc envelope đồng nhất, HATEOAS và tối ưu bảo mật response trong Spring MVC.

TL;DR: Khi response vượt ra khỏi mô hình trả JSON tĩnh thông thường, Spring MVC cung cấp 4 cơ chế nâng cao: StreamingResponseBody ghi trực tiếp từng phần dữ liệu vào servlet output stream giúp giải quyết triệt để nguy cơ tràn bộ nhớ (OOM) khi export hàng triệu bản ghi; SseEmitter duy trì kết nối HTTP để đẩy sự kiện thời gian thực (one-way realtime); ResponseBodyAdvice chặn bắt toàn bộ response trước khi tuần tự hóa để bọc cấu trúc envelope đồng nhất; HATEOAS nhúng các liên kết điều hướng (_links) vào body giúp client tự khám phá hành động tiếp theo. Ngoài ra, việc thiết lập ETag, nén gzip và các Security Headers là tiêu chuẩn bắt buộc khi đưa API lên môi trường production.

1. Khi nào nên dùng StreamingResponseBody và Server-Sent Events?

Xử lý xuất dữ liệu dung lượng lớn với StreamingResponseBody

Phương thức trả về danh sách truyền thống gom toàn bộ dữ liệu vào Java Heap trước khi serialize:

Java
// Anti-pattern: Load toan bo 1 trieu ban ghi gay OutOfMemoryError
@GetMapping("/orders/export")
public ResponseEntity<List<OrderDto>> exportAll() {
    List<OrderDto> all = orderService.findAll(); // Gay qua tai bo nho heap
    return ResponseEntity.ok(all);
}

Để tránh cạn kiệt RAM, StreamingResponseBody cho phép ghi dữ liệu từng phần trực tiếp vào socket output stream:

Java
@GetMapping("/orders/export")
public ResponseEntity<StreamingResponseBody> exportOrdersCsv() {
    StreamingResponseBody stream = outputStream -> {
        try (PrintWriter writer = new PrintWriter(outputStream);
             Stream<Order> orders = orderService.streamAll()) {

            writer.println("id,customer,total,createdAt");
            orders.forEach(order -> {
                writer.println(order.getId() + "," + order.getCustomer() + "," + order.getTotal() + "," + order.getCreatedAt());
            });
            writer.flush();
        }
    };

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType("text/csv"))
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"orders.csv\"")
        .body(stream);
}

Hai cột đối chiếu: findAll giữ hai bản sao trong heap vì List đã nằm trong RAM rồi Jackson còn dựng xong toàn bộ JSON mới ghi byte đầu tiên, còn StreamingResponseBody commit header rồi ghi từng batch 500 dòng và flush nên heap luôn chỉ giữ một batch

Ở tầng repository, cấu hình fetch size qua JDBC cursor và giữ transaction mở:

Java
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    @QueryHints(@QueryHint(name = AvailableSettings.HINT_FETCH_SIZE, value = "500"))
    Stream<Order> streamAll();
}

@Service
public class OrderService {
    @Transactional(readOnly = true) // Bat buoc de giu mo DB cursor
    public Stream<Order> streamAll() {
        return orderRepository.streamAll();
    }
}

Đẩy dữ liệu thời gian thực một chiều với Server-Sent Events (SSE)

SSE cho phép server chủ động push dữ liệu về client qua kết nối HTTP/1.1 hoặc HTTP/2 dạng text/event-stream:

Java
@GetMapping(value = "/orders/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamOrderUpdates() {
    SseEmitter emitter = new SseEmitter(60_000L); // Timeout 60s

    orderEventBus.subscribe(event -> {
        try {
            emitter.send(SseEmitter.event()
                .id(String.valueOf(event.getId()))
                .name("order-status")
                .data(event, MediaType.APPLICATION_JSON));
        } catch (IOException ex) {
            emitter.completeWithError(ex);
        }
    });

    emitter.onTimeout(emitter::complete);
    emitter.onError(ex -> emitter.complete());
    return emitter;
}

Khác với WebSocket hỗ trợ giao tiếp hai chiều (bi-directional), SSE là giao tiếp một chiều từ server tới client, cực kỳ phù hợp cho bảng điều khiển giám sát, cập nhật trạng thái đơn hàng và tiến trình tác vụ nền.

2. ResponseBodyAdvice hoạt động và can thiệp vào response toàn cục ra sao?

Nhiều tổ chức yêu cầu chuẩn hóa mọi response về dạng envelope thống nhất:

JSON
{
  "success": true,
  "data": { "id": 1024, "status": "COMPLETED" },
  "requestId": "req-9872",
  "timestamp": "2026-04-15T10:00:00Z"
}

ResponseBodyAdvice là cơ chế chặn bắt chính thức của Spring MVC cho phép thực hiện việc đóng gói này tại một điểm duy nhất:

Java
public record ApiResponse<T>(boolean success, T data, String requestId, Instant timestamp) {
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, data, MDC.get("requestId"), Instant.now());
    }
}

@RestControllerAdvice
public class ApiResponseEnvelopeAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // Chi ap dung cho JSON converter
        if (!converterType.isAssignableFrom(MappingJackson2HttpMessageConverter.class)) {
            return false;
        }
        // Bo qua neu controller tra ve String hoac da duoc wrap
        Class<?> paramType = returnType.getParameterType();
        return !paramType.isAssignableFrom(String.class)
            && !paramType.isAssignableFrom(ApiResponse.class)
            && !paramType.isAssignableFrom(Resource.class)
            && !paramType.isAssignableFrom(ProblemDetail.class);
    }

    @Override
    public Object beforeBodyWrite(
        Object body,
        MethodParameter returnType,
        MediaType selectedContentType,
        Class<? extends HttpMessageConverter<?>> selectedConverterType,
        ServerHttpRequest request,
        ServerHttpResponse response
    ) {
        return ApiResponse.success(body);
    }
}
Bẫy ClassCastException với kiểu dữ liệu String

Nếu controller trả về trực tiếp kiểu String, Spring MVC sẽ ưu tiên chọn StringHttpMessageConverter. Nếu Advice bọc String thành ApiResponse<String>, converter sẽ cố ép kiểu đối tượng này thành chuỗi và gây lỗi ClassCastException. Do đó, luôn kiểm tra và bỏ qua kiểu String trong phương thức supports().

3. HATEOAS giúp client tự khám phá API (Discoverability) như thế nào?

HATEOAS (Hypermedia As The Engine Of Application State) bổ sung khối _links vào dữ liệu JSON, chỉ dẫn cho client biết các thao tác nghiệp vụ khả dụng tiếp theo:

Java
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public EntityModel<OrderDto> getOrder(@PathVariable Long id) {
        OrderDto order = orderService.findById(id);

        return EntityModel.of(order,
            linkTo(methodOn(OrderController.class).getOrder(id)).withSelfRel(),
            linkTo(methodOn(OrderController.class).cancelOrder(id)).withRel("cancel"),
            linkTo(methodOn(OrderController.class).listItems(id)).withRel("items")
        );
    }

    @DeleteMapping("/{id}/cancel")
    public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
        orderService.cancel(id);
        return ResponseEntity.noContent().build();
    }
}

Đoạn code trên tự động tạo ra response có cấu trúc siêu liên kết:

JSON
{
  "id": 1024,
  "status": "PENDING",
  "_links": {
    "self": { "href": "http://localhost:8080/api/orders/1024" },
    "cancel": { "href": "http://localhost:8080/api/orders/1024/cancel" },
    "items": { "href": "http://localhost:8080/api/orders/1024/items" }
  }
}

Client chỉ cần truy vấn theo tên quan hệ (rel) "cancel" thay vì tự lắp ráp URL thủ công, giúp server tự do thay đổi cấu trúc URL mà không làm gãy hợp đồng tích hợp.

4. Tối ưu HTTP Caching với ETag và nén dữ liệu trong Production ra sao?

Tận dụng ETag và HTTP 304 Not Modified

ETag đại diện cho phiên bản trạng thái của bản ghi dữ liệu. Khi client gửi kèm header If-None-Match, nếu phiên bản trên DB không đổi, server phản hồi mã 304 không kèm body:

Java
@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> getOrder(
    @PathVariable Long id,
    @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
    OrderDto order = orderService.findById(id);
    String currentEtag = "\"" + order.getVersion() + "\""; // Su dung version optimistic locking

    if (currentEtag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(currentEtag).build();
    }

    return ResponseEntity.ok()
        .eTag(currentEtag)
        .cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePublic())
        .body(order);
}

Kích hoạt nén Gzip trong tệp cấu hình

Nén dữ liệu JSON giúp giảm từ 70% đến 90% dung lượng truyền tải trên mạng:

YAML
server:
  compression:
    enabled: true
    mime-types: application/json,text/html,text/csv,application/xml
    min-response-size: 1024 # Chi nen khi body tu 1KB tro len

5. Cấu hình Security Headers thế nào để bảo vệ ứng dụng?

Để bảo vệ client chống lại các cuộc tấn công Clickjacking, MIME-sniffing và XSS, API cần gửi kèm các HTTP Security Headers:

Java
@Configuration
public class SecurityWebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new HandlerInterceptor() {
            @Override
            public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
                res.setHeader("X-Content-Type-Options", "nosniff");
                res.setHeader("X-Frame-Options", "DENY");
                res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
                res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
                return true;
            }
        });
    }
}
Security HeaderTác dụng phòng vệ
X-Content-Type-Options: nosniffNgăn chặn trình duyệt tự ý đoán định dạng file tải về
X-Frame-Options: DENYChống tấn công Clickjacking (không cho nhúng iframe)
Strict-Transport-Security (HSTS)Ép buộc toàn bộ kết nối chuyển tiếp qua HTTPS
Referrer-PolicyKiểm soát thông tin URL nhạy cảm rò rỉ qua header Referer

6. Luồng xử lý toàn cảnh response pipeline trong Spring MVC diễn ra ra sao?

Quyết định cách phản hồi dựa trên kiểu dữ liệu trả về của controller method:

Return type quyết định đường đi: nhánh Object hoặc ResponseEntity đi qua ResponseBodyAdvice beforeBodyWrite rồi mới tới HttpMessageConverter, còn StreamingResponseBody và SseEmitter đi tắt không qua converter, cả ba cùng đổ ra byte network

Nhánh StreamingResponseBody và SseEmitter đi tắt qua Handler chuyên biệt và ghi thẳng ra socket stream mà không cần qua tầng HttpMessageConverter hay ResponseBodyAdvice.

7. Những cạm bẫy kỹ thuật thường gặp khi làm việc với response nâng cao là gì?

Cạm bẫy 1: Quên đóng Stream trong StreamingResponseBody

Nếu không dùng cấu trúc try-with-resources để đóng Stream<T>, JDBC cursor trên database sẽ bị treo, dẫn đến cạn kiệt connection pool dưới tải cao.

Cạm bẫy 2: Không dọn dẹp listener của SseEmitter gây rò rỉ bộ nhớ

Khi trình duyệt tắt tab, kết nối SSE bị ngắt nhưng subscription trong EventBus vẫn giữ tham chiếu mạnh tới SseEmitter:

Hai thời điểm đối chiếu: lúc client kết nối thì tạo SseEmitter, subscribe listener vào eventBus và listener giữ tham chiếu tới emitter; lúc client đóng tab thì connection đứt, emitter complete nên phía Spring đã sạch, nhưng listener vẫn còn nằm trong eventBus

Biện pháp xử lý: Luôn đăng ký callback onTimeout và onError để hủy đăng ký listener tương ứng.

8. Các kỹ thuật response nâng cao kết nối với kiến trúc hệ thống ra sao?

  • ResponseEntity & HTTP status: Đặt nền tảng quản lý header và status code trước khi bước sang các kỹ thuật streaming và advice.
  • Content Negotiation: Định hướng converter serialize dữ liệu tương thích với yêu cầu của client.
  • Problem Details RFC 9457: Phối hợp cùng @ExceptionHandler để trả về phản hồi lỗi chuẩn tắc mà không bị ghi đè bởi ResponseBodyAdvice.

9. Những điểm then chốt cần ghi nhớ là gì?

  • Sử dụng StreamingResponseBody kết hợp JDBC cursor và @Transactional(readOnly = true) để xử lý xuất dữ liệu dung lượng lớn an toàn bộ nhớ.
  • SseEmitter là giải pháp lý tưởng cho luồng dữ liệu thời gian thực một chiều trên nền HTTP.
  • Triển khai ResponseBodyAdvice để đóng gói định dạng phản hồi chung cho toàn bộ API JSON, lưu ý loại trừ kiểu trả về String.
  • HATEOAS giúp giảm thiểu sự phụ thuộc cứng vào URL giữa client và server thông qua hypermedia links.
  • Tối ưu hạ tầng production bằng ETag (304 Not Modified), nén Gzip và thiết lập đầy đủ HTTP Security Headers.

10. Bài tập tự kiểm tra mức độ hiểu sâu

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao việc trả về trực tiếp List<OrderDto> chứa 1 triệu bản ghi từ Controller dễ gây OutOfMemoryError, trong khi StreamingResponseBody lại an toàn?
  2. Q2
    Giao thức Server-Sent Events (SSE) sử dụng Media Type nào sau đây để duy trì kết nối truyền dữ liệu liên tục?
  3. Q3
    Khi cài đặt ResponseBodyAdvice, lỗi ClassCastException thường xảy ra nhất trong trường hợp nào nếu không kiểm tra kiểu trả về cẩn thận?
  4. Q4
    Lợi ích cốt lõi nhất của việc áp dụng mô hình HATEOAS trong kiến trúc REST API là gì?
  5. Q5
    Cơ chế HTTP Caching dựa trên header ETag kết hợp If-None-Match giúp tiết kiệm tài nguyên hệ thống bằng cách nào?

Bài tiếp theo: Tổng kết module

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 15/21 · REST API với Spring MVC — bài 11/21 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Tổng Kết Module 02: Xử Lý Request và Response Spring MVC