😱

【HTMX入門】話題になったHTMXでチャットアプリ作ったよ

2024/02/10に公開1

はじめに

どうもしょーやです。
Twitter(X)やってるのでよかったらフォローしてください
https://twitter.com/WisheeBell
今回は最近話題になっていたHTMXを使って簡単なチャットアプリを作っていこうと思います!

チャットを作る上でwebsocketを用いたリアルタイムな通信が必要なわけですが、あまり日本語でHTMXのwebsocketを実装している記事が見つからなく💦

公式ドキュメントと睨めっこしながら手探りで作ったのでご指摘や改善案など大歓迎です!

HTMXとは

以下公式ドキュメントの内容をdeeplで和訳したものです。

htmxは、属性を使用して、AJAX、CSSトランジション、WebSocket、およびサーバー送信イベントにHTMLで直接アクセスできるため、ハイパーテキストのシンプルさとパワーで最新のユーザーインターフェイスを構築できます。

HTMXは既存のHTMLの記法を拡張したもので、JSを書かずともAjaxやWebsocketを用いた非同期的なDOM更新ができる技術になります。

正直最初にこの説明を見た時の僕は「これだけ?」と思いました。
だってreactやvueだったら状態管理だってできるしコンポーネント指向な構成にしやすいし、ちょっとHTMXさん質素すぎやしませんかって感じですよね。

しかしそれはHTMXの思想を紐解いていくと理解できます。

HTMXはマルチページなアプリケーションのシンプルさ&柔軟さとシングルページなアプリケーションの応答性を両立させたHDA(Hypermedia Driven Application)という思想に基づいています。
つまり、最近よくあるバックエンドとフロントエンドを別々で用意しJSONでやりとりをするのではなく、LaravelやRailsでHTMLを返すように基本的には単一のwebサーバーからHTMLをSSRし、シームレスな操作が必要な場面でajaxやwebsocketを使用するようなユースケースに適した技術となっています。

そのため僕個人的にはバックエンドエンジニアこそ理解するべき技術だと感じています。

HTMXの思想に関する内容は以下の記事にわかりやすくまとめられているのでよかったら読んでみてください。
https://qiita.com/tsmd/items/0d07feb8e02cfa213cc4

実装

ここからは実際にチャットアプリを作る過程を記述していきます。

前提

まず前提としてサーバーにginを使用していきます。
ginサーバーからテンプレートエンジンを用いてHTMLを返す形で実装していきます。

  • Go v1.20.13
    • gin v1.9.1
    • gorilla/websocket v1.5.1
  • HTMX v1.9.10

またディレクトリ構成は以下のようになっている想定です。

/root/
  ├ templates/
    └ index.html
  ├ go.mod
  ├ go.sum
  └ main.go

トップページ作成

まずはチャット画面となるトップページを作成します。

index.html
<html>
  <head>
    <title>Sample Chat</title>
    <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>
  </head>

  <style>
    #chat {
      text-align: left;
      color:#ffffff;
      background: #113131;
      width: 400px;
      min-height: 300px;
      padding: 10px;
      font-family: 'Lucida Grande', 'Hiragino Kaku Gothic ProN', 'ヒラギノ角ゴ ProN W3', 'Meiryo', 'メイリオ', sans-serif;
      font-size: small;
    }
  </style>

  <body>

    <center>
      <h3>Sample Chat</h3>
      <div id="chat"></div>
      <form>
        <label id="title">{{.name}}</label>
        <input name="user" type="hidden" value={{.name}}>
        <input name="input" type="text" placeholder="say something">
      </form>
    </center>

  </body>
</html>

サーバー側でルーティングの定義を行います。

main.go
package main

import (
	"fmt"
	"math/rand"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	r.GET("/", func(ctx *gin.Context) {
		rand.New(rand.NewSource(time.Now().UnixNano()))

		ctx.HTML(
			http.StatusOK,
			"index.html",
			gin.H{
				"name": fmt.Sprintf("Guest-%03d", rand.Intn(100)),
			},
		)
	})

	r.Run()
}

この画面で行なっている処理としては、ランダムな3桁の数字を生成しそれを"Guest-000"というユーザー名として画面に表示しているという流れになります。

サーバーを立ち上げてlocalhostに接続すると以下のようなチャット画面が表示されるかと思います。

まだ入力しても何も起きませんが、ここから送受信の処理を実装していきます。

サーバー側でwebsocketによるリアルタイム通信を実装

ここからgoサーバーにwebsocketに関する処理を追加していきます。
今回はHTMXの記事なので深くは説明しませんが、詳しくは以下の記事を参考にしてください。
https://zenn.dev/show_yeah/articles/bece10823d182c

main.go
package main

import (
	/* 略 */
)

var clients = make(map[*websocket.Conn]bool)
var broadcast = make(chan Message)

type Message struct {
	Type    int    `json:"type"`
	User    string `json:"user"`
	Message string `json:"message"`
}

type Request struct {
	Header map[string]string `json:"HEADERS"`
	User   string            `json:"user"`
	Input  string            `json:"input"`
}

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")
	wsupgrader := websocket.Upgrader{
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
	}

	r.GET("/", func(ctx *gin.Context) {
		/* 省略 */
	})

	r.GET("/ws", func(ctx *gin.Context) {
		conn, err := wsupgrader.Upgrade(ctx.Writer, ctx.Request, nil)
		if err != nil {
			log.Printf("Failed to set websocket upgrade: %+v\n", err)
			return
		}

		clients[conn] = true

		for {
			t, raw, err := conn.ReadMessage()
			if err != nil {
				break
			}

			var req Request
			if err := json.Unmarshal(raw, &req); err != nil {
				fmt.Println(err)
			}

			msg := fmt.Sprintf(
				"<div hx-swap-oob=\"beforeend:#chat\"><span>%s[%s]> %s</span></br></div>",
				time.Now().Format("15:04:05"),
				req.User,
				req.Input,
			)

			broadcast <- Message{Type: t, User: req.User, Message: msg}
		}
	})
	go handleMessages()

	r.Run()
}

func handleMessages() {
	for {
		message := <-broadcast
		for client := range clients {
			if err := client.WriteMessage(message.Type, []byte(message.Message)); err != nil {
				log.Printf("error: %v", err)
				client.Close()
				delete(clients, client)
			}
		}
	}
}


ここでキモとなるのは、サーバーからクライアントへ送信するメッセージを

msg := fmt.Sprintf(
    "<div hx-swap-oob=\"beforeend:#chat\"><span>%s[%s]> %s</span></br></div>",
    time.Now().Format("15:04:05"),
    req.User,
    req.Input,
)

と整形してから送信している部分です。
HTMXではサーバーからのレスポンスとしてhtmlを書くと、よしなにdom操作して画面へ反映してくれる作りとなっています。

今回しているhx-swap-oob=\"beforeend:#chat\"は、
「idがchatとなっているdiv内の末尾に要素を挿入する」
という意味合いになります。

つまり先ほど定義したindex.htmlの<div id="chat"></div>に対して以下のように要素を追加してくれます。

<div id="caht">
  <span>yyyy-MM-dd hh:mm [Guest-000] メッセージ内容</span> <!-- ← 挿入! -->
</div>

2通目のメッセージを受信した場合は1通目のspanの下に来るような形で挿入されます。

<div id="caht">
  <span>yyyy-MM-dd hh:mm [Guest-000] メッセージ内容</span>
  <span>yyyy-MM-dd hh:mm [Guest-000] メッセージ内容</span> <!-- ← 挿入! -->
</div>

HTMXによるwebsocketの送受信周りを実装

ここからはHTMXを用いたwebsocketの送受信対応を行います。
とはいえ記述内容はとてもシンプルでindex.htmlformタグを以下のように変更します

<form hx-ext="ws" ws-connect="/ws" id="form" ws-send>

それぞれのオプションを解説すると以下の通りです
hx-ext="ws": websocket機能を有効にする
ws-connect="/ws": WebSocketサーバーのURLを指定する
ws-send: formタグに追加することでデータを送信する子フォームを指定する

以上でwebsocket対応は完了となります!

動作確認

実際にローカルでサーバーを立ち上げてウィンドウを2つ開いてみると以下のようになるかと思います

余談: 入力フォームを自動でリセットさせたい

hx-onオプションを使用するとhtmxのイベントをハンドリングできるため、<form>タグに

hx-on:htmx:wsAfterMessage="document.message-form.reset();"

と追記すればwebsocket送信後に入力フォームを空欄にさせることができるはずなのですが、うまく動作しませんでした。
hx-on:に他のイベントを指定すると正常に動くため、どうやらwebsocket周りでは使えないようです。
調べてみるとIssueが上がっていたのでもしかするとそのうち修正されるかもしれません。
https://github.com/bigskysoftware/htmx/issues/2131

あとがき

どうでもいいですけど、ここで「いかがだったでしょうか?」って書くと急にアフィリエイト臭くなりますよね。

今回はHTMXを使って簡単なチャットアプリを作ってみたわけですが、HTMXって話題になった割に意外と使ってみた系の記事が少ない気がするのですが気のせいですかね?
公式ドキュメントを和訳した記事だったり解説系の記事は出てくるのですが、実用にはまだ早いということなんでしょうか?😅
個人的にはあまりフロントエンド関連に詳しくなくても簡単に非同期処理を取り入れられるので積極的にHTMXを使っていきたいですね!

この記事が良ければいいねください!
あとツイッターのアカウントもフォローしてね!!!
https://twitter.com/WisheeBell

Discussion

rithmetyrithmety

msg := fmt.Sprintf

XSS の脆弱性がありそうです
あなたは理解していても「簡単のため XSS のあるコードになってる」と近くに置いておいた方が良いかもです

wsAfterMessage

A がメッセージを記入している途中で B からメッセージを受け取ると A のメッセージが消えそうです

document.message-form.reset

document.message から form.reset を引き算しているコードになってます
document["message-form"].reset などのように書くこともできます

form タグに

html に書く場合は hx-on:htmx:ws-after-message のように書く必要がありそうです
htmx:wsAfterMessage はおそらく JavaScript から addEventListener などで書く場合の名前と思います

下記も試してみてください

<form hx-on:htmx:ws-after-send="this.reset()">