SVX日記

2004|04|05|06|07|08|09|10|11|12|
2005|01|02|03|04|05|06|07|08|09|10|11|12|
2006|01|02|03|04|05|06|07|08|09|10|11|12|
2007|01|02|03|04|05|06|07|08|09|10|11|12|
2008|01|02|03|04|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|
2021|01|02|03|04|05|06|07|08|09|10|11|12|
2022|01|02|03|04|05|06|07|08|09|10|11|12|
2023|01|02|03|04|05|06|07|08|09|10|11|12|
2024|01|02|03|04|05|06|07|08|09|10|11|12|
2025|01|

2024-12-28(Sat) WebSocketクライアントを実装する

  しばらく前にMezatalkというチャットツールを作り、職場で活用している。チャットツールといえばWebSocketだ。発言の送信や受信には必須の機能である。通常、発言はブラウザのJavaScriptから行われる。が、Ruby版のコマンド「wsclient」も用意してある。ボットに発言させたい場合などに使える。こんな感じだ。

res = system('./wsclient',
	'ws://127.0.0.1:33109/',
	"{:REQUEST=>'login', :TYPE=>'talk', :USER=>'user1', :ROOM=>'_t~room1'}@@login",
	"{:REQUEST=>'sentence'}@@Hello.",
)

  実際、先日に記事にしたXalebotは、その名の通りボットであり、問い合わせに対する回答案などをMezatalkに発言するという連携機能も備わっている。

  で、今回「特定の発言が行われたら、別のシステムでその発言を処理する」という機能を実装する必要が生じた。まずは、別のシステムから発言を取得できるようなAPIを実装し、WebSocket経由で発言を取得できるようにした。さらに、発言が行われた場合に、設定ファイル中に記述した関数を呼び出す機能を実装し、そこから「wsclient」を実行しようとした、のだが……それが、どうやっても動かない。普通にコマンドとして実行すれば動くのだが、設定ファイル中からだと動かない。にっちもさっちもよっちもごっちも動かない。ドハマリ。

  「wsclient」は「em-websocket-client」というRubyのライブラリを使っているのだが、見よう見まねで書いたコードなので、それ以上に追求のしようがない。うーむ、こうなったら、独自に実装するか。

  というわけで、RFCの6455「The WebSocket Protocol」を眺めつつ、チマチマと実装していく。HTTPで接続後、プロトコルを切り替えたり、クライアントからの送信内容にはマスクを施したり、なんかいろいろと珍しい処理がある。面倒クサいが面白い。面白いが面倒クサい。

  そしてデキたのがコチラです。

#!/usr/bin/env ruby
 
require './wshelper'
require 'timeout'
require 'socket'
 
wsh = WebSocketHelper.new(uri = ARGV.shift)
 
Timeout.timeout(3) {
    sock = TCPSocket.open(wsh.uri.host, wsh.uri.port)
    sock.syswrite(wsh.handshake)
    sock.sysread(9999)
 
    while(request = ARGV.shift)
        sock.syswrite(wsh.encode(request))
        puts('[%s]' % wsh.decode(sock.sysread(9999)))
    end
    puts('Closed.')
}
require 'uri'
 
class WebSocketHelper
 
    attr_reader :uri
 
    def initialize(uri)
        @uri = URI.parse(uri)
    end
 
    def handshake
        (<<REQ % [@uri.path, @uri.host, @uri.port, make_websocket_key]).gsub(/\n/, "\r\n")
GET %s HTTP/1.1
Host: %s:%s
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Key: %s
Sec-WebSocket-Version: 13
 
REQ
    end
 
    def make_websocket_key
        nonce = []; 4.times { nonce << rand(0xFFFFFFFF) }
        @websocket_key = [nonce.pack('N*')].pack('m0')
    end
 
    def encode(req)
        make_masking_key
        head(req) + payload(req)
    end
 
    def make_masking_key
        @masking_key = rand(0xFFFFFFFF)
    end
 
    def head(req)
        head = ''
 
        fopc = 0
        fopc += (fin = 1) << 7
        fopc += (opcode = 1)
        head << [fopc].pack('C')
 
        mplen = 0
        mplen += (mask = 1) << 7
        if((it = req.length) < 126)
            mplen += it
            head << [mplen].pack('C')
        elsif(it < 65536)
            mplen += 126
            head << [mplen, it].pack('Cn')
        else
            mplen += 127
            head << [mplen, 0, it].pack('CNN')
        end
 
        if(mask == 1)
            head << [@masking_key].pack('N')
        end
 
        head
    end
 
    def payload(req0)
        len0 = req0.length; req = req0.dup
        req << "\x00" while(req.length % 4 != 0)
        res = []; req.unpack('N*').each {|u32|
            res << (u32 ^ @masking_key)
        }
        res.pack('N*')[0, len0]
    end
 
    def decode(res)
        fopc = res.slice!(0, 1)
        mplen = res.slice!(0, 1).unpack('C')[0]
        if(mplen < 126 and mplen == res.length)
        elsif(mplen == 126 and (res.slice!(0, 2).unpack('n')[0]) == res.length)
        elsif(mplen == 127 and (res.slice!(0, 8).unpack('NN')[1]) == res.length)
        else
            raise('Unexpected.')
        end
        res
    end
end

  まぁ、ヤルことちゃんとヤッてない。んが、仕事はキッチリこなします。まるで、オレみたいなヤツだな。んが、やっぱり、設定ファイル中から呼ぶと動かない。にっちもさっちもよっちもごっちも動かない。な、なんでぇ?

  終いにはtcpdumpでパケットまで確認してしまう。要求は出ている。んが、応答が返らない。設定ファイル中から呼んだ場合だけ。なんだこれ。いや、正確にはタイムアウトした瞬間に応答が返る。なんだこれ。なんだこれ。なんだこれ。サーバ側の問題?

  サーバ側は「em-websocket」というRubyのライブラリを使っているのだが、見よう見まねで書いたコードなので、それ以上に追求のしようがない。うーむ、こうなったら、独自に実装するか……って、イヤ、それはオオゴトすぎるべ。さすがに、これまで4年近くも動いているコアの部分はイジるべきではないだろう。

  そこでようやく気がついた。サーバ側に追加した、このコード。

it = @configs[:post_paragraph_hook] and it.call(room)

  これ、connection.onmessageの延長、つまり、コールバック関数の中で動いている。そんなトコで、さらに「wsclient」で要求を出して応答を待ったって、サーバ側もそこで待っとるっちゅーねん。つまり、要求を処理するヤツが、要求を出して応答を待ってたって、応答するヤツはテメエ自身なんだから、応答が返るわけがない。ぷふゎぁ〜……。

  長らくプログラミングしているが、こんな状況は初めてだなぁ。じゃ、どうすりゃいいか。こうすりゃいいだけだ。

it = @configs[:post_paragraph_hook] and Thread.new {
    it.call(room)
}

  ちゅーわけで、動くようになった。結局、元の「wsclient」が使えたので、WebSocketクライアントの独自実装は徒労に終わった。でも、結果として目的は達成できた。結果オーライ。

  けどね。エンジニアの諸君には言うまでもないだろうが、技術力というのは膨大な徒労によって培われるものなのだ。見える成果だけを成果とするならば、それは当たり馬券以外には金を払わない、と主張するのと同じ。

  しかし、久々に悩ませられたなぁ。そのぶん、動いたときの嬉しさは大きかった。これだからプログラミングは、やめられまへんなぁ。