はじめに
ブラウザゲームでよくある、合言葉を入力して部屋に入室するタイプのルームの作り方が気になったので、とりあえず簡単なルーム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を使います。
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
こちらは、サンプルのコードをそのまま使います。
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
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
}
修正箇所は以下の点です。
// 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
ウィンドウ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
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
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
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
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
シンプルにチャットの送信者と内容だけで定義します。
export type Message = {
from: string;
text: string;
};
できたもの
部屋IDは左から、bb, aa, aaです。同じ部屋IDの2つのウィンドウでチャットができていることが確認できます。
おわりに
サンプルをベースに作ったので、さくっと実装することができました。
次はUnityでクソゲーを作ってみたい、、。