自動化無しに生活無し

WEBとかAIとかLinux関係をひたすら書く備忘録系ブログ

【Django】channelsを使ってWebSocketを実現させる【チャットサイト開発に】

thumbnail

django-channelsのチュートリアルに倣って、WebSocketを使ったチャットサイトを作った。

とても実装難易度が高く、一筋縄では行かない。そのため実装手順の備忘録として本記事をまとめておく。

わかっていること

  • WebSocketとはプロトコルのこと、HTTPでは実現できない双方向通信を実現させるためのもの
  • 前もってHTTPでWebSocketの通信経路を作る必要がある
  • WebSocketの経路確立処理も含め、views.pyではなくconsumers.pyに書かれた内容に従い、処理が行われる
  • consumers.pyの呼び出しは、urls.pyではなくrouting.pyが行う
  • そのrouting.pyをWebSocketプロトコルで呼び出しているのは、asgi.pyである
  • そのため、wsgi.pyではなくasgi.pyを使用してDjangoを起動させる
  • asgi.pyを動作させるため、settings.pyにて、ASGI_APPLICATION 及び CHANNEL_LAYERSの設定が必要
  • バックエンドとしてチャットデータを記録するため、Redisを使用する(デプロイ時のみ。開発時はメモリに記録するため必須ではない)

ソースコード

一部は公式チュートリアルから改変し、私のブログ内で解説している書き方に倣っている。(ビュークラスを使用する、設定ディレクトリをconfigにする等)

Django3.2を使用している。

requirements.txt

aioredis==1.3.1
asgiref==3.4.1
async-timeout==4.0.2
attrs==21.4.0
autobahn==21.2.1
Automat==20.2.0
certifi==2021.10.8
cffi==1.15.0
channels==3.0.4
channels-redis==3.3.1
charset-normalizer==2.0.12
constantly==15.1.0
cryptography==37.0.1
daphne==3.0.2
Django==3.2.13
hiredis==2.0.0
hyperlink==21.0.0
idna==3.3
incremental==21.3.0
msgpack==1.0.3
oauthlib==3.2.0
pyasn1==0.4.8
pyasn1-modules==0.2.8
pycparser==2.21
pyOpenSSL==22.0.0
pytz==2022.1
requests==2.27.1
requests-oauthlib==1.3.1
service-identity==21.1.0
six==1.16.0
sqlparse==0.4.2
Twisted==22.4.0
txaio==22.2.1
typing_extensions==4.1.1
urllib3==1.26.9
zope.interface==5.4.0

config/settings.py

"""
Django settings for config project.

Generated by 'django-admin startproject' using Django 3.2.13.

For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""

from pathlib import Path

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-^7x$tm3r0%61r55ga0df#g9p4o^!-ezs(#ddq2^g(dv(#iy0-_'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []

# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    "channels",
    "chat",
]
#Django-channels使用時に必ず必要。asgi.pyを読む(ここにWebSocketのルーティング設定が書かれてある。)
ASGI_APPLICATION = 'config.asgi.application'

#開発中はRedisサーバーを建てる必要はない
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    }
}

#Radisサーバーを使用する(デプロイ仕様?)
"""
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}
"""


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',
]

ROOT_URLCONF = 'config.urls'

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',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'


# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}


# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/

STATIC_URL = '/static/'

# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

config/urls.py

"""config URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path,include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('chat/', include("chat.urls")),
]

config/asgi.py

"""
ASGI config for config project.

It exposes the ASGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
"""
import os

from django.core.asgi import get_asgi_application

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

application = get_asgi_application()
"""

import os

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

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

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    # Just HTTP for now. (We can add other protocols later.)
    
    #ここでWebSocketサーバーのルーティングを登録( ws/chat/(?P<room_name>\w+)/ でconsumerを呼び出す )
    "websocket": AuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})

chat/views.py

from django.shortcuts import render
from django.views import View

class IndexView(View):

    def get(self,request,*args,**kwargs):
        return render(request,"chat/index.html")

index   = IndexView.as_view()

class RoomView(View):

    def get(self,request, room_name, *args,**kwargs):
        context = {}
        context["room_name"]    = room_name

        return render(request,"chat/room.html",context)

room    = RoomView.as_view()

chat/urls.py

from django.urls import path
from . import views

app_name    = "chat"
urlpatterns = [
    path('', views.index, name="index"),
    path('<str:room_name>/', views.room, name='room'),
]

chat/consumers.py

これがWebSocketの駆動部

# chat/consumers.py
import json
from channels.generic.websocket import WebsocketConsumer

"""
class ChatConsumer(WebsocketConsumer):
    def connect(self):

        # Websocketを受け取り、経路を作る
        self.accept()

    def disconnect(self, close_code):
        pass

    def receive(self, text_data):

        # チャットの投稿を受け取り、それを返却する。
        
        text_data_json  = json.loads(text_data)
        message         = text_data_json['message']

        self.send(text_data=json.dumps({ 'message': "Anonymous > " + message }))
        #self.send(text_data=json.dumps({ 'message': "検閲済み" }))

"""


"""
# chat/consumers.py
import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()

    def disconnect(self, close_code):
        # Leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    # Receive message from room group
    def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))

"""


# chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # Join room group
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    # Receive message from room group
    async def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        await self.send(text_data=json.dumps({
            'message': message
        }))

chat/routing.py

WebSocket専用のurls.pyみたいなもの

# chat/routing.py
from django.urls import re_path

from . import consumers

# WebSocketConsumerは.as_asgi()で呼び出せる。
websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()),
]

templates/chat/index.html

<!-- chat/templates/chat/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Rooms</title>
</head>
<body>
    What chat room would you like to enter?<br>
    <input id="room-name-input" type="text" size="100"><br>
    <input id="room-name-submit" type="button" value="Enter">

    <script>
        document.querySelector('#room-name-input').focus();
        document.querySelector('#room-name-input').onkeyup = function(e) {
            if (e.keyCode === 13) {  // enter, return
                document.querySelector('#room-name-submit').click();
            }
        };

        document.querySelector('#room-name-submit').onclick = function(e) {
            var roomName = document.querySelector('#room-name-input').value;
            window.location.pathname = '/chat/' + roomName + '/';
        };
    </script>
</body>
</html>

templates/chat/room.html

<!-- chat/templates/chat/room.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
    <textarea id="chat-log" cols="100" rows="20"></textarea><br>
    <input id="chat-message-input" type="text" size="100"><br>
    <input id="chat-message-submit" type="button" value="Send">
    {{ room_name|json_script:"room-name" }}
    <script>
        const roomName = JSON.parse(document.getElementById('room-name').textContent);

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

        chatSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };

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

        document.querySelector('#chat-message-input').focus();
        document.querySelector('#chat-message-input').onkeyup = function(e) {
            if (e.keyCode === 13) {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        };

        document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                'message': message
            }));
            messageInputDom.value = '';
        };
    </script>
</body>
</html>

動かすとこうなる。

ROOMを作り、コメントを投稿することでそれが全てに反映される。

結論

まだまだ不明瞭な箇所が多いため、後ほど追記する予定。

一応WebSocketが動作することだけは確認はした。これでロングポーリングよりも遥かにサーバーの負荷を軽減させることができるだろう。

かなりのコストカットが期待できる。

スポンサーリンク

シェアボタン

Twitter LINEで送る Facebook はてなブログ