9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Go + Docker】ToDo管理APIを爆速で作る!golang-migrate / sqlc / Air を使ったモダンな開発フロー

9
Last updated at Posted at 2025-12-09

はじめに

この記事は GxP Advent Calendar 2025 の 10日目の記事です。

※ 本記事は連載の第2回です。
開発環境(DevContainer)の構築については、第1回の記事「Windowsでも爆速!Go + PostgreSQLの最強開発環境をDevContainerで作る」を参照してください。

今回は、Go言語でToDo管理APIを作成しながら、モダンな開発ツールを活用した効率的な開発フローを紹介します。

使用するツール

ツール 役割
golang-migrate コマンド一発でDBマイグレーション
sqlc SQLを書くだけで型安全なGoコードを自動生成
Air ファイル保存で自動リビルド(ホットリロード)

完成形のAPI

GET    /api/todos          # 一覧取得
POST   /api/todos          # 作成
GET    /api/todos/{id}     # 1件取得
PUT    /api/todos/{id}     # 更新
DELETE /api/todos/{id}     # 削除
PATCH  /api/todos/{id}/toggle  # 完了状態切替

環境構成

項目 内容
開発環境 Dev Container (Docker)
言語 Go 1.23
DB PostgreSQL 15
APIルーター chi

プロジェクト構造

第1回で作成した my-go-project に、以下のファイルを追加していきます。

my-go-project/
├── cmd/api/
│   └── main.go              # エントリーポイント(今回作成)
├── db/
│   ├── migrations/          # マイグレーションファイル
│   │   ├── 000001_...up.sql
│   │   └── 000001_...down.sql
│   └── queries/
│       └── todos.sql        # SQLクエリ定義(今回作成)
├── internal/db/             # sqlc自動生成(編集しない)
│   ├── db.go
│   ├── models.go
│   └── todos.sql.go
├── .air.toml                # ホットリロード設定(今回作成)
└── sqlc.yaml                # sqlc設定(今回作成)

Step 1: golang-migrate でテーブル作成

マイグレーションファイル作成

mkdir -p db/migrations

db/migrations/000001_create_todos_table.up.sql

CREATE TABLE IF NOT EXISTS todos (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    completed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- 検索高速化のためのインデックス
CREATE INDEX idx_todos_completed ON todos(completed);
CREATE INDEX idx_todos_created_at ON todos(created_at);

db/migrations/000001_create_todos_table.down.sql

DROP TABLE IF EXISTS todos;

マイグレーション実行

# テーブル作成
migrate -path db/migrations -database "$DATABASE_URL" up

# 出力: 1/u create_todos_table (19.199635ms)

ポイント:

  • up.sqldown.sql をセットで管理
  • ロールバックも migrate down で簡単

Step 2: sqlc で型安全なGoコード生成

sqlc設定ファイル

sqlc.yaml

version: "2"
sql:
  - engine: "postgresql"
    queries: "db/queries/"
    schema: "db/migrations/"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_interface: true
        emit_empty_slices: true

SQLクエリ定義

db/queries/todos.sql

-- name: GetTodo :one
SELECT * FROM todos WHERE id = $1 LIMIT 1;

-- name: ListTodos :many
SELECT * FROM todos ORDER BY created_at DESC;

-- name: CreateTodo :one
INSERT INTO todos (title, description)
VALUES ($1, $2)
RETURNING *;

-- name: UpdateTodo :one
UPDATE todos
SET title = $2, description = $3, completed = $4, updated_at = NOW()
WHERE id = $1
RETURNING *;

-- name: DeleteTodo :exec
DELETE FROM todos WHERE id = $1;

-- name: ToggleTodoCompleted :one
UPDATE todos
SET completed = NOT completed, updated_at = NOW()
WHERE id = $1
RETURNING *;

コード生成

sqlc generate

これだけで internal/db/ に以下が自動生成されます。

// internal/db/models.go(自動生成)
type Todo struct {
    ID          int32              `json:"id"`
    Title       string             `json:"title"`
    Description pgtype.Text        `json:"description"`
    Completed   bool               `json:"completed"`
    CreatedAt   pgtype.Timestamptz `json:"created_at"`
    UpdatedAt   pgtype.Timestamptz `json:"updated_at"`
}

sqlcのメリット:

  • SQLを書くだけで型安全なGoコードが生成される
  • コンパイル時に型エラーを検出
  • ORMより高速(生SQLを実行)

Step 3: Air でホットリロード

Air設定ファイル

.air.toml

root = "."
tmp_dir = "tmp"

[build]
  cmd = "go build -o ./tmp/main ./cmd/api"
  bin = "tmp/main"
  include_ext = ["go", "sql", "yaml"]
  exclude_dir = ["tmp", "vendor", "internal/db"]
  delay = 1000

[misc]
  clean_on_exit = true

起動

air
  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ v1.52.3

watching .
building...
running...
2025/11/29 04:28:14 ✅ DB接続成功
2025/11/29 04:28:14 🚀 サーバー起動: http://localhost:8080

ファイルを保存すると自動でリビルド! 開発効率が大幅に向上します。


Step 4: APIサーバー実装

cmd/api/main.go

package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "os"
    "strconv"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/jackc/pgx/v5/pgtype"
    "github.com/jackc/pgx/v5/pgxpool"

    "hello-world/internal/db"
)

var queries *db.Queries

func main() {
    ctx := context.Background()
    dbURL := os.Getenv("DATABASE_URL")

    // DB接続プール作成
    pool, err := pgxpool.New(ctx, dbURL)
    if err != nil {
        log.Fatal("DB接続エラー:", err)
    }
    defer pool.Close()

    queries = db.New(pool)

    // ルーター設定
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    // ToDo API
    r.Route("/api/todos", func(r chi.Router) {
        r.Get("/", listTodos)
        r.Post("/", createTodo)
        r.Get("/{id}", getTodo)
        r.Put("/{id}", updateTodo)
        r.Delete("/{id}", deleteTodo)
        r.Patch("/{id}/toggle", toggleTodo)
    })

    log.Println("🚀 サーバー起動: http://localhost:8080")
    http.ListenAndServe(":8080", r)
}

// 一覧取得
func listTodos(w http.ResponseWriter, r *http.Request) {
    todos, err := queries.ListTodos(r.Context())
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(todos)
}

// 作成
func createTodo(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title       string  `json:"title"`
        Description *string `json:"description"`
    }
    json.NewDecoder(r.Body).Decode(&input)

    // pgtype.Textに変換
    desc := pgtype.Text{}
    if input.Description != nil {
        desc = pgtype.Text{String: *input.Description, Valid: true}
    }

    todo, err := queries.CreateTodo(r.Context(), db.CreateTodoParams{
        Title:       input.Title,
        Description: desc,
    })
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(todo)
}

// 完了状態切替
func toggleTodo(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(chi.URLParam(r, "id"))
    todo, err := queries.ToggleTodoCompleted(r.Context(), int32(id))
    if err != nil {
        http.Error(w, "ToDo not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(todo)
}

// ※ getTodo, updateTodo, deleteTodo の実装は省略しています

動作確認

# ToDo作成
$ curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "牛乳を買う", "description": "低脂肪牛乳"}'

{
  "id": 1,
  "title": "牛乳を買う",
  "description": "低脂肪牛乳",
  "completed": false,
  "created_at": "2025-11-29T04:29:04.216785Z",
  "updated_at": "2025-11-29T04:29:04.216785Z"
}

# 完了状態を切り替え
$ curl -X PATCH http://localhost:8080/api/todos/1/toggle

{
  "id": 1,
  "title": "牛乳を買う",
  "completed": true,
  ...
}
# ↑ completed が false → true に変更された!

# 一覧取得
$ curl http://localhost:8080/api/todos

[
  {"id": 2, "title": "Go言語の勉強", "completed": false, ...},
  {"id": 1, "title": "牛乳を買う", "completed": true, ...}
]

Step 5: エラーハンドリングとセキュリティ改善

Step 4のコードには、本番運用時に問題となる点がいくつかあります。運用前に修正しておきましょう。

問題点

1. IDパースエラーの無視

// ❌ 問題: エラーを無視している
id, _ := strconv.Atoi(chi.URLParam(r, "id"))
// "/api/todos/abc" → id = 0 として処理されてしまう!

2. 内部エラーの漏洩

// ❌ 問題: DBエラーの詳細がクライアントに露出
http.Error(w, err.Error(), http.StatusInternalServerError)
// → "pq: duplicate key value violates unique constraint..." などが見える

改善後のコード

エラーレスポンス用の構造体とヘルパー関数

// ErrorResponse はJSONエラーレスポンスの構造体
type ErrorResponse struct {
    Error string `json:"error"`
}

// respondWithError はJSONフォーマットでエラーレスポンスを返す
func respondWithError(w http.ResponseWriter, code int, message string) {
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}

// parseID はURLパラメータからIDをパースし、エラー時はレスポンスを返す
func parseID(w http.ResponseWriter, r *http.Request) (int32, bool) {
    idStr := chi.URLParam(r, "id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        log.Printf("無効なID: %s, エラー: %v", idStr, err)
        respondWithError(w, http.StatusBadRequest, "Invalid ID")
        return 0, false
    }
    return int32(id), true
}

改善されたハンドラー

// 一覧取得
func listTodos(w http.ResponseWriter, r *http.Request) {
    todos, err := queries.ListTodos(r.Context())
    if err != nil {
        log.Printf("ListTodos エラー: %v", err)  // ログには詳細を記録
        respondWithError(w, http.StatusInternalServerError, "Failed to retrieve todos")  // クライアントには汎用メッセージ
        return
    }
    json.NewEncoder(w).Encode(todos)
}

// 1件取得
func getTodo(w http.ResponseWriter, r *http.Request) {
    id, ok := parseID(w, r)
    if !ok {
        return  // parseID内でエラーレスポンス済み
    }
    todo, err := queries.GetTodo(r.Context(), id)
    if err != nil {
        log.Printf("GetTodo エラー (ID=%d): %v", id, err)
        respondWithError(w, http.StatusNotFound, "ToDo not found")
        return
    }
    json.NewEncoder(w).Encode(todo)
}

// 作成
func createTodo(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title       string  `json:"title"`
        Description *string `json:"description"`
    }
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        log.Printf("CreateTodo JSONデコードエラー: %v", err)
        respondWithError(w, http.StatusBadRequest, "Invalid request payload")
        return
    }

    desc := pgtype.Text{}
    if input.Description != nil {
        desc = pgtype.Text{String: *input.Description, Valid: true}
    }

    todo, err := queries.CreateTodo(r.Context(), db.CreateTodoParams{
        Title:       input.Title,
        Description: desc,
    })
    if err != nil {
        log.Printf("CreateTodo DBエラー: %v", err)
        respondWithError(w, http.StatusInternalServerError, "Failed to create todo")
        return
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(todo)
}

動作確認

# 無効なID → 400 Bad Request(JSONレスポンス)
$ curl http://localhost:8080/api/todos/abc
{"error":"Invalid ID"}

# サーバーログ
無効なID: abc, エラー: strconv.Atoi: parsing "abc": invalid syntax

# 存在しないID → 404 Not Found
$ curl http://localhost:8080/api/todos/9999
{"error":"ToDo not found"}

# 無効なJSON → 400 Bad Request
$ curl -X POST http://localhost:8080/api/todos -d 'invalid'
{"error":"Invalid request payload"}

まとめ

本記事では、Go言語でToDo管理APIを構築しながら、以下の開発ツールを活用しました。

各ツールの役割

ツール 役割 メリット
golang-migrate DBスキーマ管理 バージョン管理、ロールバック対応
sqlc SQL → Go変換 型安全、コンパイル時チェック
Air ホットリロード 保存即反映で開発効率UP

開発フロー

1. air を起動(ホットリロード開始)
2. db/migrations/ にSQLを書く → migrate up
3. db/queries/ にクエリを書く → sqlc generate
4. cmd/api/main.go でハンドラー実装 → 自動リビルド

SQLを書くだけでGoの型安全なコードが生成されるのがsqlcの最大の魅力です。
ORMのように学習コストが高くなく、生SQLのパフォーマンスを維持できます。

次のステップ

本シリーズの続編として、以下のトピックを予定しています。

  • 認証機能の追加(JWT)
  • バリデーション(go-playground/validator)
  • テストコード(sqlcはモック生成もサポート)
  • Swagger/OpenAPIドキュメント生成

参考リンク


9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?