読者です 読者をやめる 読者になる 読者になる

PHPとJavaScriptでHTTPストリーミングする話(Transfer-Encoding: chunked編)

HTTPレスポンスをajaxでストリーミング的に受け取りたいとき、要するにHTTPストリーミングをしたい時には、Transfer-Encoding: chunkedなレスポンスを生成してやるとよい。こうするとAjaxではHTTPレスポンス全体を受け取るのを待たずに、レスポンスの中身にアクセスすることが出来るようになる。従って、一つのHTTPコネクションでサーバ側から任意のデータを好きなタイミングでプッシュすることが出来る。

コード

一秒ごとに生成されるJSONをストリーム的に受け取るデモのコードが以下。

<?php
// push.php

function output_chunk($chunk)
{
    echo sprintf("%x\r\n", strlen($chunk));
    echo $chunk . "\r\n";
}

header("Content-type: application/octet-stream");
header("Transfer-encoding: chunked");
flush();

for ($i = 0; $i < 10; $i++) {
    output_chunk(
        json_encode(array("response" => "hoge", "count" => $i)) .
        str_repeat(' ', 8000) .
        "\n"
    );
    flush();
    sleep(1);
}
echo "0\r\n\r\n";
<!-- index.html -->
<html><head></head><body>

<script type="text/javascript">

window.onload = function (){
    var i = 0, ajax = new XMLHttpRequest();

    var c = document.getElementById("c");
    function output(line) {
        c.value += line + "\n";
    }

    ajax.open("GET","./push.php");

    ajax.onreadystatechange = function (){
        if (ajax.readyState == 4) {
            output("response finished");
        }
    };

    ajax.send(null);

    var length = 0;
    setInterval(function() {
        if (length !== ajax.responseText.length) {
            length = ajax.responseText.length;
            var lines = ajax.responseText.split("\n"),
                line = lines[lines.length - 2];

            if (line) {
                output("received: " + line);
            }
        }
    }, 300);
};
</script>

<textarea id="c" rows="40" cols="80"></textarea></body></html>

解説

Transfer-Encoding: chunkedなHTTPレスポンスを生成すると、JavaScriptXMLHttpRequestからレスポンスの中身をストリーム的に受け取ることができる(IE以外)。上のコードは、Opera,Firefox,Chrome,Safariで動くような例として書いた。サーバの環境は、apache+mod_phpのごく標準的な設定だけど、設定によってはうまく動かないかもしれない。Transfer-Encoding: chunkedなレスポンス自体の概要についてはWikipediaの記事に乗ってるので見るとよい。

一つのチャンクを生成する処理で後ろに大量の空白を乗っけてるのは、チャンクがブラウザのバッファリングよりも小さい場合、XMLHttpRrequestオブジェクトのresponseTextに乗る前に止まってしまうらしく、小さなチャンクを送信してもうまく動かすことができなかったのが理由。この辺のことを試している方の検証があるが、ただ実際に実装するときはサーバ側の何らかのモジュールによるバッファリングが効いてる場合もあるやもだったり、ブラウザのバージョンによってバッファのサイズも変わったりする場合もあると思うので気をつける。

FirefoxSafariChromeだと、適切なサイズのチャンクを受け取るとXMLHttpRequestオブジェクトのonreadystatechangeに登録した関数が呼び出されて検知できるが、Operaだとチャンクを受け取っても検知できないのでsetIntervalでレスポンスの中身を監視して検知する方法を取っている。IEに関しては誰か教えてください。

このデモのサーバサイドはPHPで実装しているが、当然ながらchunkedなレスポンスを生成する処理はPHPじゃなくても何でもいい。サーバリソースの観点から見ると、一つのHTTPコネクションが一つのスレッドやプロセスを使うPHPよりも一つのプロセスで複数のHTTPコネクションを扱えるようなもの(Node.jsとかそういうの)で書いたほうがいいと思う。

[追記]PHPで実際にこういったストリーミングAPIのようなものを作るときは、接続したクライアントとの接続が切れているのに処理を延々と継続させるのは明らかに無駄なので、connection_aborted関数を使ってクライアントとの接続が保たれているか適時確認して、接続が切れていたら処理を終了したほうがよい。サーバリソースの観点から、こういうのを実装する場合はPHPじゃなくてNode.jsみたいなの使ったほうがいいと書いたが、それほど多くの人が接続しないような用途であればPHPで実装しても全く問題ないと思われる。

Cometと何が違うのか、もしくはComet使えばいいんじゃ?

Cometの場合、いわゆるロングボーリングで何かサーバ側から通知を受け取った後、次の通知を受けるためにはHTTPリクエストを投げなければならない。要するにクライアント側は通知の回数ごとにHTTPリクエストを投げなければならない。ストリーム的なものを流したい時に、何度も何度もサーバに接続しに行くのは効率が悪い場合が多いと思う。例えば、TwitterのStreaming APIもchunkedなレスポンスを提供しているが、これをCometで実装するのは明らかに効率悪い。また、無理やりComet使おうとするとキューを使ったりしてサーバ側の実装をすこし複雑にする必要がある。用途的には似ているようで少し違うので適切に使い分けましょうという話。

追記

IEでもIE8以上から導入されたXDomainRequest使えばいけるらしいが、手元に環境がなく検証出来ないので出来次第上に書いたデモのコードは修正する。