簡易的なLINEのwebを作る(Django + Heroku + Redis + SQLite)


websocketという非同期通信の技術を使用し，簡単なLINEの擬きを作ってみましょう!!
GitHub(デプロイするので必要)

:::note warn
Repositoryを作成しクローンしたディレクトリで作業を行ってください。
:::


# djnagoの環境構築
### Pythonのインストール
pythonのインストールは下記の公式より各自の環境にあったものをインストールしてください．(Python 3.6以上)
[Python公式サイト](https://www.python.org/)
　
### 仮想環境の作成

仮想環境の作成
```
python -m venv venv
```
:::note info
macはpython3
:::


仮想環境のアクティベート

Mac用
```
source venv/bin/activate
```
Windows用
```
venv\Scripts\activate
```

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

pipのインストール
```
python -m ensurepip --upgrade
```

rootにrequirements.txtを作成し下記を記述．
```requirements.txt
asgiref==3.8.1
autobahn==23.6.2
channels==4.1.0
channels-redis==4.2.0
daphne==4.1.2
Django==5.0.6
Twisted==24.3.0
websockets==12.0
whitenoise==6.0.0
```

requirements.txt に記述されているパッケージを一括でインストール
```
pip install -r requirements.txt
```
### プロジェクトの作成
プロジェクトの作成
>\<project_name>は好きな名前でok
今回、私はlineで作成した．
```
django-admin startproject <project_name> .
```

アプリケーションの作成
>\<yourappname>も好きな名前でok
今回、私はchatappで作成した．
```
python manage.py startapp <yourappname>
```

### templatesとstatic
rootにtemplates，staticという名前のディレクトリを作成．

### settings.py
```settings.py
ALLOWED_HOSTS = [
    'localhost', # 追加
    '127.0.0.1', # 追加
]


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    '<yourappname>', # 追加
]
```

```settings.py
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',
            ],
        },
    },
]
```

### migrate
コマンドラインで以下を実行
```
python manage.py migrate 
```

## ここまでの確認
コマンドで下記を実行するとロケットの打ちあがったページが確認できる
```
python manage.py runserver 
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/16775520-5f0d-1a37-a347-65bc17f9c154.png)

ファイル構造はこんな感じ
```
C:.
|   .gitignore
|   db.sqlite3
|   manage.py
|   README.md
|   requirements.txt
|
+---chatapp
|   |   admin.py
|   |   apps.py
|   |   models.py
|   |   tests.py
|   |   views.py
|   |   __init__.py
|   |
|   +---migrations
|   |   |   __init__.py
|   |   |
|   |   \---__pycache__
|   |           __init__.cpython-310.pyc
|   |
|   \---__pycache__
|           admin.cpython-310.pyc
|           apps.cpython-310.pyc
|           models.cpython-310.pyc
|           __init__.cpython-310.pyc
|
+---line
|   |   asgi.py
|   |   settings.py
|   |   urls.py
|   |   wsgi.py
|   |   __init__.py
|   |
|   \---__pycache__
|           settings.cpython-310.pyc
|           urls.cpython-310.pyc
|           wsgi.cpython-310.pyc
|           __init__.cpython-310.pyc
|
+---static
+---templates
+---venv
    |venvの中は省略
```


# Herokuでデプロイ準備　and　Redisのレンタル
Herokuのアカウントは各自で作ってください

https://dashboard.heroku.com/
### herokuでアプリ作成
左上のNewをクリックしCreate new appを選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/d11f8988-85d9-064f-d659-70bbd5021f94.png)

お好みのアプリ名を決め，Create app
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/29cc43da-9fe0-37f0-eeaa-7f47329e0678.png)

GitHubを選択しsearchボタンをクリック．今回のリポジトリをconnect
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/bacc98cb-59ba-d9cb-3860-c8e4389d6d52.png)

Resourcesに移動しAdd-onsでRedisと検索し，**Heroku Data for Redis**を選択．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/afb0bbc8-edca-45e6-86fc-935e6365a873.png)

最安のプランで**Submit Order Form**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/fb8cf556-69c5-5b00-638f-04aa9319f54a.png)

数分したら左下の**Heroku Data for Redis**をクリック．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/cfe0a5df-97a0-d8ca-d8c9-bbb5eb5d109c.png)

Settingの中の**View Credentials...** をクリックしURIをコピー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/9764169f-b832-8ea8-b3d0-8201ab45dc08.png)

### Djangoの環境変数設定(localで動かすつもりないなら要らない)
rootに.envファイルを作成し下記を記入
```
REDIS_URL=rediss://...みたいなの(さっきコピーしたやつね)
```

### settings.py
画面右上の**Open app**をクリックしたときに開かれたページのURLを保存する．

:::note info
これ以降上記のURLの https:// を除いた部分を **Heroku_URL** と呼ぶ
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/e54e5f1f-ffec-3c6e-1902-48553bc4c8c3.png)


```settings.py
ALLOWED_HOSTS = [
    'localhost', 
    '127.0.0.1', 
    'herokuapp.com', # 追加
    'Heroku_URL' # 追加
]


# Application definition

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

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',
    'whitenoise.middleware.WhiteNoiseMiddleware', # 追加
]

STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' # 追加
```

```settings.py
# 追加----------------------------------------------
import os

# asgiアプリケーションの位置
ASGI_APPLICATION = '<yourappname>.asgi.application'

# チャンネルレイヤーの設定
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            'hosts': [os.getenv('REDIS_URL')], # RedisのURLを環境変数から取得(localの場合は.envファイルから取得) 
        },
    },
}
# --------------------------------------------------
```

```settings.py
STATIC_URL = 'static/'

# 追加----------------------------------------------
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
    BASE_DIR / 'static',
]
# --------------------------------------------------
```
```settings.py
# 追加----------------------------------------------
CSRF_TRUSTED_ORIGINS = [
    'https://Heroku_URL',
]
# --------------------------------------------------
```

### Procfileとruntime.txtの作成
rootに**Procfile，runtime.txt**という名前のファイルを作成
```Procfile
web: daphne <project_name>.asgi:application --port $PORT --bind 0.0.0.0 -v2
```
pythonのバージョン指定
```runtime.txt
python-3.10.4
```

# websocketの設定
### asgi.py
```asgi.py
import os

from django.core.asgi import get_asgi_application
import chatapp.routing

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

os.environ.setdefault('DJANGO_SETTINGS_MODULE', '<project_name>.settings')

application = ProtocolTypeRouter({
    'http': get_asgi_application(),
    'websocket': AuthMiddlewareStack(
        URLRouter(
            chatapp.routing.websocket_urlpatterns
        )
    ),
})
```

### routing.py
app内にrouting.pyを作成し下記を記述
```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()),
]
```

### consumers.py
app内にconsumers.pyを作成し下記を記述
```consumers.py
import hashlib
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 = f'chat_{hashlib.md5(self.room_name.encode("utf-8")).hexdigest()}'
        print("Room name(consumers):", self.room_name)
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()

    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        currentRoomName = text_data_json['room']
        user = text_data_json['user']
        if currentRoomName is not None:
            print("Room name2:", currentRoomName)
        else:
            print("Room name is not provided in the message.")

        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'user': user,
                'room': currentRoomName,
                'message': message
            }
        )

    def chat_message(self, event):
        user = event['user']
        message = event['message']
        room = event['room']
        print(message, room)
        self.send(text_data=json.dumps({
            'type': 'chat',
            'user': user,
            'room': room,
            'message': message
        }))
```

# 通常のDjangoの設定
### \<project_name>/urls.py
``` project_name/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('<yourappname>.urls')),
]
```

### \<yourappname>/urls.py
\<yourappname>の中にurls.pyを作成し以下を記述
```yourappname/urls.py
from django.urls import path

from . import views

app_name    = "chat"
urlpatterns = [
    path('chat/', views.chat, name="chat_rooms"),
    path('', views.loginview, name='login'),
    path('create/', views.create_login, name="create"),
]
```

### \<yourappname>/views.py
```views.py
from django.contrib import messages
from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.shortcuts import redirect, render
from django.utils.decorators import method_decorator
from django.views import View


# チャット画面を表示するビュー
class ChatView(View):
    @method_decorator(login_required)
    def get(self,request,*args,**kwargs):
        user = request.user
        return render(request,"chat.html",{"user":user})

chat   = ChatView.as_view()


# ログイン画面を表示するビュー
class LoginView(View):
        def get(self,request,*args,**kwargs):
            return render(request,"login.html")
    
        def post(self,request,*args,**kwargs):
            # ログイン時に表示するページとログイン機能
            if request.method == 'POST':
                username = request.POST.get('username')
                password = request.POST.get('password')

                # ユーザー認証
                user = authenticate(request, username=username, password=password)

                if user is not None:
                    # ログイン成功
                    login(request, user)
                    return redirect('chat:chat_rooms')
                else:
                    # ログイン失敗
                    messages.error(request, 'ユーザー名またはパスワードが間違っています。')

            return render(request,"login.html")

loginview = LoginView.as_view()


# ユーザー作成画面を表示するビュー
class CreateLoginView(View):
    def get(self,request,*args,**kwargs):
        return render(request,"create_login.html")

    def post(self,request,*args,**kwargs):
        if request.method == 'POST':
            new_username = request.POST.get('new_username')
            new_password = request.POST.get('new_password')
            try:
                # 新しいユーザーオブジェクトを作成し、ユーザー名とパスワードを設定
                user = User.objects.create_user(username=new_username, password=new_password)
            except Exception as e:
                # ユーザ作成失敗
                messages.error(request, 'ユーザーの作成に失敗しました。エラー: {}'.format(str(e)))

            # ログイン処理
            user = authenticate(request, username=new_username, password=new_password)
            if user is not None:
                # ログイン成功
                messages.error(request, 'ログインに成功しました。')
                login(request, user)
                return redirect('chat:chat_rooms')
            else:
                # ログイン失敗
                messages.error(request, 'ユーザー名またはパスワードが間違っています。ユーザー名: {}'.format(new_password))
        return render(request,"create_login.html")
    
create_login = CreateLoginView.as_view()
```

## templates
### login.html
templatesディレクトリにlogin.htmlを追加
```login.html
<!DOCTYPE html>
<html lang="ja">

    <head>
        <meta charset="UTF-8">
        <title>Login</title>
    </head>
    
    <body>
            {% if messages %}
            <ul>
                {% for message in messages %}
                <li>{{ message }}</li>
                {% endfor %}
            </ul>
            {% endif %}
        
            <form method="post" action="{% url 'chat:login' %}">
                {% csrf_token %}
                <label for="username">ユーザー名:</label>
                <input type="text" id="username" name="username">
                <br>
                <label for="userpassword">パスワード教えろ:</label>
                <input type="password" id="userpassword" name="password">
                <br>
                <button type="submit">ログイン</button>  
            </form>
            <a href="{% url 'chat:create' %}">新規アカウント作成</a>
    </body>
</html>
```

### create_login.html
templatesディレクトリにcreate_login.htmlを追加
```create_login.html
<!DOCTYPE html>
<html lang="ja">

    <head>
        <meta charset="UTF-8">
        <title>Login</title>
    </head>
    
    <body>
            {% if messages %}
            <ul>
                {% for message in messages %}
                <li>{{ message }}</li>
                {% endfor %}
            </ul>
            {% endif %}
        
            <form method="post" action="{% url 'chat:create' %}">
                {% csrf_token %}
                <label for="username">ユーザー名:</label>
                <input type="text" id="username" name="new_username">
                <br>
                <label for="userpassword">パスワード教えろ:</label>
                <input type="password" id="userpassword" name="new_password">
                <br>
                <button type="submit">ログイン</button>
                
            </form>
    </body>
</html>
```

### chat.html
templatesディレクトリにchat.htmlを追加
```chat.html
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chat Room</title>
    <link rel="stylesheet" href="{% static 'chat.css' %}">
</head>
<body>
    <div class="chat-container">
        <div class="left-panel">
            <h1>Let's chat!</h1>
            <h2>ユーザー: {{ request.user.username }}</h2>
            <input type="hidden" id="username" value="{{ request.user.username }}">
            <div class="room-input">
                <label for="room-name">Room Name:</label>
                <input type="text" id="room-name" placeholder="Enter room name">
                <button id="join-room">Join Room</button>
            </div>
            <div id="rooms" class="room-list"></div>
        </div>
        <div id="talkroom" class="right-panel">
            <form id="form" class="message-form">
                <input type="text" name="message" placeholder="Enter message">
            </form>
        </div>
    </div>

    <script src="{% static 'chat.js' %}"></script>
</body>
</html>
```
# staticとwebsocketのクライアント側
### chat.css
```chat.css
body {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 2px;
    background-color: black;
    margin: 0;
    height: 100vh;
}

.chat-container {
    display: contents;
}

.left-panel, .right-panel {
    background-color: antiquewhite;
    width: calc(50vw - 1px);
}

.room-input {
    display: flex;
    align-items: center;
    gap: 8px;
}

.room-list {
    display: grid;
    grid-template-columns: 1fr;
    gap: 8px;
    margin-top: 8px;
}

.room-list div {
    background-color: aquamarine;
    padding: 3%;
}

.messages {
    padding: 8px;
    overflow-y: auto;
    height: calc(100vh - 40px - 3rem);
}

.message-form {
    position: absolute;
    bottom: 0;
    width: calc(50vw - 1px - 2%);
    margin: 1%;
}

.message-form input {
    width: 90%;
    padding: 8px;
}

```

### chat.js
:::note warn
const url = `wss://Herok_URL.herokuapp.com/ws/chat/${roomName}/`;
だけ注意．
:::

```chat.js
let chatSocket = null;
let currentRoomName = null;
const username = document.getElementById('username').value;

document.getElementById('join-room').addEventListener('click', joinRoom);

function joinRoom() {
    const roomName = document.getElementById('room-name').value;
    if (roomName) {
        const url = `wss://Herok_URL.herokuapp.com/ws/chat/${roomName}/`;
        console.log('Join Room', url);
        chatSocket = new WebSocket(url);
        console.log(username);
        currentRoomName = roomName;
        console.log('Chat Socket:', chatSocket, 'Room:', currentRoomName);
        initChatSocket();
        addRoomToList(roomName);
    }
}

function initChatSocket() {
    chatSocket.onmessage = function(e) {
        const data = JSON.parse(e.data);
        console.log('Data:', data, 'Room:', currentRoomName);
        if (data.type === 'chat') {
            console.log('Message:', data.message);
            console.log('Room:', currentRoomName);
            addMessage(data.message, data.user);
        }
    };

    document.getElementById('form').addEventListener('submit', function(e) {
        e.preventDefault();
        sendMessage();
    });
}

function addRoomToList(roomName) {
    const roomDiv = document.getElementById('rooms');
    roomDiv.insertAdjacentHTML('beforeend', `
        <div id="room_${roomName}">
            <p>${roomName}</p>
        </div>
    `);

    // 追加した部屋の要素を取得
    let newRoomElement = document.getElementById(`room_${roomName}`);

    // クリックイベントリスナーを追加
    newRoomElement.addEventListener('click', function() {
        changeRoom(roomName);
    });

    // 全ての chat_talks クラスを持つ要素を取得し、非表示にする
    allclose();

    // 新たなチャットルームを表示する
    const roomChat = document.getElementById('talkroom');
    roomChat.insertAdjacentHTML(
        'beforeend',
        `<div id="Room_${roomName}" class="chat_talks">
            <h2>Room: ${roomName}</h2>
            <div id="messages_${roomName}" class="messages"></div>
        </div>`
    );
}

function addMessage(message, username) {
    if ( `${message}` != '')  {
        const messagesDiv = document.getElementById(`messages_${currentRoomName}`);
        messagesDiv.insertAdjacentHTML('beforeend', `<div><p>${username}:${message}</p></div>`);
    }
}

function sendMessage() {
    const form = document.getElementById('form');
    const formData = new FormData(form);
    const message = formData.get('message');
    console.log('Send Message:', message, 'Room:', currentRoomName);
    chatSocket.send(JSON.stringify({'message': message, 'room': currentRoomName , 'user': username}));
    form.reset();
}

function allclose() {
    let chatTalksElements = document.querySelectorAll('.chat_talks');
    chatTalksElements.forEach(element => {
        element.style.display = 'none';
    });
}

function changeRoom(roomName) {
    allclose();
    currentRoomName = roomName;
    let roomElement = document.getElementById(`Room_${roomName}`);
    roomElement.style.display = 'block';
}
```

# 結果
1. Room Nameに参加したいroomを入力しJoin Roomをクリック
1. 左側にroomの一覧が表示され，右側にroomのチャット欄が表示される
1. 左側のroomの一覧からroomの変更ができる
1. 右側にチャット欄が表示され右下の入力欄に文字を入力しEnterで送信ができる．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/db8b606c-2bb0-0758-b85d-7560b26d5b0a.png)

**以上**