DRF+ReactのSPAでWebsocketのチャットサイトをつくる
タイトル通り、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 が必要。
この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確立後に、メッセージに含ませる方法。
JWT認証を実装後、カスタムユーザーモデルを使ってプロフィールを用意し、アイコンなどもつければ、LINEなどのメッセンジャーアプリとやっていることに変わりないだろう。
DRFはAPIを返しているだけなので、Reactだけでなくネイティブアプリとも連携はできる。
サーバーサイドにDRFを用意し、ブラウザのユーザーにはReactを。スマホのネイティブアプリにはReactNativeなどを使えば、どんな端末でも利用できるだろう。
スマホアプリ開発の勉強もしたいと思う。
また、ここまで来ると日本語の情報どころか英語の情報も限られ、ChatGPTでさえまともに答えられない内容が多い。
この設計で本当にセキュリティ的に大丈夫なのか疑問だ。
そこで、オフェンシブセキュリティの勉強もやっていきたいところだ。
ソースコード
https://github.com/seiya0723/drf-react-spa-websocket-chatsite