SpringでSTOMPを使わずにWebSocket
- はじめに
- 最小(と思う)構成
- メッセージの送受信
- WebSocketConfigurer
- WebSocketHandlerRegistry
- TextWebSocketHandler
- WebSocketHandler
- おわりに
- 参照
はじめに
前回はサーバー側をGolangにしていましたが、やっぱりSpring Bootでも実現したい!ということで、
今回はSpring BootでSTOMPなしでWebSocketを使い、Unityからアクセスできるようにしてみます。
なお今回の内容は、下記を参考にしています。
最小(と思う)構成
まずUnityからのアクセスによって接続を確立し、メッセージを受け取ってみます。
これをできるだけ少ないコードで再現しようとすると、こんな感じに。
build.gradle
buildscript { ext { springBootVersion = '1.5.3.RELEASE' } repositories { mavenCentral() } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") } } apply plugin: 'java' apply plugin: 'eclipse' apply plugin: 'org.springframework.boot' version = '0.0.1-SNAPSHOT' sourceCompatibility = 1.8 repositories { mavenCentral() } dependencies { compile('org.springframework.boot:spring-boot-starter-web') compile('org.springframework.boot:spring-boot-starter-websocket') testCompile('org.springframework.boot:spring-boot-starter-test') }
- spring-boot-starter-websocketを追加しています。
WebsocktestApplication.java
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class WebsocktestApplication { public static void main(String[] args) { SpringApplication.run(WebsocktestApplication.class, args); } }
- Applicationクラスです。SpringInitializrで生成したクラスから特に変更はしていません。
WebSocketConfig.java
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(messageHandler(), "/ws"); } @Bean public WebSocketHandler messageHandler() { return new MessageHandler(); } }
STOMPを使った場合と同様に設定クラスを用意します。
詳細は後述しますが、指定したURL(localhost:8080/ws)にWebSocketを使ってアクセスがあった場合の登録処理を行います。
MessageHandler.java
import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; public class MessageHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 接続が確立されたら呼ばれる. } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { // WebSocketクライアントからメッセージを受信した時に呼ばれる. } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 接続が切られたら呼ばれる. } }
メッセージの送受信
STOMPを使う場合は、接続確立時にSubscribeを行うことで、他のクライアントからメッセージを受信した場合にそれを受け取ることができていました。
ただ今回はその方法が使用できないため、接続確立時にWebSocketSessionの情報を保持しておき、
メッセージを受け取ったらメッセージの送信者以外にメッセージを送信するようにしてみます。
※下記よりスマートな方法があるような気はしますが、記事投稿時点で他に思いつきませんでしたorz
MessageHandler.java
import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.ArrayList; public class MessageHandler extends TextWebSocketHandler { private ArrayList< WebSocketSession > users; public MessageHandler(){ users = new ArrayList<>(); } @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 接続確立時、配列にWebSocketSessionの情報を追加. if(users.stream() .noneMatch(user -> user.getId().equals(session.getId()))){ users.add(session); } } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { // メッセージを受け取ったら送信元以外にメッセージを送る. users.stream() .filter(user -> !user.getId().equals(session.getId())) .forEach(user -> { try{ user.sendMessage(message); } catch (IOException ex){ System.out.println(ex.getLocalizedMessage()); } }); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 接続が切れたら配列から削除. users.stream() .filter(user -> user.getId().equals(session.getId())) .findFirst() .ifPresent(user -> users.remove(user)); } }
これでとりあえず複数アプリからlocalhost:8080/wsに接続すると、
メッセージを送り合うことができるようになりました。
次は設定クラスが継承しているWebSocketConfigurer.javaと、イベントハンドラーのTextWebSocketHandler.javaの処理を追ってみることにします。
WebSocketConfigurer
まず設定クラスから追いかけてみます。
WebSocketConfig.java
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(messageHandler(), "/ws"); } @Bean public WebSocketHandler messageHandler() { return new MessageHandler(); } }
ここでは後述するハンドラー(WebSocketHandler)の登録と、Bean定義(DIコンテナに追加する)を行っています。
「registry.addHandler(messageHandler(), “/ws”)」で、
localhost:8080/wsにアクセスした時のハンドラーを登録しています。
またBean定義についてですが、今回DIを使っていないので、下記のようにしても動いたりします。
@Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new MessageHandler(), "/ws"); }
このクラスが継承しているWebSocketConfigurer.javaは、ハンドラー登録の関数(registerWebSocketHandlers)だけを持つインターフェイスです。
では、この引数であるWebSocketHandlerRegistryを追ってみましょう。
WebSocketHandlerRegistry
WebSocketHandlerRegistry.java
package org.springframework.web.socket.config.annotation; import org.springframework.web.socket.WebSocketHandler; public interface WebSocketHandlerRegistry { WebSocketHandlerRegistration addHandler(WebSocketHandler webSocketHandler, String... paths); }
関数addHandlerの戻り値になっているWebSocketHandlerRegistryを見てみます。
package org.springframework.web.socket.config.annotation; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeHandler; import org.springframework.web.socket.server.HandshakeInterceptor; public interface WebSocketHandlerRegistration { // ハンドラーの追加.第二引数でアクセスポイントを指定. WebSocketHandlerRegistration addHandler(WebSocketHandler handler, String... paths); // ハンドシェイク(WebSocketの接続開始時に行う通信)に対するHandlerのセット. WebSocketHandlerRegistration setHandshakeHandler(HandshakeHandler handshakeHandler); // ハンドシェイクの前後に処理を追加するためのインターセプター. WebSocketHandlerRegistration addInterceptors(HandshakeInterceptor... interceptors); // Originヘッダー値の設定. WebSocketHandlerRegistration setAllowedOrigins(String... origins); // Sock.jsを使用する. SockJsServiceRegistration withSockJS(); }
ということで、設定クラスでは主にハンドラーの設定を行っています。
TextWebSocketHandler
ではそのハンドラーについて。
まずはもとのコードから(Sessionの保持やメッセージの送信などは省略します)。
MessageHandler.java
import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; public class MessageHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 接続が確立されたら呼ばれる. } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { // WebSocketクライアントからメッセージを受信した時に呼ばれる. } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 接続が切られたら呼ばれる. } }
次に継承しているTextWebSocketHandler.javaを見てみます。
TextWebSocketHandler.java
package org.springframework.web.socket.handler; import java.io.IOException; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketSession; public class TextWebSocketHandler extends AbstractWebSocketHandler { @Override protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { try { session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Binary messages not supported")); } catch (IOException ex) { // ignore } } }
ここではバイナリメッセージが送信された場合に、そのセッションを閉じています。
このようにしている理由は、WebSocketHandlerが文字列にしか対応していないため、ということのようです。
最初から実装していないと、バイナリメッセージが送信された場合に受信できずエラーになる、ということなのでしょうか。
※2017.05.17更新
バイナリメッセージが送信された時にセッションを閉じるのは、
このクラスがTextWebSocketHandlerの名前の通りテキストデータをやりとりするためのものなので、
それ以外のデータは受け付けませんよ、ということのようです。
失礼しましたorz
続けてAbstractWebSocketHandler.javaを見てみます。
AbstractWebSocketHandler.java
package org.springframework.web.socket.handler; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.PongMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketMessage; import org.springframework.web.socket.WebSocketSession; public abstract class AbstractWebSocketHandler implements WebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { } @Override public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { if (message instanceof TextMessage) { handleTextMessage(session, (TextMessage) message); } else if (message instanceof BinaryMessage) { handleBinaryMessage(session, (BinaryMessage) message); } else if (message instanceof PongMessage) { handlePongMessage(session, (PongMessage) message); } else { throw new IllegalStateException("Unexpected WebSocket message type: " + message); } } protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { } protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { } protected void handlePongMessage(WebSocketSession session, PongMessage message) throws Exception { } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { } @Override public boolean supportsPartialMessages() { return false; } }
ほとんどカラですが、handleMessageで受け取ったメッセージの型に合わせて処理を振り分けていますね。
では大本のWebSocketHandler.javaです。
WebSocketHandler
WebSocketHandler.java
package org.springframework.web.socket; public interface WebSocketHandler { // 接続確立後のイベント. void afterConnectionEstablished(WebSocketSession session) throws Exception; // メッセージを受け取ったときのイベント. void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception; // メッセージのやり取り中のエラーをハンドリングする. void handleTransportError(WebSocketSession session, Throwable exception) throws Exception; // 接続を切ったときのイベント. void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception; // サイズが大きすぎる場合などに分割されたメッセージを扱うかどうか. boolean supportsPartialMessages(); }
おわりに
Golangもそうですが、シンプルなコードで通信ができてしまうのはすごいと思いました(小並感)。
次回は下記のような部分をなんとかしたいと思います。 * セッション情報をハンドラークラスから分離する * Webブラウザから特定のページにアクセスしたときに、今接続しているアプリの情報(IDや数など)や送信しているメッセージの表示を行う
あとはUnity側から送信するメッセージの量をもっと増やした場合でも、問題が起きないかなども気になるところですね。
参照
- 26. WebSocket Support - Spring
- Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発
- WebSocketHandler (Spring Framework 4.3.8.RELEASE API) - Spring
- WebSocketHandler (Spring Framework 4.3.8.RELEASE API) - Spring
- TextWebSocketHandler (Spring Framework 4.3.8.RELEASE API) - Spring
- WebSocketConfigurer (Spring Framework 4.3.8.RELEASE API) - Spring