はじめに
htmx や golang の websocket や html/template 周りの使ってみようということでWebサーバのみで完結する簡単なチャットアプリを作ってみました。
自分用にアウトプットしたというのが主な目的なので説明はかなり雑な感じになっています。
フォルダ構成
以下作成したチャットアプリのフォルダ構成です。
.
├── go.mod
├── go.sum
├── lib
│ └── generator.go
├── main.go
├── model
│ ├── message
│ │ └── message.go
│ └── room
│ └── room.go
└── views
├── index.html
└── parts
└── message.html
実装
ルーム
チャットルームは以下のような構造体で定義されていて、この構造体からルーム全体に broadcast したり、投稿されたメッセージを取得することができます。
websocket 通信が確立したとき、ルームに参加させます。
type Room struct {
subscribers map[*websocket.Conn]bool
MessageList []*message.Message
sync.RWMutex
}
// ルームに入ったユーザの ws コネクションを追加
func (r *Room) Add(ws *websocket.Conn) {
r.subscribers[ws] = true
}
// ルームから離脱したユーザの ws コネクションを削除
func (r *Room) Del(ws *websocket.Conn) {
delete(r.subscribers, ws)
}
// 投稿されたメッセージを追加
func (r *Room) AddMessage(msg *message.Message) {
r.Lock()
r.MessageList = append(r.MessageList, msg)
r.Unlock()
}
// 投稿されたメッセージを取得
func (r *Room) GetMessageList() []*message.Message {
r.RLock()
defer r.RUnlock()
return r.MessageList
}
// ルーム全体にメッセージを送信
func (r *Room) Broadcast(msg *message.Message) error {
fmt.Println(r)
var errs []error
for ws := range r.subscribers {
if err := websocket.Message.Send(ws, msg.ToHtml()); err != nil {
errs = append(errs, err)
}
}
if len(errs) != 0 {
res := fmt.Errorf("on broadcast : there are %v errors", len(errs))
return errors.Join(res, errors.Join(errs...))
}
return nil
}
チャットアプリが動作するWebサーバのAPIは以下の三つがあります。
- index
- チャットアプリの index ページを取得する
- entry
- webscoket 通信を確立する
- チャットルームに入室する
- post
- チャットルームに投稿する
ルームへの参加
ルームへの参加は以下APIをたたくことで自動的に参加することができます。
また、そのコネクションが閉じていないか確認するためにws.Read()
を呼び出しており、コネクションが閉じた場合ルームから離脱します。
websocket 経由でクライアントから送信を行っていないため(代わりに後述の/post
を使う)、ws.Read()を一度しか呼んでいません。
func entry(ws *websocket.Conn) {
r.Add(ws)
ws.Read(nil) // クライアントから webscoket でリクエストを送信しないので、for {} で囲まない
r.Del(ws)
}
// http.Handler インターフェースを満たすために、entry は websocket.Handler に変換する
http.Handle("/entry", websocket.Handler(entry))
indexページ取得
index ページ取得のためのAPIは以下の通りです。
ルームのメッセージを取得して、テンプレートに渡しています。
http.HandleFunc("/index/", func(w http.ResponseWriter, req *http.Request) {
msgList := r.GetMessageList()
tmpl := template.Must(template.ParseFiles("views/index.html", "views/parts/message.html"))
tmpl.ExecuteTemplate(w, "index.html", msgList)
})
index ページでは「websocket 通信の確立」や「チャット投稿用のフォーム」、「チャットを表示する場所」を作成しています。
websocket 通信の確立
クライアントからの websocket 通信の要求は htmx
を使って実装しています。
まず、htmx や websocket 拡張機能を利用するためにCDNからソースを取得します。
<script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
そして、以下のようにhx-ext="ws" ws-connect="/entry"
を追加することで、ページロード後に自動的にws://{SERVER_ADDRESS}/entry"
に対してwebsocket 要求を送信してくれます。
<div hx-ext="ws" ws-connect="/entry">
...
</div>
チャット投稿用のフォーム
チャット投稿用のフォームにもhtmx
を少し利用しています。
まず、<form>
で利用しているhx-post
とhx-swap
に関して、hx-post
を指定することで非同期で指定したpathに対してpostリクエストを行ってくれます。
hx-swap
はそのリクエストに対するレスポンスを受け取ったときに挙動を指定します。
デフォルトだと<form>
のhtml要素をレスポンスの内容で書き換えますが、今回は何もしてほしくないためnone
を指定しています。
次に<input>
で利用しているhx-on
に関してです.
ここではhx-on:htmx:validation:validate
を指定することで、リクエスト送信前に行われるバリデーションの操作を追加しています。
以下の例ではユーザ名とメッセージに対して空文字を入力していた場合にエラー文を表示できるようにしています。
<form id="chat-form" hx-post="/post" hx-swap="none">
<input name="Name" type="text" placeholder="input username..."
onkeyup="this.setCustomValidity('')"
hx-on:htmx:validation:validate="if(this.value == '') {
this.setCustomValidity('ユーザ名を入力してください') // set the validation error
htmx.find('#chat-form').reportValidity() // report the issue
}">
<input name="Message" type="textarea" placeholder="input send message..."
onkeyup="this.setCustomValidity('')"
hx-on:htmx:validation:validate="if(this.value == '') {
this.setCustomValidity('メッセージを入力してください') // set the validation error
htmx.find('#chat-form').reportValidity() // report the issue
}">
<button>send</button>
</form>
チャットを表示する場所
チャットを表示する場所は以下のように記述しています。
index ページを取得する際にテンプレートにメッセージリストを渡すことで、メッセージを表示できるようにしています。
<div id="chat-room">
{{ range . }}
{{ template "message" . }}
{{ end }}
</div>
// message.html
{{ define "message" }}
<div id="message-{{ .ID }}" style="display: flex;">
<div style="color: blue; text-align: left;width: 20%;">{{ .User }}</div>
<div style="text-align: center;width: auto;">{{ .Contents }}</div>
</div>
{{ end }}
また、index ページ取得後に送信されたメッセージはどうやって追加するのかというと htmx を制御するための要素含めたレスポンスをサーバから返すことで追加しています。
どういうことかというと、以下のようにレスポンスにhx-swap-oob="beforeend:#chat-room"
を追加することで、レスポンスの内容をchat-room
というIDを持つ要素の子要素の末尾に追加するということを指定することができます。
<div hx-swap-oob="beforeend:#chat-room">
{{ template "message" . }} //message は別ファイルで定義されるテンプレート
</div>
チャットの投稿
チャットの投稿は以下のAPIから行われます。
フォームからName
とMessage
を送信しているので、それをもとにメッセージを作成して、ルームに追加及びルーム全体にメッセージを送信します。
http.HandleFunc("/post", func(w http.ResponseWriter, req *http.Request) {
user := req.PostFormValue("Name")
m := req.PostFormValue("Message")
msg := message.New(user, m)
if msg == nil {
return
}
r.AddMessage(msg)
if err := r.Broadcast(msg); err != nil {
fmt.Println(err)
}
})
メッセージ構造体は以下の通りです。
※IDや作成時刻を追加していますが、結局は使っていません
type Message struct {
ID string
User string
Contents string
CreatedAt time.Time
}
終わりに
フロントエンドはほとんど触ることがないですが、htmx の使い方を多少知るだけでいろいろなことができそうだなということがわかりました。
また、golang での websocket 通信や html/template の使い方を少し整理できたので良かったです。
今回作成したものが気になる方は以下リポジトリで確認することができます。