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.

flowchart TB
  subgraph NON["Non-streaming (SAI)"]
    direction LR
    DB1["DB: 1M rows"] -->|"findAll()"| Heap["Heap: List 500MB"]
    Heap -->|"Jackson serialize"| Net1["Network"]
  end
  subgraph STREAM["StreamingResponseBody (DUNG)"]
    direction LR
    DB2["DB cursor"] -->|"500 rows / batch"| Lambda["Lambda write chunk"]
    Lambda -->|"flush"| Net2["Network"]
    Lambda -->|"fetch next"| DB2
  end

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

flowchart LR
  A["Controller return OrderDto"] --> B["HandlerMethodReturnValueHandler"]
  B --> C["ResponseBodyAdvice.supports()?"]
  C -->|"true"| D["ResponseBodyAdvice.beforeBodyWrite(OrderDto)"]
  D -->|"return ApiResponse<OrderDto>"| E["HttpMessageConverter serialize"]
  C -->|"false"| E
  E --> F["HTTP Response body"]

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.

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:

flowchart TB
  A["Controller method return"] --> B{"Return type?"}
  B -->|"StreamingResponseBody"| C["StreamingResponseBodyReturnValueHandler<br/>write tung chunk truc tiep"]
  B -->|"SseEmitter"| D["AsyncRequestProcessingInterceptor<br/>suspend request, push event"]
  B -->|"Object / ResponseEntity"| E["ResponseBodyAdvice.beforeBodyWrite()<br/>co the wrap object"]
  E --> F["HttpMessageConverter<br/>chon theo Accept + return type"]
  F -->|"JSON"| G["MappingJackson2<br/>serialize -> bytes"]
  F -->|"Resource"| H["ResourceHttpMessageConverter<br/>stream file bytes"]
  G --> I["ETag filter / gzip filter"]
  H --> I
  I --> J["Security headers interceptor"]
  J --> K["HTTP Response bytes -> Network"]
  C --> K
  D --> K

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, StreamingResponseBody khi muốn bypass converter hoàn toà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.

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
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.

Với List<OrderDto>: Spring Data load toàn bộ 1M row vào heap thành List. Sau đó MappingJackson2HttpMessageConverter serialize list sang JSON — Jackson build full byte[] JSON trong memory. Heap spike gấp 2-3 lần data size thô trước khi write ra network. Với dữ liệu lớn đây chính là điểm OOM.

Với StreamingResponseBody: Spring phát hiện return type đặc biệt này và dùng StreamingResponseBodyReturnValueHandler thay HttpMessageConverter. Handler gọi lambda với OutputStream đã commit response header rồi. Lambda fetch row theo batch (JDBC cursor + @QueryHints HINT_FETCH_SIZE), write CSV từng row, flush. Heap giữ tối đa một batch tại mọi thời điểm — memory O(1) thay O(N).

Điểm mấu chốt: StreamingResponseBody bypass HttpMessageConverter hoàn toàn, write trực tiếp vào servlet output stream. Service cần @Transactional(readOnly = true) để hold JDBC cursor suốt quá trình streaming.

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?

Nguyên nhân: khi controller return String, Spring chọn StringHttpMessageConverter (không phải MappingJackson2HttpMessageConverter) vì String match converter đó trước. beforeBodyWrite() trả về ApiResponse<String>, nhưng StringHttpMessageConverter expect StringClassCastException.

Fix trong supports():

@Override
public boolean supports(MethodParameter returnType,
      Class<? extends HttpMessageConverter<?>> converterType) {
  // Skip String return type
  if (String.class.isAssignableFrom(returnType.getParameterType())) {
      return false;
  }
  // Skip non-Jackson converters
  if (!converterType.isAssignableFrom(
          MappingJackson2HttpMessageConverter.class)) {
      return false;
  }
  // Skip da wrap
  return !ApiResponse.class.isAssignableFrom(
      returnType.getParameterType());
}

Cách khác tốt hơn: không bao giờ return String trực tiếp từ @RestController — dùng ResponseEntity<String> hoặc DTO wrapper. Tránh edge case này từ gốc.

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?

ETag manual (version column):

  • Server đọc DB lấy version — nếu match If-None-Match, trả 304 ngay, không serialize body.
  • Tiết kiệm cả: bandwidth (không body), CPU serialize (Jackson không chạy), và nếu có Redis cache version thì còn skip DB query.
  • ETag semantically đúng — version tăng đơn điệu theo mỗi update, không bị ảnh hưởng clock skew.

ShallowEtagHeaderFilter:

  • Filter compute full response body, hash MD5 làm ETag, compare với If-None-Match. Nếu match, override response thành 304 (không write body ra network).
  • Chỉ tiết kiệm: bandwidth. Vẫn tốn CPU serialize full JSON + MD5 hash trên mọi request.
  • Dễ dùng — auto, không cần code per endpoint.

Kết luận: ETag manual tốt hơn nhiều cho endpoint quan trọng — tiết kiệm cả CPU lẫn bandwidth. ShallowEtagHeaderFilter hữu ích làm fallback cho endpoint ít quan trọng hoặc khi không có version column trong domain model.

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?

Nguyên nhân phổ biến nhất: subscriber accumulate trong event bus.

Mỗi lần client kết nối SSE, code tạo SseEmitter và subscribe một listener vào event bus. Khi client disconnect (đóng tab, mạng mất), SseEmitter timeout hoặc completeWithError, nhưng nếu không gọi eventBus.unsubscribe(listener), listener vẫn tồn tại trong event bus. 1000 client connect-disconnect → 1000 dead listener accumulate → memory leak.

Fix:

SseEmitter emitter = new SseEmitter(30_000L);

Runnable listener = event -> {
  try {
      emitter.send(event);
  } catch (IOException e) {
      emitter.completeWithError(e);
  }
};

eventBus.subscribe(listener);

// Cleanup khi connection dong
emitter.onTimeout(() -> {
  emitter.complete();
  eventBus.unsubscribe(listener);
});
emitter.onError(err -> {
  emitter.complete();
  eventBus.unsubscribe(listener);
});
emitter.onCompletion(() -> {
  eventBus.unsubscribe(listener);
});

Thêm vào: dùng WeakReference cho listener trong event bus nếu muốn GC tự thu hồi khi emitter không còn strong reference. Cũng nên giám sát số lượng active subscriber qua metric (Micrometer gauge).

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.

Nên dùng HATEOAS khi:

  1. Public API expose cho third-party, cần version stability: third-party client hard-code URL → khi server đổi URL structure, client break. HATEOAS cho client follow rel name thay URL — server tự do refactor URL mà không break client.
  2. API state machine phức tạp (banking, insurance workflow): action nào available phụ thuộc state hiện tại của resource (đơn hàng PENDING có link cancel, nhưng SHIPPED thì không). HATEOAS nhúng available action vào response — client không cần biết state machine, chỉ kiểm tra link có tồn tại không.

Nên tránh HATEOAS khi:

  1. Internal API + frontend cùng team: team frontend biết URL convention, document nội bộ, release cùng backend. Overhead HATEOAS (response size tăng ~30-50%, serialize phức tạp, schema verbose) không đổi lại lợi ích gì.
  2. Mobile app bandwidth-sensitive: mỗi response thêm _links block 200-500 byte. Với mobile 4G và response list 100 items, tổng thêm 20-50KB mỗi request — đáng kể cho người dùng data plan thấp. Better: doc URL convention rõ ràng, version bằng path /api/v2/.

Quy tắc thực tế: đánh giá theo tiêu chí "client có control không?" — nếu client cùng team và release cycle đồng bộ, HATEOAS không cần. Chỉ thêm khi client độc lập và URL stability là SLA.

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?

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