Closed5

goのhttp.FileServerでフロントエンドをシンプルに表現する

not75743not75743

最終コード

  • apiはjsonを返すだけ、htmlには依存しない
  • go run main.goするとhtmlへアクセス出来る
  • go buildすることで配布も容易

ディレクトリ構成

.
├── .air.toml
├── main.go
├── static
│   ├── app.js
│   ├── index.html
│   └── styles.css

コード

コード群

main.go

main.go
package main

import (
	"embed"
	"encoding/json"
	"io/fs"
	"log"
	"net/http"
)

//go:embed static/*
var staticFiles embed.FS

func main() {
	// APIエンドポイント - シンプルなデータのみを返す
	http.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
		// CORSヘッダーを設定
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Content-Type", "application/json")

		// データ配列のみを返す
		items := []string{"項目1", "項目2", "項目3", "テスト"}
		json.NewEncoder(w).Encode(items)
	})

	// 埋め込まれた静的ファイルを提供
	// 注: staticFiles は "static" ディレクトリを含むので、Sub を使って中身だけ取得
	staticContent, err := fs.Sub(staticFiles, "static")
	if err != nil {
		log.Fatal("静的ファイルのサブツリー取得に失敗:", err)
	}

	// 静的ファイルをルートパスで提供
	http.Handle("/", http.FileServer(http.FS(staticContent)))

	// サーバー起動
	port := ":8080"
	log.Printf("サーバーを開始: http://localhost%s", port)
	log.Printf("静的ファイルはバイナリに埋め込まれています")
	log.Fatal(http.ListenAndServe(port, nil))
}

html

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Goバックエンド + 静的フロントエンド</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="container">
        <h1>Goバックエンド + 静的フロントエンド</h1>
        <p>シンプルな構成でウェブアプリケーションを構築</p>
        
        <div class="card">
            <h2>APIデータ表示</h2>
            <div id="api-message" class="message-box">データ取得中...</div>
            <ul id="api-items" class="items-list"></ul>
            <button id="fetch-button">APIからデータを取得</button>
        </div>
    </div>

    <script src="app.js"></script>
</body>
</html>

css

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: 'Helvetica Neue', Arial, sans-serif;
    line-height: 1.6;
    color: #333;
    background-color: #f5f5f5;
}

.container {
    max-width: 800px;
    margin: 2rem auto;
    padding: 1rem;
}

h1 {
    margin-bottom: 1rem;
    color: #2c3e50;
}

.card {
    background-color: white;
    border-radius: 8px;
    padding: 1.5rem;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    margin: 2rem 0;
}

.message-box {
    margin: 1rem 0;
    padding: 1rem;
    background-color: #f0f7ff;
    border-left: 4px solid #3498db;
    border-radius: 4px;
}

.items-list {
    margin: 1rem 0;
    padding-left: 1.5rem;
}

.items-list li {
    margin-bottom: 0.5rem;
}

button {
    background-color: #3498db;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
    transition: background-color 0.2s;
}

button:hover {
    background-color: #2980b9;
}

js

document.addEventListener('DOMContentLoaded', () => {
    const apiMessageElement = document.getElementById('api-message');
    const apiItemsElement = document.getElementById('api-items');
    const fetchButton = document.getElementById('fetch-button');
    
    // メッセージをフロントエンドで定義
    const messageText = "フロントエンドで定義したメッセージ";
    
    // ボタンクリック時のデータ取得処理
    fetchButton.addEventListener('click', fetchDataFromApi);
    
    // APIからデータを取得する関数
    async function fetchDataFromApi() {
        apiMessageElement.textContent = 'データ取得中...';
        apiItemsElement.innerHTML = '';
        
        try {
            const response = await fetch('/api/data');
            
            if (!response.ok) {
                throw new Error(`APIエラー: ${response.status}`);
            }
            
            // データは直接配列で返ってくる
            const items = await response.json();
            
            // メッセージを表示(フロントエンドで定義したもの)
            apiMessageElement.textContent = messageText;
            
            // 項目リストを表示
            if (items && items.length > 0) {
                items.forEach(item => {
                    const li = document.createElement('li');
                    li.textContent = item;
                    apiItemsElement.appendChild(li);
                });
            } else {
                apiItemsElement.innerHTML = '<li>表示する項目がありません</li>';
            }
            
        } catch (error) {
            apiMessageElement.textContent = `エラーが発生しました: ${error.message}`;
            console.error('APIデータ取得エラー:', error);
        }
    }
    
    // 初回表示時に自動的にデータを取得
    fetchDataFromApi();
});
not75743not75743

モチベーション

  • goで作ったAPIにフロントエンドを作成したいが、以下を気にしている
    • reactなどのフレームワークを使うほどではないため、シンプルにhtml/css/javascriptだけで済ましたい
    • go run main.goしたらフロントエンドも立ち上がって欲しい
    • 将来フロントエンドを別に作るかも知れないので、html/templateなどに依存するのは避けたい
not75743not75743

http.fileserverを使う

https://cs.opensource.google/go/go/+/go1.24.2:src/net/http/fs.go;l=963

func main() {
	// APIエンドポイント - 単純なデータのみを返す
	http.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
		// CORSヘッダーを設定
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Content-Type", "application/json")

		// データ配列のみを返す(メッセージなし)
		items := []string{"項目1", "項目2", "項目3", "項目4", "項目5"}
		json.NewEncoder(w).Encode(items)
	})

	// 静的ファイルの提供
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)

	// サーバー起動
	port := ":8080"
	log.Printf("サーバーを開始: http://localhost%s", port)
	log.Fatal(http.ListenAndServe(port, nil))
}

こうすることでstaticディレクトリの静的ファイルへアクセス可能
airを入れることでホットリロードも可能

.
├── main.go
├── static # ここへ静的ファイルを入れる
│   ├── app.js
│   ├── index.html
│   └── styles.css
not75743not75743

go buildは静的ファイルを含めない

らしいです
https://zenn.dev/kou_pg_0131/articles/go-build-with-static-files-by-statik

前述のコードで試したところ

  • staticディレクトリが実行ファイルと同じ階層にある
    • 正常動作
  • 〃にない
    • 404で動作しない

となる。ローカルのstaticディレクトリを見に行ってしまうためである

	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)
not75743not75743

embedを使う

ことで静的ファイルも含められるとのこと
https://pkg.go.dev/embed
https://zenn.dev/rescuenow/articles/aeb7f2e8c110d0

コードをこんな感じにする

main.go
package main

import (
	"embed"
	"encoding/json"
	"io/fs"
	"log"
	"net/http"
)

//go:embed static/*
var staticFiles embed.FS

func main() {
	// APIエンドポイント - シンプルなデータのみを返す
	http.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
		// CORSヘッダーを設定
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Content-Type", "application/json")

		// データ配列のみを返す
		items := []string{"項目1", "項目2", "項目3", "テスト"}
		json.NewEncoder(w).Encode(items)
	})

	// 埋め込まれた静的ファイルを提供
	// 注: staticFiles は "static" ディレクトリを含むので、Sub を使って中身だけ取得
	staticContent, err := fs.Sub(staticFiles, "static")
	if err != nil {
		log.Fatal("静的ファイルのサブツリー取得に失敗:", err)
	}

	// 静的ファイルをルートパスで提供
	http.Handle("/", http.FileServer(http.FS(staticContent)))

	// サーバー起動
	port := ":8080"
	log.Printf("サーバーを開始: http://localhost%s", port)
	log.Printf("静的ファイルはバイナリに埋め込まれています")
	log.Fatal(http.ListenAndServe(port, nil))
}

と解消した。これで実行ファイルやらコンテナイメージやらで配布しやすくなる

このスクラップは4ヶ月前にクローズされました