Server-Sent Events
WebResponse.sse(…)는 본문 대신 text/event-stream으로 응답하며, 호출마다 이벤트 하나를 플러시한다:
app.get("/decks/{deckId}/events", req -> {
long deckId = req.pathParamLong("deckId");
return WebResponse.sse(stream -> {
while (stream.isOpen()) {
JsonObject eventData = Json.object().put("count", service.due(deckId));
stream.id(String.valueOf(revision))
.event("due")
.send(eventData.toJson());
Thread.sleep(1000);
}
});
});
id: 41
event: due
data: {"count":12}
send(data)는 이벤트 하나를 보내고, retry(…)는 재연결 지연을 설정하며, comment(text)는 어떤 리스너도 보지 못하는 하트비트를 보낸다.
event(name)과 id(…)는 다음 send가 쓰는 이벤트에 라벨을 붙이며, 그 뒤의 이벤트에는 붙지 않는다.
이름 있는 이벤트는 그 이름으로 등록된 리스너에, 이름 없는 이벤트는 EventSource.onmessage에 도착한다.
id가 있으면 브라우저가 Last-Event-ID로 재개할 수 있다.
여러 줄에 걸친 데이터는 줄마다 data: 줄 하나가 되며, 클라이언트가 이를 다시 합친다.
id나 이벤트 이름에는 그런 형태가 없으므로, 둘 중 어디에든 줄바꿈이 있으면 IllegalArgumentException을 던지며, id에 든 NUL도 마찬가지다.
재연결
retry(Duration)은 스트림이 끊긴 뒤 브라우저가 돌아오기 전에 기다리는 지연을 설정하며, 밀리초 단위의 retry: 줄 하나로 기록된다.
stream.retry(Duration.ofSeconds(2));
retry: 줄을 보내지 않는 스트림에서는 브라우저가 자체 기본값인 몇 초 뒤에 재연결한다.
지연은 애플리케이션이 정해야 하는데, 브라우저가 얼마나 빨리 돌아오길 원하는지는 배포 때 모든 스트림을 닫는 서버만 알기 때문이다.
retry: 줄은 다음 이벤트와 함께가 아니라 retry(…)를 호출한 시점에 나가는데, 지연은 이벤트 하나가 아니라 스트림에 속하기 때문이다.
지연은 다른 retry(…)가 바꿀 때까지 스트림의 나머지와 뒤따르는 연결에 유지된다.
음수 지연은 IllegalArgumentException을 던지는데, 프로토콜이 음수 값에 아무 의미도 부여하지 않고 브라우저는 이를 조용히 무시하기 때문이다.
id(…)는 재개되는 스트림의 나머지 절반이다: 브라우저는 마지막으로 본 id를 Last-Event-ID로 돌려보내고, 핸들러는 req.header("Last-Event-ID")로 읽는다.
SSE는 코어에 있고 WebSocket은 없는 이유
SSE는 별도의 등록이 아니라, 다른 형태로 응답하는 평범한 get 라우트다.
따라서 app.routes()가 이를 나열하고, 필터가 이를 다루며, 요청 로거는 스트림이 끝날 때 이를 보고한다.
WebSocket 업그레이드는 서블릿 디스패치를 벗어나므로 이 세 가지 중 어느 것도 적용되지 않는다.
이 차이가 SSE는 코어에 있고 WebSocket은 없는 이유의 전부다.
애플리케이션에 정말 필요한 것이 소켓이라면, spider-silk-jetty-websocket이 Jetty 위에서 자기 이름을 걸고 소켓을 매핑한다.
SSE에는 AppServlet 말고 필요한 것이 없으므로, 평범한 서블릿 컨테이너에 배포해도 SSE를 쓸 수 있다.
스레드와 수명
요청은 스트림이 지속되는 동안 스레드를 붙들고 있다. 스레드를 붙드는 것은 서블릿 네이티브로 남는 대가다. 가상 스레드 실행기가 많은 동시 스트림을 싸게 만든다.
스트림은 다음 세 가지 방식 중 하나로 끝나며, 어느 것도 에러가 아니다:
-
핸들러가 반환한다,
-
클라이언트가 떠난다: 이를 발견한 쓰기가
SseStream.Closed를 던지고, 핸들러는 그 자리에서 끝난다, -
app.stop()이 Jetty에 드레인을 요청하기 전에 아직 열려 있는 모든 스트림을 닫는데, 스트림은 그러지 않으면 결코 끝나지 않을 진행 중인 요청이기 때문이다. Ctrl-C나 SIGTERM에 의한 JVM 종료도 서버 자체의 셧다운 훅과 나란히 스트림을 닫는다. 클라이언트가 읽기를 멈춘 스트림은 밖에서 닫을 수 없는 쓰기에 막혀 있으므로, 그 쓰기를 기다리지 않고 닫힌 것으로 표시되며 서버의 stop timeout이 그 쓰기를 끝낸다.
Jetty 커넥터의 30초 유휴 타임아웃보다 조용한 스트림은 서버가 닫는다.
타이머로 stream.comment("ping")을 보내면 스트림이 살아 있으며, 앞단의 프록시에도 보통 같은 하트비트가 필요하다.
핸들러가 WebResponse.sse(…)를 반환하는 순간부터 스트림이 닫힐 때까지, 수명 전체가 요청 하나다: