Go言語で簡単なWeb APIサーバーを作ってみましょう!
はじめに
このステップでは、これまでに学んだ知識を活かして、シンプルなToDoリストを管理するWeb APIサーバーを作成します。
APIサーバーとは、外部からのリクエスト(お願い)を受け付けて、それに応じたデータ処理や応答を行うプログラムのことです。今回は、ToDoアイテムの追加、一覧表示、更新、削除といった基本的な機能を持つAPIを実装します。
このプロジェクトを通して、Go言語でのWebアプリケーション開発の基礎を体験しましょう!🚀
API設計 📐
まず、どのようなAPIエンドポイント(URL)とHTTPメソッド(GET, POST, PUT, DELETE)を提供するかを設計します。
HTTPメソッド | URL | 説明 |
---|---|---|
GET | /todos | 全てのToDoアイテムを取得 |
POST | /todos | 新しいToDoアイテムを作成 |
GET | /todos/{id} | 指定されたIDのToDoアイテムを取得 |
PUT | /todos/{id} | 指定されたIDのToDoアイテムを更新 |
DELETE | /todos/{id} | 指定されたIDのToDoアイテムを削除 |
次に、ToDoアイテムのデータ構造を定義します。Go言語の構造体(struct)を使います。
package main
// ToDoアイテムを表す構造体
type Todo struct {
ID int `json:"id"` // ToDoアイテムの一意なID
Title string `json:"title"` // ToDoのタイトル
Completed bool `json:"completed"` // 完了状態 (true:完了, false:未完了)
}
// `json:"..."` は、JSON形式でデータをやり取りする際に、
// フィールド名を指定するためのタグです。
必要なパッケージ 📦
今回のAPIサーバー作成には、主に以下のGo標準パッケージを使用します。
- net/http: HTTPクライアントとサーバーの実装を提供します。サーバーの起動、リクエストの処理(ルーティング)、レスポンスの送信などに使います。
- encoding/json: Goのデータ構造(構造体など)とJSON形式の相互変換(エンコード・デコード)をサポートします。APIでデータをやり取りする際に必須です。
- fmt: フォーマットされたI/O(入出力)を提供します。主にデバッグログの出力などに使います。
- strconv: 文字列と数値型(intなど)の基本的な変換を提供します。URLからIDを取得する際などに使います。
- sync: 並行処理を行う際に、共有リソースへのアクセスを安全に管理するための機能を提供します。今回は複数リクエストからToDoリストへのアクセスを安全にするためにMutexを使います。
これらのパッケージはGoの標準ライブラリに含まれているため、別途インストールする必要はありません。
実装 👨💻
それでは、実際にコードを書いていきましょう。まず、ToDoリストをメモリ上に保持するための変数と、ID生成のためのカウンター、そしてデータアクセスを保護するためのMutexを定義します。
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync" // syncパッケージをインポート
"log" // ログ出力用に追加
)
// Todo 構造体の定義
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
// ToDoリストを保持するスライス(メモリ上の簡易データベース)
var todos []Todo
// 新しいToDoのIDを採番するためのカウンター
var nextID int = 1
// todosスライスへのアクセスを保護するためのMutex
var mutex = &sync.Mutex{}
ハンドラー関数の作成
次に、各APIエンドポイントに対応するハンドラー関数を作成します。ハンドラー関数は `http.HandlerFunc` 型、つまり `func(w http.ResponseWriter, r *http.Request)` というシグネチャを持つ関数です。
1. 全ToDo取得 (GET /todos)
// getTodosHandler は /todos への GET リクエストを処理します。
func getTodosHandler(w http.ResponseWriter, r *http.Request) {
// Mutexでロックして、他のGoroutineからの同時アクセスを防ぐ
mutex.Lock()
// deferで、関数終了時に必ずロックを解除する
defer mutex.Unlock()
w.Header().Set("Content-Type", "application/json")
// todosスライスをJSON形式にエンコードしてレスポンスとして書き込む
if err := json.NewEncoder(w).Encode(todos); err != nil {
// エラーが発生したら、サーバー内部エラー(500)を返す
http.Error(w, err.Error(), http.StatusInternalServerError)
fmt.Printf("Error encoding todos: %v\n", err) // エラーログ
return // エラー発生時はここで処理を終了
}
// 成功ログを出力 (任意)
fmt.Println("GET /todos - Succeeded")
}
2. 新規ToDo作成 (POST /todos)
// createTodoHandler は /todos への POST リクエストを処理します。
func createTodoHandler(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
defer mutex.Unlock()
var todo Todo
// リクエストボディのJSONデータをデコードしてtodo変数に格納
if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest) // 不正なリクエスト(400)
fmt.Printf("Error decoding request body: %v\n", err) // エラーログ
return
}
// 簡単なバリデーション: タイトルが空でないかチェック
if todo.Title == "" {
http.Error(w, "Title cannot be empty", http.StatusBadRequest)
return
}
// 新しいIDを割り当て
todo.ID = nextID
nextID++ // 次のIDをインクリメント
// todosスライスに新しいToDoを追加
todos = append(todos, todo)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // ステータスコード 201 Created を設定
// 作成されたToDoをJSON形式でレスポンスとして書き込む
if err := json.NewEncoder(w).Encode(todo); err != nil {
// レスポンス書き込みエラーのログ出力
fmt.Printf("Error encoding response: %v\n", err)
// ここで http.Error を呼び出すとヘッダーが二重に書き込まれる可能性があるため注意
}
fmt.Printf("POST /todos - Created: %+v\n", todo)
}
3. ToDo取得/更新/削除のための共通ハンドラー (GET/PUT/DELETE /todos/{id})
特定IDのToDoを扱う処理は、URLパスからIDを取得する部分が共通しています。ここでは、リクエストメソッド (`r.Method`) によって処理を分岐させる1つのハンドラー関数 `todoIDHandler` を作成します。
// todoIDHandler は /todos/{id} 形式のパスへのリクエストを処理します。
func todoIDHandler(w http.ResponseWriter, r *http.Request) {
// URLパスからIDを取得 (例: /todos/1 -> "1")
// 注意: この実装は /todos/ の後の最初の部分のみをIDとして解釈します。
// 例: /todos/1/details は ID=1 として扱われます。
// より厳密なルーティングには外部ライブラリが推奨されます。
idStr := strings.TrimPrefix(r.URL.Path, "/todos/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ToDo ID format", http.StatusBadRequest)
return
}
// HTTPメソッドに応じてロックの種類を選択 (読み取りと書き込みで処理を分ける)
if r.Method == http.MethodGet {
handleGetTodoByID(w, r, id)
} else if r.Method == http.MethodPut || r.Method == http.MethodDelete {
handleUpdateOrDeleteTodoByID(w, r, id)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleGetTodoByID は特定のIDのToDoを取得します (読み取り)。
func handleGetTodoByID(w http.ResponseWriter, r *http.Request, id int) {
mutex.Lock() // 読み取りでもスライスの内容を読むためロック
defer mutex.Unlock()
targetIndex := -1
for i, t := range todos {
if t.ID == id {
targetIndex = i
break
}
}
if targetIndex == -1 {
http.Error(w, "ToDo not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(todos[targetIndex]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
fmt.Printf("Error encoding todo by ID: %v\n", err)
}
fmt.Printf("GET /todos/%d - Succeeded\n", id)
}
// handleUpdateOrDeleteTodoByID は特定のIDのToDoを更新または削除します (書き込み)。
func handleUpdateOrDeleteTodoByID(w http.ResponseWriter, r *http.Request, id int) {
mutex.Lock() // 書き込みのためロック
defer mutex.Unlock()
targetIndex := -1
for i, t := range todos {
if t.ID == id {
targetIndex = i
break
}
}
if targetIndex == -1 {
http.Error(w, "ToDo not found", http.StatusNotFound)
return
}
switch r.Method {
case http.MethodPut: // PUT /todos/{id}
var updatedTodo Todo
if err := json.NewDecoder(r.Body).Decode(&updatedTodo); err != nil {
http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
fmt.Printf("Error decoding update request body: %v\n", err)
return
}
// 簡単なバリデーション: タイトルが空でないかチェック
if updatedTodo.Title == "" {
http.Error(w, "Title cannot be empty", http.StatusBadRequest)
return
}
// IDは変更不可とする (パスで指定されたIDを使う)
updatedTodo.ID = id
todos[targetIndex] = updatedTodo // ToDoを更新
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(updatedTodo); err != nil {
fmt.Printf("Error encoding updated todo: %v\n", err)
// エラーが発生しても既にヘッダーは書き込まれている可能性あり
// http.Error(w, err.Error(), http.StatusInternalServerError) // ここでの呼び出しは避ける方が安全
}
fmt.Printf("PUT /todos/%d - Updated: %+v\n", id, updatedTodo)
case http.MethodDelete: // DELETE /todos/{id}
// スライスから該当要素を削除
todos = append(todos[:targetIndex], todos[targetIndex+1:]...)
w.WriteHeader(http.StatusNoContent) // ステータスコード 204 No Content
fmt.Printf("DELETE /todos/%d - Deleted\n", id)
}
}
メイン関数とサーバー起動
最後に、これらのハンドラー関数を特定のURLパスとHTTPメソッドに紐付け(ルーティング)、HTTPサーバーを起動する `main` 関数を作成します。
標準の `net/http` パッケージでは、パスに基づいたルーティングは `http.HandleFunc` で行いますが、同じパス (`/todos/{id}`) で異なるメソッド (GET, PUT, DELETE) を処理するには少し工夫が必要です。ここでは、`/todos` と `/todos/` (末尾スラッシュあり)で処理を分けるシンプルな方法を採用します。
func main() {
// "/" へのアクセス
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// ルートパス("/") 以外へのアクセスは Not Found とする
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
// GETメソッド以外は許可しない
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
fmt.Fprintln(w, "Welcome to the Simple ToDo API!") // ルートパスへの応答
})
// /todos へのリクエストを処理するハンドラー (GET, POST)
http.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getTodosHandler(w, r)
case http.MethodPost:
createTodoHandler(w, r)
default:
// サポートされていないメソッド
w.Header().Set("Allow", "GET, POST") // 許可するメソッドをヘッダーで示す
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// /todos/{id} へのリクエストを処理するハンドラー (GET, PUT, DELETE)
// "/todos/" で始まるパスを受け付ける (例: /todos/1, /todos/123)
http.HandleFunc("/todos/", func(w http.ResponseWriter, r *http.Request) {
// "/todos/" そのものへのアクセスは無効とする
if r.URL.Path == "/todos/" {
http.Error(w, "Missing ToDo ID", http.StatusBadRequest)
return
}
// サポートするメソッドか確認
switch r.Method {
case http.MethodGet, http.MethodPut, http.MethodDelete:
// パスの解析と処理は todoIDHandler に委譲
todoIDHandler(w, r)
default:
// サポートされていないメソッド
w.Header().Set("Allow", "GET, PUT, DELETE") // 許可するメソッドをヘッダーで示す
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// サーバーをポート8080で起動
port := "8080"
fmt.Printf("Starting server on port %s...\n", port)
// http.ListenAndServeがエラーを返した場合(nil以外)、ログに出力して終了
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Could not start server: %s\n", err.Error())
}
}
これで、ToDo APIサーバーの基本的な実装が完了しました! 🎉
上記のコードを全て結合し、`main.go` というファイル名で保存してください。(`package main` や `import` 文が重複しないように注意してください)
動作確認 ✅
サーバーを起動して、APIが正しく動作するか確認しましょう。
1. ターミナル(コマンドプロンプト)を開き、`main.go` ファイルがあるディレクトリに移動します。
2. 次のコマンドでサーバーを起動します。
go run main.go
# 出力例: Starting server on port 8080...
3. 別のターミナルを開き、`curl` コマンドなどを使ってAPIにリクエストを送ります。(Windowsの場合はGit BashやWSLのターミナル、または `curl` がインストールされていればコマンドプロンプトで実行できます)
ToDoの作成 (POST /todos)
# シングルクォート内のダブルクォートに注意
curl -X POST -H "Content-Type: application/json" -d '{"title": "Goの勉強", "completed": false}' http://localhost:8080/todos
# 出力例: {"id":1,"title":"Goの勉強","completed":false}
curl -X POST -H "Content-Type: application/json" -d '{"title": "買い物", "completed": false}' http://localhost:8080/todos
# 出力例: {"id":2,"title":"買い物","completed":false}
# タイトルが空の例 (エラーになるはず)
curl -X POST -H "Content-Type: application/json" -d '{"title": "", "completed": false}' http://localhost:8080/todos
# 出力例: Title cannot be empty
全ToDoの取得 (GET /todos)
curl http://localhost:8080/todos
# 出力例: [{"id":1,"title":"Goの勉強","completed":false},{"id":2,"title":"買い物","completed":false}]
# (JSONの整形は見やすさのために改行・インデントされる場合があります)
特定のToDoの取得 (GET /todos/{id})
curl http://localhost:8080/todos/1
# 出力例: {"id":1,"title":"Goの勉強","completed":false}
curl http://localhost:8080/todos/99 # 存在しないID
# 出力例: ToDo not found
ToDoの更新 (PUT /todos/{id})
curl -X PUT -H "Content-Type: application/json" -d '{"title": "Goの復習", "completed": true}' http://localhost:8080/todos/1
# 出力例: {"id":1,"title":"Goの復習","completed":true}
curl -X PUT -H "Content-Type: application/json" -d '{"title": "", "completed": true}' http://localhost:8080/todos/1 # タイトル空で更新(エラー)
# 出力例: Title cannot be empty
更新後の全ToDoの取得 (GET /todos)
curl http://localhost:8080/todos
# 出力例: [{"id":1,"title":"Goの復習","completed":true},{"id":2,"title":"買い物","completed":false}]
ToDoの削除 (DELETE /todos/{id})
curl -X DELETE http://localhost:8080/todos/2
# 出力: (なし、ステータスコード 204 No Content が返る)
curl -X DELETE http://localhost:8080/todos/99 # 存在しないID
# 出力例: ToDo not found
削除後の全ToDoの取得 (GET /todos)
curl http://localhost:8080/todos
# 出力例: [{"id":1,"title":"Goの復習","completed":true}]
これらのコマンドを実行して、期待通りの結果が返ってくれば成功です!🙌
APIクライアントツール(Postman や Insomnia など)を使うと、よりグラフィカルにリクエストを送信し、レスポンスを確認できます。
まとめと次のステップ 🚀
お疲れ様でした!このステップでは、Go言語の標準パッケージ `net/http` と `encoding/json` を使って、基本的なCRUD操作(作成、読み取り、更新、削除)を備えたToDo APIサーバーを作成しました。
以下の点を学びました:
- `http.HandleFunc` を使ったリクエストハンドリングと基本的なルーティング
- `http.ResponseWriter` と `*http.Request` の基本的な使い方
- JSONデータのデコード(リクエスト)とエンコード(レスポンス)
- 構造体(struct)を使ったデータモデル定義
- メモリ上でのデータ保持(スライスと`sync.Mutex`による排他制御)
- `curl` を使ったAPIの動作確認方法
今回作成したAPIサーバーは非常にシンプルですが、GoによるWeb API開発の第一歩としては十分な内容です。
次のステップとしては、以下のような改善や機能拡張が考えられます:
- データ永続化: 現在はサーバーを停止するとデータが消えてしまいます。データベース(PostgreSQL, MySQL, SQLiteなど)やファイルにデータを保存するように変更してみましょう。(`database/sql` パッケージや ORM ライブラリの学習が必要です)
- より良いルーティング: `gorilla/mux` や `Gin`、`Chi` などの外部パッケージを導入して、より洗練されたルーティング(パスパラメータ `/todos/:id` の簡単な取得、メソッドごとのハンドラー指定など)を実現する。
- エラーハンドリングの強化: より詳細なエラー情報をJSON形式でクライアントに返す、ログ出力を構造化するなど。
- 入力値のバリデーション: リクエストで受け取ったデータ(ToDoのタイトル、完了状態など)が適切か、より詳細にチェックするライブラリの導入も検討できます。
- テストの作成: `net/http/httptest` パッケージなどを使って、作成したAPIハンドラーに対するユニットテストや結合テストを記述する。(Step 8 の内容の応用です)
これらの課題に取り組むことで、GoによるWeb開発のスキルをさらに向上させることができます。ぜひチャレンジしてみてください!💪
これで Step 9 の「ToDoアプリのAPIサーバ作成」は完了です!次のステップ「JSON APIクライアントとデータ整形」に進みましょう!
コメント