Go + htmxでモダンフルスタックWeb開発


Go + htmxでモダンフルスタックWeb開発

近年、フロントエンド開発の複雑さが増す中、よりシンプルなアプローチとして htmx が注目されています。htmxと Go を組み合わせることで、React/VueのようなSPAのUXを、より少ないコードと複雑性で実現できます。

本記事では、GoとhtmxによるフルスタックWeb開発の実践的な手法を、サンプルコードを交えて詳しく解説します。

htmxとは

htmxは、HTMLの属性を拡張することで、JavaScriptを書かずにインタラクティブなWebアプリケーションを構築できるライブラリです。

主な特徴

  • HTMLセントリック: JavaScriptフレームワーク不要
  • AJAX簡略化: HTML属性だけでAJAXリクエスト
  • 部分更新: SPAのような体験
  • サーバーサイド重視: ロジックをサーバーに集約
  • 小サイズ: 約14KB(gzip圧縮後)

なぜGoとの相性が良いのか

  • テンプレートエンジン: Goの標準html/template
  • 高速: Goの処理速度でサーバーサイドレンダリング
  • シンプル: 両方とも複雑性を避ける哲学
  • 型安全: Goの型システムでロジックを堅牢に
  • デプロイ容易: 単一バイナリでデプロイ

プロジェクトセットアップ

ディレクトリ構造

myapp/
├── main.go
├── go.mod
├── handlers/
│   ├── home.go
│   ├── todos.go
│   └── users.go
├── models/
│   └── todo.go
├── templates/
│   ├── base.html
│   ├── index.html
│   ├── todos.html
│   └── partials/
│       ├── todo-item.html
│       └── todo-form.html
├── static/
│   ├── css/
│   │   └── style.css
│   └── js/
│       └── htmx.min.js
└── db/
    └── database.go

初期化

mkdir myapp && cd myapp
go mod init github.com/yourusername/myapp

# 依存関係のインストール
go get -u github.com/gorilla/mux
go get -u github.com/jmoiron/sqlx
go get -u github.com/mattn/go-sqlite3

main.go

package main

import (
    "log"
    "net/http"
    "html/template"

    "github.com/gorilla/mux"
    "github.com/yourusername/myapp/handlers"
)

var templates *template.Template

func main() {
    // テンプレートの読み込み
    templates = template.Must(template.ParseGlob("templates/**/*.html"))

    // ルーター設定
    r := mux.NewRouter()

    // 静的ファイル
    r.PathPrefix("/static/").Handler(http.StripPrefix("/static/",
        http.FileServer(http.Dir("static"))))

    // ルート定義
    r.HandleFunc("/", handlers.Home).Methods("GET")
    r.HandleFunc("/todos", handlers.GetTodos).Methods("GET")
    r.HandleFunc("/todos", handlers.CreateTodo).Methods("POST")
    r.HandleFunc("/todos/{id}", handlers.UpdateTodo).Methods("PUT")
    r.HandleFunc("/todos/{id}", handlers.DeleteTodo).Methods("DELETE")

    // サーバー起動
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", r))
}

基本的なHTMLテンプレート

templates/base.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{block "title" .}}Go + htmx App{{end}}</title>
    <link rel="stylesheet" href="/static/css/style.css">
    <script src="/static/js/htmx.min.js"></script>
</head>
<body>
    <nav>
        <a href="/">ホーム</a>
        <a href="/todos">TODO</a>
    </nav>

    <main>
        {{block "content" .}}{{end}}
    </main>

    <script>
        // htmx設定
        htmx.config.defaultSwapStyle = 'innerHTML';
        htmx.config.historyCacheSize = 0;
    </script>
</body>
</html>

TODOアプリの実装

models/todo.go

package models

import (
    "time"
)

type Todo struct {
    ID        int       `json:"id" db:"id"`
    Title     string    `json:"title" db:"title"`
    Completed bool      `json:"completed" db:"completed"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
}

type TodoStore interface {
    GetAll() ([]Todo, error)
    GetByID(id int) (*Todo, error)
    Create(title string) (*Todo, error)
    Update(id int, completed bool) error
    Delete(id int) error
}

db/database.go

package db

import (
    "database/sql"
    "time"

    _ "github.com/mattn/go-sqlite3"
    "github.com/yourusername/myapp/models"
)

type TodoDB struct {
    db *sql.DB
}

func NewTodoDB(dbPath string) (*TodoDB, error) {
    db, err := sql.Open("sqlite3", dbPath)
    if err != nil {
        return nil, err
    }

    // テーブル作成
    _, err = db.Exec(`
        CREATE TABLE IF NOT EXISTS todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            completed BOOLEAN DEFAULT 0,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )
    `)
    if err != nil {
        return nil, err
    }

    return &TodoDB{db: db}, nil
}

func (tdb *TodoDB) GetAll() ([]models.Todo, error) {
    rows, err := tdb.db.Query(
        "SELECT id, title, completed, created_at FROM todos ORDER BY created_at DESC",
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var todos []models.Todo
    for rows.Next() {
        var todo models.Todo
        err := rows.Scan(&todo.ID, &todo.Title, &todo.Completed, &todo.CreatedAt)
        if err != nil {
            return nil, err
        }
        todos = append(todos, todo)
    }

    return todos, nil
}

func (tdb *TodoDB) Create(title string) (*models.Todo, error) {
    result, err := tdb.db.Exec(
        "INSERT INTO todos (title) VALUES (?)",
        title,
    )
    if err != nil {
        return nil, err
    }

    id, err := result.LastInsertId()
    if err != nil {
        return nil, err
    }

    return &models.Todo{
        ID:        int(id),
        Title:     title,
        Completed: false,
        CreatedAt: time.Now(),
    }, nil
}

func (tdb *TodoDB) Update(id int, completed bool) error {
    _, err := tdb.db.Exec(
        "UPDATE todos SET completed = ? WHERE id = ?",
        completed, id,
    )
    return err
}

func (tdb *TodoDB) Delete(id int) error {
    _, err := tdb.db.Exec("DELETE FROM todos WHERE id = ?", id)
    return err
}

handlers/todos.go

package handlers

import (
    "html/template"
    "net/http"
    "strconv"

    "github.com/gorilla/mux"
    "github.com/yourusername/myapp/db"
    "github.com/yourusername/myapp/models"
)

var (
    todoDB    *db.TodoDB
    templates *template.Template
)

func init() {
    var err error
    todoDB, err = db.NewTodoDB("todos.db")
    if err != nil {
        panic(err)
    }

    templates = template.Must(template.ParseGlob("templates/**/*.html"))
}

func GetTodos(w http.ResponseWriter, r *http.Request) {
    todos, err := todoDB.GetAll()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // htmxリクエストかどうかで返すテンプレートを変える
    if r.Header.Get("HX-Request") == "true" {
        // 部分更新
        templates.ExecuteTemplate(w, "todo-list.html", todos)
    } else {
        // 全体ページ
        templates.ExecuteTemplate(w, "todos.html", map[string]interface{}{
            "Todos": todos,
        })
    }
}

func CreateTodo(w http.ResponseWriter, r *http.Request) {
    title := r.FormValue("title")
    if title == "" {
        http.Error(w, "Title required", http.StatusBadRequest)
        return
    }

    todo, err := todoDB.Create(title)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // 新しいTODO項目のHTMLを返す
    templates.ExecuteTemplate(w, "todo-item.html", todo)
}

func UpdateTodo(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, err := strconv.Atoi(vars["id"])
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }

    completed := r.FormValue("completed") == "true"

    err = todoDB.Update(id, completed)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // 更新後のTODO項目を取得して返す
    todo, _ := todoDB.GetByID(id)
    templates.ExecuteTemplate(w, "todo-item.html", todo)
}

func DeleteTodo(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, err := strconv.Atoi(vars["id"])
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }

    err = todoDB.Delete(id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // 空のレスポンス(要素が削除される)
    w.WriteHeader(http.StatusOK)
}

templates/todos.html

{{define "title"}}TODO List{{end}}

{{define "content"}}
<div class="todos-container">
    <h1>TODO リスト</h1>

    <!-- 新規TODO作成フォーム -->
    <form
        hx-post="/todos"
        hx-target="#todo-list"
        hx-swap="afterbegin"
        hx-on::after-request="this.reset()"
    >
        <input
            type="text"
            name="title"
            placeholder="新しいTODOを入力"
            required
        >
        <button type="submit">追加</button>
    </form>

    <!-- TODOリスト -->
    <div id="todo-list">
        {{range .Todos}}
            {{template "todo-item.html" .}}
        {{end}}
    </div>
</div>
{{end}}

templates/partials/todo-item.html

{{define "todo-item.html"}}
<div
    class="todo-item {{if .Completed}}completed{{end}}"
    id="todo-{{.ID}}"
>
    <input
        type="checkbox"
        {{if .Completed}}checked{{end}}
        hx-put="/todos/{{.ID}}"
        hx-vals='{"completed": "{{not .Completed}}"}'
        hx-target="#todo-{{.ID}}"
        hx-swap="outerHTML"
    >
    <span class="todo-title">{{.Title}}</span>
    <button
        class="delete-btn"
        hx-delete="/todos/{{.ID}}"
        hx-target="#todo-{{.ID}}"
        hx-swap="outerHTML swap:1s"
        hx-confirm="本当に削除しますか?"
    >
        削除
    </button>
</div>
{{end}}

htmxの高度な機能

インライン編集

<div class="todo-item" id="todo-{{.ID}}">
    <span
        class="todo-title"
        hx-get="/todos/{{.ID}}/edit"
        hx-trigger="click"
        hx-target="this"
        hx-swap="outerHTML"
    >
        {{.Title}}
    </span>
</div>

編集フォームテンプレート:

<form
    hx-put="/todos/{{.ID}}"
    hx-target="#todo-{{.ID}}"
    hx-swap="outerHTML"
>
    <input
        type="text"
        name="title"
        value="{{.Title}}"
        autofocus
    >
    <button type="submit">保存</button>
    <button
        type="button"
        hx-get="/todos/{{.ID}}"
        hx-target="#todo-{{.ID}}"
    >
        キャンセル
    </button>
</form>

無限スクロール

<div id="content">
    {{range .Items}}
        <div class="item">{{.}}</div>
    {{end}}

    {{if .HasMore}}
    <div
        hx-get="/items?page={{.NextPage}}"
        hx-trigger="revealed"
        hx-swap="afterend"
    >
        <span class="loading">読み込み中...</span>
    </div>
    {{end}}
</div>
func GetItems(w http.ResponseWriter, r *http.Request) {
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    if page == 0 {
        page = 1
    }

    items, hasMore := getItemsFromDB(page, 20)

    data := map[string]interface{}{
        "Items":    items,
        "HasMore":  hasMore,
        "NextPage": page + 1,
    }

    templates.ExecuteTemplate(w, "items-partial.html", data)
}

リアルタイム検索

<input
    type="search"
    name="q"
    placeholder="検索..."
    hx-get="/search"
    hx-trigger="keyup changed delay:300ms"
    hx-target="#search-results"
    hx-indicator="#spinner"
>

<div id="spinner" class="htmx-indicator">
    検索中...
</div>

<div id="search-results"></div>
func Search(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")

    if query == "" {
        w.WriteHeader(http.StatusOK)
        return
    }

    results, err := searchInDB(query)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    templates.ExecuteTemplate(w, "search-results.html", results)
}

楽観的UI更新

<button
    hx-post="/likes/{{.PostID}}"
    hx-target="#like-count-{{.PostID}}"
    hx-swap="innerHTML"
    hx-vals='{"optimistic": "true"}'
>
    いいね (<span id="like-count-{{.PostID}}">{{.Likes}}</span>)
</button>
func LikePost(w http.ResponseWriter, r *http.Request) {
    postID := mux.Vars(r)["id"]

    // 楽観的更新の場合、即座に+1を返す
    if r.FormValue("optimistic") == "true" {
        currentLikes := getLikes(postID)
        fmt.Fprintf(w, "%d", currentLikes+1)

        // バックグラウンドで実際の更新
        go func() {
            incrementLikes(postID)
        }()
        return
    }

    // 通常の更新
    newLikes := incrementLikes(postID)
    fmt.Fprintf(w, "%d", newLikes)
}

WebSocketとの統合

<!-- htmx WebSocket拡張 -->
<script src="/static/js/htmx-ws.js"></script>

<div
    hx-ext="ws"
    ws-connect="/ws/chat"
>
    <div id="chat-messages"></div>

    <form ws-send>
        <input name="message" placeholder="メッセージを入力">
        <button type="submit">送信</button>
    </form>
</div>
import (
    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}

func HandleWebSocket(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return
    }
    defer conn.Close()

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

        // メッセージ処理
        response := processMessage(message)

        // HTMLフラグメントを返す
        html := renderMessageHTML(response)
        conn.WriteMessage(messageType, []byte(html))
    }
}

パフォーマンス最適化

テンプレートのキャッシング

type TemplateCache struct {
    templates map[string]*template.Template
    mu        sync.RWMutex
}

func (tc *TemplateCache) Get(name string) (*template.Template, error) {
    tc.mu.RLock()
    tmpl, exists := tc.templates[name]
    tc.mu.RUnlock()

    if exists {
        return tmpl, nil
    }

    // テンプレートをロードしてキャッシュ
    tc.mu.Lock()
    defer tc.mu.Unlock()

    tmpl, err := template.ParseFiles("templates/" + name)
    if err != nil {
        return nil, err
    }

    tc.templates[name] = tmpl
    return tmpl, nil
}

gzip圧縮

import (
    "compress/gzip"
    "net/http"
    "strings"
)

type gzipResponseWriter struct {
    http.ResponseWriter
    Writer *gzip.Writer
}

func (w gzipResponseWriter) Write(b []byte) (int, error) {
    return w.Writer.Write(b)
}

func GzipMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }

        w.Header().Set("Content-Encoding", "gzip")
        gz := gzip.NewWriter(w)
        defer gz.Close()

        gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w}
        next.ServeHTTP(gzw, r)
    })
}

// 使用
r.Use(GzipMiddleware)

HTTPキャッシング

func CacheMiddleware(duration time.Duration) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(duration.Seconds())))
            next.ServeHTTP(w, r)
        })
    }
}

// 静的ファイルに適用
r.PathPrefix("/static/").Handler(
    CacheMiddleware(24 * time.Hour)(
        http.StripPrefix("/static/", http.FileServer(http.Dir("static"))),
    ),
)

テスト

package handlers

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestCreateTodo(t *testing.T) {
    // テスト用DB
    todoDB, _ = db.NewTodoDB(":memory:")

    // リクエスト作成
    body := strings.NewReader("title=Test Todo")
    req, _ := http.NewRequest("POST", "/todos", body)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("HX-Request", "true")

    // レスポンスレコーダー
    rr := httptest.NewRecorder()

    // ハンドラー実行
    handler := http.HandlerFunc(CreateTodo)
    handler.ServeHTTP(rr, req)

    // アサーション
    if status := rr.Code; status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
    }

    // HTMLが返されることを確認
    if !strings.Contains(rr.Body.String(), "Test Todo") {
        t.Errorf("handler returned unexpected body: got %v",
            rr.Body.String())
    }
}

まとめ

Goとhtmxの組み合わせは、モダンなWeb開発における強力な選択肢です。

主な利点

  • シンプルさ: 複雑なJavaScriptフレームワーク不要
  • パフォーマンス: サーバーサイドレンダリングの高速性
  • 保守性: ロジックがサーバーに集約
  • 学習コスト: GoとHTMLの知識で十分
  • SEO: 標準的なHTMLで構築

向いているユースケース

  • 管理画面・ダッシュボード
  • CRUD中心のアプリケーション
  • コンテンツ重視のサイト
  • 小〜中規模のWebアプリ

React/Vueが不要な場面は意外と多いものです。Goとhtmxで、シンプルで保守性の高いWebアプリケーションを構築しましょう。