1. 記事一覧 >
  2. ブログ記事
category logo

Material UI(MUI) Recharts WebSocket FastAPIでリアルタイムグラフ描画

(更新) (公開)

はじめに

MUI(Material-UI)v5 Recharts WebSocket FastAPI でリアルタイムグラフ描画の画面を作ってみました。

recharts websocket リアルタイム表示動画


今回、
フロントエンド:React, MUI(Material-UI)v5, Recharts
バックエンド:FastAPI
通信手段:WebSocket
によるリアルタイムグラフ描画 が実現するまでの手順を紹介していきたいと思います。

【検証環境】

Windows 10 Pro x64

 Visual Studio Code 1.69.0

Raspberry Pi Desktop OS(Linux raspberry 4.19.0-13-amd64)

 node 14.16.0

 npm 6.14.11

  react 18.2.0

  recharts 2.1.12

  mui/material 5.8.7

 Python 3.7.3

 pip 22.1.2

  fastapi 0.78.0

  uvicorn 0.18.2

この記事に Recharts の実装の話はほぼありません。(既存の実装にデータを渡しているだけです。)


MUI(Material-UI)v5

まず、MUI(Material-UI)v5 に対応したプロジェクトを作成します。
今回の趣旨とは全く関係無いですが、なんとなく、Tailwind CSS に対応した状態で始めたかったので、
https://github.com/mui/material-ui/tree/master/examples/tailwind-css
をベースに採用します。

$ curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/tailwind-css
$ cd tailwind-css
$ npm install
$ npm start

MUI(Material-UI)v5 Tailwind CSS 画面確認


OK!


Dashboard

画面を作り込まず、テンプレートの "Dashboard" を採用します。

MUI Dashboard テンプレート

https://github.com/mui/material-ui/tree/v5.8.7/docs/data/material/getting-started/templates/dashboard
から
*.tsx をダウンロードします。

Chart.tsx
Dashboard.tsx
Deposits.tsx
Orders.tsx
Title.tsx
listItems.tsx


$ mkdir src/dashboard

src/dashboard

*.tsx を置きます。

MUI Dashboard .tsx 配置


src/App.tsx を以下の内容にします。

src/App.tsx
import React from 'react';
import DashboardContent from './dashboard/Dashboard';

const App = () => {
  return <DashboardContent />;
};

export default App;

MUI Dashboard App.tsx 実装


確認します。

$ npm start

MUI Dashboard 依存パッケージが足りていなくて、エラー

依存パッケージが足りていなくて、エラーになります。
依存パッケージをインストールします。

$ npm install recharts @mui/icons-material

確認します。

$ npm start

ダッシュボードが表示されます。 なお、データは固定で、いろいろクリックしてもほとんど何も起きません。

ダッシュボード 表示 いろいろクリック


FastAPI 側

FastAPI 側(WebSocket サーバー側)を作成します。

https://github.com/ustropo/websocket-example

を参考にしました。

こちらの場合、今回と異なり、フロントエンドからメッセージ送信して、それをきっかけにして、データを取り出しています。フロントエンドの setInterval で1秒毎になっています。また、フロントエンドは、jsです。


python3, pip3 はあらかじめ入っている環境です。


ただ、そのままインストールしようとすると、エラーになりましたので、一旦 setuptools をアップグレードしています。

# pip3 install fastapi "uvicorn[standard]"
・・・
          from setuptools.command.build import build as CommandBuild  # type: ignore[import]
      ModuleNotFoundError: No module named 'setuptools.command.build'

      ----------------------------------------
  Command "python setup.py egg_info" failed with error code 1 in /tmp/pip-install-804avrft/maturin/

  ----------------------------------------
Command "/usr/bin/python3 -m pip install --ignore-installed --no-user --prefix /tmp/pip-build-env-nshvgw65 --no-warn-script-location --no-binary :none: --only-binary :none: -i https://pypi.org/simple --extra-index-url https://www.piwheels.org/simple -- maturin>=0.12,<0.13" failed with error code 1 in None

# pip3 install --upgrade pip setuptools
# pip3 install fastapi "uvicorn[standard]"
# vi run.py

以下の内容で実装します。


/ws で待ち受けて、コネクションが有ったら、1秒に1回

{
    "amount": 01までのランダムな数値,
    "time": 現在時刻のUNIX TIME
}

の json を返す仕様です。

run.py
from fastapi import FastAPI, WebSocket
import random
import time
import asyncio

app = FastAPI(title='WebSocket Example')

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    print('a new websocket to create.')
    await websocket.accept()
    while True:
        try:
            resp = {'amount': random.uniform(0, 1), 'time': time.time()}
            print('resp:', resp)
            await websocket.send_json(resp)
            await asyncio.sleep(1)
        except Exception as e:
            print('error:', e)
            break
    print('Bye..')

起動します。何も指定が無い場合、8000 番ポートで待ち受けます。

# uvicorn --host "0.0.0.0" run:app

Chart.tsx

グラフのコンポーネントは、src/dashboard/Chart.tsx です。
これに固定値 const data = が書かれていますので、コメントアウトして、引数で data を受け取るようにします。

Chart.tsx抜粋(変更前)
function createData(time: string, amount?: number) {
  return { time, amount };
}

const data = [
  createData('00:00', 0),
  createData('03:00', 300),
  createData('06:00', 600),
  createData('09:00', 800),
  createData('12:00', 1500),
  createData('15:00', 2000),
  createData('18:00', 2400),
  createData('21:00', 2400),
  createData('24:00', undefined),
];

export default function Chart() {

Chart.tsx抜粋(変更後)
// function createData(time: string, amount?: number) {
//   return { time, amount };
// }

// const data = [
//   createData('00:00', 0),
//   createData('03:00', 300),
//   createData('06:00', 600),
//   createData('09:00', 800),
//   createData('12:00', 1500),
//   createData('15:00', 2000),
//   createData('18:00', 2400),
//   createData('21:00', 2400),
//   createData('24:00', undefined),
// ];

interface Props {
  data: {
    time: string;
    amount: number;
  }[];
}

export default function Chart({ data }: Props) {

Dashboard.tsx

Chart コンポーネントは、src/dashboard/Dashboard.tsximport していますので、Dashboard.tsx で WebSocket 通信をして、得られたデータを Chart コンポーネントに渡します。

("ws://192.168.11.6:8000/ws") のIPアドレスは、環境によって変更が必要です。

Dashboard.tsx抜粋(変更前)
function DashboardContent() {
  const [open, setOpen] = React.useState(true);
  const toggleDrawer = () => {
    setOpen(!open);
  };

  return (
    <ThemeProvider theme={mdTheme}>

Dashboard.tsx抜粋(変更後)
function DashboardContent() {
  const [open, setOpen] = React.useState(true);
  const toggleDrawer = () => {
    setOpen(!open);
  };

  type Message = {
    time: string;
    amount: number;
  };
  const [message, setMessage] = React.useState<Message[]>([]);
  const ws: React.MutableRefObject<WebSocket | null> = React.useRef(null);
  React.useEffect(() => {
    ws.current = new WebSocket("ws://192.168.11.6:8000/ws");
    ws.current.onopen = () => {
      console.log("Connection Established!");
    };
    ws.current.onclose = () => {
      console.log("Connection Closed!");
    };
    ws.current.onerror = (event) => {
      console.log("WS Error", event);
    };
    return () => {
      if (ws.current !== null) ws.current.close();
    };
  }, []);

  React.useEffect(() => {
    if (!ws.current) return;
    ws.current.onmessage = (evt: MessageEvent) => {
      const response: Message = JSON.parse(evt.data);
      if (!message.length) {
        // 空配列の場合
        setMessage([response]);
      } else if (message.length > 19) {
        // 配列が20個以上の場合
        setMessage([...message.slice(1), response]);
      } else {
        setMessage([...message, response]);
      }
    };
  }, [message]);

  return (
    <ThemeProvider theme={mdTheme}>

Dashboard.tsx抜粋(変更前)
                <Paper
                  sx={{
                    p: 2,
                    display: 'flex',
                    flexDirection: 'column',
                    height: 240,
                  }}
                >
                  <Chart />
                </Paper>

Dashboard.tsx抜粋(変更後)
                <Paper
                  sx={{
                    p: 2,
                    display: 'flex',
                    flexDirection: 'column',
                    height: 240,
                  }}
                >
                  <Chart data={message} />
                </Paper>

起動します。

$ npm start

注意:サーバー側も起動しておきます。

# uvicorn --host "0.0.0.0" run:app

recharts websocket リアルタイム表示動画


OK!!


画面遷移したり、ブラウザを閉じると、コネクションが閉じます。


時刻が UNIX TIME のままだったり、目盛りが表示しきれていなかったりしますが、あとは頑張れば良いということで、以上!


React 実装の説明

最初鼻歌交じりに、以下の実装にしていたのですが、浅はかでした。
一度しか定義されない onmessage のコールバック関数にとって、その時の message (空 = [])しか覚えていなくて、データの追加がうまくいきませんでした。

NG実装
  type Message = {
    time: string;
    amount: number;
  };
  const [message, setMessage] = React.useState<Message[]>([]);
  React.useEffect(() => {
    console.log("useEffect");
    const ws = new WebSocket("ws://192.168.11.6:8000/ws");
    ws.onmessage = (evt: MessageEvent) => {
      const response: Message = JSON.parse(evt.data);
      if (!message.length) {
        // 空配列の場合
        setMessage([response]);
      } else if (message.length > 19) {
        // 配列が20個以上の場合
        setMessage([...message.slice(1), response]);
      } else {
        setMessage([...message, response]);
      }
    };
    ws.onopen = () => {
      console.log("Connection Established!");
    };
    ws.onclose = () => {
      console.log("Connection Closed!");
    };
    ws.onerror = (event) => {
      console.log("WS Error", event);
    };
    return () => {
      if (ws !== null) ws.close();
    };
  }, []);

かと言って、useEffect の第二引数を
}, [message);
にすると、コネクト →message 受信 と高速で繰り返し、無限ループします。


結果、
・接続切断関係の処理は一回だけ。
・onmessage 関係の処理は、message 受信毎。
と分離するとうまくいきました。


さらに、以下の対応が必要でした。
・WebSocket オブジェクト ws は、useEffect の中ではなく、外側のスコープで useRef の参照として宣言。

React実装の説明

loading...