django-channels を使った websocket を用いたチャットアプリの作成

21 min read

N 番煎じですが、websocket を体験したいのでチャットアプリを作成します。

https://github.com/yk-lab/django-websocket-chat-demo-app

前提

  • pipenv を導入済

フレームワークとライブラリをインストール

$ pipenv --python 3.9
$ pipenv install django channels channels-redis
$ pipenv install djangorestframework django-filter django-extensions werkzeug django-model-utils django-debug-toolbar django-crispy-forms crispy-bootstrap5

django を用います。
合わせて websocket 通信に必要な「channels」「channels-redis」をインストールします。

  • channels
  • channels-redis

また、今回の実装ではあまり恩恵はないかもですが、いつものライブラリをインストールします:

プロジェクトの作成

django のプロジェクトを作成します:

$ mkdir chat-app
$ cd chat-app
$ pipenv run django-admin startproject config .

プロジェクトの設定

config/settings.py を以下のように編集します:

config/settings.py
import os
+from distutils.util import strtobool
from pathlib import Path

+from django_extensions.utils import InternalIPS

...

# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'django-insecure-$6=xxx%xx(xx%xxx*xxxxxxx&x%xxxx)xxxxx!*xx&$xxxx=s-'
+SECRET_KEY = os.getenv('SECRET_KEY')

# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
+DEBUG = strtobool(os.getenv('DEBUG', 'false'))

...

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
+    'django.contrib.sites',
    'django.contrib.messages',
    'django.contrib.staticfiles',
+    'rest_framework',
+    'django_filters',
+    'django_extensions',
+    'crispy_forms',
+    'crispy_bootstrap5',
+    'channels',
+    'accounts',
+    'chat',
+    'debug_toolbar',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    'debug_toolbar.middleware.DebugToolbarMiddleware',
]

...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
-        'DIRS': [],
+        'DIRS': [os.path.join(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',
            ],
        },
    },
]

...

WSGI_APPLICATION = 'config.wsgi.application'
+ASGI_APPLICATION = 'config.asgi.application'

+SITE_ID = 1

...

+CHANNEL_LAYERS = {
+    'default': {
+        'BACKEND': 'channels_redis.core.RedisChannelLayer',
+        'CONFIG': {
+            "hosts": [('redis', 6379)],
+        },
+    },
+}

...

+AUTH_USER_MODEL = 'accounts.User'
+LOGIN_REDIRECT_URL = '/'

...

-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = 'ja-jp'

-TIME_ZONE = 'UTC'
+TIME_ZONE = 'Asia/Tokyo'

...

+CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
+CRISPY_TEMPLATE_PACK = 'bootstrap5'

...

+INTERNAL_IPS = InternalIPS([
+    "127.0.0.1",
+])

+REST_FRAMEWORK = {
+    'DEFAULT_FILTER_BACKENDS': (
+        'django_filters.rest_framework.DjangoFilterBackend',
+    ),
+}

合わせて .env を作成して起動に必要な設定を行います:

.env
SECRET_KEY=django-insecure-$6=xxx%xx(xx%xxx*xxxxxxx&x%xxxx)xxxxx!*xx&$xxxx=s-
DEBUG=true

また、config/urls.py に以下を追加します:

config/urls.py
+import debug_toolbar
from django.contrib import admin
-from django.urls import path
+from django.urls import include, path

urlpatterns = [
+    path('', include('chat.urls', namespace='chat')),
    path('admin/', admin.site.urls),
+    path('api-auth/', include('rest_framework.urls')),
+    path('__debug__/', include(debug_toolbar.urls)),
]

アカウント周り

ユーザレコードとログイン・ログアウト画面はアカウント配下に準備します。

$ docker-compose exec app ./manage.py startapp accounts
accounts/urls.py
from django.contrib.auth.views import LoginView, logout_then_login
from django.urls import path

urlpatterns = [
    path('login/', LoginView.as_view(), name='login'),
    path('logout/', logout_then_login, name='logout')
]

モデル定義、admin は Django AbstractBaseUserでカスタムユーザー作成 を参考に AbstractBaseUser を継承してメールアドレスでログインするシンプルなものにしました。

また、ログイン画面のためテンプレートファイルを用意します:

templates/registration/login.html
{% load crispy_forms_tags %}
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">

    <title>ログイン</title>
</head>

<body>
    <div class="container">
        <div class="card mt-3">
            <div class="card-body">
                <h5 class="card-title">ログイン</h5>
                <form method="POST">
                    {% csrf_token %}
                    {{ form|crispy }}
                    <button type="submit" class="btn btn-dark">ログイン</button>
                </form>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW"
        crossorigin="anonymous"></script>
    {% block script %}
    {% endblock %}
</body>

</html>

念の為、ログアウト用の画面テンプレートの参照先に空ファイルを設置しました(必要ないかもしれません):

いよいよチャットアプリ

ここからが本編です!

  • チャットルームがあって、それぞれのルームで会話できる
  • ログイン済ユーザのみアクセス可能
  • メッセージとユーザは紐付ける

と言ったもの作成します。

モデル定義

chat/models.py は削除して、以下3ファイルを作成します:

chat/models/__init__.py
# flake8: noqa
from .message import Message
from .room import Room
chat/models/room.py
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from model_utils.models import UUIDModel


class Room(UUIDModel, models.Model):
    name = models.CharField(max_length=50, verbose_name='ルーム名')
    created_at = models.DateTimeField(default=timezone.now)
    posted_by = models.ForeignKey(
        get_user_model(), on_delete=models.SET_NULL, null=True, editable=False)
chat/models/message.py
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from model_utils.models import UUIDModel

from .room import Room


class Message(UUIDModel, models.Model):
    room = models.ForeignKey(
        Room,
        related_name='messages',
        on_delete=models.CASCADE
    )
    content = models.TextField()
    created_at = models.DateTimeField(default=timezone.now)
    posted_by = models.ForeignKey(
        get_user_model(), on_delete=models.SET_NULL, null=True, editable=False)

View 定義と URL の設定

chat/urls.py を次の内容で作成します:

chat/urls.py
from django.urls import path

from .views import index_view, room_view

app_name = 'chat'
urlpatterns = [
    path('', index_view, name='index'),
    path('room/<str:pk>', room_view, name='room'),
]

/ でトップページ、room/<str:pk> で実際のチャットルームとします。
ひとまず、トップページにはルームの作成機能とルーム一覧の機能を持たせます。

というわけで、chat/views.py を削除後に下記 3 ファイルを作成します:

chat/views/__init__.py
# flake8: noqa
from .index import index_view
from .room import room_view
chat/views/index.py
from chat.models.room import Room
from django.contrib.auth.mixins import LoginRequiredMixin
from django.forms import ModelForm
from django.urls.base import reverse
from django.views.generic import CreateView


class RoomCreateForm(ModelForm):
    class Meta:
        model = Room
        fields = ['name']


class IndexView(LoginRequiredMixin, CreateView):
    form_class = RoomCreateForm
    template_name = 'chat/index.html'
    queryset = Room.objects.order_by('-created_at')[:5]

    def get_context_data(self):
        context = super().get_context_data()
        context['objects'] = self.get_queryset()
        return context

    def get_success_url(self) -> str:
        return reverse('chat:room', kwargs={'pk': self.object.id})

    def form_valid(self, form):
        form.instance.posted_by = self.request.user
        return super(CreateView, self).form_valid(form)


index_view = IndexView.as_view()
chat/views/room.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView

from ..models import Room


class RoomView(LoginRequiredMixin, DetailView):
    queryset = Room.objects\
        .prefetch_related('messages')
    template_name = 'chat/room.html'


room_view = RoomView.as_view()

あわせて、テンプレートファイルを 3 つ用意します:

templates/base.html
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">

    <title>{% block title %}{% endblock %}</title>
</head>

<body>
    <nav class="navbar navbar-dark bg-dark">
        <div class="container-fluid">
            <a class="navbar-brand" href="/">デモ</a>
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" aria-current="page" href="/">Top</a>
                </li>
            </ul>
        </div>
    </nav>
    <div class="container">
        {% block content %}
        {% endblock %}
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW"
        crossorigin="anonymous"></script>
    {% block script %}
    {% endblock %}
</body>

</html>
templates/chat/index.html
{% extends 'base.html' %}
{% load crispy_forms_tags %}

{% block content %}
<div class="card mt-3">
    <div class="card-body">
        <h5 class="card-title">ルームを新しく作成する</h5>
        <form method="post">
            {% csrf_token %}
            {{ form|crispy }}
            <button type="submit" class="btn btn-primary">作成</button>
        </form>
    </div>
</div>

<div class="card mt-3">
    <div class="card-body">
        <h5 class="card-title">ルーム一覧</h5>
        <div class="list-group">
            {% for room in objects %}
            <a href="/room/{{ room.id }}" class="list-group-item list-group-item-action">{{ room.name }}</a>
            {% endfor %}
        </div>
    </div>
</div>

{% endblock %}
templates/chat/room.html
{% extends 'base.html' %}

{% block content %}
<h1 class="mt-3">{{ room.name }}</h1>

<div class="row g-3" id="chat-list">
    {% for message in room.messages.all %}
    <div class="card">
        <div class="card-body">
            <h5 class="card-title">{{ message.posted_by.email }}</h5>
            <p class="card-text">{{ message.content }}</p>
        </div>
    </div>
    {% endfor %}
</div>

<div class="row g-2 mt-3 align-items-center">
    <div class="col-md">
        <div class="form-floating">
            <input type="text" class="form-control" id="msg" placeholder="メッセージを入力">
            <label for="msg">メッセージ</label>
        </div>
    </div>
    <div class="col-md">
        <button id="send" class="btn btn-dark">送信</button>
    </div>
</div>


<template id="chat-template">
    <div class="card mt-3">
        <div class="card-body">
            <h5 class="card-title"></h5>
            <p class="card-text"></p>
        </div>
    </div>
</template>
{% endblock %}


{% block script %}
<script>
    const url = 'ws://' + window.location.host + '/ws/{{room.id}}';
    const ws = new WebSocket(url);

    document.getElementById('msg').onkeydown = (e) => {
        if (((e.ctrlKey && !e.metaKey) || (!e.ctrlKey && e.metaKey)) && e.keyCode == 13) {
            document.getElementById('send').click();
            return false
        };
    }

    document.getElementById("send").onclick = function sendMessage() {
        const sendData = {
            message: document.getElementById('msg').value,
        }
        ws.send(JSON.stringify(sendData));
        document.getElementById('msg').value = '';
    }

    ws.onerror = e => {
        console.log(e);
    }

    ws.onmessage = e => {
        const receiveData = JSON.parse(e.data);

        const template = document.getElementById('chat-template');
        const clone = template.content.cloneNode(true);

        clone.querySelector('.card-title').textContent = receiveData.user;
        clone.querySelector('.card-text').textContent = receiveData.message;
        document.getElementById('chat-list').appendChild(clone);
    }
</script>
{% endblock %}

最後の templates/chat/room.html には websocket 通信向けの javascript も記述してあります。
中身としては #send ボタンをクリックすると入力済みメッセージを送信、websocket がメッセージを受け取ったら描画する、といったシンプルなものです。

websocket 通信のための設定と consumer

まずは chat/routing.py に websocket の routing を定義します:

chat/routing.py
from django.urls import path

from .consumers import ChatConsumer

websocket_urlpatterns = [
    path('ws/<str:room_id>', ChatConsumer.as_asgi()),
]

config/asgi.py に下記内容を追加して実際に websocket なら routing.py に従うように設定します:

config/asgi.py
import os

import chat.routing
from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application

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

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

あとはメインの chat/consumers.py を作成します:

chat/consumers.py
import json

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from .models import Message


class ChatConsumer(AsyncJsonWebsocketConsumer):
    groups = ['broadcast']

    async def connect(self):
        await self.accept()  # ソケットを accept する

        # URL から room id を取得してインスタンス変数に
        self.room_id = self.scope['url_route']['kwargs']['room_id']
        await self.channel_layer.group_add(  # グループにチャンネルを追加
            self.room_id,
            self.channel_name,
        )

    async def disconnect(self, _close_code):
        await self.channel_layer.group_discard(  # グループからチャンネルを削除
            self.room_id,
            self.channel_name,
        )
        await self.close()  # ソケットを close する

    async def receive_json(self, data):
        # websocket からメッセージを json 形式で受け取る
        message = data['message']  # 受信データからメッセージを取り出す
        await self.createMessage(data)  # メッセージを DB に保存する
        await self.channel_layer.group_send(  # 指定グループにメッセージを送信する
            self.room_id,
            {
                'type': 'chat_message',
                'message': message,
                'user': self.scope['user'].email,
            }
        )

    async def chat_message(self, event):
        # グループメッセージを受け取る
        message = event['message']
        user = event['user']
        # websocket でメッセージを送信する
        await self.send(text_data=json.dumps({
            'type': 'chat_message',
            'message': message,
            'user': user,
        }))

    @database_sync_to_async
    def createMessage(self, event):
        Message.objects.create(
            room_id=self.room_id,
            content=event['message'],
            posted_by=self.scope['user'],
        )

実行する

migrate 後にユーザを作成して、サーバを起動、実際にブラウザからアクセスして動作を確認します。

$ ./manage.py makemigrations # migrate ファイルを作成(accounts, chat)
$ ./manage.py migrate # migrate の実行
$ ./manage.py createsuperuser # SUPER USER を作成

適当に redis サーバを用意したら ./manage.py runserver で起動します。
あとはブラウザからアクセスするとログインページが表示されますので先ほど作成したユーザ情報でログインするとトップページにアクセスできると思います。
トップページからはすでにあるチャットルームの最新5件へのアクセスとチャットルーム作成ができます。
作成 or アクセスするとチャットができます。

今後

チャットルームにオンラインのメンバーリストとかやってみたいですね。

補足

今回は redis サーバを用意しましたが、開発環境であれば下のように config/settings.py を書き換えることでメモリで済ませることができるかもです(未検証)。

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    }
}

参考

django

django channels

環境系