vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

SpringでSTOMPを使わずにWebSocket

はじめに

前回はサーバー側を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 {
        // 接続が切られたら呼ばれる.
    }
}
  • こちらも詳細は後述しますが、接続を確立したときやクライアントからメッセージを受け取った場合のイベントハンドラーです。
    設定クラス(WebSocketConfig.java)から使用します。

メッセージの送受信

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側から送信するメッセージの量をもっと増やした場合でも、問題が起きないかなども気になるところですね。

参照