🍇

Go標準モジュールでシンプルなHTTPサーバを実装する

に公開2

はじめに

Go言語は、標準ライブラリだけで非常に強力かつ高速なHTTPサーバを簡単に構築できることで知られています。本記事では、外部フレームワークを一切使わず、Goの標準モジュール(net/httpなど)だけで完結するシンプルなAPIサーバを実装していきます。

最終的に以下のようなサーバを構築します

  • PUT /store/{key}: 任意のデータをメモリ上に保存
  • GET /store/{key}: 保存したデータを取得
  • 他のメソッド: 405エラー
  • パスが不正: 404エラー

この記事は自分の復習を兼ねて段階的に実装しているため、やや冗長な部分もありますがご了承ください 🙇‍♂️

最小のHTTPサーバーを書いてみる

まずはローカルで"Hello World!"と表示するHTTPサーバを作成します。

main.go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello World!")
	})

	fmt.Println("サーバー起動中: http://localhost:8000")
	http.ListenAndServe(":8000", nil)
}

以下のコマンドを実行した後、ブラウザでhttp://localhost:8000にアクセスすると「Hello World!」と無事表示されました。なお以降の動作確認はすべてCLI上でcurlコマンドを用いて行います。

go run main.go

実行結果

/store/{key} パスをルーティング

Goのnet/httpパッケージには、フレームワークのようなルーティング機能はありません。ですが、そのぶん非常にシンプルで柔軟な設計ができます。

今回は、以下のようなルールで /store/{key} を処理します:

  • keyは英数字のみ、1~10文字
  • パス形式:/store/{key}
  • 無効なパスや無効なkeyには404を返す
main.go
package main

import (
	"fmt"
	"net/http"
+	"regexp"
+	"strings"
)

+var keyPattern = regexp.MustCompile(`^[a-zA-Z0-9]{1,10}$`)
+
+func storeHandler(w http.ResponseWriter, r *http.Request) {
+	if !strings.HasPrefix(r.URL.Path, "/store/") {
+		http.NotFound(w, r)
+		return
+	}
+
+	key := strings.TrimPrefix(r.URL.Path, "/store/")
+
+	if !keyPattern.MatchString(key) {
+		http.NotFound(w, r)
+		return
+	}
+
+	fmt.Fprintf(w, "Accessed /store with key: %s\n", key)
+}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-               fmt.Fprintln(w, "Hello World!")
+		if strings.HasPrefix(r.URL.Path, "/store/") {
+			storeHandler(w, r)
+			return
+		}
+		http.NotFound(w, r)
+	})

	fmt.Println("サーバー起動中: http://localhost:8000")
	http.ListenAndServe(":8000", nil)
}

test:

curl http://localhost:8000/store/hello
# => Accessed /store with key: hello

curl http://localhost:8000/store/invalid!
# => 404 page not found

curl http://localhost:8000/other
# => 404 page not found

curl http://localhost:8000/abcdefghijkl
# => 404 page not found

PUTメソッドでデータを保存

PUT /store/{key} に対して、任意のバイナリデータを受け取り、メモリ上に保存できるようにします。

main.go
package main

import (
	"fmt"
+	"io"
	"net/http"
	"regexp"
	"strings"
+	"sync"
)

var keyPattern = regexp.MustCompile(`^[a-zA-Z0-9]{1,10}$`)

+type MemoryStore struct {
+	data map[string][]byte
+	mu   sync.RWMutex
+}
+
+func NewMemoryStore() *MemoryStore {
+	return &MemoryStore{
+		data: make(map[string][]byte),
+	}
+}
+
+func (s *MemoryStore) Put(key string, value []byte) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.data[key] = value
+}
+
+func (s *MemoryStore) Get(key string) ([]byte, bool) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	val, ok := s.data[key]
+	return val, ok
+}
+
+var store = NewMemoryStore()

func storeHandler(w http.ResponseWriter, r *http.Request) {
	if !strings.HasPrefix(r.URL.Path, "/store/") {
		http.NotFound(w, r)
		return
	}

	key := strings.TrimPrefix(r.URL.Path, "/store/")

	if !keyPattern.MatchString(key) {
		http.NotFound(w, r)
		return
	}

-       fmt.Fprintf(w, "Accessed /store with key: %s\n", key)
+	switch r.Method {
+	case http.MethodPut:
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, "Failed to read body", http.StatusInternalServerError)
+			return
+		}
+		store.Put(key, body)
+		w.WriteHeader(http.StatusOK)
+                fmt.Fprintf(w, "Stored key=%s (status=%d)\n", key, http.StatusOK)
+	default:
+		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
+	}
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/store/") {
			storeHandler(w, r)
			return
		}
		http.NotFound(w, r)
	})

	fmt.Println("サーバー起動中: http://localhost:8000")
	http.ListenAndServe(":8000", nil)
}

test:

curl -X PUT http://localhost:8000/store/hello -d 'Hello, World!'
# => Stored key=hello (status=200)

curl -X POST http://localhost:8000/store/hello
# => Method Not Allowed

GETメソッドでデータを取り出す

保存されたデータを GET /store/{key} で取得できるようにします。storeHandler関数内のswitch文の中身を以下のように書き換えます。

main.go
switch r.Method {
case http.MethodPut:
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusInternalServerError)
        return
    }
    store.Put(key, body)
    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, "Stored key=%s (status=%d)\n", key, http.StatusOK)

+case http.MethodGet:
+    value, ok := store.Get(key)
+    if !ok {
+        http.Error(w, fmt.Sprintf("Key '%s' not found (status=%d)", key, http.StatusNotFound), http.StatusNotFound)
+        return
+    }
+    w.WriteHeader(http.StatusOK)
+    w.Write(value)

default:
    http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}

test:

# 保存
curl -X PUT http://localhost:8000/store/hello -d 'Hello, World!'
# => Stored key=hello (status=200)

# 取得
curl http://localhost:8000/store/hello
# => Hello, World!

# 存在しないkey
curl http://localhost:8000/store/notfound
# => Key 'notfound' not found (status=404)

POST, DELETEなどの不正メソッドと不正パスを処理

最後に、以下の2つのエラーハンドリングを追加します。

  • /store/{key} に対するPUT・GET以外のメソッドには405を返す
  • /store/ 以外の全てのパスに対して404を返す
main.go
package main

import (
	"fmt"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync"
)

var keyPattern = regexp.MustCompile(`^[a-zA-Z0-9]{1,10}$`)

type MemoryStore struct {
	data map[string][]byte
	mu   sync.RWMutex
}

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		data: make(map[string][]byte),
	}
}

func (s *MemoryStore) Put(key string, value []byte) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.data[key] = value
}

func (s *MemoryStore) Get(key string) ([]byte, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	val, ok := s.data[key]
	return val, ok
}

var store = NewMemoryStore()

func storeHandler(w http.ResponseWriter, r *http.Request) {
	if !strings.HasPrefix(r.URL.Path, "/store/") {
		http.NotFound(w, r)
		return
	}

	key := strings.TrimPrefix(r.URL.Path, "/store/")

	if !keyPattern.MatchString(key) {
-		http.NotFound(w, r)
+		http.Error(w, fmt.Sprintf("Invalid key format: '%s'", key), http.StatusNotFound)
		return
	}

	switch r.Method {
	case http.MethodPut:
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "Failed to read body", http.StatusInternalServerError)
			return
		}
		store.Put(key, body)
		w.WriteHeader(http.StatusOK)
-		fmt.Fprintf(w, "Stored key=%s (status=%d)\n", key, http.StatusOK)
+		fmt.Fprintf(w, "Stored key='%s' (status=200)\n", key)

	case http.MethodGet:
		value, ok := store.Get(key)
		if !ok {
-			http.Error(w, fmt.Sprintf("Key '%s' not found (status=%d)", key, http.StatusNotFound), http.StatusNotFound)
+			http.Error(w, fmt.Sprintf("Key '%s' not found (status=404)", key), http.StatusNotFound)
			return
		}
		w.WriteHeader(http.StatusOK)
		w.Write(value)

	default:
-		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
+		http.Error(w, fmt.Sprintf("Method %s not allowed (status=405)", r.Method), http.StatusMethodNotAllowed)
	}
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/store/") {
			storeHandler(w, r)
			return
		}
-		http.NotFound(w, r)
+		http.Error(w, "Not Found (status=404)", http.StatusNotFound)
	})

	fmt.Println("サーバー起動中: http://localhost:8000")
	http.ListenAndServe(":8000", nil)
}
curl -X PUT http://localhost:8000/store/abc123 -d 'data'
# => Stored key='abc123' (status=200)

curl http://localhost:8000/store/abc123
# => data

curl -X POST http://localhost:8000/store/abc123
# => Method POST not allowed (status=405)

curl http://localhost:8000/store/INVALID_KEY!
# => Invalid key format: 'INVALID_KEY!' 

curl http://localhost:8000/unknown/path
# => Not Found (status=404)

おわりに

今回のサンプルは非常にシンプルながら、Goの標準ライブラリだけで以下のことを実現できました。

  • ルーティング
  • メソッドごとの処理分岐
  • エラーハンドリング(404, 405)
  • スレッドセーフなメモリデータストア

今後は、JSON形式のAPI対応や認証機能、自動テストなど構築していきたいと思います。

Discussion

tenkohtenkoh

こんにちは!
パス(/store)とHTTPメソッドの分岐処理を全て一つのハンドラーの中で行われていますが、Go1.22からHTTPメソッドも含めてHandleFuncのパターン部分で定義できます。(パスについてはそれ以前から可能)

func getHelloHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello, World! (GET)"))
}

func postHelloHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello, World! (POST)"))
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	http.HandleFunc("GET /hello", getHelloHandler)
	http.HandleFunc("POST /hello", postHelloHandler)

	logger.Info("Starting server on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

詳細はこちらをご覧になると良いと思います。良いGoライフを!
https://pkg.go.dev/net/http#hdr-Patterns-ServeMux

k__kankek__kanke

参考記事まで掲載して頂き誠にありがとうございます。
HundleFuncの方がより簡潔にかけてしまいますね、、積極的にキャッチアップしていこうと思います。