はじめに
いつかWebSocket通信使ってみたいなーと思っていたのをついにやってみる日が来ました。
(過去に一度、Next.jsで実装しようとして挫折した経験あり)
とりあえず、簡単なチャットアプリを作ってみます。
(このあとめちゃくちゃ簡易ブラウザゲームを開発した)
今回は、構成として
localhost内で、
・サーバーサイドで、NestJSのプロジェクト (port: 3333)
・フロントサイドで、Reactのプロジェクト (port: 3000)
が同時に動いている感じです。
フロントReactは決めてましたが、NestJSを触ってみたくてノリで採用しました。
(NestJSについて誤解しているところもあるかもしれないですが、ご容赦ください。)
WebSocketとかSocket.ioとか全体的に参考記事が少なめで、(特にNestJSだし)
バージョン違いとか、環境違いが多くて苦労しました。
特に日本語の記事は少なかったので、誰かの参考になればと、雑に書き残しておきます。
記事少ない中でも、一番参考になったのはやっぱり公式のgithubのサンプルです。
(これに少し毛が生えたくらいの内容です。)
サーバーサイド
package.jsonはこんな感じ
"dependencies": {
"@nestjs/common": "8.2.3",
"@nestjs/core": "8.2.3",
"@nestjs/platform-express": "8.2.3",
"@nestjs/platform-socket.io": "8.2.3",
"@nestjs/websockets": "8.2.3",
"@types/babel__core": "^7.1.17",
"class-transformer": "0.4.0",
"class-validator": "0.13.2",
"redis": "3.1.2",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
"rxjs": "7.4.0",
"socket.io-redis": "6.1.1"
},
"devDependencies": {
"@nestjs/cli": "8.1.5",
"@nestjs/schematics": "8.0.5",
"@nestjs/testing": "8.2.3",
"@types/express": "4.17.13",
"@types/node": "16.11.12",
"@types/socket.io": "^3.0.1",
"@types/socket.io-redis": "1.0.27",
"@types/supertest": "2.0.11",
"@types/ws": "7.4.7",
"@typescript-eslint/eslint-plugin": "4.33.0",
"@typescript-eslint/parser": "4.33.0",
"eslint": "7.32.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-import": "2.25.3",
"jest": "27.3.1",
"prettier": "2.5.1",
"supertest": "6.1.6",
"ts-jest": "27.0.7",
"ts-loader": "9.2.6",
"ts-node": "10.4.0",
"tsconfig-paths": "3.11.0",
"typescript": "4.3.5"
}
socket.ioとか、websocketとかが付いてるやつは大体関連のやつですね。
ディレクトリ構成はこんな感じ。
主にサーバーとして大事なところをピックアップすると
/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3333);
}
bootstrap();
/src/app.module.ts
import { Module } from '@nestjs/common';
import { EventsModule } from './events/events.module';
@Module({
imports: [EventsModule],
})
export class AppModule {}
/src/events/events.module.ts
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
})
export class EventsModule {}
/src/events/events.gateway.ts
import {
ConnectedSocket,
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets'
import { Server, Socket } from 'socket.io'
import { Logger } from '@nestjs/common'
type ChatRecieved = {
uname: string
time: string
text: string
}
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class EventsGateway {
@WebSocketServer()
server: Server
//ログ出力用
private logger: Logger = new Logger('EventsGateway')
//クライアント側から「chatToServer」という名前のメッセージ(?)をリッスン(好きに命名できる)
@SubscribeMessage('chatToServer')
chatting(@MessageBody() payload: ChatRecieved, @ConnectedSocket() client: Socket): void {
//@MessageBody→受信したデータ
//@ConnectedSocket→ユーザーのID(websocketで自動で割り当てられる)や、その他接続に関する情報など
this.logger.log('chat受信')
this.logger.log(payload)
//emit()とすると、指定した名前をリッスンしているクライアントに情報をプッシュできる
this.server.emit('chatToClient', { ...payload, socketId: client.id })
}
afterInit(server: Server) {
//初期化
this.logger.log('初期化しました。');
}
handleConnection(client: Socket, ...args: any[]) {
//クライアント接続時
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(@ConnectedSocket() client: Socket) {
//クライアント切断時
this.logger.log(`Client disconnected: ${client.id}`);
}
}
このあたり。
コメントいっぱい入れてるので、詳細説明しませんが、
events.gateway.tsで、接続時の処理、チャットが送信された時の処理などを書いています。
実際に動かしてみると、
npm run start
こんな感じで、
afterInitで記述したログや、"chatToServer"でリッスンしてますよー的な感じのログがでます。
ここに、クライアントからアクセスすると、
こんな感じで、「connected: [自動発行クライアントID]」みたいなログが出ます。
切断すると。
同じくこのクライアントさん切断したよ。のログが出ます。
フロントサイド
package.jsonはこんな感じ
"dependencies": {
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/express": "^4.17.13",
"@types/node": "^16.11.13",
"@types/react": "^17.0.37",
"@types/socket.io": "^3.0.2",
"@types/socket.io-client": "^3.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"react-scripts": "5.0.0",
"typescript": "^4.5.4",
"web-vitals": "^2.1.2"
},
socket.io-clientが大事なやつですね。
実際にWebSocketで接続しているコンポーネント、というかページは、こんな感じになっています。
/src/pages/Chat.tsx
import React, { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import io from 'socket.io-client';
type Chat = {
socketId: string
uname: string
time: string
text: string
}
type ChatLog = Array<Chat>
//接続
const socket = io('http://localhost:3333')
export const Chat: React.FC = () => {
const [chatLog, setChatLog] = useState<ChatLog>([])
const [uname, setUname] = useState<string>('')
const [text, setText] = useState<string>('')
useEffect(() => {
//接続が完了したら、発火
socket.on('connect', () => {
console.log('接続ID : ', socket.id)
})
//切断
return () => {
console.log('切断')
socket.disconnect()
}
}, [])
useEffect(() => {
//サーバーからのチャット情報のプッシュを感知→反映
socket.on('chatToClient', (chat: Chat) => {
console.log('chat受信', chat)
const newChatLog = [...chatLog]
newChatLog.push(chat)
setChatLog(newChatLog)
});
}, [chatLog])
//現在時刻取得
const getNow = useCallback((): string => {
const datetime = new Date();
return `${datetime.getFullYear()}/${datetime.getMonth() + 1}/${datetime.getDate()} ${datetime.getHours()}:${datetime.getMinutes()}:${datetime.getSeconds()}`
}, [])
//チャット送信
const sendChat = useCallback((): void => {
if (!uname) {
alert('ユーザー名を入れてください。')
return;
}
console.log('送信')
socket.emit('chatToServer', { uname: uname, text: text, time: getNow() });
setText('');
}, [uname, text])
return (
<>
<div>
<div>ユーザー名</div>
<div>
<input type="text" value={uname} onChange={(event) => { setUname(event.target.value) }} />
</div>
<br />
<section style={{ backgroundColor: 'rgba(30,130,80,0.3)', height: '50vh', overflow: 'scroll' }}>
<h2>チャット</h2>
<hr />
<ul style={{ listStyle: 'none', display: 'flex', flexDirection: 'column' }}>
{
chatLog.map((chat, index) => {
return (
<li key={index} style={{ margin: uname === chat.uname ? '0 15px 0 auto ' : '0 auto 0 15px' }}>
<div><small>{chat.time} [{chat.socketId}]</small></div>
<div>【{chat.uname}】 : {chat.text}</div>
</li>
)
})
}
</ul>
</section>
<br />
<div>
送信内容
</div>
<div>
<input type="text" value={text} onChange={(event) => { setText(event.target.value) }} />
</div>
<br />
<div>
<button onClick={sendChat}> send </button>
</div>
<br />
<div>
<Link to="/">トップページへ</Link>
</div>
</div>
</>
)
}
実際の画面はこんな感じになってます。(やっつけ仕事)
↑きーた目線
↓理解者目線(きーたで最初のチャットを送った後にログインしたので、1つチャットが少ない)
こんな感じで同じ画面に複数タブでアクセスすると、実際に送信したチャット内容が他のユーザにもリアルタイムで共有されているのがわかります。
簡単ですが、以上です。