Spring REST API & Data JPA/Response nâng cao — Streaming, ResponseBodyAdvice, HATEOAS, Production
11/46
Bài 11 / 46~14 phútRequest & ResponseMiễn phí lượt xem

Response nâng cao — Streaming, ResponseBodyAdvice, HATEOAS, Production

Bài này bóc 4 kỹ thuật response nâng cao: StreamingResponseBody + SSE (stream không load hết memory), ResponseBodyAdvice (wrap đồng nhất global), HATEOAS link relations (discoverability), và production headers (caching ETag, gzip, security headers). Mỗi kỹ thuật giải thích cơ chế bên dưới và trade-off khi nào nên dùng.

TL;DR: Khi response vượt ra ngoài "trả JSON đơn giản", Spring cung cấp 4 cơ chế nâng cao: StreamingResponseBody write từng chunk thẳng vào servlet output stream thay vì buffer toàn bộ vào memory (giải OOM với export triệu dòng); SseEmitter giữ connection mở để server push event realtime; ResponseBodyAdvice intercept mọi response trước khi serialize để wrap đồng nhất; HATEOAS nhúng link relation vào body cho client discover action có sẵn. Production thêm ETag để client cache + 304, gzip để tiết kiệm bandwidth, security headers để chặn tấn công phía browser.

Bài ResponseEntity & status bóc cách kiểm soát status code và header cho response thông thường. Bài này mở rộng sang các response "không thông thường": body quá lớn, kết nối mở dài, format cần nhất quán toàn hệ thống, và link-driven API.

1. Streaming — vì sao không load hết vào memory

1.1 Vấn đề với non-streaming

Endpoint export kiểu này gây OOM khi dữ liệu lớn:

// SAI — load 1 trieu record vao heap
@GetMapping("/orders/export")
public ResponseEntity<List<OrderDto>> exportAll() {
    List<OrderDto> all = orderService.findAll();   // 1M rows, 500MB+ heap
    return ResponseEntity.ok(all);
}

Jackson serialize cũng build full JSON string trong memory trước khi write. Với 1M record, heap spike gấp 3-4 lần data size thô.

Cơ chế bên dưới: khi controller return List<OrderDto>, Spring truyền object sang MappingJackson2HttpMessageConverter, converter gọi objectMapper.writeValue(outputStream, body). Với List, Jackson đệ quy serialize toàn bộ list thành byte[] trước — đây là điểm gom memory.

1.2 StreamingResponseBody — write từng chunk

StreamingResponseBody là functional interface cho phép bạn tự write vào OutputStream theo từng đơn vị nhỏ:

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

            writer.println("id,customer,total,createdAt");

            orders.forEach(order -> writer.printf(
                "%d,%s,%.2f,%s%n",
                order.id(), order.customer(),
                order.total(), order.createdAt()
            ));
        }
        // outputStream dong tu dong sau lambda
    };

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

Service dùng Stream<Order> thay List<Order> — Spring Data fetch qua JDBC cursor từng batch:

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "500"))
    Stream<Order> streamAll();
}

@Service
public class OrderService {

    @Transactional(readOnly = true)   // transaction phai mo de hold cursor
    public Stream<Order> streamAll() {
        return orderRepository.streamAll();
    }
}

Cơ chế bên dưới StreamingResponseBody: Spring phát hiện return type là StreamingResponseBody, không qua HttpMessageConverter thông thường. Thay vào đó, StreamingResponseBodyReturnValueHandler gọi stream.writeTo(response.getOutputStream()) trực tiếp. Byte được flush liên tục — HTTP response header commit ngay, body ghi theo chunk. Heap giữ tối đa một fetch batch (500 rows) tại mọi thời điểm.

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

Cách đếm đúng ở đây là đếm bản sao, không đếm bước: dữ liệu đã ở trong RAM một lần dưới dạng List, Jackson nhân nó lên lần nữa dưới dạng chuỗi JSON. Cột phải không có bản sao nào, và trả giá bằng hai ràng buộc dưới đây.

Pitfall cần tránh:

  • @Transactional trên service method là bắt buộc — cursor cần transaction mở. Không có transaction, Stream<Order> ném LazyInitializationException hoặc close cursor sớm.
  • Phải close Stream trong try-with-resources — không close là connection leak.
  • Lazy association trong Order (vd @ManyToOne lazy) sẽ gây N+1 query trong loop. Fetch eager hoặc dùng projection DTO.

1.3 Server-Sent Events — server push realtime

SSE (Server-Sent Events) là giao thức HTTP/1.1 đơn giản: client mở một connection GET, server giữ connection mở và đẩy event theo định dạng text/event-stream. Client tự reconnect khi mất kết nối — không cần WebSocket.

@GetMapping(value = "/orders/feed",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter orderFeed() {
    SseEmitter emitter = new SseEmitter(60_000L);   // timeout 60 giay

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

    emitter.onTimeout(emitter::complete);
    emitter.onError(err -> emitter.complete());

    return emitter;
}

Cơ chế bên dưới: SseEmitter không block thread controller sau khi return — Spring MVC suspend request (Async MVC, AsyncContext). Thread pool controller free ngay. Khi emitter.send() gọi, Spring dùng thread khác write vào response. Response header Content-Type: text/event-stream + Transfer-Encoding: chunked giữ connection. Browser EventSource API tự handle reconnect.

Khi dùng SSE thay WebSocket: SSE đủ khi server push one-way (dashboard realtime, notification, progress bar). WebSocket cần khi client cũng push ngược lại server. SSE đơn giản hơn, HTTP/1.1 native, proxy thân thiện hơn.

Cảnh báo production: mỗi SSE connection giữ một Tomcat thread (hoặc virtual thread Boot 3.2+). 10K concurrent SSE = 10K thread. Với thread-per-request Tomcat, đây là bottleneck. Giải pháp: Spring WebFlux (reactive) hoặc Virtual Threads.

2. ResponseBodyAdvice — wrap response global

2.1 Vấn đề cần giải quyết

Nhiều team dùng convention "mọi response wrap trong envelope":

{
  "success": true,
  "data": { "id": 42, "customer": "Alice" },
  "requestId": "abc-123",
  "timestamp": "2026-04-15T10:00:00Z"
}

Nếu mỗi controller tự wrap, code trùng lặp và dễ quên. ResponseBodyAdvice là hook Spring cho phép can thiệp vào return value sau khi controller return nhưng trước khi HttpMessageConverter serialize — đây là điểm duy nhất đúng để wrap global.

2.2 Cơ chế bên dưới

ResponseBodyAdvice được gọi bên trong AbstractMessageConverterMethodProcessor.writeWithMessageConverters(). Spring iterate danh sách ResponseBodyAdvice bean, gọi supports() để check áp dụng không, nếu có thì gọi beforeBodyWrite() và dùng kết quả làm body thay original.

Vị trí đó — nằm ngay trước converter, trên nhánh duy nhất có đi qua converter — là điều đáng nhớ nhất về hook này; sơ đồ toàn cảnh ở cuối bài đặt nó cạnh hai nhánh đi tắt.

2.3 Implementation

// Record wrap envelope
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"),        // lay requestId tu MDC (logging context)
            Instant.now());
    }
}

@RestControllerAdvice
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType,
                            Class<? extends HttpMessageConverter<?>> converterType) {
        // Chi wrap JSON converter
        if (!converterType.isAssignableFrom(
                MappingJackson2HttpMessageConverter.class)) {
            return false;
        }
        // Khong wrap neu da la ApiResponse
        if (ApiResponse.class.isAssignableFrom(
                returnType.getParameterType())) {
            return false;
        }
        // Khong wrap file download (Resource)
        if (Resource.class.isAssignableFrom(
                returnType.getParameterType())) {
            return false;
        }
        // Khong wrap error (ProblemDetail)
        if (ProblemDetail.class.isAssignableFrom(
                returnType.getParameterType())) {
            return false;
        }
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType contentType,
                                  Class<? extends HttpMessageConverter<?>> converterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        return ApiResponse.success(body);
    }
}

Pitfall quan trọng: khi controller return String trực tiếp, MappingJackson2HttpMessageConverter không được chọn — StringHttpMessageConverter được dùng. beforeBodyWrite() trả ApiResponse<String> trong khi converter expect String, kết quả là ClassCastException. Giải pháp: check String.class.isAssignableFrom(returnType.getParameterType()) trong supports() và skip, hoặc tránh return String trực tiếp từ controller.

Khi nào nên dùng wrapping: enterprise internal API cần audit trail (requestId, timestamp). Public REST API theo chuẩn thường không wrap — trả DTO trực tiếp (success), Problem Details RFC 9457 (error) — xem Exception advice.

3.1 Khái niệm

HATEOAS (Hypermedia As The Engine Of Application State) là ràng buộc REST: response bao gồm link đến các action liên quan, client discover API bằng cách follow link thay vì hard-code URL:

{
  "id": 42,
  "customer": "Alice",
  "status": "PENDING",
  "_links": {
    "self":   { "href": "/api/orders/42" },
    "cancel": { "href": "/api/orders/42/cancel" },
    "items":  { "href": "/api/orders/42/items" },
    "pay":    { "href": "/api/orders/42/pay" }
  }
}

Client không cần biết URL structure — chỉ cần follow rel cancel để huỷ đơn. URL thay đổi phía server không break client.

3.2 Spring HATEOAS

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
@RestController
@RequestMapping("/api/orders")
public class OrderController {

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

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

    @GetMapping
    public CollectionModel<EntityModel<OrderDto>> list() {
        List<EntityModel<OrderDto>> orders = orderService.findAll().stream()
            .map(o -> EntityModel.of(o,
                linkTo(methodOn(OrderController.class).get(o.id())).withSelfRel()))
            .toList();

        return CollectionModel.of(orders,
            linkTo(methodOn(OrderController.class).list()).withSelfRel());
    }
}

linkTo(methodOn(...)) dùng reflection để build URL từ controller method signature — URL không bao giờ hard-code string. Khi rename endpoint, link tự cập nhật.

Cơ chế bên dưới: methodOn() tạo proxy của controller class, gọi method để capture @RequestMapping metadata (path, method). linkTo() dùng metadata đó để dựng UriComponentsBuilder. Kết quả là Link object với href là URL tuyệt đối dựa trên ServletUriComponentsBuilder.fromCurrentRequestUri().

Khi nào dùng HATEOAS: banking/insurance API cần discoverability cao, API expose cho third-party cần version stability. Public REST API thường bỏ qua — overhead lớn, client mobile/SPA thường biết URL convention từ doc. Trade-off: HATEOAS thêm response size ~30-50%, thêm complexity serialize, và đòi client support follow link.

4. Production — caching, compression, security headers

4.1 HTTP Caching với ETag

ETag (Entity Tag) là định danh phiên bản của resource. Client lưu ETag kèm cached response, gửi lại trong header If-None-Match để hỏi "resource có thay đổi không?". Server so sánh ETag — nếu match trả 304 Not Modified (không body), tiết kiệm bandwidth và thời gian deserialize phía client.

@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> get(
    @PathVariable Long id,
    @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
    OrderDto order = orderService.findById(id);
    String etag = "\"" + order.version() + "\"";   // version tu optimistic lock column

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

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

Luồng: lần đầu client nhận 200 + body + ETag: "v3". Lần sau client gửi If-None-Match: "v3". Server check version DB — nếu vẫn v3 trả 304, không serialize body. Bandwidth tiết kiệm 100% cho request "unchanged".

Spring cũng có ShallowEtagHeaderFilter tự tính ETag = MD5 hash body:

spring:
  web:
    filter:
      shallow-etag-header:
        enabled: true

Trade-off: ShallowEtagHeaderFilter vẫn compute và serialize full body (tốn CPU), chỉ skip truyền qua network. ETag manual dựa trên version column còn skip cả DB query nếu cache Redis giữ version.

4.2 Gzip compression

server:
  compression:
    enabled: true
    mime-types: application/json,text/html,text/csv,application/xml
    min-response-size: 1024    # vuot 1KB moi compress

JSON verbose compress rất tốt: 100KB JSON → ~10KB gzip (tỷ lệ 10:1). CPU cost nhỏ (zlib native). Bandwidth tiết kiệm 80-90% cho response JSON lớn như list pagination.

Brotli tốt hơn gzip 15-20% nhưng Tomcat chưa hỗ trợ native — cần Nginx reverse proxy hoặc CDN layer thêm Content-Encoding: br.

4.3 Security headers

Mọi REST API response nên có các header bảo vệ browser:

@Configuration
public class SecurityHeadersConfig 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;
            }
        });
    }
}
HeaderBảo vệ khỏi
X-Content-Type-Options: nosniffMIME type sniffing — browser không tự đoán content type
X-Frame-Options: DENYClickjacking — ngăn nhúng trong iframe
Strict-Transport-SecurityProtocol downgrade — buộc HTTPS
Referrer-PolicyLeak URL nhạy cảm qua Referer header
Content-Security-PolicyXSS — whitelist nguồn script/style/image

Lưu ý: khi dùng Spring Security (course Spring Security), nhiều header trên được Spring Security tự config. Không cần tự thêm filter — chỉ cần không disable chúng. Content-Security-Policy cần customize theo từng app (URL allow-list), nên Spring Security không set mặc định.

Cơ chế bên dưới — toàn cảnh response pipeline

Gộp lại, pipeline xử lý response của Spring MVC từ controller tới byte network:

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

Hiểu pipeline này giúp biết hook vào đâu cho từng nhu cầu: ResponseBodyAdvice cho wrap format, Filter cho cross-cutting header (ETag, gzip, security header — chúng nằm sau cả ba nhánh nên áp cho mọi response), StreamingResponseBody khi muốn bypass converter hoàn toàn. Hệ quả trực tiếp của hình: endpoint export CSV không bao giờ có envelope, dù supports() của advice có trả true cho nó.

Pitfall tổng hợp

Nhầm 1 — Dùng StreamingResponseBody nhưng quên đóng Stream:

// SAI — Stream<Order> khong duoc close -> cursor leak
StreamingResponseBody body = out -> {
    orderService.streamAll().forEach(o -> writeCsvRow(out, o));
};

// DUNG — try-with-resources dam bao close
StreamingResponseBody body = out -> {
    try (Stream<Order> orders = orderService.streamAll()) {
        orders.forEach(o -> writeCsvRow(out, o));
    }
};

Cursor JDBC không close sẽ giữ DB connection cho tới kết thúc borrow period — dưới tải cao, pool cạn connection.

Nhầm 2 — ResponseBodyAdvice wrap String trả ClassCastException:

@GetMapping("/ping")
public String ping() { return "pong"; }
// beforeBodyWrite tra ApiResponse<String>, StringHttpMessageConverter cast fail

Phải check returnType.getParameterType().equals(String.class) trong supports() và return false.

Nhầm 3 — SSE không set timeout, connection zombie:

// SAI — emitter ton tai mai, eventBus subscribe accumulate
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);

// DUNG — set timeout thuc te, cleanup on timeout/error
SseEmitter emitter = new SseEmitter(30_000L);
emitter.onTimeout(() -> {
    emitter.complete();
    eventBus.unsubscribe(listener);   // tranh memory leak
});

Mỗi SSE connection giữ một subscription trong event bus. Nếu không cleanup khi connection đóng, listener chết tích lũy dần thành memory leak.

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

Gốc rễ là hai vòng đời lệch nhau: SseEmitter thuộc về request nên Spring dọn hộ, còn dòng subscribe là bạn viết nên cũng phải do bạn gỡ. Không có endpoint nào báo lỗi — chỉ có heap tăng đều.

Nhầm 4 — ETag hard-code string thay dùng version column:

// SAI — ETag tinh tu timestamp (khong stable, timezone issue)
String etag = "\"" + order.updatedAt().toEpochMilli() + "\"";

// DUNG — ETag tu version (optimistic lock, tang theo moi update)
String etag = "\"" + order.version() + "\"";

@Version column trong JPA tăng đơn điệu, không bị ảnh hưởng bởi timezone hay clock skew.

Liên hệ các bài khác

  • ResponseEntity & status: bài đó bóc builder pattern ResponseEntity và ngữ nghĩa status code. Bài này dùng ResponseEntity cho streaming và ETag — đọc bài 03 để hiểu đầy đủ API builder.
  • Exception advice: ResponseBodyAdvice áp dụng cho thành công; @ExceptionHandler trong @ControllerAdvice áp dụng cho lỗi. Hai hook bổ sung nhau — bài đó mổ xử lý lỗi global và Problem Details RFC 9457.
  • Spring Data JPA — Transactions: StreamingResponseBody với Stream<Order> yêu cầu transaction mở trên service method. Bài transactions giải thích propagation và tại sao @Transactional(readOnly = true) tối ưu cho streaming cursor.

📚 Deep Dive

Tài liệu chính chủ

Spring Framework Reference:

RFC / Standard:

HATEOAS:

Tóm tắt

  • StreamingResponseBody write từng chunk trực tiếp vào servlet output stream — không buffer full body vào heap. Phù hợp export triệu dòng, giữ memory O(1) thay O(N). Service cần @Transactional để hold cursor.
  • SseEmitter giữ HTTP connection mở, server push event theo định dạng text/event-stream. Spring suspend thread controller (Async MVC) — không block. Cần cleanup subscriber khi connection đóng.
  • ResponseBodyAdvice hook vào pipeline trước serialize để wrap đồng nhất. Implement supports() để skip file, error, đã-wrap. Chú ý edge case controller return String trực tiếp.
  • HATEOAS nhúng _links vào body, client follow rel thay hard-code URL. Spring HATEOAS linkTo(methodOn(...)) build URL từ method signature. Overhead lớn — chỉ dùng khi cần discoverability cao.
  • ETag dựa trên version column — client gửi If-None-Match, server trả 304 khi unchanged, tiết kiệm bandwidth.
  • Gzip cấu hình server.compression.* — JSON 100KB → 10KB, bandwidth save 90%.
  • Security headers (X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy) nên có trên mọi response. Spring Security tự config nhiều header này.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao endpoint trả List<OrderDto> với 1 triệu bản ghi gây OOM, trong khi StreamingResponseBody thì không? Giải thích theo cơ chế bên dưới của từng cách.
  2. Q2
    Bạn implement ResponseBodyAdvice để wrap mọi response trong ApiResponse<T>. Sau đó controller nào đó return String trực tiếp thì app crash. Nguyên nhân và cách fix?
  3. Q3
    So sánh ETag manual (dựa trên version column) với ShallowEtagHeaderFilter. Cái nào tiết kiệm hơn và tại sao?
  4. Q4
    App có SSE endpoint realtime. Sau vài giờ chạy, heap tăng dần không release. Suspect memory leak ở đâu và fix ra sao?
  5. Q5
    Khi nào nên dùng HATEOAS trong API Spring? Liệt kê 2 tình huống nên dùng và 2 tình huống nên tránh, kèm lý do.

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

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.

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

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

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Request & Response — tổng kết