リアルタイム機器ダッシュボード — WebSocketでインキュベーターの状態をストリーミング
このトピックを終えると
教科書で学んだ WebSocket、React state、時系列バッファー を組み合わせて、細胞培養インキュベーターが毎秒出力する温度、CO2、pH の値をブラウザでリアルタイムに表示するダッシュボードを自分で作成できます。HTTP リクエスト/レスポンスしか知らなかった皆さんが、サーバーが最初にデータをプッシュする 処理の世界に足を踏み入れます。
この記事は 教育用一般例 です。細胞培養モニタリングは実験室でよく行われる管理業務なので、題材として選びました。
「次の日の朝になって初めて気づいた」―ポーリングの限界
あなたが、細胞培養インキュベーターに貴重な細胞株を置いたとしましょう。インキュベーターのCO2濃度は**5%**に維持される必要があります。ところが、ある晩、誰かがドアを少し開けたままにしてしまい、CO2濃度が急激に低下します。次の日の朝、あなたは細胞がすべて死んでしまったことに気づきます。
このような事故を防ぐためには、リアルタイムモニタリングが必要です。「値が毎秒更新され、異常があればすぐに通知する」システムです。
最初の試みは、以下のようなものになるかもしれません。
// 素朴なポーリングによるアプローチ
setInterval(async () => {
const res = await fetch("/api/incubator");
const data = await res.json();
updateChart(data);
}, 1000);1秒ごとにサーバーに問い合わせます。 この方法は機能しますが、大きな問題があります。
- ネットワーク負荷: 1秒ごとにHTTPリクエストとレスポンスヘッダーのやり取りが発生します。ほとんどのリクエストは「新しいデータなし」という結果に終わり、無駄になります。
- 遅延: インキュベーターが0.1秒前にCO2の急激な低下を検知したとしても、次のポーリング時点(最大1秒後)までは、画面には正常な値が表示されます。
- 複数の機器への拡張が困難: 5台のインキュベーターを監視する場合、1秒間に5回リクエストを送信します。スケールを拡大すると、サーバーがポーリングの負荷で崩壊します。
これらの問題を解決するのがWebSocketです。リクエスト/レスポンスのやり取りなしに、サーバーがデータが生成されたときにクライアントにプッシュする通信です。この記事では、その仕組みを基礎から作成します。
まずは完成品を見てみましょう(ブラックボックスを先に動かす)
私たちが作成するシステムは、以下のようになります。
┌────────────────┐ WebSocket ┌──────────────────┐
│ Pythonサーバー │ ═══════════════════════════ │ Reactブラウザ │
│ (模擬インキュベーター) │ {temp, co2, pH, timestamp} │ (ダッシュボード) │
│ │ ← 1秒に数回プッシュする │ │
└────────────────┘ └──────────────────┘サーバーは、1秒に数回、以下のようなメッセージをプッシュします。
{"temp": 37.02, "co2": 4.98, "pH": 7.35, "timestamp": 1720000000.5}
{"temp": 37.03, "co2": 4.97, "pH": 7.35, "timestamp": 1720000000.7}
{"temp": 37.01, "co2": 4.98, "pH": 7.36, "timestamp": 1720000000.9}ブラウザは、これらの値を受け取り、直近の60秒間のデータを保持し、リアルタイムで3つのライングラフを描画します。CO2が危険範囲(4.5〜5.5)を超えると、画面上部に警告バナーが表示されます。
ポーリングなしで、遅延なしで、サーバーがデータを生成すると同時に画面が反応します。
このツールはどのような部品で構成されているか(部品分解図)
リアルタイム設備ダッシュボード(フルスタック)
┌──────────────────────────────────────────────────────┐
│ │
│ [サーバーサイド] [クライアントサイド] │
│ │
│ ┌─────────────────┐ ┌────────────────┐ │
│ │ インキュベーターモック値 │ │ WebSocket接続 │ │
│ │ (初期フルデータ) │ │ (受信 → React) │ │
│ └─────────┬───────┘ └────────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌────────────────┐ │
│ │ WebSocketサーバー │ ────────► │ 時系列バッファー │ │
│ │ 部品: websocket │ │ (直近60秒) │ │
│ └─────────────────┘ └────────┬───────┘ │
│ ★ 自分で作成する │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Reactグラフ │ │
│ │ + 警告バナー │ │
│ └────────────────┘ │
│ ★ 自分で作成する │
└──────────────────────────────────────────────────────┘| 部品 | どこで学んだか | このツールで何をするか |
|---|---|---|
| HTML/CSS/React基礎 | html-css-react-basics | ページ構造とコンポーネントのレンダリング |
| WebSocket | websocket-basics | サーバー→クライアントへのプッシュ通信 |
| React state | react-state | プッシュされたデータを画面に接続 |
| 時系列バッファー | time-series-buffer | 直近のデータのみ保持し、古いデータを破棄 |
📌 これらの概念を初めて見る場合は(上部のリンクを参照)
自分で作成する新しい概念は WebSocketサーバー、React state、リング型の時系列バッファーの3つ。 HTML/CSSの基本的な構造はツールとして完成品として提供します。 たったの3つ — 認知の限界内に収まっています。
1단계 — モックインキュベーターサーバー ★ (WebSocketサーバー)
✍️ 自分で記述する部分。 部品 = WebSocketサーバー (Python)。モックインキュベーターは、1秒間に複数回、状態データをプッシュします。
Pythonの websockets ライブラリを使用します。ローカルで実行できる完全なサーバーです。
# server.pyimport asyncioimport jsonimport timeimport random
import websockets # pip install websockets
class MockIncubator: """モックインキュベーター — 温度/CO2/pHをノイズとともにシミュレーションします。""" def __init__(self, target_temp=37.0, target_co2=5.0, target_ph=7.35): self.target_temp = target_temp self.target_co2 = target_co2 self.target_ph = target_ph # 少しドアが開いている状態をシミュレート self.door_open = False
def sample(self) -> dict: # 通常の状態:目標値 ± 小さなノイズ temp_noise = 0.15 co2_noise = 0.10 if not self.door_open else 0.8 ph_noise = 0.02 return { "temp": round(self.target_temp + random.gauss(0, temp_noise), 3), "co2": round(self.target_co2 + random.gauss(0, co2_noise), 3), "pH": round(self.target_ph + random.gauss(0, ph_noise), 3), "timestamp": round(time.time(), 3), "door_open": self.door_open, }
incubator = MockIncubator()
async def stream_handler(websocket): """新しいクライアント接続ごとにこの関数が呼び出されます。""" print(f"[+] クライアントが接続されました: {websocket.remote_address}") try: while True: data = incubator.sample() await websocket.send(json.dumps(data)) await asyncio.sleep(0.5) # 1秒あたり2回プッシュ except websockets.ConnectionClosed: print("[-] クライアント接続が閉じられました")
async def main(): async with websockets.serve(stream_handler, "localhost", 8765): print("サーバーを開始しました: ws://localhost:8765") await asyncio.Future() # 永遠に実行
if __name__ == "__main__": asyncio.run(main())このコードにおける重要なポイントは、while True 内の send と sleep の組み合わせです。各繰り返しで新しいサンプルを作成し、クライアントにプッシュします。HTTPのようにリクエストを待つことはありません。
ローカルテストで検証:
# test_server.py — サーバーが正しくデータをプッシュしているかどうかを、クライアントの模倣で検証import asyncioimport jsonimport websockets
async def sanity_check(): async with websockets.connect("ws://localhost:8765") as ws: # 5つのメッセージを受信 messages = [] for _ in range(5): raw = await ws.recv() messages.append(json.loads(raw)) # 検証 assert len(messages) == 5 for m in messages: assert set(m.keys()) >= {"temp", "co2", "pH", "timestamp"} assert 35 < m["temp"] < 39 # 通常の範囲 assert 3 < m["co2"] < 7 assert 6.8 < m["pH"] < 8.0 # タイムスタンプが時間順に増加 times = [m["timestamp"] for m in messages] assert all(times[i] <= times[i+1] for i in range(len(times)-1))
# asyncio.run(sanity_check()) # サーバーが起動しているときに実行🔎 WebSocketはポーリングとどう違うのか(図解 — websocket-basics) HTTPは、リクエスト → レスポンス → 接続の終了 を毎回繰り返します。WebSocketは、一度接続すると、継続的に維持される双方向のチャネル です。サーバーは、必要に応じていつでもデータをプッシュでき、クライアントもいつでも送信できます。チャット、ゲーム、リアルタイムモニタリングの標準です。
🤔 自己説明プロンプト
while True内にawait asyncio.sleep(0.5)をわざわざ入れました。これを削除するとどうなるでしょうか?サーバーのCPU使用率は?クライアントが1秒あたり何個のメッセージを受信しますか?(ヒント:sleepがない場合、CPUは100%使用 + 1ミリ秒あたり数千個のメッセージ)
ステップ2:時系列バッファー ★ (time-series-buffer)
✍️ 手動で埋めるセクション。 コンポーネント = 循環時系列バッファー。最新のN個のポイントのみを保持し、古い値は自動的に破棄します。
リアルタイムダッシュボードは、すべての過去データを保存する必要はありません。最新の60秒のウィンドウを見るだけで十分です。データが1秒あたり2つずつ来ると、60秒のウィンドウは最大120個のポイントになります。
最も単純なアプローチは、JavaScript配列に継続的にpushし、時々shiftで先頭を削除することです。
// シンプルなバッファー - 問題あり
let buffer = [];
function push(point) {
buffer.push(point);
if (buffer.length > 120) buffer.shift();
}この方法は機能しますが、問題があります。Array.shift() は O(n) です。配列の先頭を削除すると、後続のすべての要素が1つずつシフトされます。データが長時間経過すると、pushごとに O(n) のシフトコストが累積されます。
循環バッファー を使用して、O(1) の push/pop を実現します。
🔎 循環バッファーとは (図 — time-series-buffer) 固定サイズの配列を円のように使用します。書き込みインデックス を1つ使用して、次の値をどこに書き込むかを追跡します。配列の末尾に到達すると、先頭に戻って上書きします。この構造により、push/pop の両方が O(1) になります。オーディオ処理、ログリング、リアルタイムグラフに頻繁に使用されます。
// buffer.ts
export class TimeSeriesBuffer<T> {
private data: (T | null)[];
private writeIdx = 0;
private size = 0;
constructor(public capacity: number) {
this.data = new Array(capacity).fill(null);
}
push(value: T): void {
this.data[this.writeIdx] = value;
this.writeIdx = (this.writeIdx + 1) % this.capacity;
this.size = Math.min(this.size + 1, this.capacity);
}
toArray(): T[] {
// 時間順に並べられた配列を返す
if (this.size < this.capacity) {
// まだ満たされていない — 先頭から size 個
return this.data.slice(0, this.size) as T[];
}
// 満たされている — writeIdx 以降からラップして返す
return [
...this.data.slice(this.writeIdx),
...this.data.slice(0, this.writeIdx),
] as T[];
}
}検証:
// buffer.test.ts
import { TimeSeriesBuffer } from "./buffer";
const buf = new TimeSeriesBuffer<number>(5);
[1, 2, 3].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3]");
[4, 5].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3,4,5]");
// 容量超過 — 古いものを上書き
[6, 7].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[3,4,5,6,7]");O(n) の shift() を O(1) の writeIdx のインクリメント に置き換えました。データが数時間経過しても、push コストは一定です。
🤔 自己説明プロンプト
toArray()が O(n) である理由は? push は O(1) なのに、なぜ返却は O(n) なのか? リアルタイムグラフのレンダリングで、これは問題になるか? (ヒント:60Hz でレンダリングするため、毎フレーム O(120) は意味がない。)
3段階で作る — React stateフック ★ (react-state)
✍️ 自分で埋める部分。 部品 = React state + useEffect。WebSocketからプッシュされた値をバッファーに格納し、画面に反映する。
Reactでリアルタイムデータを扱う際の重要な原理は、useState + useEffect の組み合わせです。
- useState: 画面に表示する「現在のウィンドウ」のスナップショット
- useEffect: WebSocketの接続・切断ライフサイクルを管理
// useIncubatorStream.ts
import { useEffect, useState, useRef } from "react";
import { TimeSeriesBuffer } from "./buffer";
interface Sample {
temp: number;
co2: number;
pH: number;
timestamp: number;
door_open?: boolean;
}
export function useIncubatorStream(wsUrl: string, windowSize: number = 120) {
const [samples, setSamples] = useState<Sample[]>([]);
const [connected, setConnected] = useState(false);
const bufferRef = useRef(new TimeSeriesBuffer<Sample>(windowSize));
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("WebSocketに接続");
setConnected(true);
};
ws.onmessage = (event) => {
const data: Sample = JSON.parse(event.data);
bufferRef.current.push(data);
// React stateに新しいスナップショットを渡す
setSamples(bufferRef.current.toArray());
};
ws.onclose = () => {
console.log("WebSocketを切断");
setConnected(false);
};
// クリーンアップ: コンポーネントがアンマウントされたら接続を閉じる
return () => {
ws.close();
};
}, [wsUrl, windowSize]);
return { samples, connected };
}このフックの決定的な特徴は次のとおりです。
bufferRef(useRef): 循環バッファーは、コンポーネントのリレンダリングとは無関係に維持される必要があります。useRefでラップします。setSamples: メッセージごとに新しい配列を返し、Reactにリレンダリングさせます。配列参照が変更されることで、Reactが変更を検知します。- クリーンアップ関数: コンポーネントが削除されたら、必ず
ws.close()を実行します。そうしないと、ゾンビ接続が積み重なります。
🔎 useRefとuseStateは、いつどちらを使うか (ドロー — react-state) 画面に影響を与える場合はuseState (変更時にリレンダリング)。内部計算や参照のみが必要な場合はuseRef (変更してもリレンダリングしない)。循環バッファー自体はuseRefで、グラフのレンダリング対象であるスナップショットはuseStateで — この分離がパフォーマンスの鍵です。
🤔 自己説明プロンプト
bufferRefを単にlet buffer = new TimeSeriesBuffer(...)としてコンポーネント内に配置した場合、どうなるでしょうか? (ヒント: 各レンダリングごとに新しいバッファーが作成され、過去のデータがすべて失われます。)
4단계:ダッシュボードUIの組み立て ★
いよいよ最後の段階です。フックから取得したサンプルデータを、実際のグラフと警告バナーとして表示します。
// IncubatorDashboard.tsx
import { useIncubatorStream } from "./useIncubatorStream";
function isCritical(latest: any): string | null {
if (!latest) return null;
if (latest.co2 < 4.5 || latest.co2 > 5.5) return `CO2異常:${latest.co2}%`;
if (latest.temp < 36.5 || latest.temp > 37.5) return `温度異常:${latest.temp}°C`;
if (latest.pH < 7.2 || latest.pH > 7.5) return `pH異常:${latest.pH}`;
return null;
}
export function IncubatorDashboard() {
const { samples, connected } = useIncubatorStream("ws://localhost:8765");
const latest = samples[samples.length - 1];
const warning = isCritical(latest);
return (
<div className="dashboard">
<header>
<h1>インキュベーターA</h1>
<span className={connected ? "connected" : "disconnected"}>
{connected ? "リアルタイム接続中" : "接続切れ"}
</span>
</header>
{warning && (
<div className="warning-banner">
⚠️ 警告:{warning}
</div>
)}
<section className="metrics">
<MetricCard label="温度" value={latest?.temp} unit="°C" target={37.0} />
<MetricCard label="CO2" value={latest?.co2} unit="%" target={5.0} />
<MetricCard label="pH" value={latest?.pH} unit="" target={7.35} />
</section>
<section className="charts">
<LineChart data={samples} field="temp" color="#ff6b6b" />
<LineChart data={samples} field="co2" color="#4ecdc4" />
<LineChart data={samples} field="pH" color="#95e1d3" />
</section>
</div>
);
}MetricCardとLineChartは標準コンポーネントなので、詳細な実装は省略します。重要な点は、上記の1つのフックが、ダッシュボード全体のデータソースであるということです。
検証(統合テストの概要):
// integration.test.ts(Playwright / Cypress統合テストの概念)
async function test_dashboard_flow() {
// 1. サーバー起動(テストフィクスチャ)
// 2. ブラウザでダッシュボードをロード
// 3. 5秒以内に、グラフに少なくとも5つのポイントが表示されること
await waitFor(() => expect(chart.pointCount()).toBeGreaterThan(5));
// 4. WebSocket接続が有効であること
expect(document.querySelector(".connected")).toBeInTheDocument();
// 5. door_open=trueに人為的に変更すると、警告バナーが表示されること
await triggerDoorOpen(server);
await waitFor(() => expect(document.querySelector(".warning-banner")).toBeInTheDocument());
}部品を統合 — 完成したダッシュボード構造
全体のシステムをまとめると、以下のようになります。
サーバーサイド(Python):
MockIncubator → sample()を繰り返し実行 → WebSocket.send()
ファイル:server.py
クライアントサイド(React/TypeScript):
useIncubatorStream(wsUrl)
├── WebSocket接続(useEffect)
├── TimeSeriesBuffer(useRef、O(1) push)
└── samples状態(useState、レンダートリガー)
IncubatorDashboard
├── 接続状態の表示
├── 警告バナー(isCritical関数)
├── MetricCard × 3(現在の値)
└── LineChart × 3(60秒ウィンドウ)
ファイル:useIncubatorStream.ts、buffer.ts、IncubatorDashboard.tsxこのツールは、今日、産業用SCADA(Supervisory Control and Data Acquisition)、実験室LIMS(Laboratory Information Management System)などで見られるリアルタイムダッシュボードの縮小版です。実務で使用するツールには、これに加えて**認証、複数の機器へのルーティング、時系列DBへの保存(InfluxDB)、アラートのルーティング(Slack/メール)**などが追加されます。
パフォーマンスの詳細分析 — なぜこのアーキテクチャなのか
ポーリング方式(1秒ごとにfetch):
- サーバーの負荷:クライアントN個 × 1秒あたりのリクエスト1回 = Nリクエスト/秒
- 遅延:平均0.5秒(ポーリング間隔の半分)
- 帯域幅:リクエストごとにHTTPヘッダーのオーバーヘッド(〜500バイト)
WebSocket方式(継続的なプッシュ):
- サーバーの負荷:接続ごとに常時維持(ソケット1つ、TCP維持)
- 遅延:〜ミリ秒(ネットワークの往復のみ)
- 帯域幅:ヘッダーなし、ペイロードのみ(〜80バイト)数値で比較すると:
| シナリオ | ポーリング(1秒間隔) | WebSocket |
|---|---|---|
| クライアント100台・1秒あたりのリクエスト | 100リクエスト/秒 | 0リクエスト/秒 |
| 異常検知の遅延(平均) | 500ms | 〜10ms |
| ペイロードの帯域幅(1秒あたり) | 100 × 500B = 50KB/s | 100 × 80B × 2 = 16KB/s |
検知の遅延が50倍、帯域幅が1/3。 複数の機器を拡張するほど、その差は大きくなります。
別の道もある(マルチパスによる考察)
- Server-Sent Events(SSE): WebSocketよりも単純な代替手段。サーバーからクライアントへの一方通行のみをサポートするが、HTTP上に構築されているため、プロキシやファイアウォールを通過しやすい。クライアントがサーバーにコマンドを送信する必要がない場合(単純なモニタリング)であれば、SSEで十分。
- MQTTブローカー: IoT標準プロトコル。複数のデバイスをブローカーに接続し、ダッシュボードもブローカーにサブスクライブする。デバイスの数が数十〜数百の場合、こちらの方が適している。Mosquitto、HiveMQなどのオープンソースがある。
- 時系列DBとの併用: リアルタイムダッシュボードだけでは、過去のログがない。InfluxDB、TimescaleDB、Prometheusなどに同時に記録することで、リアルタイムビューと過去の分析を同時に行うことができる。
- 再接続ロジック: 弊社のフックは、サーバーが切断された場合に再接続しない。実務では、指数バックオフを使用して再接続するロジックが必須。
ws.onclose内に再試行スケジュールを記述する。 - 警告伝達の拡張: 画面バナーだけでは、夜間の事故対応は不可能。Slack webhook、SMS、PagerDutyなどの外部チャネルに警告を送信することで、より実用的なシステムを構築できる。
核心: 「サーバーがプッシュし、バッファーがウィンドウを維持し、stateが画面を繋ぐ」。この3層がそれぞれの役割を理解していれば、リアルタイムシステムのアーキテクチャが見えてくる。皆さんが今作成したものが、そのアーキテクチャの実体である。
次のステップへ(下部のリンク)
- WebSocketプロトコルの詳細 → WebSocketの原理
- 循環バッファーのさまざまなバリエーション → 時系列バッファー
- 先ほど作成した視覚化と組み合わせる → RNA-seqヒートマップ
実際にやってみよう(独立した課題)
- 再接続ロジック: WebSocketが切断された場合、1秒、2秒、4秒、8秒(指数関数的バックオフ)で再試行するロジックをフックに追加してください。
- 複数デバイス: 5台のインキュベーターを監視するダッシュボードに拡張してください。各デバイスに対して個別のWebSocket接続を確立します。パフォーマンスのボトルネックがどこにあるかを観察してください。
- ダウンサンプリング: 1秒あたり20個のメッセージが送信される場合、画面のレンダリングを1秒あたり5回に制限したいと考えています。フックにdebounce/throttleを追加してください。
- 挑戦 — SSEへの移植: 同じシステムをWebSocketの代わりにServer-Sent Eventsを使用して再実装してください。コードの複雑さはどのように変化しますか?
まとめ
「機器がリアルタイムで出力するデータを、すぐに画面に反映する」という問題を、以下の3つの要素に分解して解決しました。
- WebSocket により、サーバーがデータをプッシュする通信チャンネルが開かれました(ポーリングは不要)。
- リング型の時系列バッファー が、O(1) でのプッシュにより、最新のデータを保持しました。
- React state (+ useRef) が、データフローとレンダリングを正確に結びつける役割を果たしました。
インキュベーターが毎秒出力する値が、ミリ秒単位の遅延で画面に表示されるようになりました。翌朝になって初めて異常を発見する時代は終わりです。ドアが開いてから5秒以内に、画面に警告が表示されます。
この記事は、一般的な教育用サンプル です。実際の実験室ダッシュボードには、認証、複数機器へのルーティング、時系列データベース、通知システムなどが追加されます。より詳細なバージョンは、この基本構造にさらに機能を追加するか、実績のあるSCADA/LIMSツールを使用することで実現できます。