WebSocketを試してみる。その1。
ちょっと、WebSocketを試してみたくてしばらく試行錯誤してみた。
とりあえず、言語にはこだわるつもりはなかったのだけれども、サーバーはPython,クライアントはC#で記述。
Pythonはしばらくぶり、かつ初心者なので思い出しながら、簡単なチャットソフトを作成してみる。WebSocketは規格化されているので、言語が違ったって特に問題ない。ただ、普通のソケットプログラミングとは全くの別物なのでそこは注意。
やりたいこと。
・ユーザーは名前を付けられる。
・ユーザーは自分の発言に色を使用出来る。
・現在ログインしているユーザー一覧を表示する。なお、ユーザーの増減が生じた場合は自動で更新されること。※ただし異常終了は除く。
・クライアントが異常終了しても、サーバーが落ちないこと。
・MessagePackを通信フォーマットに使用したい。
C#版のWebSockets
C#ではHTML5Labsで公開されているWCF WebSocketsを使用。
こちらの方のサイトを参考に、同じサンプルプログラムを作成する。
present .NET で WebSocket 使うなら WCF WebSockets で FA
サーバープログラム
さて、C#のWebSocketsサーバーはサンプル通りにやっているのだけれど、クライアント(C#)がサーバー(C#)に接続後、SendMessage()を呼ぶとサーバー側でソケットが閉じられてしまうという事案が発生。
ちょっと何言っているのかわかんなーい状態。ちょうどOSをクリーンインストール後に試したりしたけど、やっぱり同じ現象。ちーん。
そこで、サーバー側はPythonで実装した。
Python版WebSocketsの実装はgeventwebsocketを使用。例のごとく、先駆者様のサイトを参考にやってみると、うまくいったぁ。
サーバーソース
#!/usr/bin/env python2.6 # -*- coding:utf-8 -*- import os import random import geventwebsocket from gevent import pywsgi from geventwebsocket.handler import WebSocketHandler websocketCons = [] userinfo = {} def send_userinfo(): sendstr = "UserInfo:" for x in userinfo: sendstr += userinfo[x] + "," i = 0 memlen = len(websocketCons) while i < memlen: try: print "send str=" + sendstr websocketCons[i].send(sendstr) except: print "remove_member ex1" i += 1 def remove_member(ws): try: websocketCons.remove(ws) del userinfo[id(ws)] except: print "remove_member ex2" print "remove memlen = " + str(len(websocketCons)) send_userinfo() def connection_handle(wenv): ws = wenv['wsgi.websocket'] websocketCons.append(ws) print "add memlen = " + str(len(websocketCons)) while True: msg = ws.receive() if msg is None: break if isinstance(msg,unicode): if msg[:5] == "Login": print "New>" + msg sp = msg.split(':') userinfo[id(ws)] = sp[1] + ";" + sp[2] send_userinfo() i = 0 delsocket = [] memlen = len(websocketCons) while i < memlen: try: websocketCons[i].send(msg) except: delsocket.append(websocketCons[i]) i += 1 for x in delsocket: remove_member(x) remove_member(ws) def websocket_app(environ, response): path = environ["PATH_INFO"] res_ConText = [("Content-Type", "text/html")] if path == "/": response("200 OK", res_ConText) return open('./index.html').read() elif path == "/chat": connection_handle(environ) else: response("404 Not Found",res_ConText) return "======= Not Found! =======" if __name__ == "__main__": server = pywsgi.WSGIServer(('192.168.1.22', 12345), websocket_app, handler_class=WebSocketHandler) server.serve_forever()
htmlの動作は割愛。
動作としてはサーバーに新しいユーザーがログインした際に、ユーザーが送信してくるIDとWebSocketsのインスタンスIDを関連付けて(dictで)覚えておき、ユーザーの増減が発生する度に全ユーザーにユーザー情報を送信するようにした。ユーザーが”Login:”を先頭で送ってきた文字列の場合のみユーザー情報を取得するようにしていて、チャットで使用されるデータはバイナリなので、基本、そのままブロードキャストするイメージ。
ユーザーがcloseをしないで異常終了するとそのソケットに対しての操作は無効になってしまう。その際のcloseイベントをどこに記述していいのかわからないので、サーバー側ではメッセージをユーザに送信するイベントが発生した際に、無効なソケットを収集したあとでremoveするという処理を記述。ちなみに、異常終了を起こしたソケットに対しての例外処理は各ユーザー毎のインスタンス分発生することになる。for文を使用しないでインデックス(変数i)によるループを使用しているのは、途中で例外が発生した場合に最後の要素まで処理を実行させるようにするため。
しかし、もうちょっとなんとかならなかったのだろうか、このソース。
using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows.Forms; using System.ServiceModel.WebSockets; using System.Diagnostics; using ExtensionsClass; namespace ChatForm { public partial class Form1 : Form { WebSocket _ws; Guid _guid; bool _userClose = false; private Dictionary<string, string> _userInfo = new Dictionary<string, string>(); public Form1(int no) { InitializeComponent(); _guid = Guid.NewGuid(); dataGridView1.Rows.CollectionChanged += (s,e)=> dataGridView1.FirstDisplayedScrollingRowIndex = dataGridView1.Rows.Count - 1; TbxName.Text = TbxName.Text + no.ToString(); } private void BtnLogin_Click(object sender, EventArgs e) { WebSocketsInit(); } private void WebSocketsInit() { _ws = new WebSocket(); _ws.Url = TbxServerAddr.Text; _ws.OnData += (s, e) => { if (e.BinaryData == null) { var str = e.TextData; Action UpdateNameList = () => { listBox1.Items.Clear(); foreach (string name in _userInfo.Values) { listBox1.Items.Add(name); } }; if (str.IndexOf("UserInfo:") == 0) { var sp = str.Replace("UserInfo:","").Split(','); _userInfo.Clear(); foreach (var name in sp.Where(p=>p.Length != 0)) { var n = name.Split(';'); _userInfo.Add(n[0], n[1]); } UpdateNameList(); } } else { Trace.WriteLine("Rev Bindata"); var data = e.BinaryData.MessagePack2Object<Message>(); dataGridView1.Rows.Add(data.MESSAGESTRING); dataGridView1.Rows[dataGridView1.Rows.Count - 2].Cells[0].Style.ForeColor = Color.FromArgb(data.STRINGCOLOR); } }; _ws.Open(); _ws.OnOpen += (s, e) => { Trace.WriteLine("OnOpen"); _ws.SendMessage("Login:" + _guid.ToString() + ":" + TbxName.Text); }; _ws.OnClose += (s, e) => { Trace.WriteLine("Socket is close"); if (!_userClose) { MessageBox.Show("サーバーエラーが発生しました"); } }; this.FormClosed += (s, e) => { if (_ws.ReadyState != WebSocketState.Closed) { _ws.Close(); } }; } private int _msgNo = 0; private void TbxSend_Click(object sender, EventArgs e) { var str = TbxName.Text + ":" + (_msgNo++).ToString() + ":" + TbxMessage.Text; var data = new Message(); data.GUID = _guid.ToString(); data.MESSAGESTRING = str; data.STRINGCOLOR = pictureBox1.BackColor.ToArgb(); _ws.SendMessage(data.Object2MessagePack()); } private void BtnLogout_Click(object sender, EventArgs e) { _ws.Close(); _userClose = true; } private void pictureBox1_Click(object sender, EventArgs e) { var colordig = new ColorDialog(); colordig.ShowDialog(); pictureBox1.BackColor = colordig.Color; } } }
using ExtensionsClassは以前作成したMessagePack用の拡張メソッドを使用。
サーバーへの接続確認はWebSocketクラスのReadyStateを参照することで判定可能だが、接続までの間ポーリングするよりOnOpen()を使用したほうが効率が良い。
_ws.OnOpen += (s, e) => { Trace.WriteLine("OnOpen"); _ws.SendMessage("Login:" + _guid.ToString() + ":" + TbxName.Text); };
メッセージ送受信用クラス
チャットのデータはMessageクラスをMessagePackでバイナリ化して送信する。
public class Message { /// <summary> /// ユーザ識別ID /// </summary> public string GUID { get; set; } /// <summary> /// チャットデータ /// </summary> public string MESSAGESTRING { get; set; } /// <summary> /// 文字色 /// </summary> public int STRINGCOLOR { get; set; } }
メッセージ送信時のMessagePackを使用したバイナリ化。
バイト配列の作成とか、わざわざコーディングしないで送信!!
var str = TbxName.Text + ":" + (_msgNo++).ToString() + ":" + TbxMessage.Text; var data = new Message(); data.GUID = _guid.ToString(); data.MESSAGESTRING = str; data.STRINGCOLOR = pictureBox1.BackColor.ToArgb(); _ws.SendMessage(data.Object2MessagePack());
バイナリメッセージ受信時のMessagePackを使用したバイナリデータからMessageクラスへの変換。
受信メッセージ(バイト配列)をMessageクラスへの変換も楽々。あー、これがしたかったんだよなー。
if (e.BinaryData == null) { /// (略) } else { Trace.WriteLine("Rev Bindata"); var data = e.BinaryData.MessagePack2Object<Message>(); dataGridView1.Rows.Add(data.MESSAGESTRING); dataGridView1.Rows[dataGridView1.Rows.Count - 2].Cells[0].Style.ForeColor = Color.FromArgb(data.STRINGCOLOR); }
実行画面(クライアント)
実行画面(サーバログ)
user@ubuntu:python server.py add memlen = 1 New>Login:9e8daa1c-f4c5-4a3a-93cb-5fed991b845f:名無しさん0 send str=UserInfo:9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無しさん0, add memlen = 2 New>Login:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf:名無しさん1 send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無しさん0, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無しさん0, add memlen = 3 New>Login:66401636-cfae-49d6-bb3f-146688fe4542:名無しさん2 send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0, add memlen = 4 New>Login:d88afe91-17f0-4a84-bc9a-79f1966b319c:名無しさん3 send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0,d88afe91-17f0-4a84-bc9a-79f1966b319c;名無しさん3, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0,d88afe91-17f0-4a84-bc9a-79f1966b319c;名無しさん3, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0,d88afe91-17f0-4a84-bc9a-79f1966b319c;名無しさん3, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0,d88afe91-17f0-4a84-bc9a-79f1966b319c;名無しさん3, remove memlen = 3 send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0, send str=UserInfo:b78c2e4d-107c-43f3-a68b-9a2a65dea8cf;名無しさん1,66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無し さん0, 192.168.1.7 - - [2013-01-08 18:40:58] "GET /chat HTTP/1.1" 101 - - remove memlen = 2 send str=UserInfo:66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無しさん0, send str=UserInfo:66401636-cfae-49d6-bb3f-146688fe4542;名無しさん2,9e8daa1c-f4c5-4a3a-93cb-5fed991b845f;名無しさん0, 192.168.1.7 - - [2013-01-08 18:41:00] "GET /chat HTTP/1.1" 101 - -
まとめ
geventwebsocketを使用した今回のサーバーはユーザからデータを受信するとそのデータをすべてのユーザに送信する仕組みなので、データの中身の処理はすべてクライアント側に記述できるという点で楽だった(C#だから)。また、通信フォーマットにMessagePackを使用できたのも面白かった。これでファイルや画像などのデータも楽にできる。
今回はちょっと試してみるというところで、なかなかおもしろかったので、あとで負荷分散とかいろいろ試してみたい。
今回作成したソースはこちら。
※ System.ServiceModel.WebSocketsとMessagePackの参照が必要。