AnyEventなウェブソケットサーバーのバイナリデータ送信

ウェブソケットサーバーからバイナリデータ送信を行う必要に迫られ、対応しました。DEN将棋EXで、Plack::App::WebSocketを使用したのでこれを使えば簡単に可能であると思っていましたが、そうでも無かったので、その時の備忘録です。

1.perlによるウェブソケットサーバー

perlでウェブソケットサーバーを作るには、色々な方法があります。下記はPlack::App::WebSocket作者による記事ですが、色々なウェブソケット関連モジュールを紹介されています。

https://debug-ito.hatenablog.com/entry/20131110/1384067233

DEN将棋EXでは、Plack::App::WebSocketを使用して、PSGIとAnyEventによる完全なイベントドリブンプログラミングを実現しました。つまり、ブラウザからのウェブソケットデータ受信とChildプロセスからのデータ受信両方ともに、AnyEvent由来のイベントハンドラーを記述できるようになりました。このおかげでコードはすっきりし私にとっては手放せないモジュールの一つとなりました。

そして、これを使用すればサーバーの画像データをブラウザに簡単に送信できると思っていたのですが、そうではありませんでした。調べながら3日間ぐらい悩みましたが、モジュールの簡単な改造で解決できました。

2.現象

DEN将棋EXで使用しているように、Plack::App::WebSocket::Connectionオブジェクトのsendメソッドで、引数$dataがテキストデータの時は、問題無く送信されブラウザ側に表示されるのですが、読み込んだpng画像のバイナリデータは表示されずに、すぐにウェブソケットがクローズされてしまいました。

$conn->send($data);

3.Net::WebSocket::Server

念のため
Net::WebSocket::Server
も試してみました。下記はNet::WebSocket::Serverによるアプリです。これだけで無限ループとなりウェブソケットサーバーとして機能します。

my $origin = 'http://**.**.**.**';
Net::WebSocket::Server->new(
listen => ****,
on_connect => sub {
my ($serv, $conn) = @_;
$conn->on(
handshake => sub {
my ($conn, $handshake) = @_;
if($handshake->req->origin eq $origin){
$Websockets{"$conn"} = $conn;#確立したソケットを保存
## ゲートウェイオブジェクトを生成
## 接続確立後の処理
}else{
$conn->disconnect();
}
},
utf8 => sub {
my ($conn, $msg) = @_;
## テキストデータ受信時の処理
# 受信データにより、ゲートウェイオブジェクトメソッドの中で
# テキストデータ送信の「$conn->send_utf8($da)」か
# バイナリデータ送信の「$conn->send_binary($da)
# を使い分ける
},
binary => sub {
my ($conn, $msg) = @_;
## バイナリデータ受信時の処理
},
);
},
)->start;

ブラウザへのテキスト送信,バイナリデータ(イメージデータ)送信は当然問題有りませんでした。ところがこのモジュールは独自のイベントループを回すので、ChildプロセスのハンドルについてのAnyEvent::Handleイベントループを定義しても機能しません。

4.コード調査と若干の改造

Plack::App::WebSocketではバイナリデータは送信できないのか?まずはこれを調査しました。

このオブジェクトを生成するnewの第二引数で、on_establishをキーとするコールバック関数はPlack::App::WebSocket::Connectionオブジェクトをその第一引数で受け取ります。
ウェブソケットサーバー側からブラウザへの送信は、このオブジェクト$connのsendメソッドを使用します。このsendメソッドの中では、

$self->{connection}->send($message);

となっており、このオブジェクトが生成された時に渡されたコネクションオブジェクトの
sendメソッドを実行するようになっています。ではこの「コネクションオブジェクト」は何か?

Plack::App::WebSocket::Connection
オブジェクトを生成するのは、
Plack::App::WebSocketオブジェクトのcallメソッドの中であり、
Plack::App::WebSocket::Connectionのnewに渡すコネクションオブジェクトは
どうやら、AnyEvent::WebSocket::Connectionオブジェクトです。

ところが、Plack::App::WebSocket::Connectionパッケージのperldocには

$connection->send($message)
Send a message via $connection.
$message should be a UTF-8 encoded string.

となっているのです。つまりテキストしか送信できないということです。

では、AnyEvent::WebSocket::Connectionバイナリ送信に対応していないのかを調査しました。
AnyEvent::WebSocket::Connectionのsendメソッドの中を見てみると
Protocol::WebSocket::Frameオブジェクトを生成するようになっています。
この時の生成コードは

if(ref $message) {
$frame = Protocol::WebSocket::Frame->new(opcode => $message->opcode,
   buffer => $message->body, masked => $self->masked, max_payload_size => 0);
} else {
$frame = Protocol::WebSocket::Frame->new(buffer => $message,
masked => $self->masked, max_payload_size => 0);
}

つまり
引数の$messageはスカラー指定と、リファレンス指定のどちらかが選択できる。
リファレンスの場合、無名ハッシュリファレンスとし下記を指定します。
$message->opcode, $message->body
それぞれ、
Protocol::WebSocket::Frame->newの引数である無名ハッシュとして、opcodeとbufferをキーとする値になります。opcodeというのは、ウェブソケットフレームのヘッダー部に配置するもので、フレームの種類を表します。
これを使えばopcode=0x2(バイナリデータ)を指定でき、バイナリイメージデータを送信できそうです。但し、同上newに渡す引数で、
max_payload_size => 0
固定なので、Protocol::WebSocket::Frameでは、
ウェブソケットのmax_payload_sizeはデフォルトの65536バイトとなってしまうようです。
これではバイナリデータ(opcode=0x2)サイズとして足りないので、
上記AnyEvent::WebSocket::Connectionのsendメソッドの if 文の ref の場合のコードを下記に変更しました。

$frame = Protocol::WebSocket::Frame->new(opcode => $message->opcode,
buffer => $message->body, masked => $self->masked, max_payload_size => 10000000);

これで10MBまで送信可能になりました。

また、上記
AnyEvent::WebSocket::Connection
のperldocには

send
$connection->send($message);

Send a message to the other side. $message may either be a string (in
which case a text message will be sent), or an instance of
AnyEvent::WebSocket::Message.

とあり、sendメソッドの引数は、
string か AnyEvent::WebSocket::Messageオブジェクトと書いてあります。
そこでこの仕組みを利用するために、
Plack::App::WebSocket::Connectionのsendメソッドを下記の通り改造しました。

$self->{connection}->send($message);

 ↓

if(ref($message)){
$self->{connection}->send(AnyEvent::WebSocket::Message->new(
  opcode => ($message->{opcode}), body => ($message->{body})));
}else{
$self->{connection}->send($message);
}

これで、psgi側で下記のようにAnyEvent::WebSocket::Connectionオブジェクトのsendメソッドを呼ぶ時に、$messageがスカラー値の時はテキスト送信(opcode=1)、リファレンスにした時は
そのハッシュキーopcodeを指定した値でフレームを変更でき、bufferキーの値を送信データとすることができます。

##テキスト送信
$conn->send($text_data);
##バイナリ送信
my %message;
$messgae{opcode}=2;
$message{body}=$bin_data;
$conn->send(\%message);

という具合です。これで無事、AnyEventなウェブソケットサーバーをバイナリ送信対応とすることができました。