Socket.ioとReact

Reactで作ったオセロゲームをSocket.ioで双方向通信しようと思います

「ブロントエンドはReact」「バックエンドはNode.js」でRender(Herokuが有料化されるらしいので😂)にデプロイしました

「Socket.io」はWebSocketプロトコルを扱えるNode.jsのライブラリです
WebSocketプロトコルとはブラウザ上での双方向通信を可能にした通信規格です
一度コネクションを確立した後は、サーバーとクライアントのどちらからでも通信が可能です

目次
  1. socket.io
    1. 初期設定
    2. イベント
      1. イベント発行とリスナー登録
      2. room
  2. フロントをReactで
  3. Renderにデプロイ

socket.io

初期設定

インストールと初期設定(socket.ioサーバーsocket.ioクライアントを接続する)
ブラウザで「http://localhost:3000/」にアクセルするとターミナルに「接続されました」を表示する
*expressもインストール(ルーティングのため)

npm init -y
//expressインストール
npm install express
//nodemonはサーバーアプリケーションが自動で再起するようにします
npm install nodemon --save-dev
//バックエンド側 socket.ioサーバー
npm install socket.io

package.jsonのscriptsに追加
これで「npm start 」でサーバー起動して変更も監視してくれます

"scripts": {
    "start": "nodemon index.js"
  },
const path = require('path');
const express = require('express');
const app = express();
//httpモジュールを使いサーバーの処理はexpress
const http = require('http');
const server = http.createServer(app)

//環境変数が取得できない場合は3000ポートを使う
const port = process.env.PORT || 3000

//ioインスタンス作成(サーバーを渡す)
const { Server } = require("socket.io");
const io = new Server(server);

//ここから編集
io.on('connection', () => {
  console.log('接続されました');
});
//ここまで

// ホーム(/)にアクセス時に返却するHTMLファイル
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname,'./public/index.html'));
});

//expressのサーバーapp.lestenでないことに注意
server.listen(port)

プロジェクトフォルダ直下にpublicフォルダを作って
index.htmlファイルを置く
「socket.ioクライアント」を読み込む

<!DOCTYPE html>
<html lang="ja">
<head>
 <!--省略 -->
</head>
<body>
    <h1>Hello Socet</h1>
</body>
<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io();
</script>
</html>

「socket.ioクライアント」でバンドルツールを使う場合

npm install socket.io-client
//モジュールの読み込み
import io from "socket.io-client";
const socket = io();

イベント

サーバーとクライアント間のデータのやり取り

イベント発行とリスナー登録

一方でイベントを発行し、他方でリスナーを登録します

  • サーバー側でクライアント側の接続を受け付けると「connectionイベント」が発生
  • このとき引数としてsocketオブジェクト(新しい接続の情報)が発生
  • socket.emitメソッドを使用してイベントをクライアントに送信

接続するクライアントの数だけ「connectionイベント」が発生します

イベントを発行
socketオブジェクトの「emitメソッド」

 socket.emit(イベント名, イベントリスナーに渡す値);

イベントリスナーを登録
socketオブジェクトの「onメソッド」

socket.on(イベント名, コールバック関数);

サーバーでイベントを発行
クライアントでイベントリスナーを登録

//サーバ
io.on("connection", (socket) => {
  socket.emit("hello", "world");
});

//クライアント
const socket = io();
socket.on("hello", (arg) => {
  console.log(arg); // world
});

クライアントでイベントを発行
サーバーでイベントリスナーを登録

//サーバー
io.on("connection", (socket) => {
  socket.on("hello", (arg) => {
    console.log(arg); // world
  });
});

//クライアント
const socket = io();
socket.emit("hello", "world");

任意の数の引数を送信できます
emit()の引数にコールバックを追加できます
このコールバックは、相手側がイベントを確認すると呼び出されます
コールバックには引数を渡すことができます
*ドキュメントより

//サーバ
io.on("connection", (socket) => {
  socket.on("update item", (arg1, arg2, callback) => {
    console.log(arg1); // 1
    console.log(arg2); // { name: "updated" }
    callback({status: "ok"});
  });
});
//クライアント
socket.emit("update item", "1", { name: "updated" }, (response) => {
  console.log('届きました', response.status); //届きました ok
});

クリックすると1プラスした値をコンソールに出力する

//サーバー側
let count = 0
io.on('connection', (socket) => {
//countUpイベント
  socket.emit('countUp', count);
//incrementイベントのリスナー
socket.on('increment', ()=>{
   count ++
//io: 全ての接続に対してcountUpイベント
io.emit('countUp', count)
  })
});

//クライアントからサーバーへデータを送る
<button>+1</button>
//省略
<script src="/socket.io/socket.io.js"></script>
<script>
    const socket = io();
    //countUpイベントのリスナー
    socket.on('countUp', (count)=>{
    console.log(count)
    })
    //クリック
    document.querySelector('button').addEventListener('click', ()=>{
    //incrementイベント
    socket.emit('increment')
 })
</script>

接続されているすべてのクライアントにイベントを送信

io.emit("hoge", "world");

送信者以外のの接続済みクライアント全員へイベントを送信

io.on("connection", (socket) => {
  socket.broadcast.emit("hoge", "world");
});

「socket.broadcast」:接続中のユーザー(新しい接続したユーザ以外)に新しい接続があることを知らせる
「’disconnect’イベント(切断された時のイベント)」:接続が切断されたことを接続中のユーザーに知らせる
クライアント側で「sendMessageイベント」を登録、「ファームのsubmit」で送信したメッセージもコンソールに出力します

//サーバー側
io.on('connection', (socket) => {
    console.log('新しい接続')
    socket.emit('message', 'Welcome!')
    socket.broadcast.emit('message', '新しい接続があります!')
  //クライアントからのsendMessageイベントのリスナー
    socket.on('sendMessage', (message) => {
        io.emit('message', message)
    })
    socket.on('disconnect', () => {
        io.emit('message', '切断されました!')
    })
})
//クライアント側
<form id="form">
    <input name="message" />
    <button>Send</button>
</form>
//省略
const socket = io()
socket.on('message', (message) => {
    console.log(message)
})
document.querySelector('#form').addEventListener('submit', (e) => {
    e.preventDefault()
    const message = e.target.elements.message.value
    socket.emit('sendMessage', message)
})

XSS対策にxss-filtersというライブラリがあるようです

npm install xss-filters

const xssFilters = require('xss-filters');

socket.emit('sendMessage', xssFilters.inHTMLData(message))

room

ルームの概念はサーバーのみで、クライアントからは参加しているルームのリストにアクセスできません

特定のチャネル(some room)だけがリスナーを呼び出せるようにする
socket.join

io.on("connection", socket => {
  socket.join("some room");
});

特定のチャネル(some room)にイベント(some event)を登録する
some room一致するすべてのソケットにパケットを送信
注意: toの引数は文字列です

io.to("some room").emit("some event");

Socketは一意の識別子(socket.id)で識別され、IDで識別されるルームに自動的に参加します

io.on("connection", socket => {
  socket.on("private message", (anotherSocketId, msg) => {
    socket.to(anotherSocketId).emit("private message", socket.id, msg);
  });
});

特定の部屋のメンバーとコミュニケーションする

io.to.emit
socket.broadcast.emit

フロントをReactで

フォルダの構成
frontendフォルダはプロジェクトフォルダ直下でnpx create-react-app frontendで作成

プロジェクト
|_ frontendフォルダ
|   |_build(本番用) 
|   |_src(開発用) 
|   |_public(開発index.htmlなど) 
|   |_package.json(フロント用)
|   
|_ index.js(サーバー側ファイル)
|   
|_package.json(サーバー用)
    

index.jsとpackage.jsonの変更箇所について
import(ESモジュール)を使います(ReactのESLintの設定でrequireがエラーとなったため)
そうすると__dirname(現在のディレクトリのパス)がそのままでは使えないのでindex.jsファイルを編集します
Reactがnpm start(開発用サーバー)で3000ポートを使用しているのでポートを3001にしています
package.json(サーバー用)には”type”: “module”を追加します

"type": "module",
  "scripts": {
    "start": "nodemon index.js"
  },
import express from 'express'
const app = express()
import path from 'path'

import { fileURLToPath } from 'url'
const port = process.env.PORT || 3001
import http from 'http'
const server = http.createServer(app);
import { Server } from 'socket.io'
const io = new Server(server);
const __dirname = path.dirname(fileURLToPath(import.meta.url));

//本番用ではnpm run build
//本番用:buildしたときに読み込むJSやCSSなど静的ファイル
app.use(express.static(path.join(__dirname, './frontend/build')));

//本番用:ホーム(/)にアクセス時に返却するHTMLファイル
// 「/」を「*」にするとすべてのパス
app.get('/', (req, res) => {
 res.sendFile(path.join(__dirname,'./frontend/index.html'));
});

開発サーバーはhttp://localhost:3000にアクセス
buildした場合はhttp://localhost:3001にアクセス

Reactのpackage.jsonファイルにproxyを設定
これで「localhost:3001」に置き換えるのでCORSポリシーによってブロックされません

 {
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "proxy": "http://localhost:3001",
//略

Socketのイベント:React Hooksを使った例
接続と切断

const [isConnected, setIsConnected] = useState(socket.connected);
useEffect(() => {
    socket.on('connect', () => {
      setIsConnected(true);
    });
    socket.on('disconnect', () => {
      setIsConnected(false);
    });
//クリーンアップする
    return () => {
      socket.off('connect');
      socket.off('disconnect');
  };
}, []);

 return (
    <div>
      {isConnected ?<p>接続中</p> :<p>切断されました!</p>}
    </div>
  );

サーバー側:接続と切断

io.on('connection', (socket) => {
   
    socket.on('disconnect', () => {
     
    })
});

Renderにデプロイ

Herokuが有料化されるらしく😂
Renderを試してみます

GitHubにアップロードして、Renderのダッシュボードからリポジトリを指定するだけで簡単にデプロイできました

*Renderの無料プランのWebサービスは1か月あたり750時間の実行が可能です
無料使用制限を超えると月末に無料使用がリセットされか有料プランにアップグレードされるまで自動的に停止されます
無料プランのWebサービスは、非アクティブ状態が15分間続くと自動的にスリープするので30秒の応答遅延が発生します

余談:スリープ対策
UptimeRobot」を利用すると定期的に自動でサイトにアクセスしてくれます
50までのネットワークを無料で監視できます
監視のタイミングは、5分〜120分の任意間隔で設定できます

GitHubでプロジェクトのリポジトリ作成します
パブリックリポジトリとプライベートリポジトリの両方がサポートされています
プロジェクト直下に.gitignoreファイル作成

node_modules/
.DS_Store

重要:Reactプロジェクトの.gitignoreからは/buildをコメントアウトして、「buildフォルダ」もリポジトリにアップします

*もしサーバーでReactのビルドも実行する場合
まずはnpm run ejectを実行してReactコマンドを環境に依存しない形に書き換えてもらい、サーバーとフロントのpackage.jsonを合体させます

git init
git add .
git commit -m'first commit'
git branch -M main
git remote add origin https://github.com/.....git
git push -u origin main

Renderダッシュボードから新しいWebサービスを作成します

プロジェクトのGitHubリポジトリを選択してリポジトリへのアクセス許可します
必要な内容を入力してWebサービスを作成します(コードに基づいていくつかの値を自動入力されています)

  • Name: Webサービスの名前
  • Environment: Node(プログラミング言語)
  • Region:デプロイ先の地理的リージョン (アジアはシンガポールでした)
  • Branch: main(ビルドするgitブランチ)
  • Build Command: yarn (npm installのこと)
  • Start Command: node index.js(Webサービスを開始するコマンド)
  • Plan: freeプラン(利用可能なプランから選択)

*Advancedを選択すると環境変数(Add Environment variable)を設定できます

ステータスが「Deploy succeeded」になれば成功です

「Webサービスの名前 .onrender.com 」でアプリケーションが公開されます

オセロのロジック

//board
export const board = Array.from(new Array(8), () =>
    new Array(8).fill(0).map(() => {
    return '';
    }));
//初期値
 board[3][3] = 'white';
 board[3][4] ='black';
 board[4][4] ='white';
 board[4][3] = 'black';

export const okList = [{ row: 2, col: 3 }, { row: 3, col: 2 }, { row: 4, col: 5 }, { row: 5, col: 4 }]

//手番から石の色を返す
const _myTrun=(myTurn) =>{
    return myTurn === true
        ? { myStone: 'black', yourStone: 'white' }
        : { myStone: 'white', yourStone: 'black' }
}
//石の数をカウントする
export const countStone =(newBoard) => {
    const count = {
        black: 0,
        white: 0
    }
    newBoard.forEach((rows) => {
        rows.forEach((cell) => {
          if (cell === 'black') {
            count.black += 1
          }
          if (cell === 'white') {
            count.white += 1
          }
        })
    })
    return count
}

//検索用の関数
 const _serch=(row, col, myTurn, othello, incY, incX) =>{
    const list = []
    const { myStone, yourStone } = _myTrun(myTurn)
     let y = row + incY
     let x = col + incX
     if (y < 0 || y > 7 || x < 0 || x > 7) return //進行方向が座標からはみ出る
     if (othello[y][x] === '') return //進行方向が空
     if (othello[y][x] === myStone) return //進行方向が自分の石
     //次の座標が相手の石
     while (othello[y][x] === yourStone) {
         list.push({ row: y, col: x })
             y += incY
             x += incX
          //斜め方向に検索した場合、最後の座標がリストに残るので
          if (y < 0 || y > 7 || x < 0 || x > 7) {
             if (incY !== 0 || incX !== 0) {
                 list.splice(0)
             }
             break
          }
     }
     //最後の場合は0に戻す
         if (y < 0) { y = 0 }
         if (x < 0) { x = 0 }
         if (y > 7) { y = 7 }
         if (x > 7) { x = 7 }
    //自分と同じ石がない場合、リストは削除
     if (othello[y][x] !== myStone ) {
            list.splice(0)
     } else return list
 }

//8方向検索する(クリックした時に反転できる石の座標)
//下1,0/上-1, 0 /左0, -1 /右0, 1
//左上-1,-1 / 左下1,-1 / 右上-1, 1 /右下1, 1
const _changeStoneLists =(row, col, myTurn, othello)=> {
    let cells = []
    cells = cells.concat(_serch(row, col, myTurn, othello, 1, 0))
    cells = cells.concat(_serch(row, col, myTurn, othello, -1, 0))
    cells =cells.concat(_serch(row, col, myTurn, othello, 0, -1))
    cells=cells.concat(_serch(row, col, myTurn, othello, 0, 1))
    cells = cells.concat(_serch(row, col, myTurn, othello, -1, -1))
    cells = cells.concat(_serch(row, col, myTurn,othello, 1, -1))
    cells= cells.concat(_serch(row, col, myTurn, othello, -1, 1))
    cells =cells.concat(_serch(row, col, myTurn, othello, 1, 1))
    return cells.filter((list) => list !== undefined)
 }

//空のセルを取得する
const _getEmptyCells=(nextBoard)=> {
    const emptysList = []
    nextBoard.forEach((row, j) => {
        row.forEach((cell, i) => {
            if (cell === '') {
              emptysList.push({row:j, col:i})
            }
        })
     })
    return emptysList
}

// クリックできる座標のリスト
export const serchOkCells = (nextBoard, myTurn) => {
    const emptysList = _getEmptyCells(nextBoard)
    const okCells = []
    emptysList.forEach(cell => {
        const result = _changeStoneLists(cell.row, cell.col, myTurn, nextBoard)
        if (result.length !== 0) {
           okCells.push(cell)
        }
    })
    return okCells
}

//反転できる石の座標と次の手番でクリックできる座標を取得
export const getStoneListAndOkList = (row, col, myTurn, othello) => {
    const stoneList = _changeStoneLists(row, col, myTurn, othello)
    stoneList.push({ row: row, col: col })
    const nextBoard =[...othello]
    stoneList.forEach(list => {
        nextBoard[list.row][list.col] = myTurn ? 'black' : 'white'
    })
    const okList = serchOkCells(nextBoard, !myTurn)
    return {
        stoneList,
        okList
    }
}