Go言語でチャットアプリを作ってみる
前置き
本記事は、みすてむずアドカレ その2
の7日目の記事です。
みすてむずアドカレ2024の一覧
- みすてむずアドカレ
- みすてむずアドカレ その2← コレの7日目
- みすてむずアドカレ レシピ
はじまり
みすてむずアドカレ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関数
- upgraderを使って、Websocketの通信が行えるように接続をアップグレード
- クライアントからのメッセージを読み取る
- 「受け取ったよ!」っていうメッセージをクライアントに送る
- 2に戻る
main関数
- http.HandleFuncで、
<address>/ws
に来たアクセスをhandle関数へルーティング - 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関数
-
ws://localhost:8080/ws
に接続 -
Hello World!!!!!!!!
をサーバーへ送信 - サーバーからのメッセージを受け取る
- メッセージを表示
- 接続を閉じる
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
- ブロードキャストを行う(サーバー側)
メッセージを扱うためのチャンネルを作って、そこに送ってあげたら、うまいことブロードキャストできそう
- それぞれのコネクションをmapに入れておいて
- ブロードキャストするメッセージが来たら、それぞれに送るという方法を取っています。
commit: https://github.com/sysnote8main/chatapp-server/commit/4511a6a196244289871eb2e7850f4dcdff8962c7
9. ポート番号変えるの難しそう...
今だと、
- Server: https://github.com/sysnote8main/chatapp-server/blob/4511a6a196244289871eb2e7850f4dcdff8962c7/main.go#L71
- Client: https://github.com/sysnote8main/chatapp-client/blob/a442c75681dbd45577184abc5df7313c86236c27/main.go#L36
のようにハードコードされちゃってて、ポート番号を変えるには工夫が必要そう...
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"
-
とりあえず、サーバーから移動していきます。
(理由としては、クライアントを残しておくことで、破壊的変更をしていないか確認できるため)
commit: https://github.com/sysnote8main/chatapp/commit/53af1218cdb7a0f65c23054c99d20fc902de9af7 -
次に、クライアントも移動します。
commit: https://github.com/sysnote8main/chatapp/commit/1e6d06bccc3644da15e64a1b6ef658da544570e4 -
どちらも、main関数をRun関数に変更して、引数でポート番号を指定できるようにしたこと以外は、元のコードと変わりません。
ということで、動作確認をしていきましょう
12. 最終動作確認
ターミナルを2つ開いて、
片方で
$ go run main.go server
もう片方で
$ go run main.go client
とやって、無事に動いていれば成功です!
お疲れ様でした。
13. 今後の課題
- もっと綺麗なCUIにする
- サーバー側のlogも見やすくする
Discussion