RubyでNEMのWebSocketを使う
RailsでNEMのアプリを作ろうとしたとき、RubyでWebSocketを扱った前例が無かったので調査しました。うまくWebSocketを扱うことに成功したので、その解説なども交えて説明します。
WebSocketとは
WebSocketとはウェブアプリにおいて、双方向通信を実現するための規格です。Webの世界では、HTTP/1.1という規格に則った通信を行っているものが殆どです。サーバーにリクエストを送り、それにサーバーが反応してレスポンスを返す、という動作をし、一つのリクエストに対して一つのレスポンスを返します。このブログを見るのも、NEMのAPIを叩くのも、HTTP/1.1による通信によって実現しています。しかし、HTTP/1.1で通信を行うのは以下のような問題もあります。
- サーバーからクライアントに能動的にデータを送信できない
- 余計な通信が多い
こういった問題から、例えばNEMの残高を監視したいと思ったときに、一定時間ごとにサーバーにリクエストを飛ばして何度もAPIを叩かなければなりません(これをポーリングという)。これではユーザーに残高の変化を伝えるのが、最大でその一定時間だけ遅れてしまうという問題がありUXが良くないですし、余計な通信をたくさんするので、サーバーの負荷も大きくなってしまいます。
これを解決するのがWebSocketです。WebSocketを使うことでコネクションが成立すると後は内部で双方向に自由に通信が行えます。先程の例のような残高監視の場合ですと、アプリはサーバーに対してコネクションを確立した後に購読を希望することで残高に変化があった時はサーバーの方から通知が送られてきます。後はその通知に対してどんな処理を行うかのみを記述すれば良いのでプログラムも単純になります。
STOMPとは
STOMPとは上記WebSocketを用いた通信上で利用されるデータの規格の一つです。NEMのWebSocketはこの規格に則った通信を行います。
COMMAND
HEADER_NAME1:HEADER_VALUE1
HEADER_NAME2:HEADER_VALUE2
︙
HEADER_NAMEn:HEADER_VALUEn
BODY
こんな感じの構造となっています。COMMANDで何をするか指示を出し、HEADERでその詳細を指定し、データなどはBODYに格納されるような使い方をされています。例えば、最新ブロック高監視のSTOMPは以下のようになっています。
SUBSCRIBE
id:sub-0
destination:/blocks
NEMでは終端文字に\u0000(nil)を使用しています。終端文字を合わせないと通信に失敗します。
このSTOMPを、WebSocketでデータを送信する単位であるframeにのせて送信することでクライアント-サーバー間でのやりとりを可能にしています。
調査
NEMのWebSocketはSockJSによる実装です。こちらを見るとそれが確認できます。しかし、私はあまりSockJSの知識がなく、WebSocketやSTOMPといった技術に触れるのもはじめてで、どのように実装すれば良いのかさっぱりわかりませんできませんでした。そこで、WireSharkを使って通信を確認し、これと同じ動作をするコードを書くことで実装を行いました。 特に、STOMPの扱いについての動作確認に重宝しました。終端文字がnilとか[“”]で覆えとか、そういう情報はこれを使ってないと気付けなかったんじゃないかと思います。
以上の調査の結果、RubyでもうまくWebSocketを動作させる事ができました。
実装例
Rubyでの実装例を紹介します
# coding: utf-8
require "json"
require 'websocket-client-simple'
require 'stomp_parser'
require 'securerandom'
host = "http://alice3.nem.ninja"
port = 7778
path = "/w/messages/"
server = format("%0#{3}d", SecureRandom.random_number(10**3))
strings = [('a'..'z'), ('0'..'5')].map { |i| i.to_a }.flatten
session = (0...8).map { strings[rand(strings.length)] }.join
endpoint = host + ":" + port.to_s + path + server.to_s + "/" +session + "/websocket/"
ws = WebSocket::Client::Simple.connect(endpoint)
ws.on :open do
connect_header = {"accept-version":"1.1,1.0","heart-beat":"10000,10000"}
stomp_connect = '["' + StompParser::Frame.new("CONNECT", connect_header,"").to_str + '"]'
ws.send(stomp_connect)
end
ws.on :message do |msg|
if msg.data[0] == "o"
end
if msg.data[0] == "h"
end
if msg.data[0] == "a"
data = msg.data
data.slice!(0,data.index("[")+2)
data.slice!(data.rindex("]")-1,data.length)
data.gsub!("\\n","\n")
data.gsub!("\\r","\r")
data.gsub!("\\/","\/")
data.gsub!("\\u0000","\u0000")
data.gsub!("\\\"","\"")
parser = StompParser::Parser.new
parser.parse(data) do |frame|
puts "We received #{frame.command} frame with #{frame.body} and headers #{frame.headers}!"
if frame.command == "CONNECTED"
blocks_header = {"id":"sub-0","destination":"/blocks"}
stomp_subscribe_blocks = '["' + StompParser::Frame.new("SUBSCRIBE", blocks_header, "").to_str + '"]'
ws.send(stomp_subscribe_blocks)
end
if frame.command == "MESSAGE"
result = JSON.parse(frame.body)
p result["timeStamp"]
end
end
end
end
ws.on :error do |e|
p(e)
end
loop do
end
解説
処理の流れは以下のようになっています。
接続先のURLを生成する
- 接続先はhttp://ホスト名:websocket用のポート/w/messages/3桁の数字で構成された乱数/8桁の小文字の英字と0~5の数字で構成された乱数/となります。
WebSocketClientを用いて接続する
- websocket-client-simpleを使用させていただきました。
open時にCONNECTコマンドを送信する
- 以下のSUBSCRIBEコマンドの場合もですが、STOMPパーサーを使うとハッシュから楽にSTOMPに対応した文字列を生成できます。
- SockJSの仕様か、生成したフレームは[“”]で覆わないとサーバー側からの返信が来ません。
サーバーから通知が来たらデータの最初の一文字目で分岐する
- “o”はサーバーがframeを開いた時に送信されるようです。
- “h”はサーバーからのハートビートです。
- “a”は[]内にサーバーからのデータが含まれています。
CONNECTEDコマンドを含む通知が来たらSUBSCRIBEコマンドを送信する
- 受信時のデータは余分なバックスラッシュ等余計なものが多く、そのままではパースできないのでここで綺麗にしています。
- このプログラムでは最新ブロック高監視を行っています。
MESSAGEコマンドを含む通知が来たらbodyをパース後データを表示
- MESSAGEコマンドを含むフレームのbodyは通常のAPIと同じくjsonで記述されているので、簡単にパースできます。
まとめ
調査の結果RubyでNEMのWebSoketを扱うことができました。この調査結果を活かしてNEM-WebSocket-Clientなgemでも作ろうと思います。Webアプリを作る上でRailsでもこういったものを扱えたらとても捗るんじゃないかなーとか考えています。
参考
github - varvet/stomp_parser
github - shokai/websocket-client-simple
github - sockjs/sockjs-client
github - sockjs/sockjs-protocol
田舎からGeekを目指す - NEMのWebSocket通信を使ってみた