[Goのはじめ方] Part31: ToDoアプリのAPIサーバ作成

Go

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` 文が重複しないように注意してください)

注意点: 標準の `net/http` のルーティング機能は非常にシンプルです。より複雑なルーティング(パスパラメータの厳密な抽出 `/todos/:id` など)やミドルウェア(リクエスト前後の共通処理、認証など)を扱いたい場合は、gorilla/muxGinChi といった外部のルーティングライブラリ(フレームワーク)の利用を検討すると良いでしょう。このチュートリアルでは、標準ライブラリの理解を深めるために、あえてシンプルな実装にしています。

動作確認 ✅

サーバーを起動して、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クライアントツール(PostmanInsomnia など)を使うと、よりグラフィカルにリクエストを送信し、レスポンスを確認できます。

まとめと次のステップ 🚀

お疲れ様でした!このステップでは、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クライアントとデータ整形」に進みましょう!

コメント

タイトルとURLをコピーしました