🚀

Goの標準パッケージのみでREST APIを作れてしまうのです #02 - カテゴリ・ページネーション・論理削除の実装

に公開

はじめに

前回の記事では、Go標準パッケージのみを使用してREST APIを構築する方法を紹介しました。

今回はその続編として、Vtuber宮乃やみさんの動画企画で出された「依頼#02」を実装していきます。実際のアプリケーションでよく必要になる以下の3つの機能を実装しました。

  1. カテゴリ機能 - データを分類して管理
  2. ページネーション - 大量データの効率的な取得
  3. 論理削除 - データを物理的に削除せず、フラグで管理

これらは実務でほぼ必須の機能ですが、意外とハマりポイントも多いです。今回の実装を通じて得られた知見を共有します。

実装環境

  • Go 1.25
  • MySQL 8.0
  • 外部ライブラリ: go-sql-driver/mysql, sql-migrate のみ
  • フレームワーク不使用(標準net/httpのみ)

実装した機能の概要

1. カテゴリ機能

サマリーデータを「雑談」「ゲーム(スプラ)」「ゲーム(APEX)」などのカテゴリで分類できるようにしました。

主な変更点:

  • summariesテーブルにcategoryカラムを追加
  • カテゴリでのフィルタリング機能
  • NULL許容フィールドとしての実装

2. ページネーション

大量のデータを効率的に取得するため、offset/limit方式のページネーションを実装しました。

主な特徴:

  • limitoffsetパラメータでページ制御
  • 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]
}

レビュー対応から学んだこと

今回、以下のレビューコメントをいただき対応しました:

  1. カテゴリをNULL許容に変更

    • デフォルト値よりも明示的なNULLの方が柔軟
  2. ポインタ型の使用

    • API層ではポインタ型で省略可能を表現
  3. 日付フォーマットの共通化

    • 同じ処理の重複を避ける
  4. 変換関数の作成

    • 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