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

f:id:Naotsugu:20210509125822p:plain


はじめに

前回からの続きです。

blog1.mammb.com

前回は WebSocket による簡単なエコーサーバを実装しました。

今回は、Java API for WebSocket についてもう少し詳しく見ていきます。

最終的には以下のようなゲームを作成します。

f:id:Naotsugu:20210515135605g:plain

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

github.com


エンドポイントの構成

前回見たように、WebSocket のエンドポイントは以下のようにアノテーションベースで定義できます。

@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) {  }
}

エンドポイントのインスタンスは接続毎に異なるインスタンスとなります。そしてエンドポイントのインスタンスのライフサイクルメソッドを実行するスレッドは常に1つだけになります(つまりサーブレットなどとは異なります)。 そのためエンドポイントでは、接続ごとにユーザーの状態を保持することができ、開発が容易になります。

アノテーションを利用しない場合は、jakarta.websocket.Endpoint を継承して構成することもできます。 例えば以下のようになります。

public class EchoEndpoint extends Endpoint {
       public void onOpen(Session session, EndpointConfig config) {
           final RemoteEndpoint remote = session.getBasicRemote();
           session.addMessageHandler(String.class, new MessageHandler.Whole<String>() {
               public void onMessage(String text) {
                   try {
                       remote.sendString(text);
                   } catch (IOException e) {  }
               }
           });
       }
   }

通常はアノテーションを使った構成になるでしょう。


サーバ側のエンドポイントはクラスレベルの @ServerEndpoint アノテーションにより定義します。

必須属性には value があり、この属性にはエンドポイントの登録先となるURIパスを指定します。

URIパス には @ServerEndpoint("/chat/{user}") のようにパスパラメータを定義できます。このように定義することで、@PathParam を通して以下のようにパラメータを取得することができます。

@OnOpen
public void onOpen(Session session, @PathParam("user") String user) { }


@ServerEndpoint には、その他の属性として以下のものを指定することもできます。

  • decoders WebSocket メッセージを任意のオブジェクトに変換するためのデコーダを指定
  • encoders 任意のクラスをWebSocket メッセージに変換するためのエンコーダを指定
  • configurator エンドポイントを動的に構成するための ServerEndpointConfig.Configurator のサブクラスを指定
  • subprotocols エンドポイントがサポートする特殊なサブプロトコルを示す文字列名を指定

decodersencoders はよく使うので後述します。


ライフサイクルアノテーション

エンドポイントには、@OnOpen, @OnMessage, @OnError, @OnClose により各ライフサイクルイベントに応じた処理が定義できます。

@OnOpen

このアノテーションを付けたメソッドは、WebSocket エンドポイントに新たな接続がなされる場合に呼び出されます。 メソッドは引数なしとすることもできますし、以下を任意で引数として受け取ることができます。

  • javax.websocket.Session
  • jakarta.websocket.EndpointConfig
  • @PathParam でアノテートされた文字列のパスパラメータ

@OnMessage

このアノテーションを付けたメソッドは、コネクション上に新しいメッセージが到達したときに呼び出されます。

メソッドの戻り値は void ならびに特定の型を指定できます。メソッドに戻り値がある場合には、その戻り値がクライアントに送信されます。Javaプリミティブは文字列に変換されてクライアントに送信されます。エンコーダが定義してある任意のクラスはエンコーダを介してクライアントに送信されます。

メソッドの引数には、以下を指定可能することができます。

  • javax.websocket.Session
  • @PathParam でアノテートされた文字列のパスパラメータ
  • String などのテキストメッセージ(その他 プリミティブ型や Reader など)
  • ByteBuffer などのバイナリメッセージ(その他 byte[] InputStream など)
  • jakarta.websocket.PongMessage
  • デコーダで定義された任意のオブジェクト

例えば以下のようなシグネチャになります。

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

boolean パラメータを含ませることで、部分的に受信途中のデータかどうかをチェックすることができます。以下のようになります。

@OnMessage
public void onMessage(byte[] b, boolean last, Session session) { }

@OnMessage は、通常はエンドポイントに対して1つのみ存在しますが、テキストメッセージ用とバイナリメッセージ用の2つを定義することもできます。

java.io.Reader, java.nio.ByteBuffer, java.io.InputStream のメッセージオブジェクトは、実装によって再利用される可能性があるため、メソッドが終了した後も参照し続けてはいけません。

@OnClose

このアノテーションを付けたメソッドは、WebSocketコネクションがクローズされようとしているときに呼び出されます。

メソッドの引数には、以下を指定可能することができます。

  • javax.websocket.Session
  • @PathParam でアノテートされた文字列のパスパラメータ
  • javax.websocket.CloseReason

javax.websocket.CloseReason からはそのコネクションがクローズされる理由(通常の閉鎖、プロトコルエラー、サービスのオーバーロードなど)を取得できます。

例えば以下のようなシグネチャになります。

@OnClose
public void onClose(Session session, CloseReason reason) {
}

@OnError

このアノテーションを付けたメソッドは、WebSocket コネクション上でエラーが発生したときに呼び出されます。

メソッドの引数には、以下を指定可能することができます。

  • javax.websocket.Session
  • @PathParam でアノテートされた文字列のパスパラメータ
  • Throwable

例えば以下のようなシグネチャになります。

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


Session

今まで出てきた javax.websocket.Session は、エンドポイントへのアクティブな WebSocket コネクションの概念を表現します。 リクエストURIやセッションIDなどのコネクションの確立時の情報を取得できる他、コネクションに対する様々な操作を行うことができます。

よく使う操作について簡単に見ていきましょう。

コネクションのクローズ

何らかの理由により、会話を終了する場合には以下のように close を呼び出すことができます。

CloseReason reason = new CloseReason(
    CloseReason.CloseCode.UNEXPECTED_CONDITION,
    "Internal Error");
session.close(reason);

CloseReason としてクローズの原因を設定できます。 単に session.close(); とすることもできますが、デバッグ目的でも原因を設定しておくべきです。

User Properties

Session には、ライフサイクルメソッドの呼び出しで共有する必要のあるアプリケーションデータを格納することができます。

Map<String, Object> userProperties = session.getUserProperties();
userProperties.put("name", "thom");

HttpSession のような使い方をすることができます。

すべての Session へ通知

session.getOpenSessions() によりその時点で開いているセッションのセットを取得できます。

例えば自身を除くクライアントにブロードキャストする場合は以下のようになります。

session.getOpenSessions().stream()
    .filter(s -> !s.equals(session))
    .filter(Session::isOpen)
    .forEach(s -> s.getAsyncRemote().sendText(message));

getAsyncRemote()でリモートエンドポイントを取得してメッセージを送信しています。

リモートエンドポイントについても見ておきましょう。


RemoteEndpoint

jakarta.websocket.RemoteEndpoint インターフェイスはコネクションの相手側にあるエンドポイントを表現します。

jakarta.websocket.RemoteEndpoint には2つのサブタイプがあります。

  • RemoteEndpoint.Async 非同期にメッセージを送信する機能を持つ
  • RemoteEndpoint.Basic 同期にメッセージを送信する機能を持つ

WebSocket でメッセージの送受信を行う場合、メッセージサイズが小さなことが多いため、通常は同期でのメッセージ送信が主となります。

バイナリメッセージを送信する場合は以下のようにすることができます。

ByteBuffer buf = ...
session.getBasicRemote().sendBinary(buf);

文字列のメッセージは以下のように送信できます。

session.getBasicRemote().sendText(message);

非同期の場合は以下のようにするこができます。

session.getAsyncRemote().sendText(message);


Encoder と Decoder

WebSocket のメッセージとして任意のオブジェクトを受信/送信するには EncoderDecoder を使います。

例えば、MyMessageEncoder というエンコーダ、MyMessageDecoder というデコーダを作成して、@ServerEndpoint に以下のように指定します。

@ServerEndpoint(
    value="/endpoint",
    encoders = MyMessageEncoder.class,
    decoders= MyMessageDecoder.class)
public class MyEndpoint {
...
}

以下のように直接 任意のオブジェクトを扱うことができます。

@OnMessage
public MyMessage onMessage(Session session, MyMessage message) throws IOException {
    return message;
}

RemoteEndpoint に対しても以下のように送信することができます。

session.getBasicRemote().sendObject(message);


例えば Json 形式のテキストメッセージを MyMessage というオブジェクトに変換する場合は以下のようなエンコーダを作成します。

public class MyMessageEncoder implements Encoder.Text<MyMessage> {

    @Override
    public String encode(MyMessage message) throws EncodeException {
        return ...;
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
    }

    @Override
    public void destroy() {
    }
}

init()destroy() はセッション確立時とセッション終了時に1度だけ呼び出されます。空実装のデフォルト定義があるので定義しなくてもかまいません。

同様にデコーダは以下のようになります。

public class MyMessageDecoder implements Decoder.Text<MyMessage> {

    @Override
    public MyMessage decode(String s) throws DecodeException {
        return ...;
    }

    @Override
    public boolean willDecode(String s) {
        return (s != null);
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
    }

    @Override
    public void destroy() {
    }
}

willDecode() はデコード対象とするかどうかを受信メッセージテキストから判定します。


Encoder インターフェースには以下のサブインターフェースがあります。

  • Encoder.Text Javaオブジェクトをテキストメッセージに変換
  • Encoder.TextStream Javaオブジェクトを文字ストリームに追加
  • Encoder.Binary Javaオブジェクトをバイナリメッセージに変換
  • Encoder.BinaryStream Javaオブジェクトをバイナリストリームに追加

Decoder インターフェイスには以下のサブインターフェースがあります。

  • Decoder.Text テキストメッセージをJavaオブジェクトに変換
  • Decoder.TextStream 文字ストリームからJavaオブジェクトを読み取り
  • Decoder.Binary バイナリメッセージをJavaオブジェクトを読み取り
  • Decoder.BinaryStream バイナリストリームからJavaオブジェクトに変換

それぞれのタイプに応じて必要なサブインターフェースの実装を用意します。


サーバから定期的にPushするエンドポイントの実装

WebSocket API について詳細を見てきたので、具体的な実装を進めてみましょう。

ここでは、定期的にサーバ時刻をクライアントにPushするエンドポイントを作成してみましょう。

@ServerEndpoint("/time")
public class TimeEndPoint {

    private static final Queue<Session> sessions = new ConcurrentLinkedQueue<>();

    static {
        Runnable command = () -> sessions.forEach(session ->
                session.getAsyncRemote().sendText(LocalDateTime.now().toString()));
        Executors.newSingleThreadScheduledExecutor()
                .scheduleWithFixedDelay(command, 1, 1, TimeUnit.SECONDS);
    }

    @OnOpen
    public void onOpen(Session session) {
        sessions.add(session);
    }

    @OnClose
    public void onClose(Session session) {
        sessions.remove(session);
    }

}

WebSocket のライフサイクル以外でイベントを扱う必要があるため、newSingleThreadScheduledExecutor で1秒毎に定期処理を行うようにしています。

先に示したように、エンドポイントは接続毎に異なるインスタンスが生成されるため、sutatic なキューにセッションを保持し、各セッションに対して定期的に時刻を送信するようにしています。

クライアント側は以下のようになります。

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

<h3>Time</h3>
<p id="msg"></p>

<script>
const socket = new WebSocket('ws://localhost:8025/websockets/time');
socket.addEventListener('message', (event) =>
    document.getElementById('msg').textContent = event.data);
</script>

</body>
</html>

これを実行すると、1秒毎にサーバからPushされた時刻が表示されます。

f:id:Naotsugu:20210512210129p:plain


クライアントからの操作でボールを動かすエンドポイントの実装

次にサーバから画像を生成してクライアントに返す例を見てみましょう。

session.getUserProperties() で取得できるユーザプロパティに座標点を格納しておき、onMessage() で座標点を更新します。

この座標点は、画像としてクライアントに返す形にしてみましょう。

@ServerEndpoint("/image")
public class ImageEndPoint {

    private static final Map<Session, Point> sessions = new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(Session session) {
        sessions.put(session, new Point());
        onMessage("", session);
    }

    @OnClose
    public void onClose(Session session) {
        sessions.remove(session);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        Point point = sessions.get(session);
        switch (message) {
            case "37" -> point.x -= 4; // left arrow
            case "38" -> point.y -= 4; // up arrow
            case "39" -> point.x += 4; // right arrow
            case "40" -> point.y += 4; // down arrow
            default -> { point.x = 10; point.y = 10; }
        }
        send(point, session);
    }

    private static void send(Point point, Session session) {

        try {

            BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_BGR);

            Graphics g = img.getGraphics();
            g.setColor(Color.ORANGE);
            g.fillOval(point.x, point.y, 10, 10);
            g.dispose();

            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ImageIO.write(img, "png", baos);

            ByteBuffer buf = ByteBuffer.wrap(baos.toByteArray());
            session.getBasicRemote().sendBinary(buf);

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

}

クライアント側は以下のようになります。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Image</title>
</head>
<body>
<img id="img">
<br>
<p>Press ← ↑ → ↓</p>
<script>
const socket = new WebSocket('ws://localhost:8025/websockets/image');
socket.addEventListener('message', (event) => {
    const url = window.URL || window.webkitURL;
    document.getElementById('img').src = url.createObjectURL(event.data);
});

document.addEventListener('keydown', (event) => {
    socket.send(event.keyCode);
});
</script>

</body>
</html>

キー操作を keydown イベントでサーバに送信するとともに、受信した画像は createObjectURL で画像表示する形としています。

実装すると以下のようになり、キー操作でボールを移動させることができます。

f:id:Naotsugu:20210512211359p:plain


まとめ

前回に続いて、今回は WebSocket API の詳細について見てきました。

これで下準備は終わりましたので、次回は WebSocket でテトリスを作っていきます。

blog1.mammb.com