自動化無しに生活無し

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

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

thumbnail

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

とても実装難易度が高い。実装手順の備忘録として本記事をまとめる。

ソースコード

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

https://github.com/seiya0723/django-channels-websocket-sample

ライブラリのインストール

pip install django channels daphne

config/settings.py

"""
Django settings for config project.

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

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

For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/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/5.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-6)asy#w-p8gyk0d&a+8s17%o0gkq9aky6(9p)4t7-d#x+&wl=e'

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

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [

    "daphne",
    "chat.apps.ChatConfig",

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

#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/5.1/ref/settings/#databases

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


# Password validation
# https://docs.djangoproject.com/en/5.1/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/5.1/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

USE_I18N = True

USE_TZ = True


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

STATIC_URL = 'static/'

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

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

チャンネルとは?

WebSocket接続を確立したユーザーとサーバーの経路のこと。

このチャンネルを管理し、必要に応じてグループ化しているのが、チャンネルレイヤー。

チャンネルレイヤーとは?

チャンネルレイヤーは DjangoChannels で非同期プロセス間で通信をするための仕組み。

WebSocketの接続(チャンネル)をグループ化して、1度にグループに含まれるチャンネル全てにメッセージを送ることができる。

DjangoChannelsでは、WebSocket上のやり取りは全て、チャンネルレイヤーを通して行われる。

このサンプルコードでは、

  • self.channel_layer.group_add() : 接続(チャンネル)をグループに追加
  • self.channel_layer.group_discard() : 接続(チャンネル)をグループから削除
  • self.channel_layer.group_send() : グループ(グループに所属するチャンネル全て)に対してメッセージを送る

この3つが使われている。

グループは能動的に「新規作成」、「削除」はできないようになっている。

  • 存在しないグループに接続を追加するとき、自動的にグループが新規作成される
  • グループに接続が1つもない場合は、自動的にグループが削除される

キャッシュサーバーとは?

キャッシュサーバーとは、データを一時的に保管しておくサーバーのことである。

例えば、DBにポーリングをしているようでは、DB(システムのディスク)に負荷がかかる上に、遅い。

そこで、ディスク内にあるデータをメモリ(RAM)に記録する。RAMであれば読み書きも高速。

ただし一般的にキャッシュサーバーは、RAM(揮発性メモリ)に記録しているため電源が切れると揮発する。

Redisとは?

Redisは、キャッシュサーバーの中でも耐障害性などに優れている。

今回、開発中にローカルメモリキャッシュを使用しているが、揮発性が高く、障害時にディスクに保存をする機能はない。

揮発したデータは取り戻せないため、Redisではディスクに保存をする設定などが用意されている。

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+)/ でconsumersを呼び出す )
    "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()

WebSocketの接続を確立させるためのページを表示している。

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

このurls.pyはビューとだけ紐付いている、WebSocketとは直接的には関係ない。

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):

    # TIPS: このconnectメソッドは クライアント側のJavaScriptで、WebSocketクラスが実行されたときに実行される。
    async def connect(self):

        # TIPS: このself.scopeには、クライアント側の情報(認証状態、ユーザー情報、送信先URLなど) が含まれている。
        # django-channels版 request オブジェクトのようなもの
        # ただし、self.scope は辞書であり、request と違って オブジェクトではない 

        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # グループに接続を追加している。
        # TIPS: self.channel_layer は settings.pyで指定したチャンネルレイヤーの操作をする
        # TIPS: .group_add() で 接続した人をグループに接続する。
        # TIPS: この self.channel_name は接続ごとに割り当てられた一意の名前
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )


        # TIPS: self.accept() でwebsocket の接続を正式に確立する。connectメソッドで↑のグループ追加をしない場合、↓だけでも良い。
        await self.accept()

    
    # TIPS: このdisconnectはブラウザを閉じる、ページ遷移したときに発動する。
    # WebSocketはステートフル。ステートレスなHTTPと違い、ブラウザ終了、ページ遷移が検知できる。
    async def disconnect(self, close_code):

        # TIPS: 接続をグループから削除している。
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )


    # TIPS: クライアントからメッセージを受け取ったときの処理。
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # TIPS: グループに対して、メッセージを送る。グループに所属している接続ごとに、chat_messageが実行される。
        # TIPS: type には、実行したいメソッドを指定する。
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )


    # TIPS: 接続に対してメッセージを送信する処理、receiveメソッドから呼び出される。
    async def chat_message(self, event):
        message = event['message']

        # メッセージをJSONにして送信する。(送信先のグループ  はreceive で指定している。)
        await self.send(text_data=json.dumps({
            'message': message
        }))

chat/routing.py

consumers.py のURL設定

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

from . import consumers

# TIPS: WebSocketConsumerは.as_asgi()で呼び出せる。
# TIPS: 一致したパターンは、コンシューマーの self.scope から参照できる。
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);

        // TIPS: routing.py で定義したWebSocketエンドポイントを引数に入れ、WebSocketのオブジェクトを作る。
        // TIPS: consumers.py で  self.accept() されると接続確立。
        const chatSocket    = new WebSocket(`ws://${window.location.host}/ws/chat/${roomName}/`);


        // TIPS: consumers.py で self.send() されたとき(メッセージを受け取ったときに発動。)
        chatSocket.onmessage = (e) => {
            const data = JSON.parse(e.data);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };

        // TIPS: consumers.pyから .group_discard() で切断されたとき
        chatSocket.onclose = (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();
            }
        };

        // TIPS: メッセージを送信する。WebSocket のオブジェクトから .send() 引数はJSON文字列
        document.querySelector('#chat-message-submit').onclick = (e) => {
            const messageInputDom   = document.querySelector('#chat-message-input');
            const message           = messageInputDom.value;

            chatSocket.send(JSON.stringify({ 'message': message }));
            messageInputDom.value = '';
        };


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

WebSocket クラスを使って接続を確立する。このときの引数には、routing.pyで取り決めたURLを指定し、コンシューマーを呼び出す。

コンシューマーは接続を了承。チャットの投稿ができるようになる。

動かすとこうなる。

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

結論

まとめるとこうなる。

  • WebSocketとはプロトコルのことである、HTTPと違いステートフルで、接続状況を管理できる
  • DjangoでWebSocketを簡易に実現させるために、django_channels がある
  • django_channels では、チャンネルレイヤーという仕組みを使って、接続をグループで管理することができる
  • self.channel_layer のメソッドを使って、接続をグループ追加、接続をグループから削除、グループにメッセージを送信することができる
  • サイトの全機能をWebSocketで実現すると、コードが非常に複雑になる。部分的に導入が定石

このように、要求される前提知識は非常に多い。

もし難しいと感じる場合は、一旦SSE(ServerSentEvents)を使ったリアルタイム通信機能も確認すると良いだろう。

DjangoでServerSentEvents(SSE)とローカルキャッシュを使い、リアルタイムでDB内の情報を表示する

このSSEも、JavaScriptでSSEストリームを受取するようサーバーに要求し、以降Django側から返されるメッセージをもとにレンダリングをしている。

これらWebSocketやSSEに、Reactを追加してSPAにするには、メッセージ受信時にStateを書き換えすれば良い。

スポンサーリンク

シェアボタン

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