Django Channels で Websocket を扱い、ホワイトボードみたいなのを作るチュートリアル


Django Channels というライブラリを用いて、Django で WebSocket を扱うチュートリアルを行います。

この記事は、 2024-09-13 に、TORICO の社内勉強会で行った内容の共有となります。

Github にソースコードがあります。 https://github.com/torico-tokyo/django-channels-tutorial

作るもの

Django Channels の機能を使って、簡易的な複数人お絵かきアプリ(ホワイトボードアプリ)を作ります。

同じサーバーに接続している他のブラウザの操作を、WebSocket を使ってすべてのブラウザに送信します。

画像

作成手順

プロジェクトフォルダの作成

mkdir django-channels-tutorial

cd django-channels-tutorial

Pipfile の作成

Pipenv で環境構築をするため、プロジェクト直下に、 Pipfile を作ります。

vim Pipfile

Pipfile

[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]

[packages]
django = "~=5.1"
redis = "*"
channels = "*"
daphne = "*"

[requires]
python_version = "3.12"

daphne をインストールするようにしています。 daphne は、ASGI モードの runserver を提供しています。

仮想環境の構築

pipenv install

仮想環境に入る

pipenv shell

Django プロジェクトの作成

プロジェクトルートの1階層下に、 Django プロジェクトを作っています。

django-admin startproject django_channels_tutorial
cd django_channels_tutorial
  • django-channels-tutorial (プロジェクトルート)
    • Pipfile
    • django_channels_tutorial (Django のプロジェクトルート)
    • .venv

テスト実行

一旦 runserverして、初期ページを表示してみます。

./manage.py runserver

http://127.0.0.1:8000 をブラウザで開くと、ロケットが飛んでいる初期ページが表示されます。

デフォルトページの起動が確認できたら、Control + c で 一旦 runserver は停止します。

エディターのセットアップ

使っているエディターでプロジェクトフォルダーを開いて、開発が行えるよう設定します。

PyCharm なら、Command + , で設定ページを開き、

  • Python Interpreter
  • Enable Django Support
  • Project Structure

の設定を行います。最近の PyCharm は自動認識します。

実行してみて、ロケットが飛んでいる初期ページが表示されればOKです。

ASGI (daphne) に切り替える

現在は、 WSGI による同期モードで開発サーバーが起動しています。

Django Channels の機能を十分に使うには、 ASGI による非同期モードで開発サーバーを動かす必要があるのでコードを修正します。

settings.py の修正

INSTALLED_APPS の一番上に 'daphne', を追加

WSGI_APPLICATION = 'django_channels_tutorial.wsgi.application'

をコメントアウトし、その下に

ASGI_APPLICATION = 'django_channels_tutorial.asgi.application'

を追加。

実行

動作検証のため実行します。

特に挙動が変更されるわけではなく、ロケットが飛んでいる初期ページが表示されればOKです。

ページの見た目は変わらないが、Django が非同期モードで動作しています。

https://channels.readthedocs.io/en/latest/installation.html

Webインターフェイスの作成

アプリを作っていきます。

HTMLテンプレートの作成

Django のプロジェクトの中の django_channels_tutorial アプリの中に、 templates フォルダを作って、 index.html ファイルを作ります。

プロジェクトルートからのパスは django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/templates/index.html です。

django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/templates/index.html
<html lang="ja">
<head>
  <title>WebSocket Scratch</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    .max-square {
      width: min(100vw, 100vh);
      height: min(100vw, 100vh);
    }
    .paint-dot {
      position: absolute;
      width: 2%;
      height: 2%;
      border-radius: 50%;
      pointer-events: none;
    }
    .name-label {
      position: absolute;
      pointer-events: none;
    }
  </style>
</head>
<body>
<div class="flex items-center justify-center h-screen bg-gray-400">
  <div class="max-square bg-white relative" id="playarea">
  </div>
</div>
<div class="fixed top-0 left-0 p-2">
  <div id="user"></div>
</div>
<script>
  const playarea = document.getElementById('playarea');

  // WebSocket クライアントを作成
  // 本来は様々なエラーハンドリングが必要だが今回はチュートリアルのため省略している
  const websocket = new WebSocket('ws://' + window.location.host + '/ws/');

  // 自分のユーザー情報を作成
  const userId = Math.random().toString(32).substring(2);
  const userName = pickPresetName();
  const userColor = pickRandomColor();

  const userSpan = document.createElement('span');
  userSpan.style.color = userColor;
  userSpan.innerText = userName;
  document.getElementById('user').appendChild(userSpan);

  // 他のユーザーのラベルエレメントを入れるもの
  const nameLabels = {};

  /**
   * WebSocket からメッセージを受信し、描画する
   */
  websocket.onmessage = (e) => {
    console.log('onmessage', e.data)
    const data = JSON.parse(e.data);
    if (data.type === 'mousemove') {
      if (data.click) {
        const div = document.createElement('div');
        div.className = 'paint-dot';
        div.style.left = `${data.xRatio * 100 - 1}%`;
        div.style.top = `${data.yRatio * 100 - 1}%`;
        div.style.backgroundColor = data.userColor;
        playarea.appendChild(div);
      }

      if (!nameLabels[data.userId]) {
        const nameLabel = document.createElement('div');
        nameLabel.className = 'name-label';
        nameLabel.style.color = data.userColor;
        nameLabel.innerText = data.userName;
        nameLabels[data.userId] = nameLabel;
        playarea.appendChild(nameLabel);
      }
      nameLabels[data.userId].style.left = `${data.xRatio*100}%`;
      nameLabels[data.userId].style.top = `${data.yRatio*100 + 2}%`;
    }
  };

  /**
   * マウスを動かしたことを WebSocket に送信
   */
  playarea.addEventListener('mousemove', (e) => {
    // playarea に対しての X, Y座標を取得する
    const x = e.offsetX;
    const y = e.offsetY;

    // 場所を割合に変換
    const xRatio = x / playarea.clientWidth;
    const yRatio = y / playarea.clientHeight;

    websocket.send(JSON.stringify({
      type: 'mousemove',
      xRatio: xRatio,
      yRatio: yRatio,
      userId: userId,
      userName: userName,
      userColor: userColor,
      click: e.buttons === 1,
    }));
  });

  /**
   * 適当なユーザー名を選択
   */
  function pickPresetName() {
    // 適当な名前
    const presetNames = [
      'Willow Grouse',
      'Swan',
      'Crane',
      'Condor',
      'Harpy Eagle',
      'Hawk',
      'Cassowary',
      'Black-headed Gull',
      'Osprey',
      'White-tailed Eagle',
      'Long-eared Bandicoot',
      'Keel-billed Toucan',
      'Manatee',
      'Jackal',
      'Giant Anteater',
      'Ostrich',
      'Owl',
      'Japanese Macaque',
      'Malayan Tapir',
      'Polar Bear',
      'Baboon',
      'Peacock',
      'Giant Anteater',
      'African Elephant',
      'American Bison',
      'Reticulated Giraffe',
      'Sandbar Shark',
      'Cat',
      'Pond Turtle',
      'Gecko',
      'Frilled Lizard',
      'Humboldt Penguin',
      'Galapagos Penguin',
      'Hummingbird',
      'Moose',
      'Antelope',
      'Tibetan Yak',
      'Coyote',
      'Camel',
      'Golden Takin',
      'Vanuatu Tree Lobster',
      'Sperm Whale',
      'Swordfish',
      'Sawfish',
      'Marlin',
    ];
    return presetNames[Math.floor(Math.random() * presetNames.length)];
  }

  /**
   * 適当な色を選択
   */
  function pickRandomColor() {
    const _h = Math.floor(Math.random() * 360);
    const _s = Math.floor(Math.random() * 70) + 30;
    const _l = Math.floor(Math.random() * 40) + 25;
    return `hsl(${_h}, ${_s}%, ${_l}%)`;
  }
</script>
</body>
</html>

urls.py の修正

urls.py に、上記 index.html を使う TemplateView を追加します。

今回は、views.py は作らずに、 urls.py の中でインラインで TemplateView.as_view をする方式とします。

django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/urls.py
from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView


urlpatterns = [
    path('', TemplateView.as_view(template_name='index.html'), name='index'),
    path('admin/', admin.site.urls),
]

settings.py の編集

INSTALLED_APPS の一番上に 'django_channels_tutorial' を追加します。

django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/settings.py
...
INSTALLED_APPS = [
    'django_channels_tutorial',  # 追加
    'daphne',
    'django.contrib.admin',
    ...
]
...

実行

ブラウザでページを表示します。

ページ中央に正方形が表示されますが、WebSocket に接続できないため、開発コンソールにエラーが表示されます。

Channels の組み込み

consumers.py の作成

django_channels_tutorial アプリの中に consumers.py を作ります。

Django のウェブサーバーの機能の views.py (ビューコントローラー) と同じようなものだと思ってもらえれば問題無いです。

受信したデータを、同じチャンネルグループにそのまま送るだけの機能を持つものを作ります。

本来は Django の認証機能やデータベースと組み合わせて機能を作れますが、今回はチュートリアルのため、極めて簡易的なものにしました。

django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/consumers.py
import json

from channels.generic.websocket import AsyncJsonWebsocketConsumer


class TutorialConsumer(AsyncJsonWebsocketConsumer):

    async def connect(self):
        """
        クライアントが WebSocket に接続した時
        """
        print(f'[{self.channel_name}] connect')
        # クライアントを broadcast グループに追加
        await self.channel_layer.group_add(
            'broadcast',
            self.channel_name,
        )
        # connect の最後に accept() する。
        await self.accept()

    async def disconnect(self, _close_code):
        """
        切断時の処理
        """
        print(f'[{self.channel_name}] disconnect')
        await self.channel_layer.group_discard(
            'broadcast',
            self.channel_name,
        )
        await self.close()

    async def receive_json(self, data):
        """
        WebSocket からイベントを受信した時
        """
        print(f'[{self.channel_name}] receive_json: {data}')
        # 第二引数の type の . を _ に置換した、self のメソッドを、
        # 指定したグループの全てのクライアントに対して実行する。
        await self.channel_layer.group_send(
            'broadcast',
            {'type': 'broadcast.message', 'data': data}
        )

    async def broadcast_message(self, event):
        print(f'[{self.channel_name}] broadcast_message: {event}')
        data = event['data']
        await self.send(text_data=json.dumps(data))

routing.py の作成

django_channels_tutorial アプリの中に routing.py を作ります。

Django のウェブサーバーの機能の urls.py にあたるものです。

WebSocket プロトコルの、パス別のパスルーティングを行います。

django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/routing.py
from django.urls import path

from .consumers import TutorialConsumer

websocket_urlpatterns = [
    path('ws/', TutorialConsumer.as_asgi()),
]

channels のバックエンドの指定

settings.py に、channels の記憶域として何を使うかを指定します。

通常は Redis を使いますが、今回はチュートリアルのため、簡易的にローカルメモリーを使うようにします。

django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/settings.py
...
CHANNEL_LAYERS = {
    'default': {
        # チュートリアルなので簡易的にローカルメモリーでやる。
        'BACKEND': 'channels.layers.InMemoryChannelLayer'
        # 実運用時は Redis を使う。
        # redis を起動するには
        # docker run --rm -p 6379:6379 redis:7
        # pipenv install channels_redis
        # 'BACKEND': 'channels_redis.core.RedisChannelLayer',
        # 'CONFIG': {
        #      'hosts': ['redis://127.0.0.1:6379/5'],
        # },
    },
}
...

WebSocket プロトコルを処理する設定

現在は http プロトコルしか処理できるようになっていません。 ws プロトコルを、 channels でルーティングできるよう asgi.py を修正します。

asgi.py を修正することで、プロトコル別にルーティングを切り替えることができるようになります。

django-channels-tutorial/django_channels_tutorial/django_channels_tutorial/asgi.py
import os

from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from . import routing

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

application = ProtocolTypeRouter(
    {
        'http': get_asgi_application(),
        'websocket': URLRouter(
            routing.websocket_urlpatterns,
        ),
    }
)

完成

これで開発は完了です。

複数のブラウザで http://127.0.0.1:8000 を表示すると、操作した内容が WebSocket で同期されるのが確認できます。

画像

現在未評価

コメント

コメントを投稿
コメントするには TORICO-ID にログインしてください。
ログイン コメント利用規約