GoとReactによるWebSocket通信でルームID付きのチャットルームを作る

# はじめに
ブラウザゲームでよくある、合言葉を入力して部屋に入室するタイプのルームの作り方が気になったので、とりあえず簡単なルームID付きのチャットルームを作ってみました。

リポジトリはこちら

https://github.com/empelt/websocket-sample

# 実装

まず、最終的なディレクトリ構成は以下のようになっています。

```
websocket
    ├── client
    |    ├── src
    |    |    ├── types
    |    |    |    └── Message.ts
    |    |    ├── App.tsx
    |    |    ├── Home.tsx
    |    |    ├── main.tsx
    |    |    ├── Room.tsx
    |    |    └── index.html
    |    ├── ...
    |    └── package.json
    |
    └── server
         ├── client.go
         ├── go.mod
         ├── go.sum
         ├── hub.go
         └── main.go
```

server(Go側)とclient(React側)に分けてまとめます。

## Server(Go側)

今回は、[gorilla/websocket](https://pkg.go.dev/github.com/gorilla/websocket)のリポジトリで公開されているチャットアプリの[サンプル](https://github.com/gorilla/websocket/blob/main/examples/chat/client.go)をベースに作ります。

### 事前準備、必要なパッケージのインストール

```
$ mkdir server && cd server
$ go mod init websocket
$ go get github.com/gorilla/websocket
$ go get github.com/labstack/echo/v4
```

### main.go

サンプルでは[net/http](https://pkg.go.dev/net/http)が使われていますが、パスパラメータの受け取りが楽なので今回は[echo](https://pkg.go.dev/github.com/labstack/echo/v4)を使います。

```golang:main.go
package main

import (
	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()

	e.GET("/rooms/:id/ws", serveWs)

	e.Logger.Fatal(e.Start(":1323"))
}
```

### hub.go

こちらは、サンプルのコードをそのまま使います。

```golang:hub.go
package main

// Hub maintains the set of active clients and broadcasts messages to the
// clients.
type Hub struct {
	// Registered clients.
	clients map[*Client]bool

	// Inbound messages from the clients.
	broadcast chan []byte

	// Register requests from the clients.
	register chan *Client

	// Unregister requests from clients.
	unregister chan *Client
}

func newHub() *Hub {
	return &Hub{
		broadcast:  make(chan []byte),
		register:   make(chan *Client),
		unregister: make(chan *Client),
		clients:    make(map[*Client]bool),
	}
}

func (h *Hub) run() {
	for {
		select {
		case client := <-h.register:
			h.clients[client] = true
		case client := <-h.unregister:
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
			}
		case message := <-h.broadcast:
			for client := range h.clients {
				select {
				case client.send <- message:
				default:
					close(client.send)
					delete(h.clients, client)
				}
			}
		}
	}
}
```

### client.go

```golang:client.go
package main

import (
	"bytes"
	"log"
	"net/http"
	"time"

	"github.com/gorilla/websocket"
	"github.com/labstack/echo/v4"
)

const (
	// Time allowed to write a message to the peer.
	writeWait = 10 * time.Second

	// Time allowed to read the next pong message from the peer.
	pongWait = 60 * time.Second

	// Send pings to peer with this period. Must be less than pongWait.
	pingPeriod = (pongWait * 9) / 10

	// Maximum message size allowed from peer.
	maxMessageSize = 512
)

var (
	newline = []byte{'\n'}
	space   = []byte{' '}
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

var hubs = map[string]*Hub{}


// Client is a middleman between the websocket connection and the hub.
type Client struct {
	hub *Hub

	// The websocket connection.
	conn *websocket.Conn

	// Buffered channel of outbound messages.
	send chan []byte
}

// readPump pumps messages from the websocket connection to the hub.
//
// The application runs readPump in a per-connection goroutine. The application
// ensures that there is at most one reader on a connection by executing all
// reads from this goroutine.
func (c *Client) readPump() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()
	c.conn.SetReadLimit(maxMessageSize)
	c.conn.SetReadDeadline(time.Now().Add(pongWait))
	c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
	for {
		_, message, err := c.conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
				log.Printf("error: %v", err)
			}
			break
		}
		message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
		c.hub.broadcast <- message
	}
}

// writePump pumps messages from the hub to the websocket connection.
//
// A goroutine running writePump is started for each connection. The
// application ensures that there is at most one writer to a connection by
// executing all writes from this goroutine.
func (c *Client) writePump() {
	ticker := time.NewTicker(pingPeriod)
	defer func() {
		ticker.Stop()
		c.conn.Close()
	}()
	for {
		select {
		case message, ok := <-c.send:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if !ok {
				// The hub closed the channel.
				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}

			w, err := c.conn.NextWriter(websocket.TextMessage)
			if err != nil {
				return
			}
			w.Write(message)

			// Add queued chat messages to the current websocket message.
			n := len(c.send)
			for i := 0; i < n; i++ {
				w.Write(newline)
				w.Write(<-c.send)
			}

			if err := w.Close(); err != nil {
				return
			}
		case <-ticker.C:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

// serveWs handles websocket requests from the peer.
func serveWs(c echo.Context) error {
	id := c.Param("id")

	if _, ok := hubs[id]; !ok {
		hubs[id] = newHub()
		go hubs[id].run()
	}
	hub := hubs[id]

	conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
	if err != nil {
		log.Println(err)
		return err
	}
	client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
	client.hub.register <- client

	// Allow collection of memory referenced by the caller by doing all work in
	// new goroutines.
	go client.writePump()
	go client.readPump()

	return nil
}
```

修正箇所は以下の点です。

```golang:client.go
// hubを管理するmapを作成
var hubs = map[string]*Hub{}

func serveWs(c echo.Context) error {
    // パスパラメータを受け取る
	id := c.Param("id")

    // idが存在していなければ新しいhubを作成してrunする
	if _, ok := hubs[id]; !ok {
		hubs[id] = newHub()
		go hubs[id].run()
	}
	hub := hubs[id]

```

### 動作確認
websocketの便利ツールとして[WebSocket Test Client](https://chromewebstore.google.com/detail/websocket-test-client/fgponpodhbmadfljofbimhhlengambbn)というchrome拡張があります。これを使って疎通確認をしてみます。

```
$ go run *.go
```
![Screenshot from 2023-12-31 01-12-30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359441/9daa1d7a-03bc-fbed-0f7e-b803fe1d2ff5.png)
![Screenshot from 2023-12-31 01-12-50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359441/f0cfa45b-f1ca-ada0-e4e9-5bda5b75f323.png)
![Screenshot from 2023-12-31 01-13-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359441/3c8b550f-acfc-09e5-3594-37fc601ffb36.png)

ウィンドウ1とウィンドウ2ではルームIDをaaに、ウィンドウ3ではルームIDをbbにしました。

しっかりとウィンドウ1とウィンドウ2でのみチャットが送りあえてることが確認できます。

## Client(React側)

### 事前準備、必要なパッケージのインストール

reactプロジェクトの作成
```
$ yarn create vite client --template react-ts
```

必要なパッケージの追加。今回は[Chakra UI](https://chakra-ui.com/)を使って、さくっとフロントエンドを実装します。

また、[Reconnecting WebSocket](https://www.npmjs.com/package/reconnecting-websocket)という、WebSocket通信の再接続を自動で行ってくれるパッケージを使用します。

```
$ yarn add reconnecting-websocket
$ yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
$ yarn add react-router-dom
```

### main.tsx
```typescript:main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";

import { ChakraProvider } from "@chakra-ui/react";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <ChakraProvider>
      <App />
    </ChakraProvider>
  </React.StrictMode>
);

```


### App.tsx
```typescript:App.tsx
import { BrowserRouter, Route, Routes } from "react-router-dom";

import Home from "./Home";
import Room from "./Room";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path={`/`} element={<Home />} />
        <Route path={`/room/`} element={<Room />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

```


### Home.tsx
```typescript:Home.tsx
import {
  Container,
  Center,
  Card,
  CardBody,
  Input,
  FormControl,
  FormLabel,
  Button,
  useToast,
} from "@chakra-ui/react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";

export default function Home() {
  const toast = useToast();
  const navigate = useNavigate();

  const [name, setName] = useState("");
  const [word, setWord] = useState("");

  const enterRoom = () => {
    if (name == "" || word == "") {
      toast({
        title: "名前と合言葉を入力してください",
        status: "error",
        isClosable: true,
        position: "top",
      });
      return;
    }
    navigate(`Room?name=${name}&word=${word}`);
  };

  return (
    <Container pt={20}>
      <Card>
        <CardBody>
          <FormControl>
            <FormLabel>名前</FormLabel>
            <Input
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
            />
          </FormControl>
          <FormControl mt={4}>
            <FormLabel>合言葉</FormLabel>
            <Input
              type="text"
              value={word}
              onChange={(e) => setWord(e.target.value)}
            />
          </FormControl>
          <Center mt={8}>
            <Button colorScheme="teal" size="lg" onClick={enterRoom}>
              入室
            </Button>
          </Center>
        </CardBody>
      </Card>
    </Container>
  );
}

```


### Room.tsx
```typescript:Room.tsx
import React from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
  Button,
  Card,
  CardBody,
  Container,
  FormControl,
  HStack,
  Input,
  List,
  ListItem,
} from "@chakra-ui/react";

import ReconnectingWebSocket from "reconnecting-websocket";
import { Message } from "./types/Message";

export default function Room() {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const word = searchParams.get("word");
  const name = searchParams.get("name");

  if (!word || !name) {
    navigate("Home");
    return null;
  }
  const [messages, setMessages] = React.useState<Message[]>([]);
  const [message, setMessage] = React.useState<string>("");
  const socketRef = React.useRef<ReconnectingWebSocket>();

  React.useEffect(() => {
    const websocket = new ReconnectingWebSocket(
      `ws://localhost:1323/rooms/${word}/ws`
    );
    socketRef.current = websocket;

    const onMessage = (event: MessageEvent<string>) => {
      const message = JSON.parse(event.data) as Message;
      setMessages((prev) => [...prev, message]);
    };
    websocket.addEventListener("message", onMessage);

    return () => {
      websocket.close();
      websocket.removeEventListener("message", onMessage);
    };
  }, []);

  const onClick = () => {
    if (!message) return;
    const m = JSON.stringify({ from: name, text: message });
    socketRef.current?.send(m);
    setMessage("");
  };

  return (
    <Container pt={20}>
      <Card>
        <CardBody>
          <HStack mb={8}>
            <FormControl>
              <Input
                placeholder="メッセージ"
                value={message}
                onChange={(e) => {
                  setMessage(e.target.value);
                }}
              />
            </FormControl>
            <Button colorScheme="teal" alignSelf={"end"} onClick={onClick}>
              送信
            </Button>
          </HStack>
          <List>
            {messages.map((message, index) => (
              <ListItem
                key={index}
              >{`${message.from}: ${message.text}`}</ListItem>
            ))}
          </List>
        </CardBody>
      </Card>
    </Container>
  );
}

```


### Message.ts
シンプルにチャットの送信者と内容だけで定義します。

```typescript:Message.ts
export type Message = {
  from: string;
  text: string;
};
```


# できたもの
![Screenshot from 2023-12-31 01-43-05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359441/a91fc09f-f75b-da8d-d68e-431aed5ddc5b.png)

![ezgif.com-speed.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359441/581bfe7f-8502-bb35-9b36-4137f592deaa.gif)

部屋IDは左から、bb, aa, aaです。同じ部屋IDの２つのウィンドウでチャットができていることが確認できます。


# おわりに
サンプルをベースに作ったので、さくっと実装することができました。

次はUnityでクソゲーを作ってみたい、、。