« 2010年03月 | メイン | 2010年05月 »

2010年04月 アーカイブ

2010年04月06日

WebSocketを使ってリモートデスクっぽいVNCっぽいWebSocketRemoteというものを作ってみました

(2010/10/07 追記:デモを作成しました。)
(2010/04/09 追記:タイトル内のVNCをSVNと間違えていたのを修正しました。)
次世代のWebの規格としてWebSocketというものがあります。WebSocketは、AjaxでもCometでもないサーバ-クライアント間の新しい通信方法です。通常のWebアクセスや、Ajax・CometはHTTPを使用していますが、WebSocketはHTTPではありません。接続のたびに接続のリクエストが発生するHTTPと違い、WebSocketはとても高速で、同時に複数の接続も可能となっています。遅延も小さく比較的容量の大きいデータも高速に転送できるため、リモートデスクトップのようなものが作成できないかと思い、WebSocketRemoteというものを作ってみました。ブラウザには、プラグインやFlash、Javaアプレット等は一切不要となっています。
作成の経緯
以前、「Ajax Mouse」というものを作成しました。
一応動作するのですが、XMLHttpRequestを使用しているため、遠隔操作に利用するには遅過ぎました。Javaのサーバの知識もJavaの高速化の知識もなかったため、改善を諦めていました。しかし、HTML5に関連した技術であるWebSocketというものを利用すれば、実用レベルまで引き上げることができるのではと考えました。ところが、WebSocketは現時点ではまだドラフト版のためか、Google Chromeでしか使用できません(SafariやFirefoxの次のバージョンで実装されるようです)。iPhone/iPod touchではなく、デスクトップ上のブラウザで実行するのだから、ついでに画面も転送してリモートデスクトップやVNCのように使えるようにならないかと考えました。
技術
クライアント側に要求されるのはWebSocketが使えるブラウザだけです。サーバ側は、JavaからGUIを操作できるRobotというクラスと、Java実装のサーバでWebSocketを実装しているJettyを利用しています。
サーバからクライアントへの画像の転送は、「Robotで画面キャプチャ => Base64化 => 分割してWebSocketで転送 => ブラウザ側で連結し表示」と処理しています。クライアントからサーバへのイベントの転送は、「JavaScriptイベント => WebSocketで転送 => Robotで操作」となっています。
転送しているイベントは、現バージョンではマウスの操作だけです。これは、ブラウザ側でキーイベント時のキーがまともに取得できないためです。特に、うまいクロスブラウザへの対応方法が不明のため、他の主要ブラウザがWebSocketを実装するまで保留とします。(文字入力だけであれば、クロスブラウザ対応ができなくもないのですが、カーソル操作もできない可能性があります。)
ライセンス
JettyとApache CommonsのCommons CodecがApache License Version 2.0を採用しているため、WebSocketRemoteもApache License Version 2.0とします。(ただし、JettyはEclipse Public License 1.0とのデュアルライセンスです。)
ダウンロード
GitHubからダウンロードして下さい。現バージョンは「WebSocketRemote-0.0.1-bin.zip」です。(2010/04/08 追記:バージョンは0.0.4に上がっています。)
実行方法
サーバにはJavaVMが必要です。クライアントにはGoogle Chromeをインストールして下さい。サーバとクライアントは別の端末を使用して下さい。(同一の端末で実行すると、ブラウザのウィンドウ上にカーソルがのった瞬間に、カーソルが別の位置に移動してしまいます。)
サーバとクライアント間は有線LAN環境をお勧めします。(無線LANでも動かないことはないのですが、とても実装に耐えるような速度ではありません。)
ダウンロードしたWebSocketRemoteを解凍します。サーバ側でWebSocketRemoteを実行して下さい。
Windows用にexeファイルが、Mac OS X用にはdmgファイル内にappファイルがあります。Linuxの場合はjarファイルを実行して下さい。
クライアントのGoogle Chromeで、「http://[サーバのアドレス]:40320/」にアクセスして下さい。(現バージョンではポート番号は固定です。)
クライアントのブラウザのウィンドウ内に、サーバのデスクトップが表示され、マウス操作が可能になります。ブラウザのウィンドウよりもサーバのデスクトップ領域が広い場合、カーソル位置にあわせて画面がスクロールします。
セキュリティ
認証等の機能は全く実装していません。WebSocketRemoteを実行していると、他の端末からいつでも画面を表示する事が可能になります。
現時点で未対応の主な機能と問題点
キーボード操作に対応していません。うまい実装方法があれば教えて下さい。
環境がないためマルチディスプレイに未対応です。対応方法は一応考えています。モニタもあります。でも、ケーブルがありません。ケーブル購入後に実装する予定です。(2010/04/08 追記:バージョン0.0.3でマルチディスプレイに対応しました。)
実装方法が悪いのか、技術的にそういうものなのかはわかりませんが、サーバ側のCPU占有率が90%にもなります。(2010/04/08 追記:バージョン0.0.4で、画像を処理が重いPNGから比較的処理が軽いJPEGに変更することで60%まで落としました。次に重い処理はキャプチャのため、これ以上処理を軽くするのは困難かもしれません。)(2010/04/10 追記:バージョン0.0.5で、キャプチャの処理時間に応じてsleepに指定する時間を変動するようにしました。この修正によりCUP占有率が35%前後になりました。)
謝辞
WebSocketRemoteのアイディアを形にできたのは、keisukenさんのおかげです。keisukenさんには、不足している多くのスキルの補って頂きました。本当にありがとうございます。

2010年04月10日

Server.addHandlerの無くなったJetty7で複数のHandlerを追加する方法

WebSocketRemoteでは、HTTPによるアクセスとWebSocketによるアクセスをひとつのJettyのインスタンスで処理しています。
このような処理をweb.xmlを使わずコードだけで行なう場合、Jetty6では「Server.setHandler」と「Server.addHandler」を使い、複数のHandlerを追加する方法が一般的だったようです。ところがJetty7ではServer.addHandlerが使えなくなっており、WebSocketが使えるJetty7が必須のWebSocketRemote作成中に愕然としてしまいました。
Javaの世界はあまり詳しくないので間違いが含まれる可能性がありますが、日本語の情報が見当たらなかったので簡単にまとめてみます。コードは最後に載せているので、そちらをご覧下さい。

WebSocketRemoteでは、HTTPアクセスとWebSocketアクセスを以下のように処理しています。
・「http://[アドレス:port]/」にアクセスされた場合、jarファイル内のリソースのhtmlディレクトリにあるhtml等を使用する
・「ws://[アドレス:port]/ws/」にアクセスされた場合、「WebSocketServlet」を継承した「WSServlet」で処理を行なう

前者には、リソースに直接アクセスするための「ResourceHandler」を利用しました。リソース内のhtmlディレクトリのURLは「this.getClass().getClassLoader().getResource("html").toExternalForm()」のように取得しました。
後者には、「WebSocketServlet」を継承した「WSServlet」を「ServletHolder」でラップし、さらに「ServletContextHandler」で包み、WebSocketに対応したHandlerを作成しました。この際に「ws://[アドレス:port]/ws/」でアクセスされた時に使用されるように指定しています。
上記ふたつのHandlerを「HandlerList」に追加し、それをServerにセットします。
最後に、複数のプロトコルに対応したServerを起動しています。
src/main/java/com/kanasansoft/WebSocketRemote/WebSocketRemote.java(説明用に一部改変)
Server server = new Server(40320);

ResourceHandler resourceHandler = new ResourceHandler();
String htmlPath = this.getClass().getClassLoader().getResource("html").toExternalForm();
resourceHandler.setResourceBase(htmlPath);

WSServlet wsServlet = new WSServlet(this);
ServletHolder wsServletHolder = new ServletHolder(wsServlet);
ServletContextHandler wsServletContextHandler = new ServletContextHandler();
wsServletContextHandler.addServlet(wsServletHolder, "/ws/*");

HandlerList handlerList = new HandlerList();
handlerList.setHandlers(new Handler[] {resourceHandler, wsServletContextHandler});
server.setHandler(handlerList);
server.start();

2010年04月11日

JettyのWebSocketを使って大容量データを高速に送信するコツ

大容量と言っても1MB以下のデータですが...。
あくまでWebSocketRemote作成中に試行錯誤してうまくいった方法です。他に良策があるかもしれないですが、書き留めておきます。掲示しているWebSocketRemoteのコードはバージョン0.0.6時点のものです。
概要
現在のWebSocketの仕様では文字列しか送信できないようです。このため、画像データをBase64化して送っています。WebSocketRemoteの開発初期の実装では、一定時間間隔でデータを送信していましたが、一部のデータが欠損したり順番が入れ替わったような動きをしていました。どうも内部的にはデータを分割しているようです。(詳細な挙動は未調査です。)
また、WebSocketが高速なのは接続をはりっぱなしにするため、コストの高いコネクション処理が一度だけなのと、データ送信を複数同時に行なえるからだそうです。
データ順の入れ替わりや欠損への対応と、WebSocketの特性を活かすために、現在の実装は以下のような動作にしています。
サーバ側
・画像を分割して送信するようにしている(現在の実装では8192バイト長に分割)
・送信するデータにヘッダをつける(セパレータは「_」)
・ヘッダには、画像IDと分割したデータ数、何番目のデータなのかという情報を含める
・画像IDからデータの新旧を判断できるようにする(「new Date().getTime()」を16進数化したもの)
・データ送信は、一定の時間間隔ではなく、要求があったときのみ行なう
クライアント側
・WebSocketの接続が確立した時にサーバ側に画像データを要求する
・受け取った画像データは画像ID別に配列で保存する
・分割された画像データが全部揃った場合、データを結合し画面に反映させる
・分割された画像データが全部揃った場合、その画像データとそれよりも古いデータを破棄する
・画面描画を終えてから一定時間経過後(現状では100ミリ秒後)に、サーバ側に画像データを要求する
・データ欠損対応として、一定時間(3000ミリ秒)が経過しても分割データが揃わなかった場合は、画像データを再要求する
コード
具体的なコードを見ていきます。
サーバ側
クライアントからデータの送信要求があった時に実行される部分を下に示します。base64が送信する画像データをBase64化したバイト配列で、outbound(この場合、Outboundインターフェイスを実装したWebSocketConnectionクラスになります)が送信先のコネクションです。分割したデータをoutbound.sendMessageへ一気に流し込んでいます。
WebSocketのサンプル等でよく使われるのは「Outbound.sendMessage(byte frame,String data)」というメソッドですが、ここでは「sendMessage(byte frame,byte[] data, int offset, int length)」を使用しています。この部分を最初に実装した時には、Base64化した時に得られるバイト配列を文字列化して送信してたのですが、Jettyのコードを追ってみると、渡された文字列をバイト配列に変換し、送信処理をしていました。つまり「バイト配列=>文字列=>バイト配列」と無駄な処理となっていたため、送信データをバイト配列で組み立て送信するようにしました。
src/main/java/com/kanasansoft/WebSocketRemote/WebSocketRemote.javaの一部分
int sendSize = 8192;
int sendCount = base64.length / sendSize;
int remainder = base64.length % sendSize;
if(remainder!=0){
    sendCount++;
}
byte[] imageId = Long.toString(new Date().getTime(),16).getBytes();
byte[] sequenceCount = Integer.toString(sendCount, 16).getBytes();
for(int i=0;i<sendCount;i++){
    byte[] sequenceNumber = Integer.toString(i + 1, 16).getBytes();
    int restLength = base64.length-i * sendSize;
    int sendLength = sendSize<restLength?sendSize:restLength;
    byte[] sendData = new byte[imageId.length + sequenceNumber.length + sequenceCount.length + sendLength + 3];
    System.arraycopy(imageId, 0, sendData, 0, imageId.length);
    System.arraycopy(sequenceNumber, 0, sendData, imageId.length + 1, sequenceNumber.length);
    System.arraycopy(sequenceCount, 0, sendData, imageId.length + sequenceNumber.length + 2, sequenceCount.length);
    System.arraycopy(base64, i * sendSize, sendData, imageId.length + sequenceNumber.length + sequenceCount.length + 3, sendLength);
    sendData[imageId.length + 0] = separatorByte;
    sendData[imageId.length + sequenceNumber.length + 1] = separatorByte;
    sendData[imageId.length + sequenceNumber.length + sequenceCount.length + 2] =separatorByte;
    try {
        outbound.sendMessage((byte)WebSocket.SENTINEL_FRAME, sendData,0,sendData.length);
    } catch (IOException e) {
        e.printStackTrace();
    }
}
しかし、このままでは問題が発生します。送信したはずのデータが、かなりの頻度でクライアントに届きませんでした。大量のデータをJettyが捌ききれていないようでした。このため、サーバ作成時にバッファサイズを指定しました。バッファサイズ指定の部分以外は、前回のエントリーと同じです。
src/main/java/com/kanasansoft/WebSocketRemote/WebSocketRemote.javaの一部分
WSServlet wsServlet = new WSServlet(this);
ServletHolder wsServletHolder = new ServletHolder(wsServlet);
wsServletHolder.setInitParameter("bufferSize", Integer.toString(8192*256,10));
ServletContextHandler wsServletContextHandler = new ServletContextHandler();
wsServletContextHandler.addServlet(wsServletHolder, "/ws/*");
ちなみに、バッファサイズのデフォルトのサイズは8192バイトとなっています。これはJettyのWebSocketServletクラスのinitメソッド内で指定されています。実は、分割データのサイズを8192バイトとしたのは、バッファサイズの既定値に使われているのだから効率が良いのではと思い、色々と実験した結果でした。
クライアント側
WebSocketでデータを受信した時に実行される部分を示します。
receiveIndexesは、保持している画像の画像IDの配列です。receiveDataは分割された画像データを保持するオブジェクト(配列ではない点に注意)です。setImageRequestTimerは、送信要求タマーをクリアし、再度送信要求タイマーを設定する関数です。onMessageWebSocketでは、最初に3000ミリ秒を指定し、最後に100ミリ秒を指定ています。onMessageWebSocket内でreturnされずに最後まで処理が実行されるのは、分割されたデータが全部揃い画面に反映された時になるため、最後のデータ受信から3000ミリ秒経過した時にデータの再送信要求が発生します。この再送信が発生する前に何らかのデータを受信すると3000ミリ秒にリセットされ、画像データを画面に反映できた時には100ミリ秒にリセットするようになっています。分割データが全部揃った段階で古いデータは全て破棄していますので、遅れて届いた古いデータも次の新しいデータが揃った時に破棄されます。
src/main/resources/html/common.jsの一部分
function onMessageWebSocket(message){
    setImageRequestTimer(3000);
    var data=message.data;
    var splitData=data.split("_");
    if(splitData.length!=4){
        return;
    }
    var imageId=splitData[0];
    var sequenceNumber=parseInt(splitData[1],16);
    var sequenceCount=parseInt(splitData[2],16);
    if(receiveIndexes.indexOf(imageId)==-1){
        receiveIndexes.push(imageId);
        receiveIndexes.sort(function(a,b){return parseInt(a,16)-parseInt(b,16);});
        receiveData[imageId]={"data":[],"receiveCount":0,"sequenceCount":sequenceCount};
    }
    var receive=receiveData[imageId];
    receive.receiveCount++;
    receive.data[sequenceNumber]=splitData[3];
    if(receive.receiveCount!=receive.sequenceCount){
        return;
    }
    var currentPosition=receiveIndexes.indexOf(imageId);
    deleteIndexes=receiveIndexes.slice(0,currentPosition+1);
    receiveIndexes=receiveIndexes.slice(currentPosition+2);
    for(var i=0;i<deleteIndexes.length;i++){
        delete receiveData[deleteIndexes[i]];
    }
    var base64="";
    for(var i=1;i<receive.data.length;i++){
        base64+=receive.data[i];
    }
    remote.setAttribute("src","data:image/jpg;base64,"+base64);
    setImageRequestTimer(100);
}
処理全部が見たいという方は、WebSocketRemoteのソースをGitHubに公開していますので、そちらからダウンロードして下さい。

2010年04月15日

WebSocketRemoteのREADMEを英語で書いてみた

タイトルの通り。
自信はない。
一応日本語版も載せておく。
README.txt
= WebSocketRemote

WebsocketRemote is a remote control application that looks like VNC.
Only web browser(supported WebSocket) is necessary, and plug-in and applet are unnecessary in client side.

== DOWNLOAD

from http://github.com/Kanasansoft/WebSocketRemote/downloads

== RUN

Double click WebSocketRemote executable file or run command on terminal on server side.

executable file(x.x.x is version number)
* for Windows:WebSocketRemote-x.x.x.exe.
* for Macintosh:WebSocketRemote-x.x.x.app.

command(x.x.x is version number)

% java -jar WebSocketRemote-x.x.x.jar 

Running sign is display WebSocketRemote icon.

* for Windows:in task tray
* for Macintosh:in menu bar
* for Linux:in menu bar

== HOW TO USE

Access "http:[server address]:40320/" by Web Browser from client side.

== QUIT

Right click WebSocketRemote icon.
Select Quit.

== REQUIREMENTS

=== SERVER SIDE

Java SE 6

=== CLIENT SITE

Web Browser(supported WebSocket)

== NOTE

Current version is supporting mouse event only.
Current version is cannot change port number.
README.ja.txt
= WebSocketRemote

WebsocketRemoteは、VNCに似たリモート制御アプリケーションです。
クライアント側では、WebSocketに対応したウェブブラウザだけが必要で、プラグインやアプレットは不要です。

== ダウンロード

http://github.com/Kanasansoft/WebSocketRemote/downloads

== 実行

サーバ側で、WebSocketRemoteの実行ファイルをダブルクリックするか、ターミナルからコマンドを実行して下さい。

実行ファイル(x.x.xはバージョン番号です。)
* Windows用:WebSocketRemote-x.x.x.exe.
* Macintosh用:WebSocketRemote-x.x.x.app.

コマンド(x.x.xはバージョン番号です。)

% java -jar WebSocketRemote-x.x.x.jar 

WebSocketRemoteのアイコンが表示されていれば、実行中となります。

* Windows用:タスクトレイに表示
* Macintosh用:メニューバーに表示
* Linux用:メニューバーに表示

== 使い方

クライアント側からウェブブラウザで"http:[server address]:40320/"にアクセスして下さい。

== 終了

WebSocketRemoteのiconを右クリックします。
Quitを選択して下さい。

== 必須条件

=== サーバ側

Java SE 6

=== クライアント側

WebSocketに対応したウェブブラウザ

== 注釈

現バージョンはマウスイベントのみサポートしています。
現バージョンはポート番号の変更はできません。

2010年04月25日

AppleScriptでTwitterの検索結果を取得しKeynoteに表示するKeynotterというものを作ろうとして失敗した

まあ、ネタアプリなんだけど...。
AppleScriptだけで作れた面白いだろうなと勢いでコーディング開始。
Twitterからのデータの取得部分が問題になると思っていた。
だから、Twitterの公式検索機能からRSSを取得できるとわかった時点でメドがたったと勘違いしてしまった。
データの取得まではできたけど、Keynote上に指定した文字列を表示させる事ができなかった。
バージョンはKeynote'08(4.0)。
もしかしたら最新バージョンでは可能かもしれないけど...。
他の表示方法も探したんだけどうまい方法は見つからなかった。
とりあえず、コードを載せておくことにする。
出力はlog出力しているのでイベントログに表示される。
set tempUserName to "keynotter"
set tempFileName to "temp.xml"
set urlText to "http://search.twitter.com/search.atom"
set lastUpdated to "" as text
set lastTweet to "" as text
set lastUserName to "" as text
set answer to display dialog "See what's happening — right now." default answer "Kanasansoft" buttons {"cancel", "search and display"} default button 2 with icon note
if button returned of answer is "cancel" then
    return
end if
set query to "q=" & text returned of answer

tell application "Finder"
    set tempUser to path to temporary items folder from user domain
    if not (exists folder tempUserName of folder tempUser) then
        make new folder at folder tempUser with properties {name:tempUserName}
    end if
    set tempApp to folder tempUserName of tempUser
    set tempFilePath to (tempApp as text) & tempFileName
end tell
repeat
    tell application "URL Access Scripting"
        download urlText to file tempFilePath replacing yes form data query with progress
    end tell
    set tempFile to file tempFileName of tempApp as text
    tell application "System Events"
        set root to XML element "feed" of contents of XML file tempFile
        log (get properties of root) --prop
        set entries to ((XML elements of root) whose name is "entry") as list
        repeat with i from 1 to count of entries
            set entry to item i of entries
            set updated to (value of XML element "updated" of entry) as text
            set tweet to (value of XML element "title" of entry) as text
            set userName to (value of XML element "name" of XML element "author" of entry) as text
            if i is 1 then
                set firstUpdated to updated as text
                set firstTweet to tweet as text
                set firstUserName to userName as text
            end if
            if updated is lastUpdated and tweet is lastTweet and userName is lastUserName then
                set lastUpdated to firstUpdated as text
                set lastTweet to firstTweet as text
                set lastUserName to firstUserName as text
                exit repeat
            end if
            log tweet
            log userName
            if i is (count of entries) then
                set lastUpdated to firstUpdated as text
                set lastTweet to firstTweet as text
                set lastUserName to firstUserName as text
            end if
        end repeat
        delay 30
    end tell
end repeat
Google

タグ クラウド