1. okumurakengo

    Posted

    okumurakengo
Changes in title
+Server-Sent Events(SSE)をPHPで使うときの自分用まとめ
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,540 @@
+SSEを自分なりに調べました、間違いなどありましたらご指摘お願いいたします:bow:
+
+# Server-Sent Events とは
+
+- [Server-Sent Events](https://www.w3.org/TR/eventsource/)
+
+Google翻訳↓
+
+> この仕様は、DOMイベントの形式でサーバーからプッシュ通知を受信するためのHTTP接続を開くためのAPIを定義します。 このAPIは、Push SMSなどの他のプッシュ通知スキームと連携するように拡張できるように設計されています。
+
+よくwebsocketと比較される。websocketはブラウザとサーバーで双方向通信できるが、SSEはサーバーからブラウザへの一方向の通信。
+
+# SSEのすぐ動かせるサンプル
+
+- [Using server-sent events - Web APIs | MDN](https://developer.mozilla.org/ja/docs/Server-sent_events/Using_server-sent_events)
+
+↑のページのコードを参考にさせていただきました
+
+```php:events.php
+<?php
+header('Content-Type: text/event-stream');
+header('Cache-Control: no-store');
+while(true) {
+ echo sprintf("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);
+}
+```
+
+```html: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 => {
+ ({ 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](https://qiita-image-store.s3.amazonaws.com/0/142910/15627b1b-f394-0b4d-3b1d-4132af71155e.gif)
+
+このとき、stream.phpは以下のように文字列を返しています
+
+![Screen Shot 2019-02-03 at 6.05.35.png](https://qiita-image-store.s3.amazonaws.com/0/142910/91c675b7-3389-c75d-a1ac-045b35ddc0c3.png)
+
+# フィールドごとに調べたこと
+
+`data`、`event`、`id`、`retry` のそれぞれのフィールドについて調べました
+
+## data
+
+基本的に送信するデータは、`'data: ホゲホゲ'`という文字列を`echo`、`print_r`などで出力すると良い、javascriptで`message`イベントで`e.data`から`ホゲホゲ`という文字列を取得できる
+
+```php
+<?php
+header('Content-Type: text/event-stream');
+echo 'data: ホゲホゲ';
+echo "\n\n";
+```
+
+```javascript
+const es = new EventSource('./events.php');
+es.addEventListener('message', e => {
+ console.log(e.data); // ホゲホゲ
+});
+```
+
+---
+
+`stream.php`の出力内容が、空白行ごとに区切られてjsのmessageイベントが実行されていきます。送信したい単位で1行空白行を入れることを忘れないようにしましょう。
+
+例1
+
+```:stream.phpのレスポンス内容
+data: ホゲホゲ
+
+data: フガフガ
+
+data: ピヨピヨ
+
+```
+
+↑だと `message` イベントは3回実行されることになります
+
+---
+
+例2
+
+空白行を入れずに複数行データを送信することもできます
+
+```:stream.phpのレスポンス内容
+data: ホゲホゲ
+
+data: フガフガ
+data: ピヨピヨ
+
+```
+
+↑だと`ホゲホゲ`と`フガフガ\nピヨピヨ`で `message` イベントは2回実行されます
+
+
+
+### NG例
+
+- 大文字小文字は区別される
+
+```php:php
+<?php
+header('Content-Type: text/event-stream;');
+echo 'Data: 送信データ'; // Dが大文字なので無視される
+echo "\n\n";
+```
+
+- 送信したい単位で空白行を1行入れないと送信されない
+
+```php:php
+<?php
+header('Content-Type: text/event-stream;');
+echo 'data: A'; // A が送信される
+echo "\n\n";
+echo 'data: B';
+echo 'data: C'; // B\nC が送信される
+echo "\n\n";
+echo 'data: D'; // この行の後に空白行が続いていないため送信されない
+```
+
+書き方が間違っていてもエラーになるわけではなく、その行が無視されるだけなのでそこが個人的にやりにくかったです
+
+### 複数データの送信
+
+phpでJSON形式の文字列を送信し、jsで`JSON.parse(e.data)` とする
+
+```php:php
+<?php
+header('Content-Type: text/event-stream');
+echo sprintf("data: %s\n\n", json_encode([
+ 'hoge' => 1,
+ 'fuga' => 'A',
+ 'piyo' => false,
+]));
+```
+
+```javascript: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
+<?php
+header('Content-Type: text/event-stream');
+echo 'data:'.json_encode($_SERVER)."\n\n";
+```
+
+![Screen Shot 2019-02-03 at 6.58.56.png](https://qiita-image-store.s3.amazonaws.com/0/142910/c1e3ce12-3515-42ee-56c6-2eecff7cf356.png)
+
+私にはこのようなやり方しか思いつきませんでしたが、とりあえずこれで人間の目にも優しく、EventSourceとしてもJSONとして扱えるように出力することができました
+
+```php
+<?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](https://qiita-image-store.s3.amazonaws.com/0/142910/f1254fa8-fabf-62dd-a2cc-9fbc2b6892b9.png)
+
+## event
+
+自分でイベント名を設定することができる
+
+```php: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: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](https://qiita-image-store.s3.amazonaws.com/0/142910/192bbcf7-d3d5-f0cf-7680-9a928502d456.png)
+
+## id
+
+`data`と一緒に一意のIDの文字列を送ることができます。
+`e.lastEventId`で取得できるようでした。
+
+
+```js:js
+const es = new EventSource('./events.php');
+es.addEventListener('message', e => {
+ console.log(e.lastEventId, e.data);
+});
+```
+
+```php: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](https://qiita-image-store.s3.amazonaws.com/0/142910/65498b48-dfc5-17c0-ea2e-9118b9ef9e41.png)
+
+---
+
+`EventSource`ですが、接続先とエラーで接続が途切れた場合、自動で再接続しに行きます。
+再接続の際にリクエストヘッダーで`Last-Event-ID`を一緒に送信するようです
+
+※events.phpの処理が正常終了した場合もエラー扱いなようで、`error`イベントが実行されるようでした。
+
+![Screen Shot 2019-02-03 at 75926 copy (1) (1).png](https://qiita-image-store.s3.amazonaws.com/0/142910/0e3c8333-9f7f-a1bc-10d4-7e056662963a.png)
+
+```php:php
+echo "data:".$_SERVER['HTTP_LAST_EVENT_ID']."\n\n"; // 3
+```
+
+## retry
+
+再接続しに行くまでの秒数を設定できます。
+デフォルトが何秒なのかを調べましたが、見つけることができませんでした。感覚的には3秒ぐらいなようです。
+
+```php:php
+<?php
+header('Content-Type: text/event-stream');
+
+echo "retry:10000\n"; // 10秒に設定
+echo "data: hoge\n\n";
+```
+
+---
+
+# 接続時のイベント
+
+```js:js
+const es = new EventSource('./events.php');
+es.addEventListener('open', e => {
+ console.log('open');
+});
+```
+
+# エラー処理
+
+`error`イベントで、接続できなかった場合、接続が途切れた場合などの処理を書ける
+
+```js:js
+const es = new EventSource('./events.php');
+es.addEventListener('error', e => {
+ console.log('error');
+ es.close(); // エラーが起きても再接続する必要がない場合はclose()を実行する
+})
+```
+
+---
+
+phpの方でも接続が切れた場合の処理を作成しみる
+
+```php
+<?php
+ignore_user_abort(true);
+header('Content-Type: text/event-stream');
+
+// 接続中の間はループ
+while(connection_aborted() !== 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);`などとすることで接続が切れた後も途中終了せず処理を実行してくれるようです。
+
+参考
+
+- [PHP: ignore_user_abort - Manual](http://php.net/manual/ja/function.ignore-user-abort.php)
+- [PHP: 実行時設定 - Manual](http://php.net/manual/ja/misc.configuration.php#ini.ignore-user-abort)
+
+# 接続状態の確認
+
+![Screen Shot 2019-02-03 at 9.54.58.png](https://qiita-image-store.s3.amazonaws.com/0/142910/f3130e44-980a-4266-6e48-7bba1d0ed7a5.png)
+
+- 引用:https://www.w3.org/TR/eventsource/ ※google翻訳↓
+ - 0:接続はまだ確立されていないか、または切断されてユーザーエージェントが再接続しています。
+ - 1:ユーザーエージェントはオープン接続を持っており、それを受け取るとイベントを送出しています。
+ - 2:接続は開かれておらず、ユーザーエージェントは再接続しようとしていません。致命的なエラーが発生したか、close()メソッドが呼び出されました。
+
+```js: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](https://qiita-image-store.s3.amazonaws.com/0/142910/bc79ae29-d54c-5035-75d6-eb3d09cd1b39.png)
+
+# クロスオリジン
+
+ajaxと同じです。
+urlを別のドメインに設定すると以下のようにエラーになります
+
+![Screen Shot 2019-02-03 at 10.47.40.png](https://qiita-image-store.s3.amazonaws.com/0/142910/eaef6a4c-9052-89c4-2016-a6b4e58847d1.png)
+
+あまりやったことがないので、以下を参考にしつつ行いました。
+間違っていたらご指摘お願いいたしますm(_ _)m
+
+- [CORSリクエストでクレデンシャル(≒クッキー)を必要とする場合の注意点 - Qiita](https://qiita.com/kawaz/items/1e51c374b7a13c21b7e2)
+- [jquery - CORS not working php - Stack Overflow](https://stackoverflow.com/questions/18382740/cors-not-working-php)
+- [php - While loops for server-sent events are causing page to freeze - Stack Overflow](https://stackoverflow.com/questions/29480791/while-loops-for-server-sent-events-are-causing-page-to-freeze/)
+
+別ドメインのphpを読み込む
+
+```js: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);
+});
+```
+
+```php:http&#58;//localhost&#58;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](https://qiita-image-store.s3.amazonaws.com/0/142910/d92adafb-2757-f905-b33d-95d4a172746b.png)
+
+---
+
+### cookieを使う
+
+```js: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);
+});
+```
+
+```php:http&#58;//localhost&#58;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](https://qiita-image-store.s3.amazonaws.com/0/142910/99da571a-b3e0-ff51-e253-6d5deb17be5c.gif)
+
+# 文字化けした
+
+文字化けした時にheaderで`utf-8`と明示すると文字化けしなくなりました
+
+```php:php
+<?php
+header('Content-Type: text/event-stream; charset=utf-8');
+```
+
+
+# nginxで実行
+
+apacheだと特に問題なく動いたのですが、nginxだとうまく行きませんでした、
+下のコードだと1秒おきに出力して欲しいのですが、バッファされているようだけのようでタイムアウトしてしまいました。
+
+↓これだとnginxではうまく動きませんでした。
+
+```php:php
+<?php
+header('Content-Type: text/event-stream; charset=utf-8');
+header('Cache-Control: no-store');
+
+while(true) {
+ echo sprintf("data: %s\n\n", json_encode([
+ 'time' => (new DateTime())->format(DateTime::RFC3339),
+ ]));
+ ob_end_flush();
+ flush();
+ sleep(1);
+}
+```
+
+`X-Accel-Buffering: no`を追加するとうまく行きました
+
+```diff
+<?php
+header('Content-Type: text/event-stream; charset=utf-8');
+header('Cache-Control: no-store');
++ header('X-Accel-Buffering: no');
+
+while(true) {
+ echo sprintf("data: %s\n\n", json_encode([
+ 'time' => (new DateTime())->format(DateTime::RFC3339),
+ ]));
+ ob_end_flush();
+ flush();
+ sleep(1);
+}
+```
+
+![c4zW9j3qGT.gif](https://qiita-image-store.s3.amazonaws.com/0/142910/c2724607-ef35-92b3-fc18-c4a606143590.gif)
+
+---
+
+nginxの設定ファイルをいじるという方法でも大丈夫なようです
+
+- [How do I enable PHP’s flush() with nginx+PHP-FPM? - Server Fault](https://serverfault.com/questions/488767/how-do-i-enable-php-s-flush-with-nginxphp-fpm)
+
+
+## ブラウザ対応状況
+
+https://caniuse.com/#search=server-sent%20events
+
+2019/2/3現在
+
+![Screen Shot 2019-02-03 at 18.16.07.png](https://qiita-image-store.s3.amazonaws.com/0/142910/018885ad-ce3e-b4ca-66eb-e3bc74efe5a3.png)
+
+IE、Edgeでは使えないようなのでpolifillを使うと良いようです
+
+- [Yaffle/EventSource: a polyfill for http://www.w3.org/TR/eventsource/](https://github.com/Yaffle/EventSource)
+
+
+# その他自分が疑問に思った部分
+
+### `ob_end_flush()`と`flush()`の両方を実行しないといけないのはなぜ?
+
+@7of9 さんから情報いただきました、ありがとうございますm(_ _)m
+
+- [PHP buffer ob_flush() vs. flush() - Stack Overflow](https://stackoverflow.com/questions/4191385/php-buffer-ob-flush-vs-flush)
+
+
+### `ob_end_flush()`と`ob_flush()`はどちらが良い?
+
+sseで使うのにどちらを使ったほうがいいのかはわかりませんでした。
+解説ページによって、`ob_flush()`だったり`ob_end_flush()`だったりしていて、どちらでも動きとしては問題なさそうでした。
+情報お持ちの方おりましたらよろしくお願いいたします
+
+
+---
+
+参考
+
+- [Server-sent events - Wikipedia](https://en.wikipedia.org/wiki/Server-sent_events)
+- [Server-Sent Events](https://www.w3.org/TR/eventsource/)
+- [Stream Updates with Server-Sent Events - HTML5 Rocks](https://www.html5rocks.com/en/tutorials/eventsource/basics/)
+- [キャッシュについて整理 - Qiita](https://qiita.com/anchoor/items/2dc6ab8347c940ea4648)
+- [ExpressでServer Sent Event (SSE) を簡単に扱ってみる - Qiita](https://qiita.com/taqm/items/881abcf1df226fb17a15)
+- [WebSocketとServer-Sent Eventの違いとリアルタイムWebアプリの作り方 - WPJ](https://www.webprofessional.jp/real-time-apps-websockets-server-sent-events/)
+- [十三章第二回 Server-Sent Events — JavaScript初級者から中級者になろう — uhyohyo.net](https://uhyohyo.net/javascript/13_2.html)
+- [サーバPUSHざっくりまとめ](https://www.slideshare.net/mawarimichi/push-37869433)
+- [Server Sent Events(SSE)の使いどころと使い方 | GREE Engineers&#039; Blog](https://labs.gree.jp/blog/2014/08/11070/)
+
+
+最後まで読んでいただいてありがとうございましたm(_ _)m