django-channels を使った websocket を用いたチャットアプリの作成
N 番煎じですが、websocket を体験したいのでチャットアプリを作成します。
前提
- 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
また、今回の実装ではあまり恩恵はないかもですが、いつものライブラリをインストールします:
- djangorestframework: Home - Django REST framework
- django-filter: django-filter — django-filter 2.4.0 documentation
- django-extensions: Welcome to the django-extensions documentation! — django-extensions 3.1.2 documentation
- django-model-utils: django-model-utils — django-model-utils 4.1.1.post16+g9deb39d documentation
- django-debug-toolbar: Django Debug Toolbar — Django Debug Toolbar 3.2.1 documentation
プロジェクトの作成
django のプロジェクトを作成します:
$ mkdir chat-app
$ cd chat-app
$ pipenv run django-admin startproject config .
プロジェクトの設定
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
を作成して起動に必要な設定を行います:
SECRET_KEY=django-insecure-$6=xxx%xx(xx%xxx*xxxxxxx&x%xxxx)xxxxx!*xx&$xxxx=s-
DEBUG=true
また、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
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 を継承してメールアドレスでログインするシンプルなものにしました。
また、ログイン画面のためテンプレートファイルを用意します:
{% 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ファイルを作成します:
# flake8: noqa
from .message import Message
from .room import Room
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)
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
を次の内容で作成します:
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 ファイルを作成します:
# flake8: noqa
from .index import index_view
from .room import room_view
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()
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 つ用意します:
<!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>
{% 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 %}
{% 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 を定義します:
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
に従うように設定します:
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
を作成します:
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
- 書類管理ウェブシステム構築(1): サーバサイド環境構築・LDAP ログイン
- Django AbstractBaseUserでカスタムユーザー作成
- Home - Django REST framework
- django-filter — django-filter 2.4.0 documentation
- django-model-utils — django-model-utils 4.1.1.post16+g9deb39d documentation
- Django Debug Toolbar — Django Debug Toolbar 3.2.1 documentation
- Welcome to the django-extensions documentation! — django-extensions 3.1.2 documentation
Discussion