Sử dụng SseEmitter trong Spring Framework để triển khai Server-Sent Events

`SseEmitter` là một lớp cốt lõi của Spring Framework dùng để thực hiện **Server-Sent Events (SSE)**. Hiểu rõ cách thức hoạt động và các nguyên tắc cơ bản về việc thiết lập kết nối, gửi thông tin và kết thúc kết nối sẽ giúp bạn sử dụng SSE hiệu quả hơn, tránh rò rỉ bộ nhớ và các vấn đề liên quan.

1. Tổng quan quy trình

sequenceDiagram participant Client as Khách hàng (EventSource) participant Spring as Spring MVC (SseEmitter) participant Handler as Phương thức Controller Client->>Spring: GET /sse (Accept: text/event-stream) Spring->>Handler: Gọi @GetMapping("/sse") Handler->>Spring: Trả về new SseEmitter() Spring-->>Client: HTTP 200 + Content-Type: text/event-stream\n(Kết nối giữ mở) loop Chuyển dữ liệu Spring->>Client: data: ...\n\n end Note right of Spring: Kết nối có thể kết thúc do hết thời gian/chủ động tắt

2. Thiết lập kết nối

Khi nào thiết lập?

  • Khi khách hàng gửi yêu cầu `GET` với header `Accept` chứa `text/event-stream`.
  • Spring MVC gọi phương thức `@GetMapping` mà bạn đã định nghĩa.
  • Bạn trả về một đối tượng `SseEmitter`.

Nền tảng hoạt động

  1. Spring nhận ra rằng kiểu trả về là `SseEmitter`.
  2. Ngay lập tức gửi phản hồi HTTP đến client:
  3.         HTTP/1.1 200 OK
            Content-Type: text/event-stream
            Cache-Control: no-cache
            Connection: keep-alive
        
  4. Kết nối HTTP không bị đóng.
  5. `HttpServletResponse` được liên kết với `SseEmitter`.
  6. Thread xử lý request được giải phóng sau khi controller hoàn thành, nhưng kết nối vẫn được duy trì bởi `SseEmitter` thông qua cơ chế Servlet bất đồng bộ.

3. Gửi thông tin

Khi nào gửi?

  • Khi phương thức `send(...)` trên đối tượng `SseEmitter` được gọi.
  • Có thể xảy ra ở nhiều ngữ cảnh khác nhau như:
    • Trong chính phương thức controller (gửi ngay).
    • Trong luồng bất đồng bộ (ví dụ: tác vụ định thời, listener MQ).
    • Trong xử lý các request HTTP khác.

Nền tảng hoạt động

  1. Phương thức `send()` viết dữ liệu theo định dạng SSE vào `ServletResponse.getWriter()`.
  2. Dữ liệu được truyền qua kết nối HTTP đã mở trước đó.
  3. Lưu ý rằng `SseEmitter` không an toàn với đa luồng. Cần đảm bảo rằng chỉ một luồng duy nhất gọi `send()` tại cùng một thời điểm.

4. Kết thúc kết nối

Kết nối có thể kết thúc theo bốn cách sau:
Cách kết thúc Thời điểm kích hoạt Phương thức gọi Có kích hoạt callback không?
Kết thúc chủ động từ server Gọi `emitter.complete()` `onCompletion()`
Xảy ra lỗi Gọi `emitter.completeWithError(Throwable)` `onError()` → `onCompletion()`
Hết thời gian Vượt quá thời gian không hoạt động `onTimeout()` → `onCompletion()`
Khách hàng đóng kết nối Client đóng kết nối (Phát hiện bị động) Có thể kích hoạt `onError()` hoặc không có callback

Chi tiết hoạt động

  • `complete()` / `completeWithError()`:
    • Ghi dấu hiệu kết thúc vào output stream (flush và đóng stream).
    • Kích hoạt các callback đã đăng ký (`onCompletion`, `onError`).
    • Giải phóng tài nguyên kết nối từ container như Tomcat hay Undertow.
  • Hệ thống giám sát timeout:
    • Spring sử dụng `ScheduledExecutorService` để kiểm tra thời gian hoạt động.
    • Bắt đầu tính thời gian từ lần cuối cùng `send()` thành công hoặc khi kết nối được thiết lập.
    • Khi hết thời gian, tự động gọi `onTimeout()` rồi `complete()`.
  • Phát hiện client đóng kết nối:
    • Không thể phát hiện ngay lập tức! Chỉ khi gọi `send()` tiếp theo thì mới phát hiện qua ngoại lệ `IOException`.

5. Callback vòng đời

Bạn có thể đăng ký các callback sau khi tạo `SseEmitter`:
SseEmitter emitter = new SseEmitter(30_000L); // Hết hạn sau 30 giây

emitter.onCompletion(() -> {
    System.out.println("Kết nối đã kết thúc bình thường");
    emitters.remove(emitter);
});

emitter.onTimeout(() -> {
    System.out.println("Kết nối hết hạn");
    emitter.complete(); // Cần phải gọi thủ công để giải phóng tài nguyên!
});

emitter.onError(error -> {
    System.out.println("Có lỗi xảy ra: " + error.getMessage());
    emitter.complete(); // Đề xuất cũng nên gọi complete
});

6. Thực hành tốt nhất

Tình huống Đề xuất
Tạo mới Đặt thời gian chờ hợp lý (30-60 giây), tránh `Long.MAX_VALUE`.
Lưu trữ Sử dụng cấu trúc tập hợp an toàn với đa luồng (như `ConcurrentHashMap`) để quản lý các emitter.
Gửi dữ liệu Đặt try-catch cho `IOException`, nếu thất bại thì xóa và gọi `complete()`.
Hết hạn/Lỗi Luôn gọi `complete()` trong `onTimeout`/`onError` để giải phóng tài nguyên.
Tính đồng bộ Chạy các thao tác `send()` trên cùng một emitter theo thứ tự tuần tự.
Vệ sinh tài nguyên Kết hợp với cơ chế phiên người dùng hoặc heartbeat để dọn dẹp các kết nối không còn hiệu lực.

7. Các câu hỏi thường gặp

Q1: Tại sao server không biết khi client đóng kết nối?

A: HTTP là giao thức không trạng thái, việc phát hiện TCP bị ngắt cần phải thông qua thao tác ghi (khi ghi mới phát hiện bên kia đã đóng).

Q2: Làm thế nào để duy trì kết nối bằng cách gửi tín hiệu sống?

A: Gửi dòng chú thích định kỳ (bắt đầu bằng `:`):

emitter.send(": heartbeat\n\n");

Q3: Số lượng kết nối lớn có gây quá tải máy chủ không?

A: Có! Mỗi `SseEmitter` chiếm một kết nối HTTP. Đặt thời gian chờ hợp lý, sử dụng pool kết nối để giám sát và điều chỉnh `proxy_read_timeout` nếu dùng Nginx.

Thẻ: Spring SseEmitter Server-Sent Events

Đăng vào ngày 20 tháng 7 lúc 22:16