@Satoh_D no blog

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

Django Channlesで投票アプリ(簡易版)を作る

先日やったDjango Channelsチュートリアルを応用して投票アプリを作成してみる。
WebSocketの練習にもぴったりですね。

前提

  • Django: 2.2.8
  • Channels: 2.4.0

手順

投票用アプリの作成

次のコマンドを叩いて投票用アプリを作成する

$ python manage startapp vote

テンプレートを作成する

問題作成用HTML, 回答用HTMLを作る

$ mkdir <project-root>/vote/templates/question
$ touch <project-root>/vote/templates/question/question.html
$ mkdir <project-root>/vote/templates/answer
$ touch <project-root>/vote/templates/answer/answer.html
<!-- question.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Create Question</title>
    <style>
        lavel {
            width: 100%;
            display: flex;
        }
    </style>
</head>
<body>

    <h1>Create Question [ Room Name: {{ room_name }} ]</h1>
    <label>
        <span>Question: </span>
        <input type="text" id="vote-question-input" size="100">
    </label>
    <input type="button" id="vote-question-send" value="Send">

    <div>
        回答状況: <span id="vote-results"></span>
    </div>

    <script>
        const roomName = '{{ room_name }}'
        // WebSocketの定義
        const questionSocket = new WebSocket('ws://' + window.location.host + '/ws/vote/' + roomName + '/')
        let answers = []

        // サーバからのメッセージ受信時処理
        questionSocket.onmessage = (e) => {
            const data = JSON.parse(e.data)

            if(data.type !== 'send_answer') return;

            // let resultsData = document.getElementById('vote-resutls').innerHTML || ''
            // resultsData += data.answer + ','
            // document.getElementById('vote-results').innerHTML = resultsData
            answers.push(data.answer)
            document.getElementById('vote-results').innerHTML = answers.join(',')
        }

        // WebSocket終了時処理
        questionSocket.onclose = (e) => {
            console.error('Question socket closed.')
        }

        // 問題送信処理
        document.getElementById('vote-question-send').onclick = (e) => {
            const questionInputDom = document.getElementById('vote-question-input')
            const question = questionInputDom.value;

            if (question === '') return;

            let questionResult = {}
            questionResult.question = question

            questionSocket.send(JSON.stringify({
                'question': question
            }))
            questionInputDom.value = ''
        }
    </script>
</body>
</html>

<!-- answer.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vote Answer</title>
    <style>
        lavel {
            width: 100%;
            display: flex;
        }
        #vote-question-answers {
            display: none;
        }
        #vote-question-answers.show {
            display: block;
        }
    </style>
</head>
<body>

    <h1>Create Question [ Room Name: {{ room_name }} ]</h1>

    <label>
        <span>Question: </span>
        <div id="vote-question-text">投票前です</div>
    </label>
    <div id="vote-question-answers">
        <!-- 今回はデモなので選択肢の文言を固定とする -->
        <label><input type="radio" name="vote-answer" value="大分県"> 大分県</label>
        <label><input type="radio" name="vote-answer" value="大分県以外"> 大分県以外</label>
        <input type="button" id="vote-question-send" value="Send">
    </div>

    <script>
        const roomName = '{{ room_name }}'
        // WebSocketの定義
        const answerSocket = new WebSocket('ws://' + window.location.host + '/ws/vote/' + roomName + '/')

        // サーバからのメッセージ受信時処理
        answerSocket.onmessage = (e) => {
            const data = JSON.parse(e.data);

            if(data.type !== 'send_question') return;

            document.getElementById('vote-question-text').innerHTML = data.question
            document.getElementById('vote-question-answers').classList.toggle('show')
        }

        // WebSocket終了時処理
        answerSocket.onclose = (e) => {
            console.error('Question socket closed.')
        }

        // 問題送信処理
        document.getElementById('vote-question-send').onclick = (e) => {
            const answerInputDoms = document.querySelectorAll('[name=vote-answer]')
            const answers = {}

            for(let i = 0; i < answerInputDoms.length; i++) {
                if(!answerInputDoms[i].checked) continue

                answers.answer = answerInputDoms[i].value
            }

            if (Object.keys(answers).length == 0) return;

            answerSocket.send(JSON.stringify(answers))
            document.getElementById('vote-question-text').innerHTML = '回答しました'
            document.getElementById('vote-question-answers').classList.toggle('show')
        }
    </script>
</body>
</html>

Views, Rouinの設定を行う

question.html, answer.htmlを利用できるよう viewsの設定を行う

# <project-root>/vote/views.py
from django.shortcuts import render

def index(request):
    pass

def question(request, room_name):
    return render(request, 'question/question.html', {
        'room_name': room_name
    })

def answer(request, room_name):
    return render(request, 'answer/answer.html', {
        'room_name': room_name
    })

viewに対する routingの設定を行う

# <project-root>/<project-app>/urls.py
urlpatterns = [
    ...
    path('vote/', include('vote.urls')),
]

# <project-root>/vote/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('question/<str:room_name>/', views.question, name='question'),
    path('answer/<str:room_name>/', views.answer, name='answer'),
]

Websocketの処理を作成する

WebSocketを利用するために consumerを作成する
複数のグループに属する場合のうまい書き方がわからなくてself.channel_layer.group_add()を複数回書いてるけどこんなんでいいのかな…

import json
from channels.generic.websocket import AsyncWebsocketConsumer

class VoteConsumer(AsyncWebsocketConsumer):
    # 接続時処理
    async def connect(self):
        # チャンネル名とグループ名を定義
        self.room_group_name_question = 'question_%s' % self.scope['url_route']['kwargs']['room_name']
        self.room_group_name_answer = 'answer_%s' % self.scope['url_route']['kwargs']['room_name']

        # グループに追加
        await self.channel_layer.group_add(
            self.room_group_name_question,
            self.channel_name
        )
        await self.channel_layer.group_add(
            self.room_group_name_answer,
            self.channel_name
        )
        await self.accept()

    # 切断時処理
    async def disconnect(self, close_code):
        # グループから削除
        await self.channel_layer.group_discard(
            self.room_group_name_question,
            self.channel_name
        )
        await self.channel_layer.group_discard(
            self.room_group_name_answer,
            self.channel_name
        )
        await self.close()
    
    # メッセージ受信時処理
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)

        if 'question' in text_data_json.keys():
            await self.channel_layer.group_send(
                self.room_group_name_answer,
                {
                    'type': 'send_question',
                    'question': text_data_json['question']
                }
            )

        if 'answer' in text_data_json.keys():
            await self.channel_layer.group_send(
                self.room_group_name_question,
                {
                    'type': 'send_answer',
                    'answer': text_data_json['answer']
                }
            )

    # 質問の送信
    async def send_question(self, event):
        question = event['question']

        await self.send(text_data=json.dumps({
            'type': 'send_question',
            'question': question
        }))

    # 回答の送信
    async def send_answer(self, event):
        answer = event['answer']

        await self.send(text_data=json.dumps({
            'type': 'send_answer',
            'answer': answer
        }))

WebSocket用のルーティングを設定する

前述のConsumerに対する routingを設定する
これを設定することでWebSocketのルーティングが有効化される

# <project-root>/<project-app>/asgi.py
...
import django.urls import re_path
import vote.consumers

application = ProtocolTypeRouter({
    "http": AsgiHandler,
    "websocket": AuthMiddlewareStack(
        URLRouter([
            ...,
            re_path(r'ws/vote/(?P<room_name>\w+)/$', vote.consumers.VoteConsumer),
        ])
    ),
})

動作確認

ここまでできたらサーバを起動する(Channels用のRedisも起動しておく)

$ redis-server &
$ python manage.py runserver 0.0.0.0:8000

ブラウザでhttp://localhost:8000/vote/question/lobby, http://localhost:8000/vote/answer/lobbyにアクセスしてみる。

/vote/question/lobbyで質問を入力し送信すると、/vote/answer/lobby側で質問が反映される。質問に回答すると/vote/question/lobby側に回答が追加されるのがわかる。

f:id:Satoh_D:20201028151112g:plain

今回は回答を固定したり、回答者の情報は送信しないなど本当に必要最低限の機能しか実装していないが、あとは作り込みの問題なので割愛とする

Django ChannelsがあるとWebSocketの利用が楽になっていいですね。