WebSocketでバイナリデータを送受信してみる
この記事はHTML5 Advent Calendarの8日目です。
いつの間にか手元の環境(Chrome17 dev + Node 0.6.3)においてWebSocketでバイナリデータが扱える様になっていたので何か作ってみようかと。
まず、NodeでWebSocketを使おうとしたらそれ用のライブラリを使う事になるのだが現時点でバイナリデータが扱えるのはWebSocket-Nodeのみだった。
当初はサーバーから画像のRAWデータをガンガンクライアントに送りまくるというのを作ろうとしたのだがnode-pngがNodeの0.6系に対応していなかったので断念。CanvasのgetImageDataで得られるデータをそのままサーバーに投げて、サーバー側で加工した物を受け取るサンプルを作った。
バイナリデータの送信
クライアントのコード(client.js)
var socket = null; function bootstrap() { // 適当な図形を描画 var c = document.getElementById('mycanvas'); var ctx = c.getContext('2d'); // (中略) // Socketの初期化 socket = new WebSocket('ws://localhost:8082'); socket.binaryType = 'arraybuffer'; socket.onopen = function() { send(ctx); } socket.onmessage = handleReceive; }; function send(ctx) { // RAWデータをそのまま送信 var data = ctx.getImageData(0, 0, 200, 200).data; var byteArray = new Uint8Array(data); socket.send(byteArray.buffer); }
CanvasのgetImageDataで得られるデータは8bit4チャンネルのRGBAの数値配列。1ピクセルあたり要素は4つ、それぞれが0-255の値なので、これをそのままUint8Arrayのコンストラクタに渡した。見なれないのは次の行ですね。
socket.binaryType = 'arraybuffer';
サーバーでRAWデータを加工
サーバーのコード(server.js)
#!/usr/bin/env node var WebSocketServer = require('websocket').server, http = require('http'), fs = require('fs'); // (中略) wsServer = new WebSocketServer({ httpServer: server, // デフォルトでは65535byte以上受けつけないので // 値を増やしてみる maxReceivedFrameSize: 0x1000000, autoAcceptConnections: false }); wsServer.on('request', function(request) { var connection = request.accept(null, request.origin); connection.on('message', function(message) { if (message.type === 'utf8') { // テキストデータを受信した場合 } else if (message.type === 'binary') { // バイナリデータを受信した場合 var data = message.binaryData; var len = data.length; // 受信したRAW画像をグレースケールにする var buf = new Buffer(len); for (var i = 0; i < len; i+=4 ) { var r = data.readUInt8(i); var g = data.readUInt8(i+1); var b = data.readUInt8(i+2); var y = Math.floor((77*r + 28*g + 151*b)/255); // Canvasにそのまま投入するために // 4チャンネル8ビットのRGBAにする var v = y + (y << 8) + (y << 16) + (0xFF << 24); buf.writeInt32LE(v, i); } // グレースケールにした物をクライアントに送信する connection.sendBytes(buf); } }); connection.on('close', function(reasonCode, description) { // (略) }); });
受信したデータを8bitずつ読んで、それぞれRGBAとして処理する。加工したデータはNodeのBufferオブジェクトに値を書き込んで connection.sendBytesメソッドに渡す。writeInt32LEを使ってバッファに書き込んでいるのはビット演算子が使いたかっただけ。writeUInt8を4回やってもいい。
バイナリデータの受信
再びクライアントのコード(client.js)
WebSocketのonmessageイベントハンドラにセットした関数。
function handleReceive(message) { // 受信したRAWデータをcanvasに var c = resultCanvas = document.getElementById('result'); var ctx = c.getContext('2d'); var imageData = ctx.createImageData(200, 200); var pixels = imageData.data; var buffer = new Uint8Array(message.data); for (var i=0; i < pixels.length; i++) { pixels[i] = buffer[i]; } ctx.putImageData(imageData, 0, 0); }
サーバーでやったのと同様に8bitずつ値を取得したかったので、Uint8Arrayを使った。
Sencha Touchのファイルサイズを150KByte小さくする方法
この記事はJavaScript advent calender 2011 Frameworkコースの4日目です。
世間ではjQuery Mobileが流行っていますね、それとは関係なくここではSencha Touchのネタを書きます。対象読者は既にSencha Touchを使っている人です。*1
さて、Sencha Touchはモバイル向けフレームワークですがMVCの仕組みを提供するモジュールや様々なUIパーツが含まれているため、ファイルサイズは圧縮状態のJSファイルだけで374KByte (gzip圧縮後は101KByte)もある。さらに標準テーマを含んだCSSファイルは143KByte*2。jQuery Mobileを使う時に必要なファイルと比較して2倍程度のサイズになる。
sencha-touch.jsから使わないクラスを除く
Sencha TouchのダウンロードパッケージにはJSBuilderというビルドツールが含まれている*3。これはSencha自体をビルドするのに使われているツールで、sencha-touch.jsb3というファイルにビルド構成が定義されている。先程述べた通りSenchaには様々な機能が含まれているため、実際には使わないクラスがかなりあるだろう。PhoneGapで動かすアプリであれば、WebStorageProxy, LocalStorageProxy, SessionStorageProxyは使わないだろうし、xml形式のデータを扱わないのであれば XmlWriter, XmlReader, XmlStore は不要。EventSimulator, EventRecorder はリリースファイルには含めないと思う。そんな感じでアプリで使っていないUIコンポーネント、データプロキシ、ストアをビルド構成から外していけば60KByteは減らせる。サブクラスを残して親クラスのみを消してしまうと動作しないので、継承関係は把握しておこう。
JSBuilderの起動は、ダウンロードしたディレクトリで
jsbuilder/JSBuilder.sh -p sencha-touch.jsb3 -d build
とやる。新たにbuildディレクトリが切られてファイルが出力される。
sencha-touch.cssから使わないスタイルを除く
Sencha TouchではCSSのビルドにcompassを使っている。CSSの定義はダウンロードしたファイルの resources/sass 以下に配置されている。標準テーマをそのまま使う定義は sencha-touch.scss に記述されている。内容は次の通り
@import 'sencha-touch/default/all'; @include sencha-panel; @include sencha-buttons; @include sencha-sheet; @include sencha-picker; @include sencha-tabs; @include sencha-toolbar; @include sencha-toolbar-forms; @include sencha-carousel; @include sencha-indexbar; @include sencha-list; @include sencha-list-paging; @include sencha-list-pullrefresh; @include sencha-layout; @include sencha-form; @include sencha-msgbox; @include sencha-loading-spinner;
見ての通り、UIコンポーネント毎にスタイルを生成しているのがわかる。使っていないUIコンポーネントの行はコメントアウトすれば良い。
scssからcssの生成は resources/sass に移動して
compass compile
する。Compassをインストールしていない場合は gem install compass しておく。
さらに次の通り、デフォルト生成をOFFにすると必要なアイコンスタイルのみを含める事ができる。
@import 'sencha-touch/default/all'; // 必要なスタイルのみを生成したいので、デフォルト生成をOFFにする $include-default-icons: false; $include-toolbar-uis: false; @include sencha-panel; @include sencha-buttons; // 中略 @include sencha-msgbox; @include sencha-loading-spinner; // 必要なアイコンスタイルのみを生成する @include pictos-iconmask('action'); @include pictos-iconmask('add'); @include pictos-iconmask('delete'); @include pictos-iconmask('settings'); // タブバーは ui=light でのみ使用する @include sencha-toolbar-ui('light', $base-color);
アイコン用のスタイルはbase64で画像がインライン展開されているのでそれなりにでかい。どの変数をいじればファイルが小さくなるかは resources/themes/stylesheets/sencha-touch/default 以下のファイルを見ればわかるだろう。上の例だと、143KByte → 53KByte に減らす事ができた。
まとめ
アプリケーションがどれだけSenchaの機能を使っているかに依るが、大体100~150KByteは減らせるのではないかと思う。
PhoneGAPを使ったアプリケーションのコードをオンラインパッチで修正する
仕事でPhoneGAP使い始めて1ヶ月ぐらい経った。PhoneGAPを使ったiOSアプリケーションをリリース後にちょっと直したい時、AppStoreに再申請するのは面倒くさい。なので、なんらかの方法でパッチを当ててクライアントの動作を変更するのがセオリーだと思っているのだが、どうやるのがBestなのかイマイチわかっていない、とりあえず1時間ぐらい考えた結果を書いておく。
要件として一度取得したパッチは、次回以降アプリが起動した時には当っている状態にしたい。もちろん端末がオフラインの時にアプリが起動されたとしても同様の動作をしなければならない。あと、iOSのUIWebViewはHTML5のApplication cache APIが使えないので、別の方法を探す。
クライアントとサーバー、それぞれに持つ物
- クライアント
- 現在適用されているパッチバージョン、1stリリース時には0
- 現在のソースリビジョン、svnとかのrevision
- 保存したパッチファイル
- サーバー
- 最新のパッチファイル(patch.js)
- 最新のパッチバージョン
パッチファイルの取得
なんらかのタイミング。applicationDidBecomeActive とか online とか1時間に一回とか適当に。
サーバーに最新のパッチバージョンを問いあわせて、自分の奴より新しいのがあったら取りにく。
パッチの適用
サーバーから取得したパッチをevalする。危ないのでパッチファイルはHTTPSで配布する。
ファイルはこんな感じ、Sencha Touchを使っている場合はExt.overrideという対象のクラスのメソッドを書き変えるための関数が用意されているので、それを使うとそれっぽい。
if (App.revision < 500) { // クライアントのソースRevisionが500未満の場合は // App.views.MainPanel#handleTapBackButtonを修正する Ext.override(App.views.MainPanel, { handleTapBackButton: function() { // // 新しい内容 // } }); } App.currentPatchVersion = 2;
パッチの保存
サーバーから取得したパッチファイルは、次回以降のアプリケーション起動時にも使うためファイルシステムに保存する。ここはPhoneGAPのfileWriterを使う。
function savePatch(patchScriptStr) { window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, gotFS, fail); function gotFS(fileSystem) { fileSystem.root.getFile("patch.js", {create: true}, gotFileEntry, fail); } function gotFileEntry(fileEntry) { fileEntry.createWriter(gotFileWriter, fail); } function gotFileWriter(writer) { writer.onwrite = function(evt) { console.log("write success"); }; writer.write(patchScriptStr); } }
保存したパッチのロード
ファイルシステムに保存したパッチはindex.htmlからスクリプトタグで読む様にしておく。こうするとonDevicecReadyまで待ってfileReaderを使って取り出すなんて事をしなくても良いし、アプリケーションの初期化処理の上書きもできる。
<!-- アプリケーションのコード --> <script type="text/javascript" src="application.js"></script> <!-- パッチファイル --> <script type="text/javascript" src="../../Documents/patch.js"></script>
うごくけど
これがスマートなのかどうか判断がつかない。
来年のはHuman-Computer InteractionとGame Theoryのクラスが気になる
スタンフォード大学のオンラインクラス2012年スタートの奴、もうサイトできてますね。気になっているのはHuman-Computer InteractionとGame Theoryのクラス。
現在私はMachine LearningとAIのクラスを受けているのですが、課題提出が厳しくて仕事とこれの勉強以外何もできない生活が続いてます……。数式アレルギーが治ったのは良い事なんですがね。(数式見てそれを実装して解を出すという課題がほとんど)
Xtion Pro live用にOpenNI開発環境をセットアップする
追記:unstableブランチへの切り替えが抜けていたので追記しました。
OpenNI本来の使い方をすべくXtion Pro liveを輸入してMacに接続した。送料と関税込みで25000JPY也。早く日本国内で買える様になって欲しい所。大きさはKinectの半分くらい、すごく軽い。
Macに接続した所
- VendorID: 0x1d27
- ProductID: 0x0600
OpenNIで実装されていない事をやるにはこの値が必要なのでメモっておく。Kinectはホストから見て3つの別々のデバイス(アレイマイク、チルトモーター&加速度センサ、距離画像センサ)として認識されたのだが、Xtion Pro liveでは一つのデバイスとして認識される。試しに以前構築したKinect-OpenNI開発環境のままOpneNIのサンプルを起動してみる。
$ ./NiViewer Device: PrimeSense/SensorKinect/5.0.1.32: The device is not connected!
当然SensorKinectはKinectを探しに行くので、Device not foundになる。
OpenNI-Kinect開発環境のクリーンアップ
$ sudo ${NITE}/uninstall.sh $ sudo ${SensorKinect}/Platform/Linux-x86/Redist/install.sh -u $ sudo ${OpenNI}/Platform/Linux-x86/Redist/install.sh -u
これだけ
OpenNIとPrimeSensorの導入
# OpenNI latest unstable のビルドとインストール git clone https://github.com/OpenNI/OpenNI.git git checkout unstable cd OpenNI/Platform/Linux-x86/CreateRedist ./RedistMaker cd ../Redist sudo ./install.sh # PrimeSensor latest unstable のビルドとインストール git clone https://github.com/PrimeSense/Sensor.git git checkout unstable cd Sensor/Platform/Linux-x86/CreateRedist ./RedistMaker cd ../Redist sudo ./install.sh
NITEの導入
NITEはgithubから落せないのでopenni.orgからダウンロード。しばらく見ないうちにダウンロードページがずいぶんと変っている。ライセンスキーもダウンロードページから消えた??
# Nite latest unstable のインストール $ mkdir NITE $ cd NITE $ mv ~/Downloads/nite-bin-macosx-v1.4.2.4.tar.bz2 . $ tar -zxvf nite-bin-macosx-v1.4.2.4.tar.bz2 $ sudo ./install.sh #ライセンスキーの入力が求められるので、以前使っていた物を入力
腕の円運動を検出するサンプルを起動してみる
$ cd Samples/Bin/Release
$ ./Sample-CircleControl
Jasmineで非同期処理のテスト
Jasmineの使い方を覚えるために複数のWebSocketコネクションが協調して動作する、というよくありがちなシナリオのテストケースを書いてみた。非同期テストの書き方のページを読んでもよくわからなったので、最初わりと苦戦した。
メモ
- waitsForは、渡した関数がtrueを返すか指定したタイムアウトまで待つ
- runsに渡した関数は遅延評価される
- waitsForの結果を待つ場合はrunsを使う
- setUpとtearDownが無いので、runsを最初と最後に使う
- waitsForを含むit以降のdescribe内のitは遅延評価される
コード
Jasmine以外にライブラリはSenchaとSocket.ioを使っている。
var appServerUrl = 'http://dev.hagino3000.com:8888'; describe("Chat Test", function() { var serverResponse = null; describe("サーバーからWebSocket接続先を取得する", function() { it("サーバーが応答する", function() { var spyF = jasmine.createSpy(); var callbacks = { onFailure: spyF, onSuccess: function(res) { serverResponse = res; } } Ext.Ajax.request({ url: appServerUrl, method: 'POST', jsonData: JSON.stringify({ method: 'getWebSocketUrl' }), success: callbacks.onSuccess, failure: callbacks.onFailure }); waitsFor(function() { return !!serverResponse; }, 'サーバーが応答を返す', 5000); runs(function() { // 失敗時のコールバックが呼ばれていない事を確認する expect(spyF).not.toHaveBeenCalled(); }); }); }); describe("サーバーレスポンスに接続先が含まれている", function() { var response; // この行は即時実行される runs(function() { // setUp相当の処理 // 一つ前のテストケースの完了後に実行される response = JSON.parse(serverResponse.responseText); }); it("エラーになっていない事", function() { expect(response.error).toBeNull(); }); it("WebSocketサーバー接続先が含まれる", function() { expect(response.result.wsServerUrl).not.toBeNull(); }); }); describe("WebSocektクライアント同士でやりとりができる", function() { var socket1, socket2, wsServerUrl; var socketOption = { // こいつをtrueにしないとサーバーからは同じ接続に見える 'force new connection': true } runs(function() { // setUp var response = JSON.parse(serverResponse.responseText); wsServerUrl = response.result.wsServerUrl; }); it("複数クライアントがWebSocketサーバーに接続ができる", function() { var ownerConnected = false, guestConnected = false; socket1 = io.connect(wsServerUrl, socketOption); socket1.on('connect', function(){ ownerConnected = true; }); socket2 = io.connect(wsServerUrl, socketOption); socket2.on('connect', function(){ guestConnected = true; }); waitsFor(function() { return ownerConnected && guestConnected; }, 1000, "両方の接続が確立する"); }); it("クライアント同士でやりとりができる", function() { var receivedMessage; var receivedOwnMessage = jasmine.createSpy(); socket1.on('message', function(msg) { receivedMessage = msg; }); socket2.on('message', receivedOwnMessage); socket2.emit('message', { text: 'Hello world', hoge: 'fuga' }); waitsFor(function() { return !!receivedMessage; }, 1000, "Client 1から送信したメッセージがClient 2に到達する"); runs(function() { // 自信が送信したメッセージは受信していない expect(receivedOwnMessage).not.toHaveBeenCalled(); // 受信データのチェック expect(receivedMessage.text).toBe('Hello world'); expect(receivedMessage.hoge).toBe('fuga'); }); }); it("片方が接続を切った場合、もう片方に通知が行く", function() { var gotChatLeaveSignal; socket1.on('chat_leave', function() { gotChatLeaveSignal = true; }); socket2.disconnect(); waitsFor(function() { return gotChatLeaveSignal; }, 1000, "切断通知の受信"); }); it("切断時にコールバックが呼ばれる", function() { var disconnectCallback = jasmine.createSpy(); socket1.on('disconnect', disconnectCallback, this); socket1.disconnect(); waits(50); runs(function() { expect(disconnectCallback).toHaveBeenCalled(); }); }); runs(function() { // tearDown if (socket1) {socket1.disconnect();} if (socket2) {socket2.disconnect();} }); }); });
ちょっと使ってみたが、指定した関数が呼ばれた or 呼ばれていないかがチェックできるspyの使い勝手が良い。タイプ数はqunitに比べて増えるけど説明を書かざるをえないので何のテストを書いたのか後でわかりやすい。