- はじめに
- エンドポイントの構成
- ライフサイクルアノテーション
- Session
- RemoteEndpoint
- Encoder と Decoder
- サーバから定期的にPushするエンドポイントの実装
- クライアントからの操作でボールを動かすエンドポイントの実装
- まとめ
はじめに
前回からの続きです。
前回は WebSocket による簡単なエコーサーバを実装しました。
今回は、Java API for WebSocket についてもう少し詳しく見ていきます。
最終的には以下のようなゲームを作成します。
ここで使用するコードは以下に置いてあります。
エンドポイントの構成
前回見たように、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
エンドポイントがサポートする特殊なサブプロトコルを示す文字列名を指定
decoders
と encoders
はよく使うので後述します。
ライフサイクルアノテーション
エンドポイントには、@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 のメッセージとして任意のオブジェクトを受信/送信するには Encoder
と Decoder
を使います。
例えば、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された時刻が表示されます。
クライアントからの操作でボールを動かすエンドポイントの実装
次にサーバから画像を生成してクライアントに返す例を見てみましょう。
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
で画像表示する形としています。
実装すると以下のようになり、キー操作でボールを移動させることができます。
まとめ
前回に続いて、今回は WebSocket API の詳細について見てきました。
これで下準備は終わりましたので、次回は WebSocket でテトリスを作っていきます。