はじめに
この記事は 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.sqlとdown.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ドキュメント生成