Spring入門した。
遅まきながらSpringを入門した時のメモを書きます。 Spring Bootではありません。
経緯
- WebScoketやりたい。
- Spring使ってみよう。
- Spring WebScoketあるじゃん使おう。
- Spring Messagingの連携良さそう。
- Stomp、SockJS?よく分かりません。(socket.ioがいいですボソッ)
・StompはデータフォーマットとそのEncoder/Decoderっぽい
・SockJSはWebSocketのフォールバックでWebSocketがない環境でも
http通信でWebSocketをエミュレーションするやつっぽい - Socket.ioのJava Implementationないの・・・?
Springじゃなくて別のフレームワークならあった。。 。 - とりあえずじゃあ、Spring WebSocketでSpring Messagingの連携書いてみよう
- WebSocketSessionの間で有効なWebSocketSessionScopeみたいなの欲しいよね。調べる。 OMG!WebSocketMessageBroker(Stompと連携するやつ)じゃないと動かない。却下。
- 独自Scope書いて切り替える処理書いてみるかー
というわけで独自Scope書きました。
まずはSpringの初期化から
web.xml書きたくないでござる!!書きたくないでござる!! ApplicationContext.xml書きたくないでござる!!書きたくないでござる!!
というわけでアプリケーションの初期化をコードで書きます。 SpringのWebアプリ―ケーションの初期化はこいつ(WebApplicationInitializer)を実装すればいいみたい。 なるほど。JAX-RSのApplicationクラスみたいですね。
WebApplicationInitializerの処理はこっちから呼び出されてるようです。 以前うらがみさんに教えてもらったServletContainerInitializerが呼ばれる仕組みの流れからWebApplicationInitializerが呼ばれているようですね。
WebApplciationInitializerを継承したクラスを書いてみます。
書きました。
public class ApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class[] { WebSocketConfig.class }; } @Override protected Class<?>[] getServletConfigClasses() { return new Class[] {}; } @Override protected String[] getServletMappings() { return new String[] { "/echo" }; } }
Springが用意している抽象クラスを使うと楽になりました。 WebApplciationInitializerを自分で実装すると ドキュメントの 「A 100% code-based approach to configuration」の部分になるようです。xml書くよりは楽みたいです。
WebSocket用の設定
WebSocketの設定クラスの中でHandlerの登録を行います。 また、独自Scopeもここで渡してます。 WebSocket向けにEnableWebSocket AspectJでトレースログを吐きたかったのでEnableAspectJAutoProxyを付けています。
@Configuration @EnableWebSocket @EnableAspectJAutoProxy @ComponentScan public class WebSocketConfig implements WebSocketConfigurer { @Autowired EchoHandler echoHandler; public static final String WEB_SOCKET_SCOPE_NAME = "test_webscoket"; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(echoHandler, "/echo"); } @Bean public static CustomScopeConfigurer customScopeConfigurer(){ CustomScopeConfigurer configurer = new CustomScopeConfigurer(); configurer.addScope(WEB_SOCKET_SCOPE_NAME, new WebSocketScope()); return configurer; } }
Scopeの実装の前に
@Component @Scope(scopeName = WebSocketConfig.WEB_SOCKET_SCOPE_NAME) public class TestService { public String serve() { return this.toString(); } }
このTestServiceをWebSocketSessionの生存範囲で動かしたいわけですが Scopeアノテーションで独自スコープ名に変更してあります。
serveの処理でtoStringの値を返しているのはハッシュコードの比較して 適切なスコープが実装されているかどうか確認します。
WebSocketのハンドラ
今回はTextMessageだけ取り扱う形にして TextWebSocketHandlerを継承しています。 メッセージを扱える際にSocketSessionHolder.setSession(session);といった形で ThreadLocalにデータを格納して、セッション単位でスコープの切り替えを行っています。 拡張をここからするなら、JacksonとかでObject Mappingをする必要があったりEventListenerが必要になります。
@Component @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) public class EchoHandler extends TextWebSocketHandler { private Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.put(session.getId(), session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session.getId()); SocketSessionHolder.removeSession(session); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { SocketSessionHolder.setSession(session); System.out.println("start"); try { WebApplicationContext context = ContextLoader.getCurrentWebApplicationContext(); System.out.println(context.getBean(TestService.class) .serve()); System.out.println(context.getBean(TestService.class) .serve()); sessions.forEach((__, other) -> { try { other.sendMessage(new TextMessage("catch " + message.getPayload())); } catch (Exception e) { e.printStackTrace(); } }); } finally { System.out.println("end"); SocketSessionHolder.removeContext(); } } }
Scopeの実装
SpringではScopeの実装のために、Scopeインターフェースが用意されています。 以下のようなコードで実装しました。
public class WebSocketScope implements Scope { @Override public Object get(String name, ObjectFactory<?> factory) { return SocketSessionHolder.getCurrentContainer().get(name, factory::getObject); } @Override public String getConversationId() { return SocketSessionHolder.current().getId(); } @Override public void registerDestructionCallback(String name, Runnable arg1) { // TODO Auto-generated method stub } @Override public Object remove(String name) { return SocketSessionHolder.getCurrentContainer().remove(name); } @Override public Object resolveContextualObject(String name) { // TODO Auto-generated method stub return null; } }
registerDestructionCallbackの実装は行っていません。 たぶんコネクションクローズした時にオブジェクトの破棄をする時のコールバックを保持しておいて Object破棄時に呼ぶ処理を実装するんだと思います。
resolveContextualObjectの実装はよく分からないのでまた今度調べます。 →いくつかサンプル見てもnull返すのでいいみたい・・・?
もう少しScopeアノテーションに関する実装の読み込みが必要なようです。
コード中に出てくるSocketSessionHolderは ThreadLocalとWebSocketSessionを紐づけて オブジェクトプールを管理するクラスです。
以下のようなコードです。
public class SocketSessionHolder { private static ThreadLocal<WebSocketSession> session = new NamedThreadLocal<>("WebScoketThreadLocal"); // TODO ConcurrentMap? private static Map<WebSocketSession, WebSocketScopeContainer> pool = new HashMap<>(); public static WebSocketSession setSession(WebSocketSession session) { SocketSessionHolder.session.set(session); if (pool.get(session) == null) { pool.put(session, new WebSocketScopeContainer()); } return session; } public static WebSocketSession current() { return session.get(); } public static WebSocketScopeContainer getCurrentContainer() { return pool.get(current()); } public static void removeContext() { session.remove(); } public static void removeSession(WebSocketSession session){ pool.remove(session); } }
EchoHandlerのhandleTextMessageのメソッドが セッション単位で並列に呼ばれることがあるのかがわからないので これは後で調べます。たぶん呼ばれる気がしますが。
WebSocketScopeContainerはただのオブジェクトプールです
public class WebSocketScopeContainer { private Map<String, Object> pool = new HashMap<>(); public Object remove(String name) { return pool.remove(name); } public Object get(String name, Supplier<?> supplier) { Object object = pool.get(name); if (object == null) { Object newObject = supplier.get(); pool.put(name, newObject); return newObject; } return object; } }
動作確認
JavaScriptで動作確認してみます。(実際にはこれを読み込んだhtmlのページを開きます。) WebSocketのSessionが確立した後に メッセージを2回送っています。
これはオブジェクトの生存スコープを確認するためです。 sendでメッセージを送った時に、ログに同じハッシュ値のオブジェクトが出てくるはずです。
const url1 = `ws://${location.host}${location.pathname}echo`; test(url1); function test(url) { const ws= new WebSocket(url); const bind=(f, v)=> f.bind(null, v); const log=console.log.bind(console); ws.onclose=bind(log, "close"); ws.onerror=bind(log, "error"); ws.onmessage=bind(log, "message"); ws.onopen=()=> { ws.send("send text1"); ws.send("send text2"); } }
というわけで結果としては動きました。動作確認はTomcat v7.xで行いました。
まとめ
初めのモチベーションとしてはSpringでWebsocketするかーぐらいの気持ちでした。
しかし、StompjsとSockJSというものが出てきて
あまり使う気に慣れません。(Socket.ioを使ったことがあるので)
また、Stompjsのリポジトリを見たところ
no longer maintainedになっております。(あとCoffeeScriptで書かれてる。)
少なくともこれに乗っかる気にはなれませんでした。
そのため、WebSocketセッション単位でライフサイクルが扱えるなら
普通にSpringでStompjsを使わずにWebSocketすればええか、と思ったのですが・・・
ScopeはMessageBrokerを使う際のみ有効だということが分かったので
少し勉強のために書いた次第です。
なお、今回書いたコードはエラーハンドリングを除いた状態かつthread safeじゃない状態で記述しているので
もう少しコードを足す必要があります。
このコードを書くために、Spring Websocketの @EnableWebSocketMessageBrokerをアノテートして有効にした場合の動きを調べました。
というわけでSpring入門しました。
リポジトリはこちらに置いてあります。
おまけ
Springのリポジトリを見たり検索していて気付いたのですが
spring-web-reactiveがspring-webfluxに替わってました。
この記事に関連してsocket.ioの動きを調べて メッセージのやり取りについて気になったので テストで試してみました。気になったら覗いてください。
socket.ioの動きの何が気になったかというと
WebSocketのエンドポイントとしては単一のはずなのに
あたかも、サブディレクトリがあるような動きをしています。
この動きはデータのエンコードの際にデータを付与して表現しているようです。
面白いですね。