はじめに
最近リアルタイム通信を行う事が出来るServer-Side Events (SSE)という技術について触れる事があったので、紹介したいと思います。
Server-Side Events(SSE) とは
サーバーがHTTP接続を通じてクライアントにリアルタイムでイベントデータを送信するための技術です。
主な特徴として以下のようなものがあります
- サーバーからクライアントへの単方向のみの通信(WebSocketの様な双方向通信ではない)
- 一度接続が確立されると、接続を閉じるまでクライアントはサーバーからのメッセージを受信し続ける
- テキストベースデータのみ(バイナリーデータは不可みたいです)
- 身近な例だとChat GPTのパラパラと表示されるレスポンスにも使用されています
実装してみる
今回はサーバー側にLaravel、フロント側にReactでSSEを使って、1秒毎に現在時刻を取得し続ける実装をしてみたいと思います
こんな感じです
動作の流れ
- クライアント(React)がサーバー(Laravel)のSSEを行うエンドポイントに接続してSSEコネクションを作る
- コネクション作成後は、クライアントはサーバーから1秒毎に現在時刻のレスポンスを受信し続ける
- クライアントは受信した時刻を更新し続ける
Laravel側
- StreamedResponseを使ってレスポンスを生成します
- ヘッダーのContent-Typeをtext/event-streamに設定することで、クライアントがSSEイベントを受信出来るようにします
- while文で1秒毎に現在時刻のデータをJSONデータを送信しています。10秒経過したら止めています
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | public function stream(Request $request) { $count = 1; return new StreamedResponse(function () use ($request, $count) { while (true) { $count++; $time = now()->toDateTimeString(); echo "data: " . json_encode(['time' => $time]) . "\n\n"; ob_flush(); flush(); sleep(1); // 1秒ごとに送信 // 10秒経過したら止める if ($count > 10) { echo "data: " . json_encode(['finished' => true]) . "\n\n"; ob_flush(); flush(); break; } } }, 200, [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', ]); } |
React側
- Event Sourceを使用して、サーバーからのイベントストリームをリッスン開始します
- Event SourceはSSE用のインターフェースでSSEの挙動(イベント受取時やエラー発生時)を簡潔に実装する事が出来ます
- サーバーからデータ受信する度に、onmessageイベントの処理が実行され、画面表示を更新しています
- エラー時とアンマウント時はSSE接続を閉じています
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | export default function Index(props) { const [serverTime, setServerTime] = useState(''); const [finished, setFinished] = useState(false); useEffect(() => { const eventSource = new EventSource('http://localhost:50080/events/stream'); eventSource.onmessage = (event) => { const data = JSON.parse(event.data); // 10秒経過したらSSEを止める if (data.finished) { setFinished(true); eventSource.close(); } setServerTime(data.time); }; // エラーハンドリング。SSEを止める eventSource.onerror = (err) => { console.error("SSE error:", err); eventSource.close(); }; return () => { // ページを離れる際にSSEを止める eventSource.close(); }; }, []); return ( <AuthenticatedLayout auth={props.auth} header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Server-Side Events</h2>} > <div> <h1>Server Time: {serverTime}</h1> </div> {finished && <div>Finished</div>} </AuthenticatedLayout> ); |
この時コンソールのネットワークタブを見ると一秒毎にデータを受信していることが確認出来ます。
Chat GPTのレスポンス
ちなみにChat GPTのプロンプトに「Hello」と入力して「Hello! How can I assist you today?」と表示されるまでのログを見ると、SSEでレスポンスを受け取っていて、partsキーに本文が入力されていく事が確認出来ました
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": [""]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": ["Hello"]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": ["Hello!"]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": ["Hello! How"]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": ["Hello! How can"]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": ["Hello! How can I"]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": ["Hello! How can I assist"]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} data: {"message": {"id": "24f5673d-1bd2-4428-8e2b-a3b3dc723e3c", "author": {"role": "assistant", "name": null, "metadata": {}}, "create_time": 1728664098.547921, "update_time": null, "content": {"content_type": "text", "parts": ["Hello! How can I assist you"]}, "status": "in_progress", "end_turn": null, "weight": 1.0, "metadata": {"citations": [], "content_references": [], "gizmo_id": null, "message_type": "next", "model_slug": "gpt-4o-mini", "default_model_slug": "auto", "parent_id": "3fb00835-1d3c-4ead-8050-2a1628903335", "model_switcher_deny": []}, "recipient": "all", "channel": null}, "conversation_id": "67095222-2fe0-8000-a98e-1f8fc5278734", "error": null} |
さいごに
Server-Side Eventsについて触ってみました。結構使えそうな場面が多そうな気がするので機会があればもう少し深掘りしてみたいなと思います。