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