Django Channels でチャットアプリ作成（Python）

# はじめに

Djangoを使ったチャットアプリを作ってみました。
その中でChannelsと呼ばれるライブラリを使用したのですが、
まだまだ記事が少なく、構築が非常に大変だと感じました。

今回は振り返りの意味も込めて、記事を残します。
不備や、間違った記載があればご指摘いただけると幸いです。

また記事の最後に今回作成したものを
Githubに上げておりますので、参考にどうぞ！
![aperture-video-72e8b811-4d39-4c56-87b4-6de80857c938.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258186/15ee63a5-27b9-ce7a-6c7f-7182ee706867.gif)

# ローカル用に構築していく

## 構成

Docker Compose を使用して、開発環境を準備いたします。
今回は下記のような環境でローカルサーバーを立てていきたいと思います。

|名称|内容|ポート番号|
|---|---|---|
|django|アプリサーバー|8001, 3001|
|nginx|Webサーバー|8000|
|mysql|DBサーバー|3306|
|redis|キャッシュサーバー|6379|

## 環境構築

DockerFileをまずゴリゴリ書いていきます。
Djangoから、

```dockerfile:Dockerfile

# Django

FROM python:3.6
ENV PYTHONUNBUFFERED 1

WORKDIR /server
ADD requirements.txt /server/
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
ADD . /server/


```

ライブラリインストール用のファイルも記載していきます。

``` txt:requirements.txt

channels_redis==2.2.1
asgiref==2.3.0
channels==2.1.2
daphne==2.2.1
Django==2.1
PyMySQL==0.9.2
pytz==2018.5
redis==2.10.6

```

Django の起動を自動で起動するためのシェルを書いていきます。

```bash:start-django.sh

#!/bin/bash
python manage.py makemigrations
python manage.py migrate
python manage.py collectstatic --noinput
nohup uwsgi --socket :8001 --module chat_demo.wsgi & daphne -b 0.0.0.0 -p 3001 --ping-interval 10 --ping-timeout 120 chat_demo.asgi:application

```

nginx用のDockerfileを書いていきます。
nginxはWebサーバーとして使用していきます。
設定ファイルの中身はGithubを参照のこと

```docker:Dockerfile

FROM nginx:1.11.7

RUN apt-get update

# 設定ファイル nginx
ADD nginx/nginx.conf /etc/nginx/nginx.conf
ADD nginx/default.conf /etc/nginx/sites-available/default
ADD nginx/default.conf /etc/nginx/sites-enabled/default
ADD nginx/start-nginx.sh /etc/nginx/start-nginx.sh
ADD nginx/uwsgi_params /etc/nginx/uwsgi_params
ADD nginx/robots.txt /usr/share/nginx/robots.txt

```

MysqlのDockerfileも作成していきます。
init.sql起点で作成、my.cnfは各自記載してもよし、記載しなくてもよしだと思います。

``` dockerfile:Dockerfile

FROM mysql:5.7

ADD ./init.sql /docker-entrypoint-initdb.d/init.sql
ADD ./my.cnf /etc/mysql/my.cnf
RUN chmod 644 /etc/mysql/my.cnf

```

Docker Compose を使って各種Dockerの構築していきます。

``` yml:docker-compose.yml
version: '3'

services:

  nginx:
    build:
      context: ./
      dockerfile: ./nginx/Dockerfile
    command: 'sh /etc/nginx/start-nginx.sh'
    environment:
      TZ: 'Asia/Tokyo'
    ports:
      - 8000:8000
    volumes:
      - ./nginx/logs/nginx/:/var/log/nginx/
      - ./nginx/uwsgi_params:/etc/nginx/uwsgi_params
      - ./django/static:/var/www/static/
    depends_on:
      - django

  redis:
    image: redis:alpine
    expose:
      - "6379"

  mysql:
    image: mysql:5.7
    ports:
      - 3306:3306
    environment:
      - MYSQL_ROOT_PASSWORD=test # YOUR PASSWORD
      - MYSQL_ROOT_HOST=%
    volumes:
      - ./db/db-datadir:/var/lib/mysql
      - ./db:/docker-entrypoint-initdb.d
      - ./db/my.cnf:/etc/mysql/my.cnf

  django:
    build:
      context: ./django
      dockerfile: Dockerfile
    command: 'sh /server/start-django.sh'
    # command: 'python /server/manage.py runserver'
    expose:
      - "8001"
      - "3001"
    volumes:
      - ./django:/server/
    depends_on:
      - mysql
      - redis
```

最後に下記コマンドを実行して、
サーバーが正常に立ち上がるか確認します。

## Djangoの初期構築

Djangoの初期構築をしていきます。

```
$ docker-compose run django django-admin startproject chat_demo
```

プロジェクトが生成されますので、確認します。
(chat_demoの中にchat_demoが作られるのが、、、という人は１階層上に上げても良いかもしれないです。)
<img width="193" alt="django01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258186/f9396355-8f54-edb0-27a4-d06023664745.png">

生成された、settings.pyに今回入れるredis, channels, databaseの設定を追加していきます。

```python:settings.py
# 一部割愛：変更・追加点のみ

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'channels', # 追加
    'chat' # 追加
]

# 〜〜〜　省略

WSGI_APPLICATION = 'chat_demo.wsgi.application'
ASGI_APPLICATION = 'chat_demo.routing.application' # 追加

# 〜〜〜　省略

# Redisの設定
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [('redis', 6379)]
        }
    }
}

# 〜〜〜　省略

# databaseの設定
import pymysql
pymysql.install_as_MySQLdb()

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'test',
        'USER': 'test',
        'PASSWORD': 'test',
        'HOST': 'db',
        'PORT': 3306,
        'OPTIONS': {'charset': 'utf8mb4'}
    }
}

```

```chat_demo/asgi.py
# coding: utf-8
import os
import django
from channels.routing import get_default_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chat_demo.settings")
django.setup()
application = get_default_application()
```

# チャットアプリケーションを作成：URL設定

チャットアプリケーションを作っていきます。
ついでにDBの接続確認がてらマイグレートもしておきます。

```
docker-compose run django python /server/chat_demo/manage.py startapp chat
docker-compose run django /server/manage.py migrate
```

上記のコマンドを実行するとチャットアプリとなる骨組みを生成します。
画像のようなファイルが作成されていればOKです。
<img width="188" alt="chat.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258186/916d1b13-900e-7477-fc9a-f395f47f9684.png">

ルーティングを設定していきます。
今回は部屋ごとにルームがあり、そこから
チャットを展開していくようなアプリを作っていきたいと思います。

```none:urlイメージ

# 表示用のルーティング設定
/ ルート
ルーム作成する所、既存のルーム一覧も表示される
/chat/{ルーム名}
ルーム内、チャットを表示していく所  
/room/{ルーム名}
ルームの作成処理

# Websocketで繋いでいくとこの設定
/ws/{ルーム名}/{個人ID}
ルーム毎に個人IDを割り振っていきます。

```

まず、大元のURLを設定していきます。
下記のような形でルーティングを設定していきます。
今後の機能追加も鑑みて大元のURLから
アプリケーション毎に辿れるような設計にします。

```py:chat_demo/urls.py
urlpatterns = [
    path('', include('chat.urls', namespace='chat')), # 追加
    path('admin/', admin.site.urls),
]
```

```py:chat/urls.py
from django.urls import include, path
from .views import *

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

```py:chat_demo/routing.py
from django.urls import include, path
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.sessions import SessionMiddlewareStack
from django.urls import path
from chat.consumers import *

websocket_urlpatterns = [
    path('<str:room_name>', ChatConsumer),
]
application = ProtocolTypeRouter({
    'websocket': AuthMiddlewareStack(
        URLRouter(
            websocket_urlpatterns
        )
    ),
})
```

# チャットアプリケーションを作成：model作成

URLの設定が完了しましたら、DBに入れるテーブルを作成していきます。
Djangoではmodels.pyに作成した後、マイグレーションすることで、
mysqlに自動で紐づくような作りになっています。

テーブル設計を簡単にしていきます。
今回はルームとそれに紐づくメッセージのみとします。

#### Roomテーブル

|名称|内容|型|
|---|---|---|
|id|ユニークキー|uuid|
|name|ルーム名|char|
|created_at|作成日時|datetime|

#### Messageテーブル

|名称|内容|型|
|---|---|---|
|id|ユニークキー|uuid|
|room|外部キー（Roomテーブル）|ForeignKey|
|name|会話者|char|
|centent|会話内容|text|
|created_at|作成日時|datetime|

実際にゴリゴリ実装していきます。

```python:chat/models.py
import uuid
from django.db import models
from django.utils import timezone

class Room(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    name = models.CharField(max_length=50)
    created_at = models.DateTimeField(default=timezone.now)

class Message(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    room = models.ForeignKey(
        Room,
        blank=True,
        null=True,
        related_name='room_meesages',
        on_delete=models.CASCADE
    )
    name = models.CharField(max_length=50)
    content = models.TextField()
    created_at = models.DateTimeField(default=timezone.now)
```

# チャットアプリケーションを作成：View作成

Chatの見える部分（フロント部分）と、
ルーティングした際のサーバー側の動きを実装していきます。

まずはフロントに当たるテンプレートのhtmlファイルに結びつける
モデルをいれていきます。

```chat/views.py
from .models import *
from django.http import HttpResponseRedirect, HttpResponse
from django.template import loader
from django.shortcuts import redirect
from django.urls import reverse

def index(request):
    room_list = Room.objects.order_by('-created_at')[:5]
    template = loader.get_template('chat/index.html')
    context = {
        'room_list': room_list,
    }
    return HttpResponse(template.render(context, request))

def chat(request, room_name):
    messages = Message.objects.filter(room__name=room_name).order_by('-created_at')[:50]
    room = Room.objects.filter(name=room_name)[0]
    template = loader.get_template('chat/chat_room.html')
    context = {
        'messages': messages,
        'room': room
    }
    return HttpResponse(template.render(context, request))

def room(request):
    name = request.POST.get("room_name")
    room = Room.objects.create(name=name)
    return HttpResponseRedirect(reverse('chat:chat_room', args=[name]))
```

次にhtmlファイルとCSSファイルを作っていきます。
Javascriptは、送信するときのWebsocketサーバーに向き先を宛てて
実装していきます。

```html:chat/index.html
{% load static %}

<link rel="stylesheet" type="text/css" href="{% static 'chat/style.css' %}">

<div class="header">
Django - チャットアプリデモ
</div>

<h2>ルームを新しく作成する<h2>
<div class="create-room-box">
    <form action="/room/" method="post">
        {% csrf_token %}
        <input placeholder="ルーム名を入力" type="text" name="room_name" value="">
        <input type="submit" class="send" name="button" value="作成"> 
    </form>
</div>

<h2>ルーム一覧</h2>
<ul class="select-room-box">
{% for room in room_list %}
    <a href="/chat/{{ room.name }}">{{ room.name }}</a>
{% endfor %}
</ul>
```

```html:chat/chat_room.html
{% load static %}

<link rel="stylesheet" type="text/css" href="{% static 'chat/style.css' %}">

<div class="container">

<div class="header">
<a href="/" class="back">＜戻る</a>
Django - チャットアプリデモ - {{ room.name }}
</div>

<div class="chat-room-body">
{% for message in messages %}
    <div class="chat-box">
        <div class="chat-header">
            名前：{{message.name}}
        </div>
        <div class="chat-body">
            {{message.content}}
        </div>
    </div>
{% endfor %}
    <div id="footer"></div>
</div>

<div class="chat-room-footer">
    <div class="send-msg">
        <input placeholder="名前を入力" id="name" value=""/>
        <input placeholder="メッセージを入力" id="msg" value=""/><button id="send">送信</button>
    </div>
</div>

</div>

<script>
const url = 'ws://localhost:8000/ws/' + '{{room.name}}'
var ws = new WebSocket(url)

document.getElementById("send").onclick = function sendMessage () {
    var sendData = {
        name: document.getElementById('name').value,
        message: document.getElementById('msg').value
    }
    ws.send(JSON.stringify(sendData))
}

ws.onmessage = e => {
    var receiveData = JSON.parse(e.data)
    var messageBox = document.createElement('div')
    messageBox.className = 'chat-box'
    var header = '<div class="chat-header">名前：' + receiveData.name + '</div>'
    var body = '<div class="chat-body">' + receiveData.message + '</div>'
    document.getElementById('footer').insertAdjacentHTML('beforebegin', header + body)
    document.getElementById('footer').appendChild(messageBox)
}

</script>
```

ここまで来ると、ある程度形ができてきているかと思います。
<img width="979" alt="mitame.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258186/8e87ab22-a9f1-93ce-565e-038a77b25e1b.png">


# チャットアプリケーションを作成：WebSocketサーバー構築

最後にチャットアプリの肝となるWebSocketサーバー部分を作成していきます。
asgi.pyやnginx側では設定が完了しておりますが、
肝心の中身がまだなので、実装していきます。

```chat/consumer.py
from channels.generic.websocket import AsyncWebsocketConsumer
from django.db import connection
from django.db.utils import OperationalError
from channels.db import database_sync_to_async
from django.core import serializers
from django.utils import timezone
import json
from .models import *
from urllib.parse import urlparse
import datetime
import time

class ChatConsumer(AsyncWebsocketConsumer):
    groups = ['broadcast']

    async def connect(self):
        try:
            await self.accept()
            self.room_group_name = self.scope['url_route']['kwargs']['room_name']
            await self.channel_layer.group_add(
                self.room_group_name,
                self.channel_name
            )
        except Exception as e:
            raise

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )
        await self.close()

    async def receive(self, text_data):
        try:
            print(str(text_data))
            text_data_json = json.loads(text_data)
            message = text_data_json['message']
            name = text_data_json['name']
            await self.createMessage(text_data_json)
            await self.channel_layer.group_send(
                self.room_group_name,
                {
                    'type': 'chat_message',
                    'message': message,
                    'name': name,
                }
            )
        except Exception as e:
            raise

    async def chat_message(self, event):
        try:
            message = event['message']
            name = event['name']
            await self.send(text_data=json.dumps({
                'type': 'chat_message',
                'message': message,
                'name': name,
            }))
        except Exception as e:
            raise

    @database_sync_to_async
    def createMessage(self, event):
        try:
            room = Room.objects.get(
                name=self.room_group_name
            )
            Message.objects.create(
                room=room,
                name=event['name'],
                content=event['message']
            )
        except Exception as e:
            raise
```

# 完成

ここまで来ると完成となります。
Githubは[こちら](https://github.com/masanori0209/channels_demo)になります。
docker-compose up -dで起動できると思いますのでお試しください。