Help us understand the problem. What is going on with this article?

Server-Sent Events(SSE)をPHPで使うときの自分用まとめ

SSEを自分なりに調べました、間違いなどありましたらご指摘お願いいたします:bow:

Server-Sent Events とは

Google翻訳↓

この仕様は、DOMイベントの形式でサーバーからプッシュ通知を受信するためのHTTP接続を開くためのAPIを定義します。 このAPIは、Push SMSなどの他のプッシュ通知スキームと連携するように拡張できるように設計されています。

よくwebsocketと比較される。websocketはブラウザとサーバーで双方向通信できるが、SSEはサーバーからブラウザへの一方向の通信。

SSEのすぐ動かせるサンプル

↑のページのコードを参考にさせていただきました

events.php
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-store');
while(true) {
    printf("data: %s\n\n", json_encode([
        'time' => (new DateTime('now', new DateTimeZone('Asia/Tokyo')))->format('H:i:s'),
        'word' => 'abcあいう😀😁😂',
    ]));
    ob_end_flush();
    flush();
    sleep(1);
}
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script>
    document.addEventListener('DOMContentLoaded', e => {
        const es = new EventSource('./events.php');
        es.addEventListener('message', e => {
            const { time, word } = JSON.parse(e.data);
            sample.appendChild(document.createElement('li')).textContent  = `${time} ${word}`;
        });
    });
    </script>
</head>
<body>
    <ul id="sample"></ul>
</body>
</html>

jP6D7HvW9e.gif

このとき、events.phpは以下のように文字列を返しています

Screen Shot 2019-02-03 at 6.05.35.png

フィールドごとに調べたこと

dataeventidretry のそれぞれのフィールドについて調べました

data

基本的に送信するデータは、'data: ホゲホゲ'という文字列をechoprintなどで出力すると良い、javascriptでmessageイベントでe.dataからホゲホゲという文字列を取得できる

<?php
header('Content-Type: text/event-stream');
echo 'data: ホゲホゲ';
echo "\n\n";
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
  console.log(e.data); // ホゲホゲ
});

events.phpの出力内容が、空白行ごとに区切られてjsのmessageイベントが実行されていきます。送信したい単位で1行空白行を入れることを忘れないようにしましょう。

例1

events.phpのレスポンス内容
data: ホゲホゲ

data: フガフガ

data: ピヨピヨ

↑だと message イベントは3回実行されることになります


例2

空白行を入れずに複数行データを送信することもできます

events.phpのレスポンス内容
data: ホゲホゲ

data: フガフガ
data: ピヨピヨ

↑だとホゲホゲフガフガ\nピヨピヨmessage イベントは2回実行されます

NG例

  • 大文字小文字は区別される
php
<?php
header('Content-Type: text/event-stream;');
echo 'Data: 送信データ'; // Dが大文字なので無視される
echo "\n\n";
  • 送信したい単位で空白行を1行入れないと送信されない
php
<?php
header('Content-Type: text/event-stream;');
echo 'data: A'; // A が送信される
echo "\n\n";
echo 'data: B';
echo "\n";
echo 'data: C'; // B\nC が送信される
echo "\n\n";
echo 'data: D'; // この行の後に空白行が続いていないため送信されない

書き方が間違っていてもエラーになるわけではなく、その行が無視されるだけなのでそこが個人的にやりにくかったです

複数データの送信

phpでJSON形式の文字列を送信し、jsでJSON.parse(e.data) とする

php
<?php
header('Content-Type: text/event-stream');
printf("data: %s\n\n", json_encode([
    'hoge' => 1,
    'fuga' => 'A',
    'piyo' => false,
]));
javascript
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
    ({hoge, fuga, piyo} = JSON.parse(e.data));
    console.log(hoge, fuga, piyo); // 1 "A" false
});

JSONをプリティプリントする

送信するJSONの文字列が長くなってしまうとデバッグがしずらくなってしまいます

<?php
header('Content-Type: text/event-stream');
echo 'data:'.json_encode($_SERVER)."\n\n";

Screen Shot 2019-02-03 at 6.58.56.png

私にはこのようなやり方しか思いつきませんでしたが、とりあえずこれで人間の目にも優しく、EventSourceとしてもJSONとして扱えるように出力することができました

<?php
header('Content-Type: text/event-stream');

// echo 'data:'.json_encode($_SERVER)."\n\n";
echo implode("\n", preg_replace('/^/', 'data:', explode("\n", json_encode($_SERVER, JSON_PRETTY_PRINT))))."\n\n";

Screen Shot 2019-02-03 at 7.02.54.png

event

自分でイベント名を設定することができる

php
header('Content-Type: text/event-stream');

echo "event: add\n";
echo "data: 追加\n\n"; // addイベントで受け取れる

echo "data: ホゲ\n\n"; // event名を設定していないので、messageイベントで受け取れる

echo "event: update\n";
echo "data: 更新\n\n"; // updateイベントで受け取れる
phpのレスポンス内容
event: add
data: 追加

data: ホゲ

event: update
data: 更新
js
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
    console.log('message -> '+e.data); // ホゲ
});
es.addEventListener('add', e => {
    console.log('add -> '+e.data); // 追加
});
es.addEventListener('update', e => {
    console.log('update -> '+e.data); // 更新
});

Screen Shot 2019-02-03 at 7.22.37.png

id

dataと一緒に一意のIDの文字列を送ることができます。
e.lastEventIdで取得できるようでした。

js
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
    console.log(e.lastEventId, e.data);
});
php
<?php
header('Content-Type: text/event-stream');

echo "id: 1\n";
echo "data:hoge\n\n";

echo "id: 2\n";
echo "data:fuga\n\n";

echo "id: 3\n";
echo "data:piyo\n\n";

Screen Shot 2019-02-03 at 7.53.30.png


EventSourceですが、接続先とエラーで接続が途切れた場合、自動で再接続しに行きます。
再接続の際にリクエストヘッダーでLast-Event-IDを一緒に送信するようです

※events.phpの処理が正常終了した場合もエラー扱いなようで、errorイベントが実行されるようでした。

Screen Shot 2019-02-03 at 75926 copy (1) (1).png

php
echo "data:".$_SERVER['HTTP_LAST_EVENT_ID']."\n\n"; // 3

retry

再接続しに行くまでの秒数を設定できます。
デフォルトが何秒なのかを調べましたが、見つけることができませんでした。感覚的には3秒ぐらいなようです。

php
<?php
header('Content-Type: text/event-stream');

echo "retry:10000\n"; // 10秒に設定
echo "data: hoge\n\n";

接続時のイベント

js
const es = new EventSource('./events.php');
es.addEventListener('open', e => {
    console.log('open');
});

エラー処理

errorイベントで、接続できなかった場合、接続が途切れた場合などの処理を書ける

js
const es = new EventSource('./events.php');
es.addEventListener('error', e => {
    console.log('error');
    es.close(); // エラーが起きても再接続する必要がない場合はclose()を実行する
})

phpの方でも接続が切れた場合の処理を作成しみる

<?php
ignore_user_abort(true);
header('Content-Type: text/event-stream');

// 接続中の間はループ
while(!connection_aborted()) {
    echo "data: ホゲ\n\n";
    ob_end_flush();
    flush();
    sleep(1);
}

// 接続が切れた場合の処理
// ...

connection_aborted()でクライアントとの接続が切れているかわかるようです。
ただphpのデフォルトではクライアントが接続を破棄した後はスクリプトは終了してしまうため、whileを抜けた後の処理が実行されません。
なのでignore_user_abort(true);、またはini_set('ignore_user_abort', 1);などとすることで接続が切れた後も途中終了せず処理を実行してくれるようです。

参考

接続状態の確認

Screen Shot 2019-02-03 at 9.54.58.png

  • 引用:https://www.w3.org/TR/eventsource/ ※google翻訳↓
    • 0:接続はまだ確立されていないか、または切断されてユーザーエージェントが再接続しています。
    • 1:ユーザーエージェントはオープン接続を持っており、それを受け取るとイベントを送出しています。
    • 2:接続は開かれておらず、ユーザーエージェントは再接続しようとしていません。致命的なエラーが発生したか、close()メソッドが呼び出されました。
js
   const es = new EventSource('./events.php');
   console.log('init -> '+es.readyState);
   es.addEventListener('open', e => {
       console.log('open -> '+es.readyState);
   });
   es.addEventListener('message', e => {
       console.log('message -> '+es.readyState);
   });
   es.addEventListener('error', e => {
       console.log('error -> '+es.readyState);
       es.close();
       console.log('error -> '+es.readyState);
   })

以下は一度接続し、接続中にサーバをシャットダウンした時の動作です。es.readyStateで正しく状態が取得できるのが確認できました。

Screen Shot 2019-02-03 at 9.59.58.png

クロスオリジン

ajaxと同じです。
urlを別のドメインに設定すると以下のようにエラーになります

Screen Shot 2019-02-03 at 10.47.40.png

あまりやったことがないので、以下を参考にしつつ行いました。
間違っていたらご指摘お願いいたしますm(_ _)m

別ドメインのphpを読み込む

js
// http://192.168.33.10 から http://localhost:8888 を読み込みに行く
const es = new EventSource('http://localhost:8888/events.php');
es.addEventListener('message', e => {
    console.log(e.data);
});
http://localhost:8888/events.php
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header("Access-Control-Allow-Origin: *");

echo "data:{$_SERVER["HTTP_HOST"]}{$_SERVER["REQUEST_URI"]}\n\n";

別ドメインからデータを取得できました

Screen Shot 2019-02-03 at 11.39.59.png


cookieを使う

js
// http://192.168.33.10 から http://localhost:8888 を読み込みに行く
const es = new EventSource('http://localhost:8888/events.php', { withCredentials: true });
es.addEventListener('message', e => {
    console.log(e.data);
});
http://localhost:8888/events.php
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
header('Access-Control-Allow-Credentials: true');

setcookie('user', 'Jhon');

echo "data: {$_SERVER["HTTP_HOST"]}{$_SERVER["REQUEST_URI"]}\n";
echo "data: ".json_encode($_COOKIE)."\n\n";

クロスオリジンでcookieを設定できました

Cv0k3HxAPg.gif

文字化けした

文字化けした時にheaderでutf-8と明示すると文字化けしなくなりました

php
<?php
header('Content-Type: text/event-stream; charset=utf-8');

nginxで実行

apacheだと特に問題なく動いたのですが、nginxだとうまく行きませんでした、
下のコードだと1秒おきに出力して欲しいのですが、バッファされているようだけのようでタイムアウトしてしまいました。

↓これだとnginxではうまく動きませんでした。

php
<?php
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-store');

while(true) {
    printf("data: %s\n\n", json_encode([
        'time' => (new DateTime())->format(DateTime::RFC3339),
    ]));
    ob_end_flush();
    flush();
    sleep(1);
}

X-Accel-Buffering: noを追加するとうまく行きました

<?php
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-store');
+ header('X-Accel-Buffering: no');

while(true) {
    printf("data: %s\n\n", json_encode([
        'time' => (new DateTime())->format(DateTime::RFC3339),
    ]));
    ob_end_flush();
    flush();
    sleep(1);
}

c4zW9j3qGT.gif


nginxの設定ファイルをいじるという方法でも大丈夫なようです

ブラウザ対応状況

https://caniuse.com/#search=server-sent%20events

2019/2/3現在

Screen Shot 2019-02-03 at 18.16.07.png

IE、Edgeでは使えないようなのでpolifillを使うと良いようです

その他自分が疑問に思った部分

ob_end_flush()flush()の両方を実行しないといけないのはなぜ?

@7of9 さんから情報いただきました、ありがとうございますm(_ _)m

ob_end_flush()ob_flush()はどちらが良い?

sseで使うのにどちらを使ったほうがいいのかはわかりませんでした。
解説ページによって、ob_flush()だったりob_end_flush()だったりしていて、どちらでも動きとしては問題なさそうでした。
情報お持ちの方おりましたらよろしくお願いいたします


参考

最後まで読んでいただいてありがとうございましたm(_ _)m

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした