自動化無しに生活無し

WEB開発関係を中心に備忘録をまとめています

DRF+ReactのSPAでWebsocketのチャットサイトをつくる

thumbnail

タイトル通り、Django REST FrameworkとReactのSPAで、WebSocketを使ったチャットサイトを作った。

以前のWebSocket搭載チャットサイト(下記)はReactを使っていないため、SPAではない。今回はReactを搭載したSPAとしている。

DjangoでWebSocketを使って、チャットサイトを作る

ただし、今回はJWT認証の実装は見送った。理由は後述。

開発要件

  • WebSocketを使ってチャットサイトを作る
  • サーバーサイドにDjango REST Framework
  • フロントサイドにReact
  • react-router-dom でページング
  • ルームの検索機能
  • ルームのページネーション
  • JWT認証は見送る

WebSocketとJWT認証

JWT認証は、リクエストヘッダにアクセストークンを付与することで、認証状態をチェックできる。

一方、WebSocket自体には認証方法を制限する仕様はどこにもない。Cookieを使用した認証も使えるようだ。

参照1: https://qiita.com/comware_terashi/items/d9e357b0330bb312a37d

しかし、Cookieを使用した認証方法を採用すると、CSRF検証が動く。CSRF対策を厳重にしておく必要がある。

参照2: DRFはいつCSRF検証をするのか?

ということで、セキュリティ上の理由から、セッションベースの認証方法は却下。JWT認証とWebSocketを両立させる。

だが、JWT認証とWebSocketを両立させるには、 Sec-WebSocket-Protocol が必要。

参照3: https://nykergoto.hatenablog.jp/entry/2021/05/12/Websocket_%E3%81%AE%E8%AA%8D%E8%A8%BC_%28Authentication%29_%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E3%83%A1%E3%83%A2

このSec-Websocket-Protocol利用の際には、HTTPSとWSSの利用が必須。

そこで、別途DjangoのMIDDLEWAREを作る必要がある上、トークンの有効期限なども考慮すると対応は尋常でなく複雑なので、今回は実装は見送った。どうにか最適解を確立させてからのほうが良いだろう。

ちなみに、クエリストリングにJWT認証のトークンをセットする方法もあるが、HTTPSやWSSでもログに残るので、こちらも却下した。

サーバーサイド

今回DRFとWebSocketを使うため

  • 設定
    • config/settings.py
    • config/urls.py
    • config/asgi.py
  • アプリ
    • chat/models.py
    • chat/views.py
    • chat/forms.py
    • chat/serializers.py
    • chat/consumers.py
    • chat/routing.py

以上のファイルを用意して、編集した。

ソースコードには、すぐにReactを切り離すことができるよう、DRFではないビューとテンプレートも残しておいた。

requirements.txt は以下の通り。

asgiref==3.8.1
attrs==24.2.0
autobahn==24.4.2
Automat==24.8.1
certifi==2024.8.30
cffi==1.17.1
channels==4.2.0
charset-normalizer==3.4.0
constantly==23.10.4
cryptography==44.0.0
daphne==4.1.2
Django==5.1.4
django-cors-headers==4.6.0
djangorestframework==3.15.2
hyperlink==21.0.0
idna==3.10
incremental==24.7.2
oauthlib==3.2.2
pyasn1==0.6.1
pyasn1_modules==0.4.1
pycparser==2.22
pyOpenSSL==24.3.0
requests==2.32.3
requests-oauthlib==2.0.0
service-identity==24.2.0
six==1.17.0
sqlparse==0.5.2
tomli==2.2.1
Twisted==24.11.0
txaio==23.1.1
typing_extensions==4.12.2
urllib3==2.2.3
zope.interface==7.2

コマンドで個別にインストールする場合は以下の通り。

pip install django django-cors-headers djangorestframework daphne channels

また、今回はまだデプロイを想定していないため、Redisも使っていない。

config/settings.py

"""
Django settings for config project.

Generated by 'django-admin startproject' using Django 5.1.4.

For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""

from pathlib import Path

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-6)asy#w-p8gyk0d&a+8s17%o0gkq9aky6(9p)4t7-d#x+&wl=e'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [
    "daphne",
    "chat.apps.ChatConfig",

    'corsheaders',
    'rest_framework',

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

#Django-channels使用時に必ず必要。asgi.pyを読む(ここにWebSocketのルーティング設定が書かれてある。)
ASGI_APPLICATION = 'config.asgi.application'

#開発中はRedisサーバーを建てる必要はない
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    }
}

#Radisサーバーを使用する場合はこちら
"""
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}
"""

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',

    'corsheaders.middleware.CorsMiddleware',
]

# この設定は不要?
CORS_ORIGIN_WHITELIST = [
     'http://localhost:3000',
     'http://localhost:8000',
     'ws://localhost:3000',
     'ws://localhost:8000',
]
# この設定だけでOK?
CSRF_TRUSTED_ORIGINS = [
     'http://localhost:3000'
]



ROOT_URLCONF = 'config.urls'

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',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'


# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}


# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/

STATIC_URL = 'static/'

# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

今回は、DRF+ReactでWebSocketを実装するのが目的なので、Redisの使用は見送った。デプロイ時に使用する。

CORSのホワイトリストの設定は不要?

# この設定は不要?
CORS_ORIGIN_WHITELIST = [
     'http://localhost:3000',
     'http://localhost:8000',
     'ws://localhost:3000',
     'ws://localhost:8000',
]
# この設定だけでOK?
CSRF_TRUSTED_ORIGINS = [
     'http://localhost:3000'
]

ここで、CORSのホワイトリスト設定をしているが、全てコメントアウトしても正常にWebSocketに接続できた。

config/urls.py

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

from rest_framework import routers 
from chat import views 

router = routers.DefaultRouter()
router.register(r"rooms", views.RoomView, "rooms")
#router.register(r"rooms/<int:room_id>/chatlogs", views.ChatLogView, "chatlogs")

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include(router.urls)),
    path("api/rooms/<int:room_id>/chatlogs/", views.ChatLogView.as_view({"get": "list"}), name="chatlogs"),
]



from django.http import JsonResponse
from django.middleware.csrf import get_token

def csrf_token(request):
    return JsonResponse({"csrfToken": get_token(request)})

urlpatterns.append( path("api/csrf-token/", csrf_token, name="csrf_token") )

JWT認証をしていないため、CSRFトークンを返している。

config/asgi.py

"""
ASGI config for config project.

It exposes the ASGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""

"""
import os

from django.core.asgi import get_asgi_application

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

application = get_asgi_application()

"""

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application

# chatアプリのrouting.pyを読み込み、asgi.py につなげる。
import chat.routing

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

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    #ここでWebSocketサーバーのルーティングを登録( ws/chat/(?P<room_name>\w+)/ でconsumersを呼び出す )
    #"websocket": AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns)),

    "websocket": URLRouter(chat.routing.websocket_urlpatterns),
})

chat/routing.py をセットしている。

chat/models.py

from django.db import models
from django.utils import timezone

class Room(models.Model):
    created_at  = models.DateTimeField(verbose_name="投稿日時",default=timezone.now)
    name        = models.CharField(verbose_name="部屋名", max_length=200, unique=True)

    # TODO: 実践では、この部分はUserモデルとの多対多とする、数をカウントして表示する。JWT認証実装後に対応
    headcount   = models.PositiveIntegerField(verbose_name="参加人数", default=0)

class ChatLog(models.Model):
    created_at  = models.DateTimeField(verbose_name="投稿日時",default=timezone.now)
    message     = models.CharField(verbose_name="メッセージ", max_length=200)
    room        = models.ForeignKey(Room , verbose_name="部屋", on_delete=models.CASCADE)

今回JWT認証をしていないため、人数はPositiveIntegerField で対応している。

認証を実装した後は、UserモデルとのManyToManyFieldを使えば良いだろう。

chat/views.py

"""
from django.shortcuts import render,redirect
from django.views import View

from .models import Room,ChatLog
from .forms import RoomForm,ChatLogForm

class IndexView(View):
    def get(self, request, *args, **kwargs):

        context = {}
        context["rooms"]    = Room.objects.all()

        return render(request,"chat/index.html", context)

    def post(self, request, *args, **kwargs):
        form    = RoomForm(request.POST)

        if not form.is_valid():
            return redirect("chat:index")

        room    = form.save()

        return redirect("chat:room", room.id)

index   = IndexView.as_view()

class RoomView(View):

    def get(self,request, room_name, *args,**kwargs):
        context = {}
        context["room_name"]    = room_name

        context["room"]         = Room.objects.filter(id=room_name).first()
        context["chat_logs"]    = ChatLog.objects.filter(room=room_name).order_by("created_at")

        return render(request,"chat/room.html",context)

room    = RoomView.as_view()
"""


# == This code was created by https://noauto-nolife.com/post/django-auto-create-views/ == #

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated

from rest_framework.response import Response

from .models import Room,ChatLog
from .serializers import RoomSerializer,ChatLogSerializer

class RoomView(viewsets.ModelViewSet):
    #permission_classes  = [IsAuthenticated]
    serializer_class    = RoomSerializer
    queryset            = Room.objects.all()


# そもそも、ChatLogの読み書きはconsumers.py のやること。ここはコメントアウト
# ↑そんなわけない、最初のロード時にこれまでのチャットデータをロードして返す必要がある。
class ChatLogView(viewsets.ModelViewSet):
    #permission_classes  = [IsAuthenticated]
    serializer_class    = ChatLogSerializer
    queryset            = ChatLog.objects.all()

    def list(self, request, room_id=None):
        # room_id を利用して関連するChatLogを取得
        chatlogs    = ChatLog.objects.filter(room_id=room_id)
        serializer  = ChatLogSerializer(chatlogs, many=True)
        return Response(serializer.data)

RoomViewは、ルームの操作をするAPI

ChatLogView はWebSocketで対応するため不要のように思えるが、最初のロード時に何も表示されなくなるので、読み込みのlistメソッドだけ有効にした。

chat/forms.py

# == This code was created by https://noauto-nolife.com/post/django-auto-create-models-forms-admin/ == #

from django import forms
from .models import Room,ChatLog

class RoomForm(forms.ModelForm):
    class Meta:
        model	= Room
        fields	= [ "name","headcount" ]

class ChatLogForm(forms.ModelForm):
    class Meta:
        model	= ChatLog
        fields	= [ "message", "room" ]

このフォームクラスはシリアライザで代用できるので、あえて作らなくても良い。

chat/serializers.py

シリアライザはモデルをJSONに変換することができる。

WebSocketとも親和性が高いが、今回 consumers.py では使っていない。

# == This code was created by https://noauto-nolife.com/post/django-auto-create-models-forms-serializers/== #

from rest_framework import serializers
from .models import Room,ChatLog

class RoomSerializer(serializers.ModelSerializer):
    created_at  = serializers.DateTimeField(format="%Y年%m月%d日 %H時%M分%S秒",required=False)
    headcount   = serializers.IntegerField(max_value=None, min_value=0, required=False)

    class Meta:
        model	= Room
        fields	= ["id", "created_at", "name", "headcount"]

class ChatLogSerializer(serializers.ModelSerializer):

    created_at  = serializers.DateTimeField(format="%Y年%m月%d日 %H時%M分%S秒",required=False)

    class Meta:
        model	= ChatLog
        fields	= ["id", "created_at", "message", "room"]

シリアライザのIntegerField は最小値と最大値を指定できるようになっている。

chat/consumers.py

WebSocketのビュー部分

# chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async

from .models import Room
from .forms import ChatLogForm
from django.utils import timezone 

class ChatConsumer(AsyncWebsocketConsumer):

    async def connect(self):
        self.room_name          = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name    = 'chat_%s' % self.room_name
    
        await self.enter_the_room()

        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()

    # 入室時、人数を追加する。
    @sync_to_async
    def enter_the_room(self):
        room    = Room.objects.filter(id=self.room_name).first()
        room.headcount += 1
        room.save()


    async def disconnect(self, close_code):
        await self.leave_the_room()

        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )


    # 退室時、人数を減らす。
    @sync_to_async
    def leave_the_room(self):
        room    = Room.objects.filter(id=self.room_name).first()
        room.headcount -= 1
        room.save()

    async def receive(self, text_data):

        text_data_json  = json.loads(text_data)
        message         = text_data_json['message']

        chat_log = await self.save_chat_log(message)

        if chat_log == None:
            return False

        local_created_at    = timezone.localtime(chat_log.created_at)
        created_at_str      = local_created_at.strftime('%Y年%m月%d日 %H時%M分%S秒')  # 文字列に変換

        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'id': chat_log.id ,
                'message': chat_log.message ,
                'created_at': created_at_str ,
            }
        )

    # TODO: djangoの通常のORM動作は非同期ではないため、sync_to_async でラップする。
    @sync_to_async
    def save_chat_log(self, message):
        dic = {}
        dic["room"]     = self.room_name
        dic["message"]  = message
        form    = ChatLogForm(dic)

        if not form.is_valid():
            print(form.errors)
            return None
        
        return form.save()


    async def chat_message(self, event):
        data    = {}
        data["id"]          = event["id"]
        data["message"]     = event["message"]
        data["created_at"]  = event["created_at"]

        await self.send( text_data=json.dumps(data) )

consumers.pyでSerializerを使うには?

ここで、チャンネルレイヤーにメッセージを追加している。

await self.channel_layer.group_send(
    self.room_group_name,
    {
        'type': 'chat_message',
        'id': chat_log.id ,
        'message': chat_log.message ,
        'created_at': created_at_str ,
    }
)


async def chat_message(self, event):
    data    = {}
    data["id"]          = event["id"]
    data["message"]     = event["message"]
    data["created_at"]  = event["created_at"]

    await self.send( text_data=json.dumps(data) )

このjsonの部分をシリアライザにすると良いだろう。

chat/routing.py

WebSocketのルーティング部分

from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path('ws/chat/<room_name>/', consumers.ChatConsumer.as_asgi()),
]

特筆するべき点は特にない。コンシューマーは、 .as_asgi() でルーティングできる。

フロントサイド

CRAはもう開発が止まっているため、Viteを使った。package.json は以下の通り

{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@fortawesome/fontawesome-free": "^6.7.2",
    "axios": "^1.7.9",
    "bootstrap": "^5.3.3",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^7.1.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.17.0",
    "@types/react": "^18.3.18",
    "@types/react-dom": "^18.3.5",
    "@vitejs/plugin-react": "^4.3.4",
    "eslint": "^9.17.0",
    "eslint-plugin-react": "^7.37.2",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.16",
    "globals": "^15.14.0",
    "vite": "^6.0.5"
  }
}

CRAと違い、Viteでは、package.jsonを直接編集しない。コマンドでライブラリをインストールする場合は

npm install @fortawesome/fontawesome-free axios bootstrap react-router-dom

続いて、vite.config.json

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
    plugins: [react()],

    server: {
        /*
        hmr: {
            port: 3001, // HMR の WebSocket を別ポートに変更
        },
        */
        //hmr : false,

        port: 3000, // 使用するポート番号を指定
        proxy: {
            '/api': {
                target: 'http://localhost:8000/',
                changeOrigin: true,
            },
            '/ws': {
                target: 'http://localhost:8000/',
            },
        },

    },
})

WebSocketの接続がうまく行かないため、HMRの設定を無効化するなどしていたが、問題は別にあったようだ。

使用するコンポーネントは以下の通り。

  • App.jsx
  • components/Base.jsx
  • components/Index.jsx
  • components/Room.jsx

簡易掲示板よりもページ数は少ないため、使用するコンポーネントも少ない。

App.jsx

import './App.css'

import '@fortawesome/fontawesome-free/css/all.min.css';
import 'bootstrap/dist/css/bootstrap.min.css';

import Base from "./components/Base";


const App = () => {
  return (
    <>
      <Base />
    </>
  )
}

export default App

components/Base.jsx

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Link } from "react-router-dom";

import Index from "./Index";
import Room from "./Room";

const Base = () => {

    return (
        <>
            <BrowserRouter>
                <header className="bg-primary">
                    <h1>
                        <Link className="text-white text-decoration-none" to={"/"}>DRF+ReactのSPAでWebSocketチャットサイト</Link>
                    </h1>
                </header>

                <main className="container">
                    <Routes>
                        <Route path={`/`}          element={<Index />} />
                        <Route path={`/rooms/:id`} element={<Room />} />
                    </Routes>
                </main>
            </BrowserRouter>

        </>
    )
};

export default Base;

react-router-domを使ってルーティングしている。

components/Index.jsx

import React , { useEffect,useState } from 'react';
import { Link } from "react-router-dom";

import axios from "axios";

const Index = () => {

    const [rooms, setRooms] = useState({});
    const [newRoom, setNewRoom] = useState({});

    useEffect(() => {
        loadRooms();
    }, []);

    // 今回JWT認証はオミットしたため、CSRFトークンを取得する。
    const getCsrfToken = async () => {
        try {
            const response = await axios.get("/api/csrf-token/");
            return response.data.csrfToken;
        } catch (error) {
            console.error('Error fetching CSRF token:', error);
            return null;
        }
    }

    const loadRooms = async () => {
        try {
            const response = await axios.get("/api/rooms/");

            const processed = {}
            for (let room of response.data){
                processed[room.id] = room
            }
            setRooms(processed);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }
    
    const handleNewRoom = (event) => {
        setNewRoom( (prevNewRoom) => {
            const updatedNewRoom = { ...prevNewRoom };
            updatedNewRoom[event.target.name] = event.target.value;
            return updatedNewRoom;
        });
    }


    const submitNewRoom = async () => {
        try {
            const csrfToken = await getCsrfToken();

            // TODO:後でJWT認証をするので、CSRFトークンは無しにしておく。
            await axios.post("/api/rooms/", newRoom,
                { headers: { 'X-CSRFToken': csrfToken } },
            );


            loadRooms();
        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    const deleteRoom = async (id) => {
        try {
            const csrfToken = await getCsrfToken();

            // TODO:後でJWT認証をするので、CSRFトークンは無しにしておく。
            await axios.delete(`/api/rooms/${id}/`,
                { headers: { 'X-CSRFToken': csrfToken } },
            );
            loadRooms();
        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    return (
        <>
            <h2>ルーム作成</h2>

            <form action="">
                <input className="form-control w-auto d-inline-block" type="text" placeholder="ルーム名を入力してください" name="name" onChange={handleNewRoom} />
                <input className="btn btn-outline-primary" type="button" value="作成" onClick={submitNewRoom} />
            </form>



            <h2>ルーム一覧</h2>

            <table className="table table-striped text-center">
                <thead>
                    <tr>
                        <th>ルーム名</th>
                        <th>参加人数</th>
                        <th>入室</th>
                        <th>閉鎖</th>
                    </tr>
                </thead>
                <tbody>
            {
                Object.entries(rooms).map( ([id, room]) => (
                    <tr key={id}>
                        <td>{ room.name }</td>

                    {/* 参加人数の表示はリアルタイムではない */}
                        <td>{ room.headcount } 人</td>
                        <td>
                            <Link className="btn btn-outline-success" to={`rooms/${id}`}>入室</Link>
                        </td>
                        <td>
                            <span className="btn btn-outline-danger" onClick={ () => { deleteRoom(id) }} >閉鎖</span>
                        </td>
                    </tr>
                ))
            }
                </tbody>
            </table>


        </>
    );
}

export default Index;

ルームの作成と削除を受け付けている。

JWT認証をしていないため、CSRFトークンをセットしてリクエストしている。

components/Room.jsx

import React , { useEffect,useState,useRef } from "react";
import axios from "axios";

import { useParams } from "react-router-dom";
import { Link } from "react-router-dom";

const Room = () => {
    const { id } = useParams();

    const [chatLogs, setChatLogs] = useState({});
    const [newChatLog, setNewChatLog] = useState("");

    const ws = useRef(null);

    // WebSocketの接続
    useEffect(() => {
        loadChatLogs();

        // WebSocketサーバーのURL
        // const socket = new WebSocket(`ws://${window.location.host}/ws/chat/${id}/`);
        // TODO: エンドポイントのオリジンをハードコードしているので、どうにかする。
        const socket = new WebSocket(`ws://localhost:8000/ws/chat/${id}/`);

        socket.onopen = () => {
            console.log("WebSocket接続完了");
        };

        socket.onmessage = (event) => {
            // WebSocketサーバーから受け取ったメッセージをStateにセットする。
            setChatLogs((prevChatLogs) => {
                const updatedChatLogs = { ...prevChatLogs };

                // event.data は JSON文字列なので、パースする。
                const data  = JSON.parse(event.data);

                updatedChatLogs[data.id] = data;

                return updatedChatLogs;
            });
            console.log(event.data);
        };

        socket.onerror = (error) => {
            console.error("WebSocket エラー:", error);
        };

        socket.onclose = () => {
            console.log("WebSocket 切断");
        };

        ws.current = socket;

        // コンポーネントがアンマウントされた時にWebSocketを閉じる
        return () => {
            if (socket) {
                socket.close();
            }
        };

    }, []);

    const loadChatLogs = async () => {
        try {
            const response = await axios.get(`/api/rooms/${id}/chatlogs/`);
            
            // { 1: { オブジェクト,  }, 2: {...}, 3: {...} } この形式に変換する
            const processed = {}; 
            for (let chatlog of response.data){
                processed[chatlog.id] = chatlog;
            }
            setChatLogs(processed);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    const handleNewChatLog = (e) => {
        setNewChatLog(e.target.value);
    };

    const submitNewChatLog = () => {
        if (ws.current && newChatLog.trim() !== "") {

            ws.current.send(JSON.stringify({ 'message': newChatLog }));
            setNewChatLog("");
        }
    };


    return (
        <>
            <h2>ルーム</h2>
            <input className="form-control w-auto d-inline-block" type="text" value={newChatLog} onChange={handleNewChatLog} placeholder="メッセージを入力" />
            <input className="btn btn-outline-primary" type="button" onClick={submitNewChatLog} value="送信" />

            <div>
                {Object.entries(chatLogs).map( ([id , chatlog]) => (
                    <div key={id}>{chatlog.created_at}:{chatlog.message}</div>
                ))}
            </div>
        </>
    );  
};

export default Room;

ReactでWebSocketを動作させるには?

まず、useEffectでWebSocketの接続をする。

ここでWebSocketの接続をしている。エンドポイントのオリジンをハードコードしているのは、どうにかしたいところだ。

const socket = new WebSocket(`ws://localhost:8000/ws/chat/${id}/`);

このWebSocketのオブジェクトは、useRefに格納しておくと良いだろう。useRefは値が変化しても再レンダリングはしない。

webSocketのオブジェクトが変わるわけではないが、useStateに格納する理由はないため、今回はuseRefとした。

動かすとこうなる

これがルーム一覧画面。

ルームの作成と削除ができる。

ルーム内の画面。メッセージのやり取りができる。

結論

後はJWT認証を実装させれば完璧。

JWT認証とWebSocketを両立させるには、Sec-WebSocket-Protocol というものを使えば良いらしい。もしくはWebSocket確立後に、メッセージに含ませる方法。

https://nykergoto.hatenablog.jp/entry/2021/05/12/Websocket_%E3%81%AE%E8%AA%8D%E8%A8%BC_%28Authentication%29_%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E3%83%A1%E3%83%A2

JWT認証を実装後、カスタムユーザーモデルを使ってプロフィールを用意し、アイコンなどもつければ、LINEなどのメッセンジャーアプリとやっていることに変わりないだろう。

DRFはAPIを返しているだけなので、Reactだけでなくネイティブアプリとも連携はできる。

サーバーサイドにDRFを用意し、ブラウザのユーザーにはReactを。スマホのネイティブアプリにはReactNativeなどを使えば、どんな端末でも利用できるだろう。

スマホアプリ開発の勉強もしたいと思う。

また、ここまで来ると日本語の情報どころか英語の情報も限られ、ChatGPTでさえまともに答えられない内容が多い。

この設計で本当にセキュリティ的に大丈夫なのか疑問だ。

そこで、オフェンシブセキュリティの勉強もやっていきたいところだ。

ソースコード

https://github.com/seiya0723/drf-react-spa-websocket-chatsite

スポンサーリンク