【Go Echo】PostgreSQLにおけるマイグレーション管理

# はじめに

今回は、Goの強力なWebフレームワーク「Echo」を使用して、PostgreSQLをバックエンドとするAPIサーバーを構築し、`golang-migrate/migrate`を用いてデータベースのマイグレーションを管理する方法をご紹介します。

## 前提条件

以下のツールと環境がインストールされていることを確認してください。

- **Go**: [公式サイト](https://golang.org/doc/install)からインストール
- **PostgreSQL**: [公式サイト](https://www.postgresql.org/download/)からインストールし、データベースを作成済み
- **migrate CLI**: [インストール手順](https://github.com/golang-migrate/migrate/tree/master/cmd/migrate)に従ってインストール

---

## ステップ1: プロジェクトのセットアップ

まず、プロジェクトのディレクトリを作成し、必要なパッケージをインストールします。

```bash
# プロジェクトディレクトリを作成
mkdir echo-migrate-tutorial
cd echo-migrate-tutorial

# Goモジュールの初期化
go mod init github.com/yourusername/echo-migrate-tutorial

# 必要なパッケージのインストール
go get github.com/labstack/echo/v4
go get github.com/golang-migrate/migrate/v4
go get github.com/golang-migrate/migrate/v4/database/postgres
go get github.com/golang-migrate/migrate/v4/source/file
go get github.com/lib/pq
go get github.com/spf13/viper
```

> **注**: `github.com/yourusername/echo-migrate-tutorial` は適宜自身のGitHubユーザー名に置き換えてください。

---

## ステップ2: 設定ファイルの作成

データベースの接続情報やその他の設定を管理するために、設定ファイルを作成します。ここでは、`config.yaml`を使用します。

```bash
# 設定ファイル用のディレクトリを作成
mkdir config
```

`config/config.yaml` を作成し、以下の内容を記述します。

```yaml
# config/config.yaml
database:
  user: your_db_user         # PostgreSQLのユーザー名
  password: your_db_password # PostgreSQLのパスワード
  name: your_db_name         # 使用するデータベース名
  host: localhost            # データベースホスト
  port: 5432                 # データベースポート
  sslmode: disable           # SSLモード設定
server:
  port: 8080                 # APIサーバーのポート番号
```

> **注意**: `your_db_user`、`your_db_password`、`your_db_name` は自身の環境に合わせて設定してください。

---

## ステップ3: データベース接続の設定

設定ファイルからデータベースの接続情報を読み取り、接続を確立するためのコードを作成します。`config.go`というファイルを作成します。

```go
// config/config.go
package config

import (
    "database/sql"
    "fmt"
    "log"

    "github.com/spf13/viper"
    "github.com/lib/pq" // PostgreSQLドライバ
)

// Config構造体は設定ファイルの内容を保持します
type Config struct {
    Database struct {
        User     string `mapstructure:"user"`
        Password string `mapstructure:"password"`
        Name     string `mapstructure:"name"`
        Host     string `mapstructure:"host"`
        Port     int    `mapstructure:"port"`
        SSLMode  string `mapstructure:"sslmode"`
    } `mapstructure:"database"`
    Server struct {
        Port int `mapstructure:"port"`
    } `mapstructure:"server"`
}

// LoadConfigは設定ファイルを読み込み、Config構造体を返します
func LoadConfig() *Config {
    viper.SetConfigName("config")     // 設定ファイル名（拡張子不要）
    viper.SetConfigType("yaml")       // 設定ファイルの形式
    viper.AddConfigPath("./config")   // 設定ファイルのパス

    // 設定ファイルを読み込む
    if err := viper.ReadInConfig(); err != nil {
        log.Fatalf("設定ファイルの読み込みエラー: %s", err)
    }

    var config Config
    // 設定ファイルの内容をConfig構造体にマッピング
    if err := viper.Unmarshal(&config); err != nil {
        log.Fatalf("設定ファイルの解析エラー: %v", err)
    }

    return &config
}

// InitDBはデータベース接続を初期化し、*sql.DBを返します
func InitDB(cfg *Config) *sql.DB {
    // PostgreSQL接続文字列をフォーマット
    psqlInfo := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
        cfg.Database.User,
        cfg.Database.Password,
        cfg.Database.Host,
        cfg.Database.Port,
        cfg.Database.Name,
        cfg.Database.SSLMode,
    )

    // データベースに接続
    db, err := sql.Open("postgres", psqlInfo)
    if err != nil {
        log.Fatal("データベース接続エラー:", err)
    }

    // データベース接続の確認
    if err := db.Ping(); err != nil {
        log.Fatal("データベースへのPingエラー:", err)
    }

    log.Println("データベースに接続しました。")
    return db
}
```

### 解説

- **Config構造体**: `config.yaml`の内容を保持するための構造体です。`mapstructure`タグを使用して、Viperが設定ファイルのキーと構造体のフィールドをマッピングできるようにしています。
- **LoadConfig関数**: Viperを使用して設定ファイルを読み込み、`Config`構造体にデータをマッピングします。設定ファイルの読み込みに失敗した場合は、アプリケーションを終了します。
- **InitDB関数**: `Config`構造体からデータベース接続情報を取得し、PostgreSQLに接続します。接続に成功すると、`*sql.DB`を返します。

---

## ステップ4: マイグレーションファイルの作成

データベースのスキーマを管理するために、マイグレーションファイルを作成します。`migrations`フォルダを作成し、初期マイグレーションを追加します。

```bash
mkdir migrations
```

例えば、`users`テーブルを作成するマイグレーションを作成します。

```bash
migrate create -ext sql -dir migrations create_users_table
```

これにより、`migrations`フォルダに以下のようなファイルが生成されます。

- `000001_create_users_table.up.sql`
- `000001_create_users_table.down.sql`

### マイグレーションファイルの内容

**`migrations/000001_create_users_table.up.sql`**

```sql
-- migrations/000001_create_users_table.up.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,          -- ユーザーID
    name VARCHAR(100) NOT NULL,     -- ユーザー名
    email VARCHAR(100) UNIQUE NOT NULL -- ユーザーのメールアドレス
);
```

**`migrations/000001_create_users_table.down.sql`**

```sql
-- migrations/000001_create_users_table.down.sql
DROP TABLE users; -- usersテーブルを削除
```

> **注**: `migrate create`コマンドにより、自動的にタイムスタンプ付きのマイグレーションファイルが作成されます。

---

## ステップ5: マイグレーションの適用

マイグレーションをデータベースに適用するためのコードを`migration/migration.go`に追加します。

```go
// migration/migration.go
package migration

import (
    "database/sql"
    "fmt"
    "log"

    "github.com/golang-migrate/migrate/v4"
    "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file" // ファイルソースのサポート
)

// RunMigrationsはマイグレーションを実行します
func RunMigrations(db *sql.DB) {
    // PostgreSQL用のマイグレーションドライバーを作成
    driver, err := postgres.WithInstance(db, &postgres.Config{})
    if err != nil {
        log.Fatal("マイグレーションドライバーの初期化エラー:", err)
    }

    // マイグレーションインスタンスを作成
    m, err := migrate.NewWithDatabaseInstance(
        "file://migrations", // マイグレーションファイルのパス
        "postgres",          // データベース名
        driver,              // ドライバーインスタンス
    )
    if err != nil {
        log.Fatal("マイグレーションインスタンスの作成エラー:", err)
    }

    // マイグレーションの実行
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        log.Fatal("マイグレーションの適用エラー:", err)
    }

    fmt.Println("マイグレーションが適用されました。")
}
```

### 解説

- **postgres.WithInstance**: 既存の`*sql.DB`インスタンスからPostgreSQL用のマイグレーションドライバーを作成します。
- **migrate.NewWithDatabaseInstance**: マイグレーションファイルのソースとデータベースドライバーを指定して、マイグレーションインスタンスを作成します。
- **m.Up()**: すべての未適用のマイグレーションを順番に適用します。既に最新のマイグレーションが適用されている場合は、`migrate.ErrNoChange`が返されます。

---

## ステップ6: Echoサーバーの設定とAPIエンドポイントの作成

Echoフレームワークを使用して、APIサーバーとエンドポイントを設定します。`main.go`に以下のコードを追加します。

```go
// main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/yourusername/echo-migrate-tutorial/config"
    "github.com/yourusername/echo-migrate-tutorial/migration"
)

// Userはユーザー情報を表す構造体です
type User struct {
    ID    int    `json:"id"`    // ユーザーID
    Name  string `json:"name"`  // ユーザー名
    Email string `json:"email"` // ユーザーのメールアドレス
}

func main() {
    // 設定の読み込み
    cfg := config.LoadConfig()

    // データベース初期化
    db := config.InitDB(cfg)
    defer db.Close()

    // マイグレーション実行
    migration.RunMigrations(db)

    // Echoインスタンス作成
    e := echo.New()

    // ルートエンドポイントの設定
    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, Echo with Migrate!")
    })

    // ユーザー取得エンドポイントの設定
    e.GET("/users", func(c echo.Context) error {
        // usersテーブルから全ユーザーを取得
        rows, err := db.Query("SELECT id, name, email FROM users")
        if err != nil {
            // クエリ実行時のエラーをJSON形式で返す
            return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
        }
        defer rows.Close()

        var users []User

        // 各行をUser構造体にマッピング
        for rows.Next() {
            var user User
            if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil {
                return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
            }
            users = append(users, user)
        }

        // ユーザーリストをJSON形式で返す
        return c.JSON(http.StatusOK, users)
    })

    // ユーザー作成エンドポイントの設定
    e.POST("/users", func(c echo.Context) error {
        var user User
        if err := c.Bind(&user); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
        }

        // データベースにユーザーを挿入
        err := db.QueryRow("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id", user.Name, user.Email).Scan(&user.ID)
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
        }

        return c.JSON(http.StatusCreated, user)
    })

    // APIサーバーの起動
    serverAddress := fmt.Sprintf(":%d", cfg.Server.Port)
    if err := e.Start(serverAddress); err != nil {
        log.Fatal("サーバー起動エラー:", err)
    }
}
```

### 解説

- **User構造体**: `users`テーブルの各レコードを表す構造体です。JSONタグを付与することで、APIレスポンス時に適切なキー名でデータが返されます。
- **main関数**:
  - **設定の読み込み**: `config.LoadConfig()`を呼び出して、`config.yaml`から設定を読み込みます。
  - **データベース初期化**: `config.InitDB(cfg)`を呼び出して、データベースに接続します。接続は関数の終了時に閉じられます。
  - **マイグレーションの実行**: `migration.RunMigrations(db)`を呼び出して、マイグレーションを適用します。
  - **Echoインスタンスの作成**: `echo.New()`でEchoのインスタンスを作成します。
  - **ルートエンドポイント**: `/`にアクセスすると「Hello, Echo with Migrate!」と表示されます。
  - **ユーザー取得エンドポイント**: `/users`にGETリクエストを送ると、`users`テーブルの全ユーザー情報がJSON形式で返されます。
  - **サーバーの起動**: 設定ファイルで指定したポート番号でAPIサーバーを起動します。

---

## ステップ7: サーバーの実行

```bash
go run main.go
```

> ※1 PostgreSQLのサービスが起動していること
> ※2 DB内のユーザー・テーブルが存在すること（存在しない場合、以下を参考にしてユーザー・テーブルを作成してください。）
> ```bash
> psql postgres
> CREATE ROLE your_db_user WITH LOGIN PASSWORD 'your_password';
> ALTER ROLE your_db_user CREATEDB;
> ALTER ROLE your_db_user WITH SUPERUSER;
> CREATE DATABASE your_db_name OWNER your_db_user;
> ```

### 動作確認

`curl`を使用してAPIエンドポイントの動作を確認します。

1. **ルートエンドポイントの確認**:
   
   以下のコマンドをターミナルで実行します。

   ```bash
   curl -i http://localhost:8080/
   ```

   **期待される出力**:
   
   ```
   HTTP/1.1 200 OK
   Content-Type: text/plain; charset=UTF-8
   Date: Thu, 09 Nov 2024 12:00:00 GMT
   Content-Length: 23

   Hello, Echo with Migrate!
   ```

   この出力により、ルートエンドポイントが正しく動作していることが確認できます。

2. **ユーザー取得エンドポイントの確認**:
   
   以下のコマンドを実行します。

   ```bash
   curl -i http://localhost:8080/users
   ```

   **期待される出力**:
   
   ```
   HTTP/1.1 200 OK
   Content-Type: application/json; charset=UTF-8
   Date: Thu, 09 Nov 2024 12:00:00 GMT
   Content-Length: 2

   []
   ```

   現時点では`users`テーブルにデータがないため、空の配列 `[]` が返されます。

3. **ユーザーの追加**:

   オプションとして、ユーザーを追加するエンドポイントを作成し、`curl`を使用してユーザーを追加および取得する方法を紹介します。

   ### ユーザーの追加

   以下の`curl`コマンドを実行して、新しいユーザーを追加します。

   ```bash
   curl -i -X POST http://localhost:8080/users \
   -H "Content-Type: application/json" \
   -d '{"name": "山田太郎", "email": "yamada@example.com"}'
   ```

   **期待される出力**:
   
   ```
   HTTP/1.1 201 Created
   Content-Type: application/json; charset=UTF-8
   Date: Thu, 09 Nov 2024 12:05:00 GMT
   Content-Length: 49

   {
     "id": 1,
     "name": "山田太郎",
     "email": "yamada@example.com"
   }
   ```

   ### ユーザー取得の確認

   再度、ユーザー取得エンドポイントを実行します。

   ```bash
   curl -i http://localhost:8080/users
   ```

   **期待される出力**:
   
   ```
   HTTP/1.1 200 OK
   Content-Type: application/json; charset=UTF-8
   Date: Thu, 09 Nov 2024 12:06:00 GMT
   Content-Length: 51

   [
     {
       "id": 1,
       "name": "山田太郎",
       "email": "yamada@example.com"
     }
   ]
   ```

   この出力により、ユーザーが正しく追加され、取得できることが確認できます。

### DB内のテーブル確認

PostgreSQLのテーブル内にデータが格納されていることをSQL実行して確かめます。

```bash
psql -h localhost -U sakae -d test
```

```sql
SELECT * FROM users;
```

```
 id |    name    |       email
----+------------+--------------------
  1 | 山田太郎   | yamada@example.com
(1 rows)
```


---

## 最終的なディレクトリ構成

プロジェクトが完成した後のディレクトリ構成は以下のようになります。

```
echo-migrate-tutorial/
├── config
│   ├── config.yaml       # 設定ファイル
│   └── config.go         # 設定読み込みとDB初期化コード
├── go.mod                # Goモジュールの依存関係
├── go.sum                # Goモジュールのチェックサム
├── main.go               # サーバーのエントリーポイントとAPIエンドポイント
├── migration
│   └── migration.go      # マイグレーション実行コード
└── migrations
    ├── 000001_create_users_table.down.sql # マイグレーションのダウンスクリプト
    └── 000001_create_users_table.up.sql   # マイグレーションのアップスクリプト
```

- **config/**: 設定ファイルと設定読み込み用のコードを格納します。
  - `config.yaml`: データベースやサーバーの設定を記述。
  - `config.go`: 設定ファイルを読み込み、データベース接続を初期化するコード。
- **migration/**: マイグレーション関連のコードを格納します。
  - `migration.go`: マイグレーションを実行する関数。
- **migrations/**: マイグレーションファイルを格納します。
  - `000001_create_users_table.up.sql`: マイグレーションのアップ（適用）スクリプト。
  - `000001_create_users_table.down.sql`: マイグレーションのダウン（ロールバック）スクリプト。
- **main.go**: サーバーのエントリーポイントとAPIエンドポイントの定義。
- **go.mod / go.sum**: Goモジュールの依存関係を管理。

---

## 追加: マイグレーションのロールバック

必要に応じて、マイグレーションをロールバックすることもできます。以下のコマンドを使用します。

```bash
migrate -path migrations -database "postgres://your_db_user:your_db_password@localhost/your_db_name?sslmode=disable" down
```

> **注意**: `your_db_user`、`your_db_password`、`your_db_name` は自身の環境に合わせて設定してください。

このコマンドを実行すると、最新のマイグレーションが1つロールバックされます。複数回ロールバックしたい場合は、`-steps`フラグを使用してステップ数を指定できます。

```bash
# 2ステップ分ロールバック
migrate -path migrations -database "postgres://your_db_user:your_db_password@localhost/your_db_name?sslmode=disable" down -steps=2
```

---

## まとめ

今回は、GoのEchoフレームワークを使用してPostgreSQL向けのAPIサーバーを構築し、`golang-migrate/migrate`を用いてデータベースのマイグレーションを管理する方法を解説しました。
さらに、データベースの接続情報を設定ファイルに分離することで、設定管理をより効率的に行う方法もご紹介しました。以下のポイントを押さえました。

- **プロジェクトのセットアップ**: 必要なパッケージのインストールとGoモジュールの初期化。
- **設定ファイルの作成**: `config.yaml`にデータベースやサーバーの設定を記述。
- **データベース接続の設定**: `config.go`で設定ファイルを読み込み、PostgreSQLへの接続を初期化。
- **マイグレーションの作成と適用**: `migrate` CLIを使用してマイグレーションファイルを作成し、コードで適用。
- **Echoサーバーの設定**: シンプルなAPIエンドポイントを作成。
- **ディレクトリ構成**: プロジェクトの最終的なフォルダ構成を確認。
- **マイグレーションのロールバック**: 必要に応じてマイグレーションをロールバックする方法。

この基本的なセットアップを基に、さらなる機能追加やエンドポイントの拡充、複雑なマイグレーションの管理など、プロジェクトを拡張していくことができます。

---
### 会社紹介

**株式会社 Mosaica**  
最先端テクノロジーで社会課題を解決し、持続可能な未来を創造する IT カンパニー。
AI ソリューション、クラウド統合、DX 推進、経営コンサルティングなど包括的なサービスでビジネス変革を支援しています。  

詳しくは [公式サイト](https://mosaica.co.jp/) までお気軽にご相談ください。
公式サイト： https://mosaica.co.jp/