🐷

Go と Next.jsでリアルタイムチャット

2023/04/30に公開

個人開発でリアルタイムチャットアプリを開発しており、考えを整理するために記事にしました。バックエンドはGoで実装し、フロントエンドはNext.jsで実装しています。長くなりそうなので今回はバックエンドの実装だけまとめます。

websocketとは

リアルタイム通信でよく使われる技術です。websocketの概要は以下の記事を読んでください。下記の記事はwebsocketの基本的な説明になっています。

https://www.freshvoice.net/knowledge/word/6323/

今回の記事では、上記のwebsocketを使ってリアルタイムチャットを実装する内容になっています。下記の画像はイメージです。フロントエンドからメッセージを送ると、goroutine Aでメッセージが受信され、チャンネルを経由してもう一つのgoroutine Bにメッセージが送信されます。そこからブラウザにメッセージをリアルタイムで送信する流れになっています。

実装の流れ

  • エンドポイントの実装
    • レスポンスの型定義
    • WebSocketsのエンドポイントハンドラの実装
    • エンドポイントの設定
  • WebSocketsのハンドリング
    • WebSocketsコネクション、ペイロードの型定義
    • コネクション情報格納用の変数を宣言
    • チャンネルにメッセージを送信するListenForWs関数の実装
    • チャンネルからメッセージを受信するListenToWsChannel関数の実装
    • ブラウザにメッセージを送るbroadcastToAll関数の実装

エンドポイントの実装

この章ではエンドポイントの設定をしていきます。
まず、rootディレクトリにwebsocket.goを作成してください。本来であればディレクトリ構成をもう少し考える必要がありますが、今回はわかりやすさを優先させて、一つのファイルに処理をまとめます。

レスポンスの型定義

まずはレスポンス用の型を定義します。Messageはフロントエンドに送るメッセージです。

package websocket

// WebSocketsからの返却用データの構造体
type WsJsonResponse struct {
	Message string `json:"message"`
}

WebSocketsのエンドポイントハンドラの実装

次にWebSocketsのエンドポイントハンドラを実装します。Websocketを実装するにあたって以下のライブラリを使用します。

https://github.com/gorilla/websocket

import (
	"fmt"
	"log"
	"net/http"

	"github.com/gorilla/websocket"
)


// wsコネクションの基本設定、詳しくはライブラリ参照
var upgradeConnection = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin:     func(r *http.Request) bool { return true },
}

// WebSocketsのエンドポイント
func WsEndpoint(w http.ResponseWriter, r *http.Request) {
	ws, err := upgradeConnection.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
	}

	log.Println("OK Client Connecting")
}
  • upgradeConnection.Upgradeで受け取ったリクエストをWebソケット用のリクエストにアップグレードしています。

エンドポイントの設定

main.gowebsocket.goを読み込みます。

import (
	"net/http"
	"root/websocket"

	"github.com/go-chi/chi"
)

func main(){
	router := chi.NewRouter()
	router.Get("/websocket", websocket.WsEndpoint)
}

エンドポイントを実装するのにgithub.com/go-chi/chiを使ってますが使うライブラリは何でも大丈夫です。

WebSocketsのハンドリング

送信したメッセージがリアルタイムで他のブラウザにもメッセージが表示されるようなWebSocketsのハンドリングの実装を行います。全体像は以下の画像のとおりです。
処理の流れは

  • フロントエンド1からメッセージを送信してListenForWsで受け取る
  • ListenForWsで受け取ったメッセージをチャンネルに送信する
  • チャンネルを経由してListenToWsChannelで受信する
  • 受け取ったメッセージをブラウザに送信する

WebSocketsコネクション、ペイロードの型定義

ブラウザにリアルタイムでメッセージを送るために、それぞれのブラウザのコネクション情報を保持じておく必要があります。そのために、コネクション情報を格納する型、クライアントに送信するデータの型を定義します。

type WebScoketConnection struct {
	*websocket.Conn
}

type WsPayload struct {
	Message  string              `json:"message"`
	Conn     WebScoketConnection `json:"-"`
}

コネクション情報格納用の変数を宣言

コネクションの値は保持したいので、グローバル変数で宣言します。

var (
	// ペイロードチャネルを作成
	wsChan = make(chan WsPayload)

	// コネクションマップを作成
	// keyはコネクション情報, valueにはユーザー名を入れる
	clients = make(map[WebScoketConnection]string)
)

map,make(),チャンネルの使い方はこちらに貼っておきます。わからない場合は軽く見てください。makeで配列を作り、mapでコネクションの情報を管理します。
https://golang.keicode.com/basics/go-map.php
https://y-hiroyuki.xyz/go/slice/make-func
https://zenn.dev/mikankitten/articles/6344d71f4f4920

コネクション情報を保持する

コネクション情報は、ブラウザを立ち上げたときに配列に格納したいです。なので、WsEndpointにコネクションを格納します。

func WsEndpoint(w http.ResponseWriter, r *http.Request) {
	// HTTPサーバーコネクションをWebSocketsプロトコルにアップグレード
	...

	// コネクション情報を格納
	conn := WebScoketConnection{Conn: ws}
	// ブラウザが読み込まれた時に一度だけ呼び出される
	clients[conn] = "user1"

	err = ws.WriteJSON(response)
	...
	}
}

格納したときのログは以下のようになります。例えば、ブラウザを2つ立ち上げた場合、コネクションの情報は2つ格納されています。user1の部分にはユーザーnameがはいります(今回は実装しません)。コネクションを格納すると以下のようになります。

チャンネルにメッセージを送信するListenForWs関数の実装

フロントエンドからメッセージを受け取ったときにチャンネルにメッセージを送信します。常時フロントエンドからメッセージを受け取れる状態にしたいので、for文で無限ループをさせています。

func ListenForWs(conn *WebSocketConnection) {
	defer func() {
		if r := recover(); r != nil {
			log.Println("Error", fmt.Sprintf("%v", r))
		}
	}()

	var payload WsPayload

	for {
		err := conn.ReadJSON(&payload)
		if err == nil {
			payload.Conn = *conn
			wsChan <- payload
		}
	}
}
  • defer ステートメントを使って、この関数の最後に recover() 関数を呼び出して、パニックを回復するようにしています。パニックはランタイムエラーが発生したときに発生する状態で、この recover() 関数を使うことで、エラーが発生してもプログラムが異常終了するのを防ぐことができます。
  • for ループを使って、常時起動させておいてWebSocket接続を監視します。
  • conn.ReadJSON(&payload) を呼び出して、WebSocket接続から新しいメッセージを読み取ります。読み取ったメッセージは payload 変数に格納されます。
  • errがnilになったときに、payload 変数に conn を格納して wsChan というチャネルに送信します。

この関数は、非同期で動作させる必要があるので、WsEndpoint関数からgoroutineを使って呼び出しましょう。

func WsEndpoint(w http.ResponseWriter, r *http.Request) {
	...
	conn := WebSocketConnection{Conn: ws}
	clients[conn] = "user1"

	err = ws.WriteJSON(response)
	...
	}

	go ListenForWs(&conn) // goroutineで呼び出し
}

チャンネルからメッセージを受信するListenToWsChannel関数の実装

チャンネルからメッセージを受け取ったときにbroadcastToAll関数に値を渡します。broadcastToAll関数に関しては次の項で説明します。

func ListenToWsChannel() {
	var response WsJsonResponse
	for {
		e := <-wsChan
		response.Data = e.Data

		broadcastToAll(response)
	}
}

ブラウザにメッセージを送るbroadcastToAll関数の実装

メッセージを受け取り、クライアントにメッセージを送信する関数です。

func broadcastToAll(response WsJsonResponse) {
	for client := range clients {
		err := client.WriteJSON(&response.Data)
		if err != nil {
			_ = client.Close()
			delete(clients, client)
		}
	}
}
  • clientsにはクライアントとのコネクション情報が格納されており、for文を使ってメッセージを送信します。
  • もしメッセージを送ることができなければ、コネクション情報を削除します。

ListenToWsChannel関数内に以下のように置いてください。

func ListenToWsChannel() {
	var response WsJsonResponse
	for {
		e := <-wsChan
		response.Data = e.Data

		broadcastToAll(response)
	}
}

いかがでしょうか、かなり細かく説明したので、文量がかなり多くなってしまいました。でもわかりやすく書いたつもりなのでこの記事だけでリアルタイムチャット通信は理解できると思っています。

Discussion