Go言語でチャットアプリを作ってみる

2024/12/07に公開

前置き

本記事は、みすてむずアドカレ その2の7日目の記事です。

みすてむずアドカレ2024の一覧

はじまり

みすてむずアドカレ7日目は、「Go言語でチャットアプリ」を作っていこうと思います。
2日目では、Cobraを用いてCLIアプリを作成していましたが、今日はCobraは出てきません。(ごめんね)

作っていくもの

Websocket通信を用いたチャットアプリ

  • サーバー、クライアント共にGo言語で作る。
  • クライアントはお手軽にコマンドライン上で扱えるようにする。

注意点

  • Go言語は完全に独学なので、お作法に反しているコードが出てきたりします。
    • 許して。

0.Setup

  • Server
    • go mod init ~
    • go get "github.com/gorilla/websocket"
  • Client
    • サーバーと同じように準備

1. とりあえずサーバーを立てる

クライアントからのメッセージを受け取って、「受け取ったよ!」って返すサーバーを作る
リポジトリ: https://github.com/sysnote8main/chatapp-server

package main

import (
	"log/slog"
	"net/http"

	"github.com/gorilla/websocket"
)

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

func handle(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		slog.Error("Failed to upgrade request", slog.Any("error", err))
	}
	defer conn.Close()

	for {
		msgType, msgByte, err := conn.ReadMessage()
		if err != nil {
			slog.Error("Failed to read message", slog.Any("error", err))
			break
		}

		slog.Info("Message received!", slog.String("message", string(msgByte)))
		err = conn.WriteMessage(msgType, []byte("Server got a message!"))
		if err != nil {
			slog.Error("Failed to respond message", slog.Any("error", err))
			break
		}
	}
}

func main() {
	http.HandleFunc("/ws", handle)
	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		slog.Error("Failed to listen http server", slog.Any("error", err))
	}
}
詳しい説明

packageとimportについては割愛

var内について

  • upgrader
    • upgraderは、Httpの確立された接続を別のプロトコルにアップグレードするためのすごいやつです。
    • 読み込み、書き込みのバッファの大きさを両方とも1024に設定
    • OriginCheckをtrueでバイパス(本当に必要かは不明)
var (
	upgrader = websocket.Upgrader{
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
		CheckOrigin: func(r *http.Request) bool {
			return true
		},
	}
)

handle関数

  1. upgraderを使って、Websocketの通信が行えるように接続をアップグレード
  2. クライアントからのメッセージを読み取る
  3. 「受け取ったよ!」っていうメッセージをクライアントに送る
  4. 2に戻る

main関数

  1. http.HandleFuncで、<address>/wsに来たアクセスをhandle関数へルーティング
  2. http.ListenAndServe関数を使って、サーバーを起動

2. クライアントもとりあえず作る

Hello World!!!!!!!!をサーバーに送って、返ってきたメッセージを表示するだけのクライアントを一旦作ってみる
リポジトリ: https://github.com/sysnote8main/chatapp-client

package main

import (
	"log/slog"

	"github.com/gorilla/websocket"
)

var (
	dialer = websocket.Dialer{
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
	}
)

func main() {
	conn, _, err := dialer.Dial("ws://localhost:8080/ws", nil)
	if err != nil {
		slog.Error("Failed to dial", slog.Any("error", err))
		return
	}

	err = conn.WriteMessage(websocket.TextMessage, []byte("Hello World!!!!!!!!"))
	if err != nil {
		slog.Error("Failed to send message", slog.Any("error", err))
		return
	}

	msgType, msgByte, err := conn.ReadMessage()
	if err != nil {
		slog.Error("Failed to read message", slog.Any("error", err))
		return
	}

	slog.Info("Message received!", slog.String("message", string(msgByte)), slog.Int("msgtype", msgType))
	err = conn.Close()
	if err != nil {
		slog.Error("Failed to close connection", slog.Any("error", err))
	}
}
詳しい説明

packageとimportについては割愛

var内

  • dialer
    • Websocketの通信を開くためのもの
    • こちらも同じく、読み書き両方のバッファサイズを1024に設定
var (
	dialer = websocket.Dialer{
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
	}
)

main関数

  1. ws://localhost:8080/wsに接続
  2. Hello World!!!!!!!!をサーバーへ送信
  3. サーバーからのメッセージを受け取る
  4. メッセージを表示
  5. 接続を閉じる

3. 実行してみよう!

サーバーを起動してから、クライアントを起動してみると、

$ go run main.go
2024/12/05 22:22:35 INFO Message received! message="Server got a message!" msgtype=1

こんな感じのメッセージが返ってくるはず...
(返ってこなかったら、0.Setupからやり直してみてください!)

サーバー側のログはこんな感じ

$ go run main.go
2024/12/06 22:22:35 INFO Message received! message="Hello World!!!!!!!!"
2024/12/06 22:22:35 ERROR Failed to read message error="websocket: close 1006 (abnormal closure): unexpected EOF"

とりあえず、接続してメッセージを送ることはできていそう!

4. 問題点を洗い出す

今の問題点

  • サーバー
    • エラーが出ていて、精神衛生上良くない
  • クライアント
    • 好きなメッセージを送れない
    • 一回しか送信していない

5. 4で出た問題点を修正

  • ERROR Failed to read message error="websocket: close 1006 (abnormal closure): unexpected EOF"に対応
    server sideのコード内のconn.readMessageをこれに修正
if err != nil {
	if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
		slog.Error("Failed to read message with unexpected error", slog.Any("error", err))
	} else {
		slog.Info("Connection closed.")
	}
	break
}

そうすると、さっきまでエラーが出てたところが、
INFO Connection closed.に変わる

  • クライアントからのメッセージを自由に設定して送れるようにする
    とりあえず、標準入力で受け取る

fmt.Scanだと、スペースを開けたら別の文として判定してしまうので、bufioを使う
dialの後から変更(writeMessageは上書き)

scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Message: ")
scanner.Scan()

err = conn.WriteMessage(websocket.TextMessage, []byte(scanner.Text()))
  • 複数回送れるようにする
    fmt.Printからslog.Infoにかけてforで包む

  • unreachable codeができた
    signal.NotifyContextで対応
    最初にこれ

exitSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt)

conn開いて、エラーハンドリング後にこれ

	// Graceful shutdown
	go func() {
		<-exitSignal.Done()
		err = conn.Close()
		if err != nil {
			slog.Error("Failed to close connection", slog.Any("error", err))
		}
		os.Exit(0)
	}()

これで修正は一旦終わり。

6. 一旦落ち着いて、動作確認

3と同じように実行した時に

  • さっきと同じようにメッセージが送れる
  • エラーメッセージがConnection Closed.になっている
    であればOK

7. 複数人チャットの対応に向けて

まずは、やることを洗い出す

  • ユーザー名も送れるようにする
  • サーバー側で他のユーザーに向けてブロードキャストを行う
  • ポート番号を変えて、違うサーバーに接続できるようにする

注意: ここから先は、コードを実際に提示するのが難しいため、Githubのコミットへのリンクが貼られます。

8. 7で洗い出したことを一つずつこなしていく

  • ユーザー名の送信
    とりあえず、爆速でメッセージが流れるような環境でもないので、下のコードみたいな構造体をjsonで送り合えばいけそう
type Msg struct {
	Username string
	Message  string
}

※ クライアント側の起動時に、一回だけユーザー名を尋ねるようになりました。
サーバー側のcommit: https://github.com/sysnote8main/chatapp-server/commit/0ee2f2fc50867e5d481f229f24111f80d71829b3
クライアント側のcommit: https://github.com/sysnote8main/chatapp-client/commit/02ed3e59b107565c156db90dc32b18705b3ac57c

  • ブロードキャストを行う(サーバー側)
    メッセージを扱うためのチャンネルを作って、そこに送ってあげたら、うまいことブロードキャストできそう
  1. それぞれのコネクションをmapに入れておいて
  2. ブロードキャストするメッセージが来たら、それぞれに送るという方法を取っています。
    commit: https://github.com/sysnote8main/chatapp-server/commit/4511a6a196244289871eb2e7850f4dcdff8962c7

9. ポート番号変えるの難しそう...

今だと、

10. 私はこうする

いっそのこと、CLIアプリにして、サーバーとクライアントを同じリポジトリ内で管理できるようにする。
そうすれば

  • メッセージ用の構造体(Msg)に変更を加えた時に、片方だけ変更漏れが起きることがない
  • ライブラリのアップデートなどのメンテコストが下がる
    などの良い利点がありそう...
他の意見について

「標準ライブラリのflagを用いて、やればいいじゃん!」っていう意見も分かるんですが、今回は、CLIアプリに誘導したかったので、無理やり持っていってます。勘弁してください。

11. ということで、やってみる

リポジトリ: https://github.com/sysnote8main/chatapp
今回のCLI用のライブラリは、urfave/cliのv2を使用します。

  • urfave/cliのv2を依存関係に入れるコマンド
$ go get "github.com/urfave/cli/v2"

ということで、動作確認をしていきましょう

12. 最終動作確認

ターミナルを2つ開いて、
片方で

$ go run main.go server

もう片方で

$ go run main.go client

とやって、無事に動いていれば成功です!
お疲れ様でした。

13. 今後の課題

  • もっと綺麗なCUIにする
  • サーバー側のlogも見やすくする

Discussion