R-SQUARE.net

研究開発部

STOMP over WebSocket

Spring Framework 4 で STOMP over WebSocketをやってみた。

WebSocket ってなに?

  • フツーのhttp通信では、クライアント側が要求しない限り、サーバ側がレスポンスを返してくることはない。3 way hand shake.
  • 定期的に画面を更新するようなページをAjaxで実装した場合、画面更新するの?どうなの?と頻繁にクライアント側からサーバ側へお伺いを立てなければいけない。実際には更新する必要がなくてもクライアント側からの通信が発生するのでトラフィック的にどうよ?って感じ。
  • WebSocket通信では、最初にコネクションを確立した後は、クライアント側が要求しなくても、サーバ側の都合でメッセージを送ってくる。
  • なので、WebSocket通信ではトラフィックが減るんじゃないかって期待されるらしい。
  • WebSocketでは、1対1だけでなく1対多の通信も可能。

WebSocket通信の例

  1. WebSocket Connect

    • join to server by websocket
    • ユーザがWebページにアクセス。
    • ページに仕込まれたJavaScriptがサーバへWebSocket通信で接続。
    • response from server by websocket
    • サーバがレスポンスを返して、WebSocket Connection が確立。
    • 何人ものユーザが同様に同ページへアクセス。
  2. WebSocket Send

    • send message to server by websocket
    • ユーザの一人が画面操作。
    • ページに仕込まれたJavaScriptがサーバへWebSocketメッセージを送信。
    • サーバでなんやかや処理。
  3. WebSocket Callback

    • response from server by websocket
    • サーバがユーザみんなへメッセージを送信。(今回は、1対多の例ってことで)
    • ページに仕込まれたJavaScriptのCallbackメソッドがメッセージを受信。
    • ページに仕込まれたJavaScriptiがなんやかや処理。


ソースコード抜粋(build.gradle)

gradle依存関係に、WebSocket通信のために必要なライブラリを設定。jacksonを忘れずにね。
dependencies {
  //websocket
  compile "org.springframework:spring-websocket:4.2.1.RELEASE"
  compile "org.springframework:spring-messaging:4.2.1.RELEASE"
  compile 'com.fasterxml.jackson.core:jackson-databind:2.6.2'

  //他にもたくさん.....
}


ソースコード抜粋(Java Config)

"/lottery"は最初にWebSocket接続するためのエンドポイント。"/queue"は1(サーバ)対1(クライアント)の通信。"/topic"は1(サーバ)対多(クライアント)の通信。"/app"はクライアントからの発信。(でいいのかな?)
package net.rsquare.r2bingo.web.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

import net.rsquare.r2bingo.web.DrawHandshakeHandler;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/lottery")    //---- ここ(1)
    .setAllowedOrigins("*").setHandshakeHandler(new DrawHandshakeHandler())
    .withSockJS();
    // DrawHandshakeHandler は
    // org.springframework.web.socket.server.support.DefaultHandshakeHandler を継承したクラス
  }

  @Override
  public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker("/queue","/topic");    //---- ここ(2)(3)
    registry.setApplicationDestinationPrefixes("/app");    //---- ここ(4)
  }
}


ソースコード抜粋(Java)

WebSocket通信の実装。queueは送信元のクライアントにのみレスポンスを返す(2)。topicはWebページにアクセスしているクライアントみんなにレスポンスを返す(3)。
package net.rsquare.r2bingo.web;
/* 主要なimport以外は省略 */
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Controller;

import net.rsquare.r2bingo.userdetail.CustomUserDetails;

@Controller
public class DrawController {
  static Logger LOGGER = LoggerFactory.getLogger(DrawController.class);
  @Autowired private SimpMessagingTemplate template;
  
  @MessageMapping("/join/{eventId}")
  public void join(Message<Object> message, @DestinationVariable Long eventId) {
    LOGGER.debug("headers, {}. payload. {}.", message.getHeaders().toString(), message.getPayload().toString());
    
    CustomUserDetails user = this.getUserFromMessageHeaders(message.getHeaders());
    if (user == null) {
      return;
    }
    Long userId = user.getUserId();
    String username = user.getUsername();
    
    //
    //なんやかや処理......
    //
    
    Map<String,Object> ret = new HashMap<String,Object>();
    ret.put("drawnNums", drawnNums);
    ret.put("rank", rankMap);
    String destination = String.format("/queue/join/%s", eventId);  //----ここ(2)
    template.convertAndSendToUser(username, destination, ret);    //----ここ(2)
  }
  
  @MessageMapping("/draw/{eventId}")
  public void draw(Message<Object> message, @DestinationVariable Long eventId){
    LOGGER.debug("headers, {}. payload. {}.", message.getHeaders().toString(), message.getPayload().toString());
    
    //
    //なんやかや処理......
    //
    
    Map<String,Object> ret = new HashMap<String,Object>();
    ret.put("draw_result", drawnNum);
    ret.put("rank", rankMap);
    ret.put("isFinish", fin);
    String destination = String.format("/topic/draw/%s",eventId);  //----ここ(3)
    template.convertAndSend(destination, ret);    //----ここ(3)
  }
  @MessageMapping("/reset/{eventId}")
  public void reset(Message<Object> message,
      @DestinationVariable Long eventId){
    
    LOGGER.debug("headers, {}. payload. {}.", message.getHeaders().toString(), message.getPayload().toString());
    
    //
    //なんやかや処理......
    //

    Map<String,Object> ret = new HashMap<String,Object>();
    ret.put("draw_result", new ArrayList<Short>());
    ret.put("rank", new HashMap<Long,Long>());
    String destination = String.format("/topic/reset/%s",eventId);  //----ここ(3)
    template.convertAndSend(destination, ret);    //----ここ(3)
  }
  @MessageExceptionHandler
  @SendToUser("/queue/errors")  //----ここ(2)
  public String handleException(Throwable exception) {
    String ret = exception.getMessage();
    
    LOGGER.error("WebSocket over STOMP, Error = {}\n",ret, exception);
    
    if (ret == null) {
      ret = "NullPointerException or something else.";
    }
    return ret;
  }
}


ソースコード抜粋(JavaScript)

STOMP Over WebSocket をお手軽に実装できるありがたいJavaScriptライブラリを読み込む。
<script src="/r2bingo/js/stomp.min.js"></script>
<script src="/r2bingo/js/sockjs.min.js"></script>

上記Java Configで設定したエンドポイント(1)に向かって、WebSocket接続し、 Web画面上のボタン操作ポチりで、WebSocketメッセージを送る(4)
var eventId = 1;
var isStart = false;
var R2Stomp = {
    contextPath : '/r2bingo',
    client : null,
    connect : function(_headers, _connectCallback, _errorCallback) {
        this.client = Stomp.over(new SockJS(this.contextPath +'/lottery', ['v11.stomp']));  //--- ここ(1) 
        this.client.connect(_headers,_connectCallback,_errorCallback);
    },
    disconnect : function(){
        this.client.disconnect();
    },
    subscribe : function(_destination, _callback){
        return this.client.subscribe(_destination, _callback);
    },
    send : function(_destination, _headers, _body){
        this.client.send(_destination, _headers, JSON.stringify(_body));
    },
};
/*------------------------------------------------------------------*/
var connectCallback = function(frame) {
    console.debug("connect\n",frame.command,frame.headers);
    var subscriptionJoin = R2Stomp.subscribe('/user/queue/join/' + eventId, joinCallback);
    var subscriptionDraw = R2Stomp.subscribe('/topic/draw/' + eventId, drawCallback);
    var subscriptionReset = R2Stomp.subscribe('/topic/reset/' + eventId, resetCallback);
    var subscriptionError = R2Stomp.subscribe('/user/queue/errors', errorCallback);
    
    join();
};
var connectErrorCallback = function(frame) {
  console.error("Connect Error\n",frame.body);
  var mes = "disconnect from Server. ";
  if (frame.body) {
      mes = frame.body;
  }
  showError("Connect Error
" + mes); }; var joinCallback = function(frame){ var res = JSON.parse(frame.body); console.debug("joinCallback\n", res); // いろいろ処理..... }; var drawCallback = function(frame){ var res = JSON.parse(frame.body); console.debug("drawCallback\n", res); // いろいろ処理..... }; var resetCallback = function(frame){ var res = JSON.parse(frame.body); console.debug("resetCallback\n", res); // いろいろ処理..... join(); }; var errorCallback = function(frame){ console.error("Apprication Error\n", frame.body); showError("Apprication Error
" + frame.body); }; /*------------------------------------------------------------------*/ var join = function(){ R2Stomp.send('/app/join/' + eventId,{},{}); }; /*------------------------------------------------------------------*/ $( document ).ready(function(){ //connect R2Stomp.connect({}, connectCallback, connectErrorCallback); $('button#drawBtn').click(function(){ hideError(); R2Stomp.send('/app/draw/' + eventId,{},{}); //--- ここ(4) }); $('button#resetBtn').click(function(){ hideError(); disableDrawBtn(true); disableResetBtn(true); R2Stomp.send('/app/reset/' + eventId,{},{}); //--- ここ(4) }); hideError(); disableDrawBtn(true); disableResetBtn(true); }); $( window ).unload(function(){ //disconnect R2Stomp.disconnect(); });


実行例

  1. 上記のソースを使った WebSocket デモサイト
  2. Chrome と Firefox と safari でそれぞれ別のユーザで WebSocket デモサイトへログインして実行してみた動画

まとめ

  • いちおうHTML5対応ブラウザだけれどWebSocketには未対応なブラウザへの対策として、sock.js を使う。
  • STOMPを使うと、フツーのメソッドを呼び合うような感覚でWebSocket通信が行える。
  • 実は、最初のWebSocket Connectionを確立するためのurlの書き方が一番の悩みどころ。http:なのか、ws:なのか。ようわからん。
  • Web開発ツールとか Firebugs とかで通信の状態を見ながら操作すると面白い。


last modified : 2015-11-26T19:10:33+09:00