WebSocket
spider-silk-jetty-websocket은 앱의 HTTP 라우트 곁에 WebSocket 엔드포인트를 매핑한다.
구조상 Jetty 전용이며, 그래서 App의 메서드가 아니라 이름을 가진 모듈이다.
WebSocket은 프로토콜 업그레이드이고 업그레이드는 서블릿 디스패치를 벗어나는데, 그 이유는 SSE는 코어에 있고 WebSocket은 없는 이유에서 설명한다.
모듈은 JettyServer에 이미 있는 customizeServer 훅에 연결되므로, 코어는 아무것도 바꿀 필요가 없다.
설치
dependencies {
implementation 'net.benelog.spidersilk:spider-silk-jetty-websocket:1.1.0'
}
이 모듈은 Handler 수준의 Jetty 서버 측 WebSocket인 jetty-websocket-jetty-server를 가져오며, 코어가 쓰는 것과 같은 Jetty 버전에 고정되어 있다.
같은 이름의 ee10 아티팩트 대신 이 아티팩트를 고른 것은 의도한 선택이다.
ee10 쪽은 서블릿 컨테이너 통합이고, 애너테이션 스캐닝, ASM, CDI, JNDI를 함께 끌고 오는데, 이 프레임워크는 그중 어느 것도 가까이 두고 싶어 하지 않는다.
경로 매핑
new App()
.get("/", req -> WebResponse.text("hi"))
.server((app, port) -> new JettyServer(app).port(port)
.customizeServer(new WebSockets()
.at("/echo", (request, response) -> new EchoSocket())))
.start(8080);
업그레이드 핸들러는 서블릿 앞에 삽입되므로, 경로가 매핑과 일치하는 요청은 업그레이드되고 나머지는 모두 그대로 앱으로 넘어간다. 경로는 라우트와 마찬가지로 서버의 컨텍스트 경로에 상대적이다.
WebSockets는 Consumer<Server>이며, 이는 정확히 customizeServer가 받는 타입이다.
customizeContext가 아니라 customizeServer를 쓰는 이유는, Jetty의 WebSocket 컨테이너가 Server의 버퍼 풀과 실행기로 만들어지는데 컨텍스트는 컨텍스트 커스터마이저가 모두 실행된 뒤에야 서버에 연결되기 때문이다.
다음 다이어그램은 업그레이드 핸들러가 Jetty의 핸들러 체인 어디에 놓이는지, 그리고 어떤 요청이 그 뒤의 서블릿에 도달하는지 보여준다:
엔드포인트
WebSocketHandler는 업그레이드부터 종료까지의 연결 하나다.
팩토리가 연결마다 하나씩 만들므로 구현체는 연결별 상태를 필드에 보관해도 된다: 같은 연결에 대해 콜백이 동시에 호출되는 일은 없다.
연결의 끝도 마찬가지다: 유휴 타임아웃이나 실패한 쓰기는 Jetty의 다른 스레드에서 onError와 onClose를 일으키며, 이들은 실행 중인 onText나 onBinary가 반환되기를 기다린다.
final class EchoSocket implements WebSocketHandler {
@Override
public void onOpen(Session session) {
session.sendText("welcome", Callback.NOOP);
}
@Override
public void onText(Session session, String message) {
session.sendText(message, Callback.NOOP);
}
}
모든 메서드(onOpen, onText, onBinary, onClose, onError)에 아무 일도 하지 않는 기본 구현이 있으므로, 구현체는 응답할 것만 오버라이드한다.
Session은 한 번만 건네지는 것이 아니라 각 메서드에 매번 전달되므로, 응답하기 위해 필드에 기억해 둘 것이 없다.
Session은 Jetty 자체의 것이며 감싸지 않는다.
모듈은 이름부터 Jetty에 묶여 있으므로, Jetty를 파사드 뒤에 숨겨 봐야 이름이 이미 배제한 이식성을 사는 셈이다.
sendText와 sendBinary는 비동기이며 Callback을 받는다.
Callback.NOOP은 보내고 잊는 형태이고, 핸들러가 전송 실패를 알아채려면 실제 콜백을 넘긴다.
핸들러는 session.demand()를 호출하지 않는데, 모듈의 엔드포인트가 자동 요구(auto-demanding) 방식이기 때문이다: 콜백이 반환되는 즉시 다음 메시지를 요청한다.
onBinary에 전달되는 버퍼는 Jetty의 것이며, 메서드가 반환되는 즉시 풀로 돌아간다.
호출이 끝난 뒤에도 필요한 데이터는 복사해 둔다.
업그레이드 요청부터 종료까지, 연결 하나는 다음과 같이 진행된다:
업그레이드 거부
팩토리는 핸드셰이크가 응답되기 전에 실행되며, 그 시점에 업그레이드는 아직 평범한 HTTP 요청이다: 헤더, 쿠키, 쿼리 문자열을 모두 읽을 수 있다.
new WebSockets().at("/chat", (request, response) -> {
if (!authorized(request)) {
return null; // 403, and the handshake is answered
}
if (request.hasSubProtocol("chat")) {
response.setAcceptedSubProtocol("chat");
}
return new ChatSocket(rooms);
});
null을 반환하면 업그레이드를 403으로 거부하며, 팩토리가 응답에 설정한 상태가 에러 상태라면 그 상태로 거부한다. Jetty 자체의 계약은 거부된 업그레이드를 응답하지 않은 채로 둔다. 생성자(creator)가 직접 응답을 쓰고 콜백을 완료해야 하며, 이를 잊은 생성자는 클라이언트를 결코 오지 않을 핸드셰이크를 기다리게 만든다. 이 모듈은 그 에러 응답을 직접 쓰며, 이는 경로 매핑 외에 모듈이 하는 몇 안 되는 일 중 하나다.
경로
경로는 코어의 라우터 문법이 아니라 Jetty의 스펙 문법이다: 정확한 경로, 접두사는 /rooms/, 접미사는 .ws다.
{name} 세그먼트는 없다.
가변 경로는 접두사 매핑에 팩토리에서 Request.getPathInContext(request)를 읽는 것으로 만든다:
new WebSockets().at("/rooms/*", (request, response) ->
new ChatSocket(Request.getPathInContext(request).substring("/rooms/".length())));
Jetty가 인식하지 못하는 스펙은 시작 시점이 아니라, 그 스펙을 적은 at(…) 호출에서 실패한다.
튜닝
new WebSockets()
.at("/echo", (request, response) -> new EchoSocket())
.idleTimeout(Duration.ofMinutes(5))
.maxTextMessageSize(64 * 1024)
.maxBinaryMessageSize(1024 * 1024)
.customizeContainer(container -> container.setAutoFragment(false));
Jetty는 기본적으로 30초 동안 유휴 상태인 연결을 닫는다.
조용한 구간에도 열어 두는 소켓은 양쪽 끝이 서로 핑을 주고받아야만 이 타임아웃을 넘긴다.
customizeContainer는 시작할 때마다 Jetty의 ServerWebSocketContainer를 상대로 실행되며, WebSockets에 전용 메서드가 없는 설정을 다룬다: 프레임 크기, 버퍼 크기, 세션 리스너다.
소켓에 닿지 않는 것
업그레이드는 서블릿 디스패치를 영구히 벗어나므로, 프레임워크의 어떤 것도 뒤따르지 않는다:
-
라우터가 따르지 않으며,
app.routes()는 소켓을 나열하지 않는다, -
beforeRequest,beforeRoute,afterRoute,responseFilter필터가 실행되지 않는다, -
statusPage(status, …)가 이를 다루지 않는다, -
요청 로거는 업그레이드를 포함해 그 연결을 전혀 보지 못하는데, 업그레이드 핸들러가
AppServlet보다 앞서 응답하고 서블릿은 실행되지 않기 때문이다, -
WebTest의 클라이언트는 평범한 HTTP만 말하고 소켓을 열 방법이 없으므로WebTest는 닿을 수 없다.
그 목록이 WebSocket이 코어에 없는 이유의 전부다: 코어가 자기 기능이 소리 없이 다루지 못하는 API를 공개하는 셈이 되기 때문이다.
모듈의 테스트는 반대편에서 같은 한계를 보여준다.
대신할 하네스가 없으므로, 테스트는 빈 포트에 실제 Jetty를 띄우고 JDK 자체의 java.net.http.WebSocket 클라이언트로 통신한다.
app.stop()은 아직 열려 있는 소켓을 닫는다.
진행 중인 SSE 스트림을 기다려 주는 방식과 달리, 소켓을 기다리느라 우아한 드레인(graceful drain)을 붙들어 두지는 않는다.
리플렉션
Jetty는 엔드포인트의 메서드를 찾아 각각에 MethodHandle을 얻는 방식으로 콜백을 바인딩하며, 엔드포인트 클래스별로 캐시한다.
이 모듈이 여는 모든 연결은 모듈 자체의 어댑터인 같은 클래스 SessionListener이므로, Jetty가 찾아보는 클래스는 그 하나뿐이다.
애플리케이션이 작성하는 WebSocketHandler는 인터페이스 호출로 도달하며 그 형태가 어떻든 리플렉션 대상이 되지 않는데, 이는 jte가 생성한 클래스가 받는 것과 같은 격리다.