WebSocket によるサーバサイドレンダリングなテトリスの作り方(その1)

f:id:Naotsugu:20210509125822p:plain


はじめに

WebSocket の利用方法について説明します。 サーバ側には JSR 356 Java API for WebSocket を使い、クライアント側には JavaScript を使っていきます。

簡単なエコーサーバから始め、WebSocket を使ったテトリスの実装までを行います。

ここで使用するコードは以下に置いてあります。

github.com


今回は導入編として単純なエコーサーバを作成します。


WebSocket とは

2011年に RFC 6455 として IETF により標準化された軽量な通信プロトコルです。 HTTPをアップグレードしたTCPベースのプロトコルであり、低レイテンシかつ双方向・全二重通信が可能な軽量プロトコルとなります。

HTTP ではクライアントから pull する形での通信しかできませんでした。 このような制限の中でリアルタイムな情報連携を行うには、クライアントから定期的に情報を取得しに行くか、クライアントからの pull リクエストを長時間維持するロングポーリングなどの対応が必要となります。しかし、このような方法では、双方向の通信毎に少なくはない HTTP ヘッダの情報がやり取りされるパフォーマンス上の問題があり、かつメッセージの到着順が保証されないといった問題もあります。

WebSocket は、HTTP によるハンドシェイク要求でコネクションを確立すれば、切断するまでそのコネクションが維持され、その間は双方向・全二重でメッセージ交換が可能となります。 そして、HTTPヘッダのオーバーヘッドも発生せず、プロキシやファイアウォールなどの HTTP と同じ基盤で動作します。

WebSocket は、クライアントから以下のような GET リクエストから開始します。

GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

サーバ側では以下のような Switching Protocols レスポンスを返します。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

これにより WebSocket プロトコルでのコネクションに切り替わります。


Java API for WebSocket

JSR 356 Java API for WebSocket は、Java EE 7 の一部としてリリースされた仕様です。 基本的には、JAX-RS と同じようにアノテーション・ベースで WebSocket のエンドポイントを定義していくプログラミングモデルが提供されます。

この API は Java EE 7 の一部にはなりますが、JSR 356 の RI である tyrus では grizzly ベースのサーバ実装も提供してしているため、特に Java EE サーバを利用せずとも、Java SE で気軽に始めることもできます。

本稿では tyrus を Java SE 上で使っていくことにします。

Gradle の Kotlin DSL では、以下のような依存を追加します。

dependencies {
    implementation("org.glassfish.tyrus:tyrus-server:2.0.0")
    implementation("org.glassfish.tyrus:tyrus-container-grizzly-server:2.0.0")
}


エンドポイント

Java API for WebSocket では @ServerEndpoint によりエンドポイントを定義します。

@ServerEndpoint("/echo")
public class EchoEndPoint {

    @OnOpen
    public void onOpen(Session session) {
    }

    @OnMessage
    public String onMessage(String message, Session session) {
        return message;
    }

    @OnError
    public void onError(Session session, Throwable cause) {
    }

    @OnClose
    public void onClose(Session session) {
    }
}

@OnOpen といったアノテーションで、各イベントに応じた処理を定義します。

上記例は、@OnMessage でメッセージの到着に応答し、受け取ったメッセージをそのまま返却する単純なエンドポイントです。

各アクションには Session を引数として受け取ることができます。 Session に対して、以下のようにしてクライアントにメッセージを送信することもできます。

session.getBasicRemote().sendText("hello");


Tyrus サーバ

Java API for WebSocket の RI である Tyrus には、tyrus-server というモジュールがあります。 tyrus-container-grizzly-server という Grizzly ベースのコンテナを使うことで、Java SE 環境で簡単に WebSocket サーバを利用することができます。

以下のようにすることで、/websockets を contextPath とした WebSocket サーバ が利用できます。

Server socketServer = new Server("localhost", 8025, "/websockets", null, EchoEndPoint.class);
staticServer.start();

サーバの実体は、org.glassfish.tyrus.container.grizzly.server.GrizzlyServerContainer となり、Grizzly サーバが裏で動きます。


WebSocket クライアント

WebSocket のクライアントには Java 11 で正式導入された HTTP Client もありますが、ここでは単純な Vanilla JS で実装していきます。

WebSocket のインスタンスを作成します。

const socket = new WebSocket('ws://localhost:8025/websockets/echo');

あとは、このインスタンスに各イベントのリスナを登録するだけです。

socket.addEventListener('open', (event) => console.log('open'));
socket.addEventListener('close', (event) => console.log('close'));
socket.addEventListener('message', (event) => console.log(event.data));


先程のエコーエンドポイントに対応するものとして、以下のような HTML ファイルを用意します。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"/>
    <title>Echo</title>
</head>
<body>

<h3>Echo</h3>
<input type="text" id="text"/>
<br>
<p id="msg"></p>

<script>
const socket = new WebSocket('ws://localhost:8025/websockets/echo');
socket.addEventListener('open', (event) => console.log('open'));
socket.addEventListener('close', (event) => console.log('close'));

document.getElementById('text').addEventListener('input', (event) =>
    socket.send(event.target.value));

socket.addEventListener('message', (event) =>
    document.getElementById('msg').textContent = event.data);
</script>

</body>
</html>


サーバの実装

作成したHTMLファイルを扱う HTTP サーバをついでに含めます。

tyrus-container-grizzly-server は先程説明した通り、Grizzly を含むため、静的なHTMLファイルを扱う HTTP サーバは以下のようにするだけで実装できます。

HttpServer staticServer = HttpServer.createSimpleServer("docroot", 8080);
staticServer.start();

docroot は HTTPサーバのドキュメントルートになるのでディレクトリを作成して中に先程の HTMLを入れておけば完了です。


ここまでの流れをまとめると、Gradle の Kotlin DSL は以下のようになります。

plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.glassfish.tyrus:tyrus-server:2.0.0")
    implementation("org.glassfish.tyrus:tyrus-container-grizzly-server:2.0.0")
}

application {
    mainClass.set("com.mammb.code.example.websocket.App")
}

tasks.named<JavaExec>("run") {
    standardInput = System.`in`
}

アプリケーションのメインクラスは以下のようになります。

package com.mammb.code.example.websocket;

import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.tyrus.server.Server;
import java.awt.Desktop;
import java.net.URI;

public class App {

    public static final String RED = "\u001B[31m%s\u001B[0m";
    private final HttpServer staticServer;
    private final Server socketServer;

    public App() {
        this.staticServer = HttpServer.createSimpleServer("docroot", 8080);
        this.socketServer = new Server("localhost", 8025, "/websockets", null,
                EchoEndPoint.class);
    }

    public void run() {
        try {
            staticServer.start();
            socketServer.start();
            System.out.println(String.format(RED, "Press any key to stop the server..."));
            browse();
            System.in.read();
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            staticServer.shutdown();
            socketServer.stop();
        }
    }

    public static void main(String[] args) {
        var app = new App();
        app.run();
    }

}

最後に WebSocket のエンドポイントは以下のようになります。

package com.mammb.code.example.websocket;

import jakarta.websocket.OnClose;
import jakarta.websocket.OnError;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;

@ServerEndpoint("/echo")
public class EchoEndPoint {

    @OnMessage
    public String onMessage(String message, Session session) {
        System.out.println(" ## onMessage [" + message + "][" + session.getId() + "]");
        return message;
    }

}


実行して http://localhost:8080/echo.html にアクセスすれば以下のようになります。

f:id:Naotsugu:20210509124850p:plain

入力内容は、WebSocket にてサーバに送信され、同じ内容がエコーされ、リアルタイムで下段に表示されます。


まとめ

今回は、WebSocket 用のサーバ実装について説明しました。

WebSocket のエンドポイントは、受信した内容をそのままエコーする単純なものです。

次回はエンドポイントの実装についてもう少し詳しく見ていきます。