Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

Spring4.0で、APサーバに依存しないWebSocketサーバを実現する方法

こんにちは、阪本です。


昨年末に、Springがメジャーバージョンアップして4.0になりましたね。
代表的な変更点を上げてみても、

  1. コールバック関数のJava8ラムダ式対応
  2. Java EE 6 & 7 対応
  3. GroovyによるSpring定義の記述対応
  4. WebSocket、SockJS、STOMP対応
  5. 非同期REST対応等のRESTインタフェース機能拡張

のように、大きな機能追加・変更が行われていますが、その中でも、最近流行りのWebSocketに対応したということで、少し触ってみました。

まずは実装

超シンプルなチャットプログラムを作ってみます。
(エラー処理等は割愛。)

まずはサーバサイドから。
Mavenのpom.xmlのdependenciesに、Springの標準ライブラリに加えて、WebSocket用のライブラリ(spring-websocket)を定義します。

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context</artifactId>
  <version>${spring.version}</version>
  <exclusions>
    <exclusion>
      <groupId>commons-logging</groupId>
      <artifactId>commons-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-webmvc</artifactId>
  <version>${spring.version}</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-websocket</artifactId>
  <version>${spring.version}</version>
</dependency>

もちろん、 ${spring.version} は 4.0.0 です。

次にweb.xml
通常のHTTPリクエストを処理するのと同じく、WebSocketのハンドシェイクを受けるのもDispatcherServletです。

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
    id="WebApp_ID" version="3.0">
  <display-name>WebSocketServer</display-name>

  <!-- JavaConfigを用いるための設定 -->
  <context-param>
    <param-name>contextClass</param-name>
    <param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
  </context-param>
  <context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>websocket.config.ApplicationConfig</param-value>
  </context-param>

  <listener>
    <listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
  </listener>
  <listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>

  <servlet>
    <servlet-name>rootServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
      <param-name>contextClass</param-name>
      <param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
    </init-param>
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value></param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>

  <servlet-mapping>
    <servlet-name>rootServlet</servlet-name>
    <url-pattern>/echo</url-pattern>
  </servlet-mapping>

</web-app>


SpringのBean定義。JavaConfigを用いると、以下のように書きます。
@EnableWebSocket アノテーションを書くことで、WebSocketが有効になります。
合わせて、WebSocketConfigurerインタフェースを実装します。
registerWebSocketHandlersメソッドの中で、実際のWebSocket通信を処理するハンドラ(ここではEchoHandler)を登録します。
なお、コネクションごとにハンドラオブジェクトを分ける場合は、PerConnectionWebSocketHandlerを用います。

package websocket.config;

import org.springframework.context.annotation.Configuration;
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 websocket.handler.EchoHandler;

@Configuration
@EnableWebSocket
public class ApplicationConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry)
    {
        registry.addHandler(new EchoHandler(), "/echo");
        // セッションごとにオブジェクトを分ける場合は以下のように書く
        // registry.addHandler(new PerConnectionWebSocketHandler(XxxHandler.class), "/echo");
    }

}


ハンドラ(EchoHandler)の中身。受信した文字列(TextMessage)を、各クライアントに転送します。
今回は文字列の送受信を行うので、TextWebSocketHandlerクラスを継承しています。
バイナリデータを送受信する場合はBinaryWebSocketHandlerクラスを継承します。

package websocket.handler;

import static java.util.Map.Entry;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

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;

public class EchoHandler extends TextWebSocketHandler {
    /** セッション一覧 */
    private Map<String, WebSocketSession> sessionMap_ = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        this.sessionMap_.put(session.getId(), session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session,
            CloseStatus status) throws Exception {
        this.sessionMap_.remove(session.getId());
    }

    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 接続されているセッション(自分も含め)に転送する
        for (Entry<String, WebSocketSession> entry : this.sessionMap_.entrySet()) {
            entry.getValue().sendMessage(message);
        }
    }

}


最後に、HTMLファイルの内容です。
テキストフィールドに文字列を入力し、「送信」ボタンを押下すると、サーバに文字列を送信します。
サーバからメッセージを受信したら、divの中身に追記します。

<!DOCTYPE html>
<html>

<head>
  <script type="text/javascript" src="./views/lib/jquery.js"></script>
  <script type="text/javascript">
    $(function(){
      var ws = new WebSocket("ws://localhost:8080/WebSocketServer/echo");

      ws.onopen = function(){
      };

      ws.onclose = function(){
      };

      ws.onmessage = function(message){
        $("#log").append(message.data).append("<br/>");
        $("#message").val("")
      };

      ws.onerror = function(event){
        alert("エラー");
      };

      $("#form").submit(function(){
        ws.send($("#message").val());
        return false;
      });
    });
  </script>
</head>

<body>
  <div id="log"></div>
  <form id="form" action="#">
    <input type="text" id="message" /> <input type="submit" id="send" value="送信" />
  </form>
</body>

</html>


ちなみに、こんな画面になります。
f:id:acro-engineer:20140112003431p:plain



なお、上の例では、Springの定義をJavaConfigで記述しましたが、XMLで記述したい場合は以下のように書きます。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:websocket="http://www.springframework.org/schema/websocket"
  xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/websocket
    http://www.springframework.org/schema/websocket/spring-websocket.xsd">

    <websocket:handlers>
        <websocket:mapping path="/echo" handler="echoHandler" />
    </websocket:handlers>

    <bean id="echoHandler" class="websocket.handler.EchoHandler" />

</beans>

IE8/9に対応する(SockJSを使う)

WebSocket自体、IEは10以降でないと対応していません。
業務系ではまだまだIE8/9を使用しているところも多いはず。

そんなIE8/9ユーザには、WebSocketの動作をエミュレートする「SockJS」を使うと、
似たような動きを実現することができます!

WebSocketを用いる場合はWebSocketオブジェクトを生成していましたが、
SockJSを用いる場合はSockJSオブジェクトを生成します(引数のURLのプロトコル部分がhttpとなっていることに注意)。
これだけ。

<head>
  <script src="http://cdn.sockjs.org/sockjs-0.3.min.js"></script>
  <script type="text/javascript">
    $(function(){
      var sock = new SockJS("http://localhost:8080/WebSocketServer/echo");

      sock.onopen = function(){
      };

      sock.onclose = function(){
      };

      sock.onmessage = function(message){
        $("#log").append(message.data).append("<br/>");
        $("#message").val("")
      };

      sock.onerror = function(event){
        alert("エラー");
      };

      $("#form").submit(function(){
        sock.send($("#message").val());
        return false;
      });
    });
  </script>
</head>


SockJSはWebSocketを擬似的に再現しているに過ぎないため、通信の仕組みが異なります。
そのため、サーバサイドもWebSocketと同じ実装では動きません。

・・・

と言いましたが、Spring4.0では、なんと、たった13バイト付け足すことでSockJSに対応できます。
addHandlerメソッドの呼び出しの後ろに「.withSockJS()」を付けるだけ!超簡単!便利!!

package websocket.config;

import org.springframework.context.annotation.Configuration;
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 websocket.handler.EchoHandler;

@Configuration
@EnableWebSocket
public class ApplicationConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry)
    {
        registry.addHandler(new EchoHandler(), "/echo").withSockJS();
        // セッションごとにオブジェクトを分ける場合は以下のように書く
        // registry.addHandler(new PerConnectionWebSocketHandler(XxxHandler.class), "/echo").withSockJS();
    }

}


Springの定義をXMLで記述する場合も、「<websocket:sockjs />」を記述するだけ。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:websocket="http://www.springframework.org/schema/websocket"
  xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/websocket
    http://www.springframework.org/schema/websocket/spring-websocket.xsd">

    <websocket:handlers>
        <websocket:mapping path="/echo" handler="echoHandler" />
        <websocket:sockjs />
    </websocket:handlers>

    <bean id="echoHandler" class="websocket.handler.EchoHandler" />

</beans>

ハンドラ等はWebSocketと同じものをそのまま使えます。

補足:APサーバに依存しない理由

今までは、WebSocketプログラムを作る場合、APサーバが提供するServletクラスを継承して作っていたため、プログラムがAPサーバに依存した作りになっていました。
(例えばJettyの場合は、org.eclipse.jetty.websocket.servlet.WebSocketServletクラスを継承してServletを作ります。)
それに比べて、上のチャットプログラムは、APサーバに依存しない実装となっています。

APサーバ依存脱却!

・・・といいつつも、WebSocketの実現の仕組みは、APサーバによって異なるはず。

では、どこでAPサーバ依存の処理を行っているのでしょうか?

気になってSpringのソースコードを見てみると・・・やはりありました!
Spring WebSocketモジュールのDefaultHandshakeHandlerクラスで、APサーバ依存の処理を切り分けています。

private static final boolean jettyWsPresent = ClassUtils.isPresent(
        "org.eclipse.jetty.websocket.server.WebSocketServerFactory", DefaultHandshakeHandler.class.getClassLoader());

private static final boolean tomcatWsPresent = ClassUtils.isPresent(
        "org.apache.tomcat.websocket.server.WsHttpUpgradeHandler", DefaultHandshakeHandler.class.getClassLoader());

private static final boolean glassFishWsPresent = ClassUtils.isPresent(
        "org.glassfish.tyrus.servlet.TyrusHttpUpgradeHandler", DefaultHandshakeHandler.class.getClassLoader());

(中略)

private static RequestUpgradeStrategy initRequestUpgradeStrategy() {
    String className;
    if (jettyWsPresent) {
        className = "org.springframework.web.socket.server.jetty.JettyRequestUpgradeStrategy";
    }
    else if (tomcatWsPresent) {
        className = "org.springframework.web.socket.server.standard.TomcatRequestUpgradeStrategy";
    }
    else if (glassFishWsPresent) {
        className = "org.springframework.web.socket.server.standard.GlassFishRequestUpgradeStrategy";
    }
    else {
        throw new IllegalStateException("No suitable default RequestUpgradeStrategy found");
    }
    try {
        Class<?> clazz = ClassUtils.forName(className, DefaultHandshakeHandler.class.getClassLoader());
        return (RequestUpgradeStrategy) clazz.newInstance();
    }
    catch (Throwable ex) {
        throw new IllegalStateException("Failed to instantiate RequestUpgradeStrategy: " + className, ex);
    }
}

Jetty、TomcatGlassFish、それぞれのWebSocket用クラスが存在するかチェックして、ハンドシェイク処理クラスを切り替えています。
これにより、私たちはAPサーバの種類を意識せずに実装できます。

・・・もうお分かりのように、Spring4.0のWebSocket対応APサーバは、Jetty、TomcatGlassFishです^^

おわりに

今後、WebSocketを使用したシステムは増えてきます。
APサーバ依存性の排除や実装量の削減に、Springを活用するのは有効な手段と思います。
ぜひ、試してみてください。

では。


Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ