0
2

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

Posted at

はじめに

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

リポジトリはこちら

実装

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

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のリポジトリで公開されているチャットアプリのサンプルをベースに作ります。

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

$ 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が使われていますが、パスパラメータの受け取りが楽なので今回はechoを使います。

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

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

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

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
}

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

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というchrome拡張があります。これを使って疎通確認をしてみます。

$ go run *.go

Screenshot from 2023-12-31 01-12-30.png
Screenshot from 2023-12-31 01-12-50.png
Screenshot from 2023-12-31 01-13-02.png

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

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

Client(React側)

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

reactプロジェクトの作成

$ yarn create vite client --template react-ts

必要なパッケージの追加。今回はChakra UIを使って、さくっとフロントエンドを実装します。

また、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

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

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

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

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

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

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

できたもの

Screenshot from 2023-12-31 01-43-05.png

ezgif.com-speed.gif

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

おわりに

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

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

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2