@Satoh_D no blog

大分にUターンしたので記念に。調べたこととか作ったこととか食べたこととか

Django と Channelsでチャット作成のチュートリアルをやってみる(Tutorial Part 2: Implement a Chat Server)

前回の続きでDjangoとChannelsについて頑張る

satoh-d.hatenablog.com

今回はいよいよChannelsを利用してチャットのデモを作るところ
チュートリアルと環境が違うこと、Redis用に新たにdockerコンテナを作るところがあったけど敢えてコンテナを作らず直接インストールしようとしたらすんごいハマってしまった。
でもうまく行ったからよしとしよう

前提

  • Django: 2.2.8(本当はDjang 3.0+がいいけどローカルがこれだったので。。)
  • Channels: 2.4.0
  • Redis-server: 5.0.9

3. tutorial

3-1. Channlesのセットアップ

channles用アプリを作る

$ python manage.py startapp chat

今回はchat/views.py, chhat/__init__.pyしか使わないので他のファイルは削除する <project-app>/settings.pyに先程追加したchatを有効化する処理を追記する

INSTALLED_APPS = [
    ...
    # My applications
    'chat',
    ...
]

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>

chat/views.pyに以下を追記する

def index(request):
    # chat/templates/chat/index.html をビューとして返す
    return render(request, 'chat/index.html')

chat/urls.py, <project-app>/urls.pyにルーティングを追加する

# chat/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index')
]
# <project-app>/urls.py
from django.contrib import admin
from django.urls import path, include

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

3-2. チャットサーバーの実装(Implement a Chat Server)

チャット部屋用のviewを追加する

$ touch <project-app>/chat/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>

追加したテンプレートに対応するview, ルーティングを<project-root>/chat/views.py, urls.pyに追記する

def room(request, room_name):
    return render(request, 'chat/room.html', {
        'room_name': room_name
    })
urlpatterns = [
    path('', views.index, name='index'),
    path('<str:room_name>/', views.room, name='room'), # 追記
]

http://localhost:8000/chat/lobbyにアクセスしてみるとチャット用画面が表示される
試しにテキストボックスにHelloと入力しsendを押してみるとWebSocketのエラーが発生する
これはWebSocketのルーティングが定義されていないためである

Websocketの処理を記述するために<project-root>/chat/consumers.pyを作成する

$ touch <project-root>/chat/consumers.py
# chat/consumers.py
import json
from channels.generic.websocket import WebsocketConsumer

class ChatConsumer(WebsocketConsumer):
    # 接続時処理
    def connect(self):
        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': message
        }))

consumers.py用に新規でルーティングを<project-root>/chat/routing.pyに記載する

$ touch <project-root>/chat/routing.py
# chat/routing.py
from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()),
]

# Django 2.2以下では次のように記載する
# `ChatConsumer` has no attribute `as_asgi()`とエラーが出るため
websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer),
]

上記のas_asgi()はユーザの接続ごとにconsumerのインスタンスを生成するASGIアプリケーションである
Djangoas_view()のようにユーザの接続ごとにviewを生成する動きにと同じようなもの

次に<project-root>/<project-app>/asgi.pyを以下のように編集する

# mysite/asgi.py
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", "mysite.settings")

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

# Django 2.2以下は以下のようなコードになる(get_asgi_applicationあたりが変わっている)
import os

import django
from channels.auth import AuthMiddlewareStack
from channels.http import AsgiHandler
from channels.routing import ProtocolTypeRouter, URLRouter
import chat.routing

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

application = ProtocolTypeRouter({
    "http": AsgiHandler,
    "websocket": AuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})

AuthMiddlewareStackWebSocketの接続情報を定義することでWebSocketの接続プロトコルws://, wss://)で接続できるようになる

改めてhttp://localhost:8000/chat/lobbyにアクセスする
テキストボックスにHelloと入力しsendを押すとエラーなく上のテキストエリアにHellloと表示される

しかしタブを複数開きそれぞれでテキストボックスに文字を入力しても、別のタブには反映されない
これはChatConsumerがそれぞれの接続ごとにインスタンス化されているが、お互いに会話はできていない状態となっている
Channelsにはchannel layerというレイヤーが提供されており、これを利用することでChatConsumer同士で会話ができるようになる

チャンネルレイヤーとは

各コンシューマインスタンス同士やDjangoの他のパーツとの会話ができるようになるシステム
チャンネルレイヤーは以下を提供している

  • channel: メールボックス的なもの。各チャンネルには名前があり、その名前があれば(所属していれば?)誰でもメッセージを送ることができる
  • group: チャンネルの関連的なもの。各グループには名前があり、その名前があれば(所属していれば?)グループの追加/削除に、グループに所属している全てのチャンネルにメッセージを送ることができる

チャンネルレイヤーを利用するにはRedisが動いている必要がある
Redisをインストールする(Tutorialではdockerを起動しているが、PythonのDockerに手動でインストール → 起動してみる)

$ cd /code
# redis-serverのインストール
$ wget http://download.redis.io/releases/redis-5.0.9.tar.gz
$ tar xzf redis-5.0.9.tar.gz
$ cd redis-5.0.9
$ make install
# redis-serverの起動(続けて操作するために&をつけることでBG起動)
$ /usr/loca/bin/redis-server &
# ダウンロードしたファイルの削除
$ cd ..
$ rm redis-5.0.9.tar.gz
$ rm -r redis-5.0.9

RedisPythonから利用するためのパッケージをインストールしておく

$ pip install channels_redis

チャンネルレイヤーを使うための設定を<project-root>/<project-app>/settings.pyに追記する

# mysite/settings.py
# Channels
ASGI_APPLICATION = 'mysite.routing.application'
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

チャンネルレイヤーがRedisの元で動いているか確認をするためにshellで実行してみる

$ cd <project-root>
$ python manage.py shell

# チャンネルレイヤーをimport
>>> import channels.layers
>>> channel_layer = channels.layers.get_channel_layer()
>>> from asgiref.sync import async_to_sync
# test_channelに`hello`とメッセージを送信
>>> async_to_sync(channel_layer.send)('test_channel', {'type': 'hello'})
# test_channelに送信されたメッセージを受信
>>> async_to_sync(channel_layer.receive)('test_channel')
# helloが取得できれば成功
{'type': 'hello'}

これでチャンネルレイヤーを使う準備ができたので、ChatCoonsumerを以下の通り修正する

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

    # メッセージ受信時処理
    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
            }
        )
    
    # メッセージの受信→送信
    def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))
  • ユーザがPOSTしたメッセージはJavaScriptにてWebSocketを通じてChatConsumerに届く
  • ChatConsumerはメッセージに対応するグループに属するChatConsumerに転送する
    • グループ名はURLの部屋名称がChatConsumer.connectにて設定されている
  • 同じグループに属するChatConsumerは受け取ったメッセージをWebSocketを通じてJavaScriptに転送する(chatsocket.onmessage部分)

ソースコードの修正が完了したら http://localhost:8000/chat/lobby を2窓開き、それぞれでメッセージを送信してみる
お互いの窓でメッセージが表示されれば成功

参考サイト