Djangoでwebsocketを使ってみた。

# はじめに
オンラインゲームでよく使われる通信プロトコルである`WebSocket`を使えるようになりたいと思い、今回`Django Channels`を使って、ボタンを押したらボタンの内容をリアルタイムで送り続けるアプリケーションを作ってみました。

加えて、websocketについて調べてまとめてみようと思います。(間違ってる部分は教えてください）

## WebSocketって何？
### 概要
`WebSocket`は、`HTTP`と同じように通信プロトコルであり、双方向通信を低コストで行えるのが特徴であり、リアルタイム通信で主に使われます。

:::note
#### そもそもHTTPって？
`Hyper　Text　Transfer　Protocol`の略称であり、主にハイパーテキスト(HTMLや画像や音声等）を通信するために使われる。主にブラウザとサーバーが通信するときに使っているのはこいつ。
:::

### HTTPとの違い
`HTTP`と特に違うのは、TCPでコネクションを確立した後の通信の仕方が違います。

コネクション確立には、`3ウェイハンドシェイク`と呼ばれるコネクション確立を行なっています。
まず、クライアント側から`SYNパケット`を送り、それを受け取ったサーバーが`SYNパケット`と`ACKパケット`を同時に送ります（`SYN/ACKパケット`とも呼ばれます)。そして、`SYNパケット`と`ACKパケット`を受け取ったクライアントが`ACKパケット`を返してサーバーが受け取った後、通信ができるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/38488d62-931f-3996-71ae-8f8577f9e9ba.png)
>SYNパケットは、クライアントからサーバーに接続したいときに送られるパケット。TCPヘッダのSYNフィールド(SYNフラグ）が1になっている。

>ACKパケットは、何らかの肯定的な返答を返す際に送られるパケット。TCPヘッダのACKフィールド(ACKフラグ）が1になっている。

>SYN/ACKパケットでは、SYNフィールドとACKフィールドがどちらも1になっている。

`HTTP`と`WebSocket`が違うのは、ここからで`HTTP`は１リクエストに対して１コネクションを繋ぎ、１リクエストに対する１レスポンスが終わるとコネクションを閉じます。なので、次にリクエストを送るには、もう一度コネクションを繋ぎ直す必要があります。
一方で、`WebSocket`は初回リクエストでコネクションを繋いだら、その状態を保持したまま、独自のプロトコルによる通信に切り替わり、双方向通信を行うことができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/1250c65d-ec9a-2a81-87b6-ff1fed1140ef.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/90519f78-31d4-fc67-86a9-bcf0c61176b6.png)

### DjangoでWebSocket使うには？
DjangoでWebSocketを使うには、主に`channels`と`channels_redis`というライブラリを使用します。`channels`今回の[`DjangoChannels`](https://channels.readthedocs.io/en/stable/)を使うために必要なライブラリで、`channels_redis`はwebsocketに適しているredisサーバーを使うためのライブラリです。

### DjangoChannelsって？
Djangoは標準でWSGIベースとなっており、`HTTP`のみサポートしているが、他の通信はサポートしていません。なので、HTTPを拡張し、ASGIと呼ばれるPythonの仕様に基づいて構築したパッケージを`DjangoChannels`という。

:::note
channelはチャネルでもチャンネルでも呼び方はどっちでもいいそうです、一般的にはチャネルが多そうです。
:::

>WSGI（ウィスキー）とは
Web Server Gateway Interfaceの略称。名前の通り中継地点にあたるもので、webサーバーとアプリケーションとDBなど繋げる役割を担う。同期通信をベースとしています。

>ASGIとは
Asynchronous Server Gateway Interfaceの略称。非同期通信を可能にしたWSGIのことです。

### redisサーバーって？
リアルタイム通信でよく使われるデータベースサーバーの１種で、一般的なサーバーは、ディスクからメモリにデータを読み込みそれを通信で送りますが、redisサーバーはメモリにデータを保持し続けるので、高速なデータの取り出しが可能です。


## 実際にWebSocketを実装する。
今回は、Dockerで環境構築をしようと思ったのですが、問題としてDjangoプロジェクトを作っていない状態でDockerfileの作業ディレクトリを指定して記述するのは難しいと感じたので、まず仮想環境を作ってから行おうと思います。（理由は後ほど後述します）

最終的なディレクトリ構造を示しておくのでわからなくなったら、参考にしてください
```
.
├── Dockerfile
├── db.sqlite3
├── docker-compose.yml
├── manage.py
├── requirements.txt
├── djangowebsocket
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── websocket
    ├── __init__.py
    ├── admin.py
    ├── apps.py
    ├── consumers.py
    ├── migrations
    │   └── __init__.py
    ├── models.py
    ├── routing.py
    ├── templates
    │   └── websocket
    │       └── index.html
    ├── tests.py
    ├── urls.py
    └── views.py
```

### 仮想環境での構築
まず、仮想環境を作成します。
```
python3 -m venv venv
```
仮想環境に入ります。
```
source venv/bin/activate
```
djangoのプロジェクトを作るために、下のコマンドを入力します。
```
pip3 install django
```
下のコマンドでDjangoのプロジェクトを作成します。
```
django-admin startproject djangowebsocket
```

これが終わるとvenvフォルダがある階層にプロジェクトディレクトリができます。（今回で言う`djangowebsocket`)

一応djangoプロジェクトが立ち上がってるかを確認するために、プロジェクトフォルダにある`manage.py`を使います。
下のコマンドで`djangowebsocket`ディレクトリに移動します。
```
cd djangowebsocket/
```
下のコマンドでサーバーを立ち上げます。
```
python3 manage.py runserver
```


下の画像ができたら完了です。
![スクリーンショット 2024-06-13 13.27.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/ff3f124b-a24f-05f2-152e-4304f85ee620.png)

djangoアプリケーションを立ち上げます。
```
python3 manage.py startapp websocket
```
`djangowebsocket`と同じ階層に`websocket`というフォルダができました。

ここからdjangoプロジェクトの設定を行います。
```djangowebsocket/setting.py
import os

ALLOWED_HOSTS = [
    '*'#追加
]

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

#追加
ASGI_APPLICATION = 'djangowebsocket.asgi.application'

#追加
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            'hosts': [os.getenv('REDIS_URL')], # import os を忘れないように
        },
    }
}

LANGUAGE_CODE = 'ja'#一応追加

TIME_ZONE = 'Asia/Tokyo'#一応追加
```

```djangowebsocket/asgi.py
#下からの内容を追加
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from websocket.routing import websocket_urlpatterns

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangowebsocket.settings')

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

)
```

### Dockerに移行
ここからDockerに移行します。
下のコマンドで`requirements.txt`を記述します。

```
pip3 freeze > requirements.txt
```

```requirements.txt
asgiref==3.8.1
Django==5.0.6
sqlparse==0.5.0
```
これを出力したら、venvフォルダは消しても大丈夫です。
これに追記します。

```requirements.txt
asgiref==3.8.1
Django==5.0.6
sqlparse==0.5.0
#追記
channels
channels_redis
daphne
```
>daphne
DjangoChannelsが推奨しているASGIサーバーです。インストールすることでASGIを使用することが可能になります。

`Dockerfile`と`docker-compose.yml`記述します.

これらのファイルは、`manage.py`のあるディレクトリに入れてください。

```Dockerfile
FROM python:latest

ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

WORKDIR /code

COPY requirements.txt /code/

RUN pip install -r /code/requirements.txt

COPY . /code/
```

```docker-compose.yml
services:
  web:
    build: .
    command: daphne -b 0.0.0.0 -p 8000 djangowebsocket.asgi:application
    volumes:
      - .:/code
    ports:
     - "8000:8000"
    depends_on:
      - redis

  redis:
    image: redis:latest
    ports:
      - "6379:6379"

```
:::note
djangoのプロジェクトを立ち上げると一つ下のディレクトリにmanage.pyがある状態になってしまい、Dockerfileがファイルを参照できるように記述する必要があるので、仮想環境を先に作りました。
また、daphneを使って実行するときに`djangowebsocket`にある`asgi.py`を参照する必要があるので、こちらも複雑に記述がしたくないため、仮想環境で先に構築しました。
:::

以下のコマンドでコンテナを立ち上げます。
```
docker compose up　--build
```

ターミナルに以下のような表示が出てきたら完了です。
```
web-1    | 2024-06-13 15:44:17,028 INFO     Starting server at tcp:port=8000:interface=0.0.0.0
web-1    | 2024-06-13 15:44:17,028 INFO     HTTP/2 support not enabled (install the http2 and tls Twisted extras)
web-1    | 2024-06-13 15:44:17,028 INFO     Configuring endpoint tcp:port=8000:interface=0.0.0.0
web-1    | 2024-06-13 15:44:17,029 INFO     Listening on TCP address 0.0.0.0:8000
```




### websocketを使えるように実装する

ここからwebsocketが使えるように実装していこうと思います。

```djangowebsocket/urls.py

from django.contrib import admin
from django.urls import path,include

urlpatterns = [
    path('admin/', admin.site.urls),
    path("",include("websocket.urls")),#追加
]
```

ここからアプリケーション`websocket`の設定します。`views.py`でURLの情報を見れるようにします。

```websocket/views.py
from django.shortcuts import render

def index(request):
    return render(request, 'websocket/index.html')
```
`urls.py`を作成して、以下のように記述します。
```websocket/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),
]
```

`routing.py`を作成して、記述します。これは、一般的に`HTTP`以外のプロトコルのルーティングを決める際に記述します。
```websocket/routing.py
from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path("ws/websocket/",consumers.WebsocketConsumer.as_asgi()),
]
```

`consumers.py`を作成して、記述します。ここでは、`websocket`を使ってどのような通信を行うかを記述します。

```websocket/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer

class WebsocketConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        #グループ定義しないと動かなかったです
        #現在のwebsocketをsendmessageというグループに追加します
        await self.channel_layer.group_add("sendmessage",self.channel_name)
        print("Websocket connected")
        await self.accept()

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard("sendmessage",self.channel_name)
        print("Websocket disconnected")

    async def receive(self, text_data=None):
        print("Received websocket message", text_data)

        #受けったデータに内容がなかったら終了
        if not text_data.strip():
            return
        try:
            #jsonをparseする
            data = json.loads(text_data)
        except json.JSONDecodeError as e:
            print(e)
            return

        #辞書を作成
        messages = {
            "message":data.get("message"),
        }

        await self.channel_layer.group_send(

            "sendmessage",{
                "type":"send_message",#websocket通信を受け取ったら下のsend_messageを呼び出す
                "content":messages,
            }
        )
    async def send_message(self, event):
        #contentの中にある辞書を取り出し
        message = event["content"]
        #辞書をjson型にする
        await self.send(text_data=json.dumps(message))
```


`views.py`で指定した`websocket/index.html`を表示します。Bodyタグ内にButtonを配置し、押したら`Hello WebSocket`を送るようにしてみました。

```websocket/index.html
<<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>test</title>
    <script>
<!--      domを読み込んでから-->
        document.addEventListener("DOMContentLoaded", function() {
            let interval;
            // websocketの接続先
            const url = "ws://" + window.location.host + "/ws/websocket/"
            const ws = new WebSocket(url);
            console.log(url)
            const button = document.getElementById("send");

            // websocketでの接続が確立されたときに動く
            ws.onopen = function() {
                console.log("Connected");
            };
            //websocketでメッセージを受信されたら動く
            ws.onmessage = function(e) {
                const data = JSON.parse(e.data);
                console.log("Received message:", data.message);
            };

            //ボタンを長押ししたら送り続ける
            button.addEventListener("mousedown", function() {
                console.log()
                interval = setInterval(function (){
                    sendMessage("Hello WebSocket");
                },10
                )
            });

            //ボタンを離したら送るのをやめる
            button.addEventListener("mouseup",function(){
                clearInterval(interval)
            })

            function sendMessage(message) {
                ws.send(JSON.stringify({"message": message}));
            }
});

    </script>
</head>
<body>
    <button id="send">Send Hello</button>
</body>
</html>
```

## websocketを使ってみる
以下のコマンドを打ってイメージの作り直しとコンテナを立ち上げます。
```
docker compose up --build
```
http://localhost:8000 にログインすると以下の画像になっています。
![スクリーンショット 2024-06-13 22.46.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/31aeac7a-a6ce-11a5-5ad1-be419c900f5a.png)

これを[postman](https://www.postman.com/downloads/)を使って、メッセージを送れるか試してみます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/cfa8fce2-4199-7205-168f-dd9b54709398.gif" style="width: 80%; height: auto;" alt="websockettest">

画質の関係でわかりづらいですがいけました！

:::note
postmanはアピテストに使えるツールで、websocketでもテストができるのでぜひ使ってみてください
:::

# 最後に
今回channelとgroupとconsumerという新たな概念が出てきて、個人的に困惑したので自分なりにまとめてみました。

-  channel
websocketに繋がったクライアントをつなげるためのパイプライン。各channelには名前があり、consumerにメッセージを送る。
- group
channelをひとまとめにするもの、groupに所属していると一斉に同じ内容を送れる。
- consumer
channnelから受け取った内容を処理するためのクラス。

今回を機にwebsocketで何か作ってみようと思いました。

リポジトリはこちらです

https://github.com/maooz4426/Django-WebSocket