Diggの高速化技術MXHRを解説してみる

これのこと。

どこにも解説が無かったので、詳しく読んだ。

上の記事から引用すると、「サーバーとクライアント間で、ただひとつだけのHTTPコネクションを開く。これによりサーバーがページのどのパーツを先行して読み込むかをコントロールすることが可能になり、ユーザーにとってはページ読み込みがほぼ一瞬で済むことを意味する。」という技術。XMLHttpRequest を使って複数のデータを受信する場合に効果がある。

まずデモから

デモ1は10個のテキストをダウンロードして表示するもの。

デモ2は300個の画像をダウンロードして表示するもの。

どちらも左側 (MXHR 有効) が完了した後に右側 (MXHR 無効) を開始するので驚かないように。

それから、もしかしたらリロードするとブラウザによっては左側だけキャッシュが働くかもしれない。(右側だけ明示的に URL をランダムにしてある)

あと、Safari では append された順に画像を読み込むわけではないようなので、最後の画像の onload で時間を取っているのはちょっとおかしい。

それらを加味して考えても、たぶん結果はデモ1では両方同じぐらいのスピードで、デモ2では左側が圧倒的に速いぐらいだと思う。

Multipartって

MXHR の M にあたる Multipart というのは、レスポンスの Content-Type を multipart/mixed として返すところから。

メールの添付ファイルで使われるらしいのだけど、例えば Content-Type: multipart/mixed; boundary="hogehoge" だったら、

--hogehoge
Content-Type: image/gif
base64エンコードした画像
--hogehoge
Content-Type: text/javascript
テキスト
--hogehoge

みたいな感じになっているらしい。

ここらへんは Java, Perl, Ruby, Python による実装例を見ればわかると思う。

例えば Python だとこのぐらいの簡単な実装になる。(実際にはキャッシュ機構などを搭載することになる)

class MXHRStreamer(object):
    """Simple class that handles streaming multipart payloads"""
    def __init__(self):
        self.payloads = []
        self.boundary = "_%d-%s" % (time(), md5.new(str(random.randint(0, 2**32))).hexdigest())
        
    def get_boundary(self):
        return self.boundary
        
    def add_payload(self,payload, content_type):
        self.payloads.append([payload,content_type])
            
    def stream(self):
        stream = ""
        for payload, content_type in self.payloads:
            stream += "--%s\n" % self.get_boundary() 
            stream += "Content-Type: %s\n" % content_type
            stream += "%s\n" % payload
            
        stream += "--%s--" % self.get_boundary() 
        self.payloads = []
        
        return stream

クライアント側のJavaScript

JavaScript 部分もこのぐらいの簡単なソースで作れる。

まず XMLHttpRequest オブジェクトを用意してリクエストを投げ、readyState==3 になるのを待つ。

readyState==3 になると responseText が取得できるので、setInterval で responseText を15ms ごとに監視して、boundary (区切り) で分けて、受信完了した部分から順次処理するという感じ。readyState==4 になったら clearInterval する。

使うときは、デモ2のソースを参考にすると

var s = new DUI.Stream();

s.listen('image/gif', function(payload) {
    $('#stream').append('<object type="image/gif" data="data:image/gif;base64,' + payload + '" width="20" height="20"></object>');
});

s.listen('complete', function() {
    /* 受信完了時の処理 */
});

s.load('testImageData.php');

という感じになる。

使いどころ

まず、デモ1で特に効果が出なかったように、テキストの受信には向かない。それに、単なるテキストだけなら multipart/mixed を使わなくても普通に繋げればいいだけだし。

画像の場合も、デモ2のようにキャッシュがまったく効いてない画像を大量に表示する場合には速くなるけど、全部の画像がキャッシュされてる場合は MXHR を使わないほうが15倍以上速かった。

というわけで、有用な使いどころは (Digg のブログにも書いてあるように)、

  1. キャッシュ無しの
  2. 画像を
  3. 大量に表示する

場合だけということになる。

digg は記事ページで新着50人分のコメントを動的に取得するらしく、そういう場合には確かに効果があると思う。(キャッシュされているユーザーアイコンが出ることはまず無いはず)

逆に言うと、こういうものすごく限定された用途でのみ有効な手法でしかない。いや、もっと大きな可能性を秘めてるかも。コメント欄参照。

例えば twitter の public timeline では使えるだろう (しかし何人の人が public timeline を見てるのか)。Google イメージ検索などでも使える (一度に表示する画像はそんなに多くないけど)。はてなブックマークの記事ページ (大量のユーザーアイコンを取得する) で「もしかしたら」使えるかも、という感じか。

あと、HTTP 接続を一つしか開かないので、もしかしたら大量のアクセスを捌くようなサーバーでは特に有効かもしれない。base64 エンコードするコストはキャッシュを使えば (大量アクセスがあるなら) 特に問題ないかも? リクエスト毎に String を繋げるとしたら…どうだろ? (このへんは根拠のない想像として書いてます)

それから、先にテキストだけ全部取得して、次に画像を取得したい、という場合も効果がある。というか本来の目的はそれだと書いてあるな。


実際に使う場合には、こんな感じになると思う。

var users = ['foo','bar','baz', ...];  // という配列があったとする
var count = 0;
var s = new DUI.Stream();
s.listen('image/gif', function(payload) {
    var user = users[++count];
    Array.prototype.forEach.call(document.getElementsByClassName('icon_' + user), function(image){
        image.type = 'image/gif';
        image.src = 'data:image/gif;base64,' + payload;
    });
});
s.load('usericons.cgi?users='+users.join('+'));

けっこう面倒?

今後の課題など

自分の感想。

  • JavaScript を有効にしてないとそもそも画像が見られない?
    • まず全部の画像部分に共通の画像を表示しておいてから MXHR を使うことになるかな?
    • AutoPagerize とも相性が悪い。
  • IE では data: スキームの画像が使えない。(digg のブログにも言及されている)
  • img.src で append するのに比べてどうなの?
  • というか最大接続数の影響で速く表示したいテキストが画像の転送に阻まれてしまう、というのを防ぎたいだけなら画像だけ別ドメインにすればいいんじゃないの?


digg のブログではこれからの課題として、こう書いてある。

  • Cache detection. If you wanted to implement DUI.Stream right now, you’d have to switch between regular requests and MXHRs on your own. We don’t recommend that.
  • Background caching. A big part of implementing this is going to be loading the MXHR through DUI.Stream, then turning any cacheable chunks into their normal, cache-friendly tags.
  • Support for multiple headers per chunk. Specifically, we’ll be adding a set of custom headers to allow for UI-specific information to be sent, like CSS selectors for greater control in handlers.
  • XMLHttpRequest.multipart support. Right now we’re not using this flag, since we aren’t keeping connections open for a server-push implementation yet. Like I said, It’s an alpha ;)
  • IE7 and IE8 object tag workarounds. In the current build, bouncing binary streams into object tags in IE has some interesting results.
Digg the Blog » Blog Archive » DUI.Stream and MXHR

うーん、分かりにくい。

最初のは、localStorage とかを使ってキャッシュしておくということだろうか? MXHR を使うか普通のリクエストを使うかを意識しないで使えるようにするらしい。

次のやつは、サーバー側のキャッシュ機構のことかな?

3番目の点は、上に挙げた例でいうと、たぶんこういう感じでレスポンスを返すことにして、

--hogehoge
Content-Type: image/gif
CunstomHeader: foo
画像
--hogehoge

foo という identifier を受け取れるように。(想像だけど)

s.listen('image/gif', function(payload, user) { // ←ここに引数を渡せる。
    Array.prototype.forEach.call(document.getElementsByClassName('icon_' + user), function(image){
        image.type='image/gif';
        image.src='data:image/gif;base64,' + payload;
    });
});

4番目は XMLHttpRequest#multipart フラグ対応に (Mozilla だけ? 調べたけどよくわからなかった)。サーバー側から push のために接続を開けておく部分の実装も。

5番目は IE を何とかすると。

というわけで

まだまだ開発段階らしいので、期待つつ静観しようかな。

使い道が限定されすぎてるからなあ…

そういえば

上ではてなブックマークの例を挙げたけど、はてなブックマークのページに表示されるユーザーアイコンはけっこうな数がそもそも同じ画像なわけで。(全員がデフォルト画像から変えてるわけじゃない)

MXHR 的に考えたら、ユーザーアイコンが存在するユーザーのみ画像データを返せばいいんだから、サーバーの負荷はかなり減る気がする。

--hogehoge
Content-Type: image/gif
画像
--hogehoge
Content-Type: text/plain
デフォルトだよ
--hogehoge
Content-Type: text/plain
デフォルトだよ
--hogehoge
Content-Type: image/gif
画像
--hogehoge

みたいになってたら、上の例でいうと

var users = ['foo','bar','baz', ...];  // という配列があったとする
var default_icon_url = 'hoge';
var count = 0;
var s = new DUI.Stream();
s.listen('image/gif', function(payload) {
    var user = users[++count];
    Array.prototype.forEach.call(document.getElementsByClassName('icon_' + user), function(image){
        image.type = 'image/gif';
        image.src = 'data:image/gif;base64,' + payload;
    });
});
s.listen('text/plain', function(payload) {
    if (payload == 'デフォルトだよ') {
        var user = users[++count];
        Array.prototype.forEach.call(document.getElementsByClassName('icon_' + user), function(image){
            image.src = default_icon_url;
        });
    }
});
s.load('usericons.cgi?users='+users.join('+'));

こうすれば、リクエストの回数は1回になるし、送信データ量も減らせる。