DjangoでWebsocketしたい 02 ~いよいよWebSocket通信~

# 01. 前回まで
ここまでのおさらいでです。
ブラウザにあるボタンを押すと、
http通信でリクエストが送られ、
レスポンスとしてサーバーの現在時刻がブラウザに送られ、
ブラウザで表示を書き換え
ということを行いました。
今回はWebSocketでこれを実装していきます。

動作はよく似ていますが、ブラウザとサーバーはコネクションが確立したまんまになる・・・と思えばいいのかな？


# 02. Websocket通信化していく
いきなりですが、設定していきます。
最初は何やっているかわかりませんが、2度読みするとわかると思います。（私がそうだった）
実は一番言いたいのはここで、「2度読みしないと何やっているかわかりにくい」ってところです。ぜひ、Djangoでwebsocketする記事は2回読んでください。

まず、前提として以下を使います。他にも方法はあるみたいですが、欲張らずにこれでいきます。

- daphne
https://docs.djangoproject.com/ja/5.2/howto/deployment/asgi/daphne/
Djangoドキュメントのここ↑に記載
これはasgiサーバーのようです。asgiをググると、Asynchronous Server Gateway Interfaceとのことなので、非同期のゲートウエイインターフェースサーバー・・・ってことですね。ふわっと、「サーバー側で何かしら変更されたら都度ページを更新するためにデータを送信するためのサーバー」とでも思えばいいのでしょうか？
下記のsettings.pyもこのドキュメントに従って修正しています。

<br>

- channels
このページがわかりやすかったです。
https://qiita.com/massa142/items/cbd508efe0c45b618b34
処理サーバーとdaphne（asgi）の間に入って、非同期な処理を処理サーバーに任せる役割と言えばいいんでしょうか？

<br>

- channelsのInMemoryChannelLayer
channelsの機能の一つで、メモリーで処理レイヤーを処理するような感じでしょうか？
複数のスレッドで動かさなければいけないときはredisなどを使わないとダメみたいです。今回はシンプルなwebsocketなのでこの機能を使います。
一つ上のchannelsで紹介したページに詳しく書かれているのでしっかり読んでください。

<br>


## 02-0. 追加インストール
daphneとchannelsをインストールします。
```bash
pip install daphne channels
```
ま、これらのインストールはサクサクっとやっちゃいなy。

:::note
バージョン情報
django==5.2
channels==4.2.2
daphne==4.1.2
python==3.11.9
:::

<br>


## 02-1. setting.pyを修正
以下のように修正します。
```python:settings.py
ALLOWED_HOSTS = ["*"] # 修正

# Application definition
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'strange_watch.apps.StrangeWatchConfig',
    'websocket_proj',
    'channels',  # 追記
    'daphne',  # 追記
]

##### 途中省略 #####

# 以下追記
ASGI_APPLICATION = 'websocket_proj.asgi.application'

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels.layers.InMemoryChannelLayer',
    },
}
```
追加したchannelsとdaphneを追記します。
そして、asgiの設定を行います。websocket_proj/websocket_proj/asgi.pyのapplicationを使え！ってことでしょうかね？ここではまだasgi.pyが存在していませんので、「何のこっちゃ？」です。一旦脇に置いて次に進みましょう。
CHANNEL_LAYERSも忘れず設定しておきます。

<br>


## 02−2. asgi.pyの編集
さっき、設定で気になったwebsocket_proj/websocket_proj/asgi.pyを編集します。

```python:websocket_proj/websocket_proj/asgi.py
import os

from django.core.asgi import get_asgi_application

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

application = get_asgi_application()

# 以下追記　channelsのルーティングを追加
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from strange_watch.routing import websocket_urlpatterns

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(
            websocket_urlpatterns
        )
    ),
})
```
どうやら、ここでstrange_watch.routing.pyのwebsocket_urlpatternsを読み出すみたいですね。
settings.pyではこのapplicationというProtocolTypeRouterクラスのインスタンスを指定しているようです。
ここで辞書を渡していて、インスタンスの初期化をやっているようです
ここで、後で設定するrouting.pyのwebsokcet_urlpatternsを渡しています。
ここで何やらルーティングを設定している模様。（推測）


<br>

## 02-3. 二つのurls.pyによるルーティング設定
httpでもやってきましたが、二つのurls.pyを修正します。しなくてもいいけど、気持ち悪いので。w
目的はhttpで設定したルーティングを使えなくすることです

```python:websocket_proj/strange_watch/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.index),
    path('index.html', views.index),
    # path('get_current_time/', views.get_server_time), #コメントアウト
]
```
httpで通信した時のurlのルーティングを消します。

<br>


```python:websocket_proj/websocket_proj/urls.py
from django.contrib import admin
from django.urls import path, include
import strange_watch.urls

urlpatterns = [
    # path('admin/', admin.site.urls), # コメントアウト
    path('', include(strange_watch.urls)),
    path('index', include(strange_watch.urls)),
    # path('get_current_time/', include(strange_watch.urls)),  # コメントアウト
]
```
こっちも同様です。

<br>


## 02-4. strange_watch（アプリフォルダ）内の編集
アプリフォルダ（今回はstrange_watch）の中に以下のファイルを作ります。
まだ空っぽでもOKデス。

consumers.py
routing.py

![スクリーンショット 2025-04-20 14.30.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655320/bebd0ae9-2d1b-4a63-a752-8a08b5fbafcc.png)

consumers.pyはhttp通信でのviews.pyに近い機能持つことになります。
routing.pyはこのアプリに届いたURLをconsumers.pyのどのクラスに割り当てるかを記述することになります。

<br>





### 02-4-1. routing.pyの編集
```python:websocket_proj/strange_watch/routing.py
from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path('ws/server_time/', consumers.ServerTime.as_asgi()),
]
```
そして、新たに作ったrouting.pyにリンクを作ります。
`ws:server_time`というurlで通信をできるようにします。が、どうやってここにルーティングするねんと...
そう、思い出してください。asgi.pyを作ったことを！
asgi.pyさんがここにルーティングしてくれるようです。
djangoはいろんな機能を持つことができるのでちょっと複雑ですね。初心者の僕には理解がなかなかできませんでした。

そして、cunsumers.ServerTimeって何やねん．．．
ということで作っていきましょう。

<br>



### 02-4-1. consumers.pyの編集
```python:websocket_proj/strange_watch/consumers.py
from channels.generic.websocket import AsyncWebsocketConsumer
import datetime
import asyncio
import json


class ServerTime(AsyncWebsocketConsumer):
    async def connect(self):
        await self.accept()
        self.send_task = asyncio.create_task(self.send_message())

    async def disconnect(self, close_code):
        self.send_task.cancel()
        pass

    async def send_message(self):
        # サーバー時刻を取得し、レスポンス用データを作る
        while True:
            # サーバー時刻を取得
            current_time = datetime.datetime.now()
            current_time = current_time.strftime("%H:%M:%S")

            data = {
                'current_time': current_time,
            }
            print(data)
            # メッセージを送信
            await self.send(text_data=json.dumps(data))
            await asyncio.sleep(1)
```
当然これはDjangoに書かれているのでサーバー側です。現在時刻を取得して、current_timeとしてブラウザに送る仕組みにしました。



## 02-5. htmlの編集
```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Test Page</title>
</head>
<body>
    <h1>Test page Yheaaaaaa!!</h1>
    <h2>WebSocket Test</h2>
    <span> <!-- 時計表示部分 -->
        <p id="correct_watch">時計表示部分</p>
        <button id="time_button">現在時刻取得</button>
    </span>

    <script> // ここからJavaScript
        // WebSocketの接続を確立し、データを受け取り、時計表示部分にサーバーから取得した時刻を表示
        let url = 'ws://' + window.location.host + '/ws/server_time/'; // WebSocketのURL
        let ws = new WebSocket(url);
        console.log(ws);
        
        ws.onmessage = function(event) {
            let data = JSON.parse(event.data);
            console.log(data);
            document.getElementById("correct_watch").textContent = data.current_time;
        };
    </script>
    <!-- ここまで -->
</body>
</html>
```
scriptタグの中を読んで貰えばわかりそうですね。ws/server_time/というurlにアクセスするとwebsocket通信をしてくれそうです。

ここまでできたらasgiサーバーを動かします。

```
cd websocket_proj
daphne websocket_proj.asgi:application
```
![タイトルなし2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655320/04e328fd-4473-4659-aa09-c46683bc7357.png)
画面に時刻が毎秒自動で更新されればOKです。
普通のhttpであっても同様の動作はできますが、JavaScript側に時刻を表示、更新するプログラムが必要です。
今回はサーバー側（consumers.py)のsend_message関数のwhile文でデータ（時刻）が送信され、ブラウザの更新が行われています。

これを開発者ツールのsourcesを見ていると、時刻を表示するところがピコピコと光りながら更新されていきます。
同じ動作はhttp通信でもできるのですが、この時刻表示とは全く別経路で非同期に通信するためにはwebsocketが必要だってことですね。
ということで非同期通信であるwebsocketの良さを少しでも体験できるように改造していきます。

<br><br>


# 03. 改造「ボタンに機能を追加」
アプリ名をstrange_watchとしているので、少し変わった時計にしたいと思います。
ボタンを押した時の時刻をボタンの下に表示したいと思います。
ではhtmlから書いていきましょう。


## 03-1. html
```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Test Page</title>
</head>
<body>
    <h1>Test page Yheaaaaaa!!</h1>
    <h2>WebSocket Test</h2>
    <!-- ここから -->
    <span> <!-- 時計表示部分 -->
        <p id="correct_watch">時計表示部分</p>
        <button id="time_button">現在時刻取得</button>
        <p id="button_display"></p>  <!-- 追記部分 -->
    </span>

    <script> // ここからJavaScript
        // WebSocketの接続を確立する
        let url = 'ws://' + window.location.host + '/ws/server_time/'; // WebSocketのURL
        let ws = new WebSocket(url);
        console.log(ws);
        
        ws.onmessage = function(event) {
            let data = JSON.parse(event.data);
            console.log(data);
            document.getElementById("correct_watch").textContent = data.current_time;
        };
    
        // ここから下が追記部分
        let button = document.getElementById("time_button");
        let button_display = document.getElementById("button_display");

        // ボタンを押した時に動く関数
        button.addEventListener("click", function(e) {
            let button_url = 'ws://' + window.location.host + '/ws/get_time_now/'; // WebSocketのURL
            let ws_button = new WebSocket(button_url);

            // 使わないけど、通信を開始した時に動作する関数
            ws_button.onopen = function() {
                ws_button.send(JSON.stringify({ 'message': 'send message from browser' }));
            };

            // 使わないけど、通信を止める時の関数
            ws_button.onclose = function() {
                console.log('WebSocket connection closed');
            };

            // サーバーから受け取ったデータからサーバー時間を書き換えて表示する関数
            ws_button.onmessage = function(event) {
                data = JSON.parse(event.data);
                console.log(data);
                button_display.textContent = data.res_time;
            };
        });

    </script>
    <!-- ここまで -->
</body>
</html>
```
ボタンの下にpタグを追加したことと、
// ボタンを押した時に動く関数
というところから下を追記しました。
ボタンを押したら通信をして、pタグのところに書き込みます。

<br>



## 03-2. routing.py
```python:routing.py
from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path('ws/server_time/', consumers.ServerTime.as_asgi()),
    path('ws/get_time_now/', consumers.ServerTimeByButton.as_asgi()), # 追記
]
```
JavaScriptで書いたurl（ws/get_time_now/）を受け取ったら、cosumers.pyのServerTimeByButtonに誘導します。あ、クラス名がカッコ悪いのは気にしないで。汗

<br>


## 03-3 consumers.py
以下を追記します。
```python:consumers.py（追記内容）
class ServerTimeByButton(AsyncWebsocketConsumer):
    async def connect(self):
        await self.accept()

    async def disconnect(self, close_code):
        pass

    async def receive(self, text_data):
        # サーバー時刻を取得
        current_time = datetime.datetime.now()
        current_time = current_time.strftime("%H:%M:%S")

        data = {
            'res_time': current_time,
        }
        print(data)
        # メッセージを送信
        await self.send(text_data=json.dumps(data))
```
こちらのクラスにはループ処理がありません。
そう、requestを受けたら、ただ単にその時の時刻を返すだけです。
全く、websocketでやる必要のない内容ですが、2秒ごとに更新される最初の時計と時間をずらしながら打っても都度更新されます。ServerTimeクラスの`await asyncio.sleep(1)`を2秒とか3秒に変更するとわかりやすい。

なんだかここまできたら改造は楽そうですね。

<br>

## 03-4. サーバー起動
これでサーバーを起動します。


![スクリーンショット 2025-04-19 23.54.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655320/454e938b-a44f-418c-8d0b-140c0d81b895.png)
ブラウザの開発者ツールのconsoleも表示しました。
current_timeというのがServerTimeクラスから帰ってくる1秒ごとの時刻
res_timeというのがボタンを押して、ServerTimeByButtonクラスから帰ってくる時刻です。
毎秒更新の時計のタイミングとずらしても、リアルタイムに通信して下側の時計が更新されてます。

<br>


# 04. 終わりに
ということで、ひとまずwebsocket通信できるようになりました。そして、websocketがなんとなく理解できるようになりました。

- 残った課題
    - 今のままではボタンを押すと通信が増え続ける
    - cssが当たらない


今の課題はおそらくここの方法だと、ボタンを押す毎に通信が1個ずつ増えていきそうです。使わなくなった通信をクローズする必要も出てくるのではないでしょうか？
![スクリーンショット 2025-04-20 17.19.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655320/b35c7776-4023-42ef-8521-56018073c20c.png)
何度かボタンを押した後、ブラウザのタブを閉じるとwebsocketがdisconnectしますが、上記の通り、のように押した回数分だけ/ws/get_time_now/の通信がdisconnectします。
つまり余分な通信が残っているってことになります。

<br>


asgiだと静的にcssを当てることができないみたいです。いくつか方法があるようなので、試してみたいと思います。

ということで、この辺りの解決ができたらまたQiitaに投稿したいと思います。アドバイスがあればぜひコメントに書いていただけると助かります。

初心者の皆さん、一緒に頑張ろうぜ！

<br><br><br>





# 05. 終わらなかった
数時間後...軽くcssを書いて、htmlファイルに当てられないかなと試そうと思った。

```bash
cd websocket_proj
python3 manage.py runserver
```
こう、実行してみたんだ。httpならcssが当たるだろうから、まずはcssを当ててからwebsocketで試そうと思ってね。

```
ERRORS:
?: (daphne.E001) Daphne must be listed before django.contrib.staticfiles in INSTALLED_APPS.
```
頭ん中に？？？が流れた。もしや...

<br>


## 05-1. settings.pyを修正
```python:settings.py
# Application definition
INSTALLED_APPS = [
    'daphne',  # 追記
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'strange_watch.apps.StrangeWatchConfig',
    'websocket_proj',  # 追記
    'channels',  # 追記
]
```
このように修正してみた。そう、エラーコードの指摘の通りにdaphneを`djang.contrib.staticfiles`より前にしてみた。それも一番前に。

<br>


## 05-2. cssファイルを用意
そして、以下を実施。
`strange_watch`フォルダの中に`static`フォルダを用意して、そこに`style.css`ファイルを用意。そして以下のようなcssを書きました。
```css:strange_watch/static/style.css
.displayAppWrapper {
    max-width: 530px;
    margin: 0 auto;
    background-color: white;
    box-shadow: 5px 5px 15px 3px #777777;
    border-radius: 10px;;
}
```

<br>


```html:strange_watch/templates/index.htmlのヘッダー部分
<!-- 省略 -->
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    {% load static %}
    <link rel="stylesheet" type="text/css" href="{% static 'style.css' %}">
    <title>Test Page</title>
</head>
<body>
    <div class="displayAppWrapper">
        <h1>Test page Yheaaaaaa!!</h1>
        <h2>WebSocket Test</h2>
        <!-- ここから -->
        <span> <!-- 時計表示部分 -->
            <p id="correct_watch">時計表示部分</p>
            <button id="time_button">現在時刻取得</button><p id="button_display"></p>
        </span>
    </div>
<!-- 省略 -->
```
ヘッダー部分にstaticフォルダを読み込んで、staticフォルダ内にあるstyle.cssフォルダをあてて、divタグで全体を囲ってそこに上記のcssを当てるように指定しました。

<br>



## 05-3. サーバー起動
そして、再度
```bash
python3 manage.py runserver
```

<br>

![スクリーンショット 2025-04-20 16.46.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655320/b0be2928-9c39-4ff3-9930-aa6e7f7fd480.png)
あれ！ちゃんとcss当たるじゃん！
しかもdaphneを使ってwebsocket通信しています。

<br>


実は本当に事故みたいな解決でした。
`daphne websocket_proj.asgi:application`で起動するものばかりと思っていましたが、`python3 manage.py runserver`で起動してもdaphneが機能するんですね。

# 06. 終わりに...2度目
まさかこんな方法で解決するとは思いませんでした。
基本中の基本である、「**エラーコードはしっかり読みましょう**」というのが役に立ちました。
基本が大事とは言いますが、本当に大事だとおもいます。

基本というか、その次くらいのステップになるとは思いますが、以下の本をお勧めします。
半世紀製のおじさんが電車で読むには少し恥ずかしい表紙ですが、初心者から脱却するためには非常にいい本だと思います。僕のおすすめ。彼のYouTubeもおすすめ。


https://gihyo.jp/book/2024/978-4-297-14285-8


昨日もお出かけ途中に寄ったbucyocoffeeで読み直していました。

<br>

この本と、Udemyの
「現役シリコンバレーエンジニアが教えるPython 3 入門 + 応用 +アメリカのシリコンバレー流コードスタイル」（Udemyのリンクの貼り方がわからん）
は僕のバイブルです。


<br>

ということで、また