Goの標準パッケージのみでREST APIを作れてしまうのです #02 - カテゴリ・ページネーション・論理削除の実装
はじめに
前回の記事では、Go標準パッケージのみを使用してREST APIを構築する方法を紹介しました。
今回はその続編として、Vtuber宮乃やみさんの動画企画で出された「依頼#02」を実装していきます。実際のアプリケーションでよく必要になる以下の3つの機能を実装しました。
- カテゴリ機能 - データを分類して管理
- ページネーション - 大量データの効率的な取得
- 論理削除 - データを物理的に削除せず、フラグで管理
これらは実務でほぼ必須の機能ですが、意外とハマりポイントも多いです。今回の実装を通じて得られた知見を共有します。
実装環境
- Go 1.25
- MySQL 8.0
- 外部ライブラリ:
go-sql-driver/mysql,sql-migrateのみ - フレームワーク不使用(標準
net/httpのみ)
実装した機能の概要
1. カテゴリ機能
サマリーデータを「雑談」「ゲーム(スプラ)」「ゲーム(APEX)」などのカテゴリで分類できるようにしました。
主な変更点:
-
summariesテーブルにcategoryカラムを追加 - カテゴリでのフィルタリング機能
- NULL許容フィールドとしての実装
2. ページネーション
大量のデータを効率的に取得するため、offset/limit方式のページネーションを実装しました。
主な特徴:
-
limitとoffsetパラメータでページ制御 -
has_nextフラグで次ページの有無を判定(LIMIT+1パターン) - 総件数(
total)も同時に返却
{
"summaries": [...],
"total": 100,
"limit": 20,
"offset": 0,
"has_next": true
}
3. 論理削除
データを物理的に削除せず、deleted_atカラムで削除フラグを管理する論理削除を実装しました。
主な変更点:
-
deleted_at TIMESTAMP(6) NULLカラムを追加 - DELETE文からUPDATE文への変更
- 全SELECT文に
WHERE deleted_at IS NULLを追加
技術的なポイント
カテゴリ機能の実装
マイグレーション
最初はNOT NULL制約で実装していましたが、レビューでNULL許容に変更しました。
-- 最終的な実装
ALTER TABLE summaries
ADD COLUMN category VARCHAR(100) NULL
COMMENT 'カテゴリ(雑談、ゲーム等)' AFTER content,
ADD INDEX idx_category (category);
ドメインモデルでのNULL対応
GoでMySQLのNULLを扱うにはsql.NullStringを使用します。
type Summary struct {
domain.WYHBaseModel
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Category sql.NullString `json:"category"` // NULL許容
UserID string `json:"user_id"`
User *user.User `json:"user"`
}
API層でのポインタ型
リクエスト/レスポンスではポインタ型を使用し、omitemptyで省略可能にします。
// リクエスト
type SaveSummaryRequest struct {
Title string `json:"title" validate:"required,max=255"`
Category *string `json:"category" validate:"required,max=100"`
// ...
}
// レスポンス
type DetailSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Category *string `json:"category,omitempty"` // NULLの場合は出力しない
// ...
}
変換ヘルパー関数
ポインタ型とsql.NullStringの相互変換が頻繁に発生するため、専用パッケージを作成しました。
// pkg/null_value/null_value.go
func PointerToSqlString(s *string) sql.NullString {
if s == nil {
return sql.NullString{Valid: false}
}
return sql.NullString{String: *s, Valid: true}
}
func SqlStringToPointer(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
使用例:
// リクエスト → ドメインモデル
model := &summary.Summary{
Category: nullvalue.PointerToSqlString(req.Category),
}
// ドメインモデル → レスポンス
response := &DetailSummary{
Category: nullvalue.SqlStringToPointer(s.Category),
}
ページネーション実装
LIMIT+1パターンで次ページ判定
has_nextを効率的に判定するため、LIMIT+1件取得して判定する手法を採用しました。
func (s *summaryRepository) List(ctx context.Context, opts summary.ListOptions) (*summary.ListResult, error) {
// 総件数を取得
var total int
countQuery := `SELECT COUNT(*) FROM summaries s WHERE s.deleted_at IS NULL`
// ...
// LIMIT+1件取得して次ページの有無を判定
limit := opts.Limit + 1
query := `
SELECT s.id, s.title, s.description, s.content, s.category,
s.user_id, s.created_at, s.updated_at,
u.id, u.name, u.email, u.user_type, u.created_at, u.updated_at
FROM summaries s
INNER JOIN users u ON s.user_id = u.id
WHERE s.deleted_at IS NULL
ORDER BY s.created_at DESC
LIMIT ? OFFSET ?
`
rows, err := db.QueryContext(ctx, query, limit, opts.Offset)
// ...
// LIMIT+1件取得したかどうかで次ページの有無を判定
hasNext := len(summaries) > opts.Limit
if hasNext {
summaries = summaries[:opts.Limit] // 余分な1件を削除
}
return &summary.ListResult{
Items: summaries,
Total: total,
Limit: opts.Limit,
Offset: opts.Offset,
HasNext: hasNext,
}, nil
}
この方法のメリット:
- 追加のCOUNT(*)クエリが不要
- シンプルで高速
- ページング可能かどうかが正確に分かる
カテゴリフィルタリング
カテゴリが指定された場合のみWHERE句に追加します。
whereClause := "WHERE s.deleted_at IS NULL"
args := []interface{}{}
if opts.Category != "" {
whereClause += " AND s.category = ?"
args = append(args, opts.Category)
}
query := fmt.Sprintf(`
SELECT COUNT(*) FROM summaries s %s
`, whereClause)
論理削除の実装
マイグレーション
ALTER TABLE summaries
ADD COLUMN deleted_at TIMESTAMP(6) NULL DEFAULT NULL
COMMENT '削除日時(論理削除)' AFTER updated_at,
ADD INDEX idx_deleted_at (deleted_at);
マイクロ秒精度(TIMESTAMP(6))を使用することで、より正確な削除時刻を記録できます。
DELETE → UPDATEへの変更
物理削除から論理削除への変更:
// 変更前
query := `DELETE FROM summaries WHERE id = ?`
// 変更後
query := `UPDATE summaries SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL`
全SELECT文への対応
削除済みデータを取得しないよう、すべてのSELECT文に条件を追加:
query := `
SELECT ...
FROM summaries s
WHERE s.id = ? AND s.deleted_at IS NULL
`
インデックスを追加することで、この条件による性能劣化を防ぎます。
日付フォーマットの共通化
日付のフォーマット処理が重複していたため、専用パッケージに切り出しました。
// pkg/date/date.go
package date
import "time"
const DefaultFormat = "2006-01-02 15:04:05"
func FormatDefault(t time.Time) string {
return t.Format(DefaultFormat)
}
使用例:
// 変更前
CreatedAt: s.CreatedAt.Format("2006-01-02 15:04:05")
// 変更後
CreatedAt: date.FormatDefault(s.CreatedAt)
ハマりポイントと学び
1. sql.NullStringの扱い
最初、ドメインモデルでstring型を使っていましたが、NULL値を扱えず型エラーが発生しました。
学び: MySQLのNULL許容カラムには必ずsql.NullXxx型を使用する
2. テストでの型不一致
テストで以下のようなエラーが発生:
Error: Not equal:
expected: string("雑談")
actual: sql.NullString{String:"雑談", Valid:true}
解決策: テストデータもsql.NullStringで定義
Category: sql.NullString{String: "雑談", Valid: true}
3. gofmtのフォーマット
構造体のタグのインデントがずれているとCIでエラーになりました。
解決策: コミット前に必ずgofmt -w .を実行
// NG
type Summary struct {
Category sql.NullString `json:"category"`
}
// OK (gofmt後)
type Summary struct {
Category sql.NullString `json:"category"`
}
4. ページネーションのエッジケース
最後のページでhas_nextが正しく動作しない問題がありました。
解決策: LIMIT+1パターンの正確な実装
// 取得したデータ数とLIMITを比較
hasNext := len(summaries) > opts.Limit
if hasNext {
summaries = summaries[:opts.Limit]
}
レビュー対応から学んだこと
今回、以下のレビューコメントをいただき対応しました:
-
カテゴリをNULL許容に変更
- デフォルト値よりも明示的なNULLの方が柔軟
-
ポインタ型の使用
- API層ではポインタ型で省略可能を表現
-
日付フォーマットの共通化
- 同じ処理の重複を避ける
-
変換関数の作成
-
pkg/null_valueパッケージで変換ロジックを集約
-
学び: レビューを通じて、より保守性の高いコード設計が身につきました。
テストデータの拡充
ページネーション機能のテストのため、シードデータを100件に拡充しました。
-- 雑談カテゴリ: 30件
-- ゲーム(スプラ): 25件
-- ゲーム(APEX): 25件
-- ゲーム(その他): 20件
これにより、以下のテストが可能になりました:
- カテゴリフィルタリングの動作確認
- ページネーションの次ページ判定
- 大量データでのパフォーマンス確認
まとめ
今回、カテゴリ機能・ページネーション・論理削除という実務で必須の3つの機能を実装しました。
実装のポイント:
- NULL許容フィールドには
sql.NullStringとポインタ型を使い分け - 変換ヘルパー関数で煩雑さを軽減
- LIMIT+1パターンで効率的なページネーション
- 論理削除でデータの履歴を保持
- 共通処理はパッケージに切り出し
開発ワークフロー:
- gofmt、make test、make lintをコミット前に必ず実行
- MCPツールでPR作成・レビュー確認
- レビューコメントは小さいコミットで対応
Go標準パッケージだけでも、実用的なREST APIが十分に構築できることが実感できました。
Discussion