tsujimotterのノートブック

日曜数学者 tsujimotter の「趣味で数学」実践ノート

HTTP Streaming パターン ~ HTTPサーバーからデータをプッシュする方法

データがサーバー上で更新されたら即時にブラウザで反映される。そんなWEBアプリケーションを作りたいと思ったことはありませんか。



たとえば、野球速報みたいなものです。Twitterのタイムラインでもいいかもしれません。

こういうアプリケーションを実現するときの常とう手段としては、ブラウザ側が「更新情報のリクエスト」を一定間隔でサーバーに送って、「データが更新された」というレスポンスが返ってきたときだけ表示を変更する方法が考えられます。これを、Polling パターンと呼びます。ブラウザが一定間隔でサーバーへ問い合わせることをPollingというのです。

しかし、Pollingでは、サーバーの更新がない場合は不必要なリクエストを送り続けることになりますし、逆にデータの更新が早い場合はすべての更新を受け取れない場合があり、効率の悪い方法です。

ほかにも、最近だとWebSocketCometなどのHTTP以外のプロトコルを使って通信する方法もあります。(FacebookのメッセージがCometを使って実装されていたという話を聞いたことがあります。)

しかしながら、これらのプロトコルはサーバーによっては実現が困難だったりします。一例として、対応するポート番号のポートがセキュリティの都合で空いていなかったりします。ほかにも、実装しようとしても、たとえばレンタルサーバーではPHPしか使えなかったりして、WebSocketの実装が困難だったりする場合もあります。

そこで本記事では、更新情報をサーバー側のタイミングでプッシュし、ブラウザ上にリアルタイムで反映させる方法を紹介します。通信プロトコルはHTTPしか使いません。レンタルサーバーでも動きます。それがタイトルのHTTP Streamingパターンです。

※2015/03/09 修正:
「"Comet" は "HTTP" とは異なるプロトコルである」という旨の記述をしておりましたが,誤りであるとのご指摘を頂きました。詳しくは,ページ下部のコメントをご確認ください。

完成品イメージ

f:id:tsujimotter:20131208135114p:plain

図の通りですが、ポイントは以下の2点です。

  • クライアントからサーバーへの通信は、最初の接続のときのみであること
  • サーバーの情報が更新されたタイミングで、サーバーから情報がプッシュされること

デモ

HTTP Streaming パターン テスト

このデモでは、push.phpから送信されるJSON形式のデータを受け取って、最新の情報を表示するものです。

サーバーサイド

一定間隔でブラウザにデータを掃きだし続けるサーバーサイド(PHP)のスクリプトです。

<?php
/**
 * push.php - 一秒に一回 json データを chunked data として掃き出す
 */

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");
ob_flush();
flush();

$i = 0;
while ( !connection_aborted() ) {
    // クライアントからの接続が切断されない限り繰り返す
    
    // JSON に入れるデータ
    $id        = $i;
    $message   = "hoge".$i;
    $createdAt = date("Y-m-d H:i:s");

    // JSON データを作る
    $json = json_encode( 
        array(
            "id" => $id, 
            "message" => $message, 
            "createdAt" => $createdAt
        ) 
    ); 

    // データを掃き出す
    output_chunk(
        $json . str_repeat(' ', 8000) . "\n"
    );
    ob_flush();
    flush();

    // 一秒停止
    sleep(1); // 一秒ごとに生成

    $i++;
}
echo "0\r\n\r\n";

ポイントはヘッダを次のようにすること。

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

「chunked」とは「塊」の意味で、レスポンステキストを一気に送るのではなく、ある程度のサイズの塊にしてちょっとずつ送っていく方式の通信が実現できます。


塊を送るたびに、毎回 flush() することもポイントの1つです。


この例では、push.phpから掃き出されるデータはoctet-stream形式なので、ちゃんとサーバーからデータが送られているかブラウザ上で確認することができません。そこで、次のようにヘッダ情報を設定することで、テキスト形式でブラウザ上に掃きださせることができます。

header("Content-type: text/plain");
header("Transfer-encoding: chunked");

http://{your.server.com}/http-streaming/push.php

にアクセスしてみてください。

次のようなデータが表示されるはずです。

{"id":0,"message":"hoge0","createdAt":"2013-12-06 00:49:29"}
{"id":1,"message":"hoge1","createdAt":"2013-12-06 00:49:30"}
{"id":2,"message":"hoge2","createdAt":"2013-12-06 00:49:31"}
{"id":3,"message":"hoge3","createdAt":"2013-12-06 00:49:32"}
{"id":4,"message":"hoge4","createdAt":"2013-12-06 00:49:33"}
{"id":5,"message":"hoge5","createdAt":"2013-12-06 00:49:34"}

このデータをクライアントで受け取って加工すればいいですね。


注意したいのは、サーバーが一定間隔でデータを送らない場合です。データがある程度の時間送られないと、HTTP通信のセッションタイムアウトとなってしまい、接続が切れてしまいます。一定間隔で空の文字列を送るようにしましょう。

クライアントサイド

クライアントサイドのJavaScriptコードを置いておきます。上のデモとは若干書き方が違うのですが、基本は同じです。

$(function(){
    /**
     * タイマー
     */ 
    var mytimer = null;

    /**
     * データ受信先のURL
     */
    var url = './push.php';

    /**
     * 引数のテキストが JSON かどうか 判別する内部関数
     */
    var isJSON = function(arg) {
        arg = (typeof arg === "function") ? arg() : arg;
        if (typeof arg  !== "string") {
            return false;
        }
        try {
            arg = (!JSON) ? eval("(" + arg + ")") : JSON.parse(arg);
            return true;
        } catch (e) {
            return false;
        }
    };

    // サーバーに通信のコネクションを張る
    $.ajax({
        type: 'get',
        url:  url,
        cache: false, // ブラウザにキャッシュさせない
        xhrFields: {
            onloadstart: function() {
                var xhr = this;

                // 前回取得したデータの文字数
                var textlength = 0;

                // データが来るまで待つ (100ms間隔でレスポンステキストを確認する)
                mytimer = setInterval(function() {

                    // 受信済みテキストを保存                    
                    var text    = xhr.responseText;

                    // 前回の取得からの差分を取得
                    var newText = text.substring(textlength);
                    
                    // JSONデータを取得
                    var lines   = newText.split("\n");

                    if( text.length > textlength ) {
                        // 長さを更新
                        textlength  = text.length;

                        lines.forEach(function(line){
                            if( isJSON(line) ){
                                // 正常な JSON データの時
                                var json = JSON.parse(line);

                                /**
                                 * ここに更新処理を書く
                                 */
                                console.log('updated.');
                            }
                        });
                    } 

                }, 100);                
            }
        },
        success: function() {
            console.log('successed.');

            // 一秒後にタイマー停止
            setTimeout(function(){
                clearInterval(mytimer);
            }, 1000);
        },
        error: function(XMLHttpRequest, textStatus, errorThrown) {
            console.log('error occured.');
            
            // 即座にタイマー停止
            clearInterval(mytimer);
        }
    });    
});

ここで、setIntervalを使って繰り返し処理を行っていますが、レスポンステキストを確認しているだけで、サーバーへの通信は最初の一回しか行っていません。

ChromeではGETアクセスに対して、キャッシュを作ります。それが原因で同じブラウザから同時にアクセスしようとすると片方の受信が止まってしまうという問題がありました。ここでは、Ajaxリクエストのパラメータとして、

cache: false,

を付けることでこれを解決するできました。

GitHub

今回の記事の内容をソースコードとしてまとめました。
Cloneしてサーバーに置けばデモと同様に動くはずです。
MIT Licenseなので自由に使ってくださって結構です。

junpeitsuji/http-streaming · GitHub