リアルタイムチャートをDjango ChannelsとEpoch.jsでつくってみた

# はじめに
あるとき、モーションセンサデータをリアルタイムで可視化したいなと思うことがありました。そこで、ネットでリアルタイム可視化ツールについて調べてみると、近年のIoTブームもありたくさんのツール、製品が世の中に出ていました。無料のツールだと、「[Grafana](https://grafana.com/)」などがあります。個人的には「[Node-RED](https://nodered.org/)」の[dashboard](https://flows.nodered.org/node/node-red-dashboard)も気に入っています。

では、**なぜDjangoでリアルタイムチャートを作ろうと思ったのか？**
理由は3つです。

- **Webアプリケーション**であるためWebブラウザから手軽に利用できる
- 可視化をちょっと試してみたい、ちょっとカスタマイズしたいとき、**言語がPythonだと嬉しい人が多そう**（フロントエンド部分は結局jsに頼るのですが。。）
- **自分の勉強のため**

自分の勉強がてら、誰かのお役に立てれば幸いです。

# システム構成
先に結論です。完成版はこちらです。

https://github.com/yakiimo121/realtime_chart_project

**概要**
今回のシステムは**[Django Channels](https://channels.readthedocs.io/en/stable/)**を用いて実装します。Django Channelsでは、Channelsを介してメッセージをやり取りすることで、HTTPと似た方法でWebsocketの実装が行えます。（画像：[Finally, Real-Time Django Is Here: Get Started with Django Channels](https://blog.heroku.com/in_deep_with_django_channels_the_future_of_real_time_apps_in_django)より）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344018/c7ca0ecf-678f-2d7a-a865-c7b213c80dc2.png" width=500)/>

**HTTP & Websocket**
今回のシステムではブラウザから送られてくるHTTPリクエストの処理は**HTTP**（`VIEW`）が、センサーから送られてくるデータをブラウザへ送信する処理は**Websocket**（`COUSUMER`）が実行します。

**センサとの通信**
センサデータは`CONSUMER`へ送信します。プロトコルで任意です（MQTTやUDPなど）。今回は[HASC Logger](http://hasc.jp/tools/hasclogger.html)というモーションセンサアプリを用いて、加速度をセンシングし、UDPで送信します。

**可視化**
可視化部分はリアルタイムチャートの実装に特化したライブラリである[Epoch.js](https://epochjs.github.io/epoch/)を使用します。

# 環境構築
今回はWSL（ubuntu-18.04）上で作業していきます（普段はLinux上で作業することが多いので）。
Python環境はpyenvで構築します。anaconda3-5.3.1をインストールします（pyenvのインストール方法は省略します）

```
pyenv install anaconda3-5.3.1
pyenv global anaconda3-5.3.1
```

Pythonのバージョンは3.7.0です。

```
python -V
Python 3.7.0
```

djangoとchannelsはpipでインストールします（django==3.2.4 channels==3.0.3で動作確認しました）。

```
pip install django==3.2.4 channels==3.0.3
```


# 実装
## プロジェクトを作成する
`realtime_chart_project`という名前のプロジェクトを作成します。

```
django-admin startproject realtime_chart_project
```

一応、動作確認します

```
cd realtime_chart_project
python manage.py runserver
```

ブラウザから[http://127.0.0.1:8000](http://127.0.0.1:8000)へアクセスします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344018/0f094b96-ff93-514a-ca9a-5c6d02d114fa.png" width=400)/>

## アプリケーションを作成
`realtime_chart`という名前のアプリを作成します。

```
python manage.py startapp realtime_chart
```

`settings.py`の`INSTALLED_APPS`に`'realtime_chart'`を追加します。

````realtime_chart_project/realtime_chart_project/settings.py
INSTALLED_APPS = [
    'realtime_chart',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]
```

## HTTPリクエスト/レスポンスの実装

はじめにURLを設定していきます。DjangoはHTTPリクエストを受け取ると、ルートのURLの設定から参照して `views.py`を探し、`views.py`を呼び出してリクエストを処理します。

```realtime_chart_project/realtime_chart/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.chart, name='chart'),
]
```

```realtime_chart_project/realtime_chart_project/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('realtime_chart/', include('realtime_chart.urls')),
    path('admin/', admin.site.urls),
]
```

続いて`views.py`にリクエストを処理するコードを記述します。ここではチャートを表示する`chart.html`を返す関数を定義します。

```realtime_chart_project/realtime_chart/views.py
from django.shortcuts import render

def chart(request):
    return render(request, 'chart.html', {})
```

`chart.html`の中身は可視化の部分で説明します。

## Websocketの実装（Django Channelsの設定）
`settings.py`の`INSTALLED_APPS`に`'channels'`を追加します。

```settings.py
INSTALLED_APPS = [
    'channels',
    'realtime_chart',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]
```
また、ルートのルーティング設定にChannelsを追加するために、以下を`settings.py`に追記します。

````realtime_chart_project/realtime_chart_project/settings.py
# Channels
ASGI_APPLICATION = 'realtime_chart_project.asgi.application'
```

`consumers.py`を実装します。
ここではUDPでセンサからデータを受け取り、Websocketでブラウザにデータを送信する処理が行われています。

```realtime_chart_project/realtime_chart/consumers.py
from channels.generic.websocket import WebsocketConsumer
import json
import threading
import time
import random

from socket import socket, AF_INET, SOCK_DGRAM


class SensorConsumer(WebsocketConsumer):

    def connect(self):
        self.accept()
        self.start_publish()

    def disconnect(self, close_code):
        self.stop_publish()

    def start_publish(self):
        self.publishing = True
        self.t = threading.Thread(target=self.publish)
        self.t.start()

    def stop_publish(self):
        self.publishing = False
        self.t.join()

    def publish(self):
        # UDPの設定
        HOST = ''   
        PORT = 4001

        s = socket(AF_INET, SOCK_DGRAM)
        s.bind((HOST, PORT))

        while True:
            # センサーからUDPでデータを受信
            msg, address = s.recvfrom(8192)

            t = int(float(msg.decode('utf-8').split('\t')[0]))
            x = float(msg.decode('utf-8').split('\t')[-1].split(',')[1])
            y = float(msg.decode('utf-8').split('\t')[-1].split(',')[2])
            z = float(msg.decode('utf-8').split('\t')[-1].split(',')[3])

            # Websocketで送信
            if self.publishing == False:
                break
            self.send(text_data=json.dumps([
                {'time': t,'y': x,},
                {'time': t,'y': y,},
                {'time': t,'y': z,},
                ]))

        s.close()

```

`rounting.py`を設定します。

```realtime_chart_project/realtime_chart/rounting.py
from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path('ws/', consumers.SensorConsumer.as_asgi()),
]
```

最後にルートのルーティング設定に`realtime_chart.routing`モジュールを追加します。`asgi.py`で、`AuthMiddlewareStack`、`URLRouter`、`realtime_chart.routing`をインポートし、`ProtocolTypeRouter`を以下の形式で`"websocket"`キーに挿入します。

```realtime_chart_project/asgi.py
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
import realtime_chart.routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'realtime_chart_project.settings')

application = ProtocolTypeRouter({
  "http": get_asgi_application(),
  "websocket": AuthMiddlewareStack(
        URLRouter(
            realtime_chart.routing.websocket_urlpatterns
        )
    ),
})
```

## 可視化の実装
`Epoch.js`を使用するのに必要なファイルを[ダウンロード](https://epochjs.github.io/epoch/)します。
ダウンロードしたファイルの中の`css/epoch.min.css`と`js/epoch.min.js`を`realtime_chart_project/static/`下に置きます。

```
static
├── css
│   └── epoch.min.css
└── js
    └── epoch.min.js
```

D3.jsはCDN（Content Delivery Network）上のものを使用します。

```html
<script src="//d3js.org/d3.v3.min.js" charset="utf-8"></script>
```

`chart.html`を作成します。

```realtime_chart_project/templates/chart.html
<html>
<head>
    <title>graph test</title>
</head>
<body>
    <h1>Real time chart</h1>
    <div id="graph" class="epoch" style="height: 200px;"></div>

    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
    <script src="//d3js.org/d3.v3.min.js" charset="utf-8"></script>
    {% load static %}
    <script type="text/javascript" src="{% static 'js/epoch.min.js' %}"></script>
    <link rel="stylesheet" type="text/css" href="{% static 'css/epoch.min.css' %}">
    <script type="text/javascript">

    const chatSocket = new WebSocket(
        'ws://'
        + window.location.host
        + '/ws/'
    );

    var data = [
        { label: "X-axis", values: [] },
        { label: "Y-axis", values: [] },
        { label: "Z-axis", values: [] },
        ];
    var lineChart = $('#graph').epoch({
        type: 'time.line',
        data: data,
        axes: ['left', 'right', 'bottom'],
    });

    chatSocket.onmessage = function(e) {
        const current = JSON.parse(e.data);
        lineChart.push(current);
    };

    chatSocket.onclose = function(e) {
            console.error('Chat socket closed unexpectedly');
        };

    </script>
</body>
</html>
```

最後に、`templates`と`static`のディレクトリを`settings.py`に追加します。

````realtime_chart_project/realtime_chart_project/settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR, 'templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

STATICFILES_DIRS = (
    [BASE_DIR, 'static']
)
```

# 実行
今回はスマートフォンで観測した3軸加速度データをリアルタイムで可視化するデモを行います。

初めにHASC Loggerをスマートフォンにインストールします。
HASC Loggerから`[ホストのIP]:4001`へ３軸加速度をUDPで送信します。設定の方法は「[角速度から回転行列を求める - 実装編](https://qiita.com/yakiimo121/items/1433044a2ca975f0eebe)」を参考にしてください。

リアルタイムチャートアプリを起動します。

```
python manage.py migrate
python manage.py runserver
```

ブラウザから[http://127.0.0.1:8000/realtime_chart](http://127.0.0.1:8000/realtime_chart)へアクセスします。

グラフがリアルタイムで表示されたら成功です。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Django ChannelsとEpoch.jsでセンサデータをリアルタイムに表示するチャート作ってみました。Django Channelsを使用することで、HTTPと同じくらい簡単にWebsocketを実装出来ました。Epoch.jsも学習コストが低く使いやすかったです。 <a href="https://t.co/oflpVucoVk">pic.twitter.com/oflpVucoVk</a></p>&mdash; yakiimo121 (@yakiimo121) <a href="https://twitter.com/yakiimo121/status/1406586719856594949?ref_src=twsrc%5Etfw">June 20, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# まとめ
Django ChannelsとEpoch.jsを用いてリアルタイムチャートを作成しました。Django Channelsを使用することで、HTTPと同じくらい簡単にWebsocketを実装出来ました。Epoch.jsも学習コストが低く使いやすかったです。

ただし、Epoch.jsでも100Hzのモーションセンサデータをリアルタイムで描画するのは困難でした。遅延が発生したり、それにともないキューからデータがこぼれたりします。さすがにWebベースの限界なのでしょうか？それとも他のライブラリを使えば解決できるのでしょうか？？もちろん、温度データを可視化する場合などでは、もっとサンプリングレートが低くていいので問題ありません。

今後は機能を拡張していきたいと思います。データの保存機能、MQTTへの対応、デザインをもっとかっこよくするなどですかね。

# 参考
- chart
    - https://qiita.com/shiro-kuma/items/0607e01a19e093fdb631
    - https://4009.jp/post/archives/20
- Websocket
    - https://www.keicode.com/script/html5-websocket-1.php
    - https://pypi.org/project/gevent-websocket/
    - https://pypi.org/project/django-gevent-websocket/
- Django Channels
    - https://docs.djangoproject.com/ja/3.2/intro/tutorial01/
    - https://channels.readthedocs.io/en/latest/tutorial/index.html
    - https://qiita.com/massa142/items/cbd508efe0c45b618b34#groups
    - https://qiita.com/ekzemplaro/items/a6b81bd1d181fdd0cc24
    - http://engmng.blog.fc2.com/blog-entry-110.html
    - https://kivantium.hateblo.jp/entry/2020/05/02/151321
    - https://blog.heroku.com/in_deep_with_django_channels_the_future_of_real_time_apps_in_django
- Epoch
    - https://qiita.com/okoppe8/items/d8d8bc4e68b1da4a0a36
    - https://syncer.jp/d3js
    - http://epochjs.github.io/epoch/