PR

DockerとGo言語(Golang)からPostgreSQLとORM「Bun」を使う方法

3. 応用

こんにちは。Tomoyuki(@tomoyuki65)です。

Webサービスの開発では何らかのRDB(リレーショナルデータベース)を使うことが多いですが、これから新規開発をするなら将来の複雑化を見越してPostgreSQLを使うケースが増えているのではと思います。

加えて、Go言語(Golang)からDB操作をする際は何らかのORM(Object Relational Mappingのことで、プログラム側のオブジェクトとDB側のテーブルを相互に変換・対応付けする仕組み)を使うことになると思いますが、将来的に複雑なSQLも実行できる前提のものだと、比較的モダンなORM「Bun」がよさそうです。

そこでこの記事では、Docker環境とGo言語(Golang)からPostgreSQLやORM「Bun」を使う方法についてまとめます。

 

DockerとGo言語(Golang)からPostgreSQLとORM「Bun」を使う方法

まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir go-pg-bun && cd go-pg-bun
$ mkdir -p docker/local/go && touch docker/local/go/Dockerfile
$ mkdir -p docker/local/db && touch docker/local/db/Dockerfile
$ mkdir src && touch src/main.go
$ touch .env compose.yml

 

次に作成したファイルをそれぞれ以下のように記述します。

・「docker/local/go/Dockerfile」

FROM golang:1.25.5-alpine3.23

WORKDIR /go/src

COPY ./src .

# go.modがあれば依存関係をインストール
RUN if [ -f ./go.mod ]; then \
      go install; \
    fi

# 開発用のライブラリをインストール
RUN go install github.com/air-verse/air@v1.64.0
RUN go install honnef.co/go/tools/cmd/staticcheck@latest

EXPOSE 8080

 

・「docker/local/db/Dockerfile」

FROM postgres:18.1

ENV LANG ja_JP.utf8

# PostgreSQLの日本語化で「ja_JP.utf8」を使うために必要
RUN apt-get update && \
    apt-get install -y locales && \
    rm -rf /var/lib/apt/lists/* && \
    localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8

 

・「src/main.go」

package main

import (
    "log/slog"
    "net/http"

    "github.com/labstack/echo/v4"
)

func main() {
    // echoのルーター設定
    e := echo.New()

    e.GET("/", func(c echo.Context) error {
        // レスポンス結果の設定
        res := map[string]string{
            "message": "Hello World !!",
        }

        return c.JSON(http.StatusOK, res)
    })

    // ログ出力
    slog.Info("start go-pg-bun")

    // サーバー起動
    e.Logger.Fatal(e.Start(":8080"))
}

 

・「.env」

ENV=local
DB_NAME=local-db-name
DB_USER=local-db-user
DB_PASSWORD=local-db-password
DB_HOST=db
DB_PORT=5432
DB_MAX_OPEN_CONNS=20
DB_MAX_IDLE_CONNS=10
DB_CONN_MAX_LIFETIME=5

 

・「compose.yml」

services:
  api:
    container_name: go-pg-api
    build:
      context: .
      dockerfile: ./docker/local/go/Dockerfile
    command: air -c .air.toml
    volumes:
      - ./src:/go/src
    ports:
      - "8080:8080"
    env_file:
      - .env
    tty: true
    stdin_open: true
    depends_on:
      - db
  db:
    container_name: go-pg-db
    build:
      context: .
      dockerfile: ./docker/local/db/Dockerfile
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      TZ: Asia/Tokyo
      # ローカル環境でもパスワードを有効化する設定
      POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256
    volumes:
      - db-data:/var/lib/postgresql
    ports:
      - "5432:5432"
    env_file:
      - .env
volumes:
  db-data:

 

次に以下のコマンドを実行し、Dockerコンテナをビルドします。

$ docker compose build --no-cache

 

次に以下のコマンドを実行し、Go言語の初期化処理を行います。

$ docker compose run --rm api go mod init go-pg-bun
$ docker compose run --rm api go mod tidy
$ docker compose run --rm api air init

 

次に以下のコマンドを実行し、Dockerコンテナの再ビルドと起動を行います。

$ docker compose build --no-cache
$ docker compose up -d

 

次に以下のコマンドを実行し、Dockerコンテナの起動を確認します。

$ docker compose ps

 

コマンド実行後、以下のようにGoとDBのコンテナがそれぞれ起動していればOKです。

 

次にブラウザで「http://localhost:8080」を開き、JSON形式で想定通りのメッセージが出力されていればOKです。

 

DBコンテナに接続してPostgreSQLを試す

DBコンテナへの接続

次に以下のコマンドを実行し、DBコンテナへ接続します。

$ docker compose exec db bash

※compose.ymlでDBコンテナのホスト名を「db」にしているため、ここでは「db」を指定しています。

 

コマンド実行後、以下のようにDBコンテナに接続できればOKです。

 

PostgreSQLにログイン

次に以下のコマンドを実行し、PostgreSQLにログインします。

$ PGPASSWORD=local-db-password psql -U local-db-user -d local-db-name

※パスワード、ユーザー、DB名は、環境変数用ファイル「.env」で設定した値です。

 

コマンド実行後、以下のようにPostgreSQLにログインできればOKです。

 

データベース一覧を確認

まずPostgreSQLにあるデータベース一覧を確認したい場合は、以下のコマンドを実行します。

\l

 

コマンド実行後、以下のようにデータベース一覧が確認できればOKです。

※今回使うDBは「local-db-name」です。別のDBに切り替えたい場合はPostgreSQLでログインし直しが必要になります。

 

SQLでテーブルやインデックスを作成

次にSQLを使ってユーザー関連テーブルや注文関連テーブルを例に、テーブルやインデックスなどを作成してみます。

まずは以下のSQLをそれぞれ実行し、ユーザー関連テーブルを作成します。

・ユーザーテーブルの作成

CREATE TABLE users (
  id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name VARCHAR NOT NULL,
  email VARCHAR NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

※1)PostgreSQLではスキーマという概念がありますが、今回は省略しているためデフォルトのpublicスキーマにテーブルが作成されます。スキーマを明示的にする場合は「CREATE TABLE public.users ()」のようになります。

※2)「NOT NULL」はNULLを許可しない設定、「GENERATED BY DEFAULT AS IDENTITY」はオートインクリメント設定、「PRIMARY KEY」はテーブル内のレコードの一意性を保証する設定、「VARCHAR」は「character varying」型のエイリアス、「TIMESTAMPTZ」は「timestamp with time zone」型のエイリアス、UNIQUEは一意性を保証するためのインデックスを付与する設定です。

 

次に以下のコマンドを実行し、作成したユーザーテーブルの詳細を確認します。

\d users

 

コマンド実行後、以下のようにユーザーテーブルが作成されていればOKです。

 

次に以下のSQLをそれぞれ実行し、注文関連テーブルを作成します。

・注文テーブルの作成

CREATE TABLE orders (
  id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  order_no VARCHAR NOT NULL UNIQUE,
  customer_id BIGINT NOT NULL,
  total_amount NUMERIC(12,2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

※金額などの正確な計算結果が求められるものは「NUMERIC」型を使います。

 

・注文明細テーブルの作成

CREATE TABLE order_details (
  id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  order_id BIGINT NOT NULL,
  product_name VARCHAR NOT NULL,
  price NUMERIC(10,2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT fk_order_details_order
    FOREIGN KEY (order_id)
    REFERENCES orders(id)
);

※注文明細テーブルのデータについては、注文テーブルのデータ1件に対して必ずn件存在する仕様を想定しているため、「FOREIGN KEY」を使って「order_id」を外部キーにしています。(親子関係がある)

 

次に以下のコマンドを実行し、作成した注文テーブルの詳細を確認します。

\d orders

 

コマンド実行後、以下のように注文テーブルが作成されていればOKです。

 

次に以下のコマンドを実行し、作成した注文明細テーブルの詳細を確認します。

\d order_details

 

コマンド実行後、以下のように注文明細テーブルが作成されていればOKです。

 

テーブル一覧を確認

次に作成したテーブル一覧を確認するため、以下のコマンドを実行します。

\dt

 

コマンド実行後、以下のようにテーブル一覧が確認できればOKです。

 

SQLでデータの登録・取得・更新・削除を試す

次にSQLを使って上記で作成したテーブルに対して、データの登録・取得・更新・削除などを試します。

・ユーザーテーブルにデータを1件登録

INSERT INTO users (name, email) VALUES ('田中太郎', 't.tanaka@example.com');

 

・ユーザーテーブルから全てのデータを取得

SELECT * FROM users;

 

SQL実行後、以下のようにデータが取得できればOKです。

 

・ユーザーテーブルの対象データのメールアドレスの値を更新

UPDATE users SET email = 'taro.tanaka@example.com', updated_at = NOW() WHERE id = 1;

 

SQL実行後に再度データ取得のSQLを実行し、以下のように対象データが更新されていればOKです。

 

・ユーザーテーブルの対象データを削除

DELETE FROM users WHERE id = 1;

 

SQL実行後に再度データ取得のSQLを実行し、以下のように対象データが削除されていればOKです。

 

次に以下のSQLをそれぞれ実行し、注文関連データを作成してみます。

・ユーザーテーブルにデータを1件登録

INSERT INTO users (name, email) VALUES ('田中太郎', 't.tanaka@example.com');

 

・ユーザーテーブルから全てのデータを取得

SELECT * FROM users;

 

SQL実行後、作成されたユーザーの「id」を確認します。

 

・注文テーブルにデータを1件登録

INSERT INTO orders (order_no, customer_id, total_amount) VALUES ('XXXX-XXXX-0001', 2, 1500);

※customer_idには上記で事前に確認したユーザーの「id」の値を設定します。

 

・注文テーブルから全てのデータを取得

SELECT * FROM orders;

 

SQL実行後、作成された注文の「id」を確認します。

 

・注文明細テーブルに商品1のデータを登録

INSERT INTO order_details (order_id, product_name, price) VALUES (1, '商品1', 1000);

※order_idには上記で事前に確認した注文の「id」の値を設定します。

 

・注文明細テーブルに商品2のデータを登録

INSERT INTO order_details (order_id, product_name, price) VALUES (1, '商品2', 500);

※order_idには上記で事前に確認した注文の「id」の値を設定します。

 

・注文データに紐づく明細データを取得

SELECT
  od.*
FROM
  orders o,
  order_details od
WHERE
  o.id = 1
AND
  o.id = od.order_id;

※上記で確認したidが「1」の注文データを注文明細テーブルの外部キー「order_id」とリレーションさせて対象の注文明細データを取得しています。

 

SQL実行後、以下のように注文明細データが取得できればOKです。

 

PostgreSQLとDBコンテナから抜ける

上記でPostgreSQLの各種操作などを試せましたが、終了するためにPostgreSQLからログアウトする場合は以下のコマンドを実行します。

\q

 

続けて、DBコンテナから抜けたい場合は以下のコマンドを実行します。

exit

 

DBを初期化したい場合

上記でDBに各種データが作成されていますが、DBを初期化したい場合は以下のコマンドを実行してコンテナを削除して下さい。

$ docker compose down -v

※オプション「-v」を使うとボリュームも一緒に削除します。

 

SQLのDDLとDMLについて

上記では各種SQLを扱いましたが、SQLにはDDL(Data Definition Language)DML(Data Manipulation Language)といった種類があります。

それぞれの違いは以下のようになるので覚えておきましょう。

種類 用途
DDL データベースの構造を操作 CREATE, ALTER, DROP, TRUNCATE
DML データそのものを操作 SELECT, INSERT, UPDATE, DELETE

 

スポンサーリンク

DBクライアントツール「pgAdmin 4」を試す

上記ではDBコンテナに接続してPostgreSQLの各種操作を行いましたが、OSSにPostgreSQL用のDBクライアントツール「pgAdmin 4」があり、それを使えばGUIで各種操作が可能になるため、今回はこれも試してみます。

インストールするには、Macを使っている場合はお馴染みのパッケージ管理ツール「Homebrew」から簡単に行えます。

以降では既に「Homebrew」を利用している前提で進めますが、まずは以下のコマンドを実行してHomebrewを最新化します。

$ brew update

 

次に以下のコマンドを実行し、「pgAdmin 4」をインストールします。

$ brew install --cask pgadmin4

※オプション「–cask」は、HomebrewでGUIアプリなどをインストールするためのオプションです。尚、コマンド実行後のインストールは少し時間がかかります。

 

インストール完了後、インストール済みアプリケーションに「pgAdmin 4」が追加されるため、起動します。

 

アプリ起動後、ポップアップが表示されるので「開く」をクリックします。

 

アプリ画面表示後、ポップアップが表示されるので「許可」をクリックします。

 

「pgAdmin 4」起動後、以下のような画面が表示されればOKです。

 

「pgAdmin 4」の接続設定

次に以下のコマンドを実行し、Dockerコンテナを再起動します。

$ docker compose down -v
$ docker compose up -d

 

次に「pgAdmin 4」の画面にある「Add New Server」をクリックします。

 

次にサーバー設定画面が表示されるので、サーバー名に「go-pg-db」を入力後、タブ「Connection」をクリックします。

 

次にホスト名に「localhost」、DB名に「local-db-name」、ユーザー名に「local-db-user」、パスワードに「local-db-password」を入力後、Save password?をクリックしてONにし、画面右下の「Save」をクリックします。

 

DBへの接続成功後、以下のような画面が表示されればOKです。

 

SQLの実行を試す

次にSQLの実行を試してみますが、まずは左のツリーから「go-pg-db > Database(2) > local-db-name」を右クリックしてメニューを開き、「Query Tool」をクリックします。

 

これでSQLを実行するための画面が開けます。

 

次に上記で試したユーザーテーブルを作成するSQLを記述し、画面上のメニューにある実行ボタン「▶︎」(左側のExecute scriptボタンは記述内容を全て実行するやつで、右側のExecute queryボタンは単一のSQLを実行するやつです)をクリックします。

※デフォルトではオートコミット設定になっているため、実行すると合わせてコミットされます。

 

SQL実行後、画面下のMessageタブにメッセージが表示されます。

 

次に画面左のツリーから「go-pg-db > Database(2) > local-db-name > Schemas(1) > public > Tables(1) > users」を確認し、作成したテーブル「users」が存在すればOKです。

 

次に上記で試したユーザーテーブルにデータを1件登録するSQLを実行します。

 

次に上記で試したユーザーテーブルから全てのデータを取得するSQLを実行し、登録したデータが表示されればOKです。

 

このように、DBクライアントツールを使うと各種操作がやりやすくなるため、利用しているDBに対応するクライアントツールがあれば開発時に利用するのがおすすめです。

ただし、このようなツールはあくまでも開発用であり、本番環境用のDBに接続させて使うものではないので注意しましょう。

 

オートコミットを辞めたい場合の設定方法

デフォルト設定ではオートコミットされるようになっていますが、SQL実行ボタンの右側にあるリストボタンを表示し、「Auto commit?」をクリックしてチェックを外すと手動コミットに設定できます。

 

また、コミットやロールバックボタンは下図の場所にあります。

 

テーブルの詳細を確認する方法

テーブルの詳細を確認したい場合は、左側のツリーから対象のテーブルを右クリックしてメニューを開き、「Properties…」をクリックします。

 

これでテーブルの詳細画面が表示され、画面上のタブを切り替えて各種内容を確認できます。

 

スポンサーリンク

Go言語(Golang)のORM「Bun」を試す

上記ではSQLを手動で実行してテーブルを作成したりしましたが、実際にはマイグレーションツール(現在の環境から新しい環境へ安全かつ効率的に移行させるツール)を使って自動的に行えるようにしたりします。

今回はGo言語(Golang)のORMとして「Bun」を使ってみますが、設定すればマイグレーションも可能になるため、それも含めて試します。

 

マイグレーション設定

まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir -p src/internal/infrastructure/migration/migrations
$ touch src/internal/infrastructure/migration/migrations/migrations.go src/internal/infrastructure/migration/migrations/.keep.sql
$ mkdir -p src/internal/infrastructure/database
$ touch src/internal/infrastructure/database/bun.go
$ mkdir -p src/cmd/migrate && touch src/cmd/migrate/main.go

※ディレクトリ「src/internal/infrastructure」についてはDDD(ドメイン駆動設計)用のディレクトリ構成を想定しています。そして「src/internal/infrastructure/migration/migrations/.keep.sql」については、コマンド実行の際にディレクトリ内に一つ以上のファイルが必須のため、一時的に作成します。以降でSQLファイル作成後に削除します。

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/infrastructure/migration/migrations/migrations.go」

package migrations

import (
    "embed"

    "github.com/uptrace/bun/migrate"
)

//go:embed *.sql
var sqlMigrations embed.FS

var Migrations = migrate.NewMigrations()

func init() {
    if err := Migrations.Discover(sqlMigrations); err != nil {
        panic(err)
    }
}

※「embed」を使ってディレクトリ内のSQLファイルを「sqlMigrations」に読み込む設定をし、それを利用しています。

 

・「src/internal/infrastructure/database/bun.go」

package database

import (
    "database/sql"
    "fmt"
    "os"
    "strconv"
    "time"

    "github.com/uptrace/bun"
    "github.com/uptrace/bun/dialect/pgdialect"
    "github.com/uptrace/bun/driver/pgdriver"
    "github.com/uptrace/bun/extra/bundebug"
)

// NewDB はBunのDB接続インスタンスを生成します
func NewBunDB() *bun.DB {
    // 環境変数「ENV」の値を取得
    env := os.Getenv("ENV")
    if env == "" {
        env = "local"
    }

    // DB接続用の環境変数の設定
    var dbHost, dbPort, dbName, dbUser, dbPassword, dbMaxOpenCons, dbMaxIdleCons, dbConnMaxLifetime string

    if dbHost = os.Getenv("DB_HOST"); dbHost == "" {
        dbHost = "localhost"
    }

    if dbPort = os.Getenv("DB_PORT"); dbPort == "" {
        dbPort = "5432"
    }

    if dbName = os.Getenv("DB_NAME"); dbName == "" {
        dbName = "local-db-name"
    }

    if env == "testing" {
        dbName = "testing-db"
    }

    if dbUser = os.Getenv("DB_USER"); dbUser == "" {
        dbUser = "local-db-user"
    }

    if dbPassword = os.Getenv("DB_PASSWORD"); dbPassword == "" {
        dbPassword = "local-db-password"
    }

    if dbMaxOpenCons = os.Getenv("DB_MAX_OPEN_CONNS"); dbMaxOpenCons == "" {
        dbMaxOpenCons = "20"
    }

    if dbMaxIdleCons = os.Getenv("DB_MAX_IDLE_CONNS"); dbMaxIdleCons == "" {
        dbMaxIdleCons = "10"
    }

    if dbConnMaxLifetime = os.Getenv("DB_CONN_MAX_LIFETIME"); dbConnMaxLifetime == "" {
        dbConnMaxLifetime = "5"
    }

    // DSN設定
    var dsn string
    if env == "production" {
        dsn = fmt.Sprintf("postgres://%s:%s@%s:%s/%s", dbUser, dbPassword, dbHost, dbPort, dbName)
    } else {
        dsn = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, dbName)
    }

    // sql.DB の初期化(pgdriverを使用)
    sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))

    // コネクションプールの設定
    maxOpenCons, _ := strconv.Atoi(dbMaxOpenCons) // 最大接続数
    sqldb.SetMaxOpenConns(maxOpenCons)

    maxIdleCons, _ := strconv.Atoi(dbMaxIdleCons) // アイドル時の保持接続数
    sqldb.SetMaxIdleConns(maxIdleCons)

    maxLifetime, _ := strconv.Atoi(dbConnMaxLifetime) // 接続の寿命(分)
    sqldb.SetConnMaxLifetime(time.Duration(maxLifetime) * time.Minute)

    // BunDBのインスタンス生成
    db := bun.NewDB(sqldb, pgdialect.New())

    // デバッグログ設定(SQL表示)
    if env != "production" {
        db.AddQueryHook(bundebug.NewQueryHook(
            bundebug.WithEnabled(true), // デバッグログ機能の有効化
            bundebug.WithVerbose(true), // ログ詳細表示を有効化
        ))
    }

    return db
}

 

・「src/cmd/migrate/main.go」

package main

import (
    "fmt"
    "log"
    "os"
    "strings"

    "github.com/uptrace/bun/migrate"
    "github.com/urfave/cli/v2"

    "go-pg-bun/internal/infrastructure/database"
    "go-pg-bun/internal/infrastructure/migration/migrations"
)

func main() {
    // DBインスタンスを取得
    db := database.NewBunDB()
    defer db.Close()

    // マイグレーション用ツール設定
    migrator := migrate.NewMigrator(db, migrations.Migrations)

    app := &cli.App{
        Name: "migrate",
        Usage: "database migrations tool",
        Commands: []*cli.Command{
            // 初期化用(マイグレーション管理用テーブルの作成。最初に一度だけ実行する。)
            {
                Name: "init",
                Usage: "create migration tables",
                Action: func(c *cli.Context) error { return migrator.Init(c.Context) },
            },
            // SQLファイル作成用(upとdown用の二つを作成する。)
            {
                Name: "create_sql",
                Usage: "create up and down SQL migrations",
                Action: func(c *cli.Context) error {
                    name := strings.Join(c.Args().Slice(), "_")
                    files, err := migrator.CreateSQLMigrations(c.Context, name)
                    if err != nil {
                        return err
                    }
                    for _, f := range files {
                        fmt.Printf("created %s (%s)\n", f.Name, f.Path)
                    }
                    return nil
                },
            },
            // マイグレーション状態の確認
            {
                Name: "status",
                Usage: "print migrations status",
                Action: func(c *cli.Context) error {
                    ms, err := migrator.MigrationsWithStatus(c.Context)
                    if err != nil {
                        return err
                    }
                    fmt.Printf("migrations: %s\n", ms)
                    fmt.Printf("unapplied migrations: %s\n", ms.Unapplied())
                    fmt.Printf("last migration group: %s\n", ms.LastGroup())
                    return nil
                },
            },
            // マイグレーションの実行
            // (同一タイミングで実行したSQLファイルを一つのグループとして管理している。)
            {
                Name: "migrate",
                Usage: "migrate database",
                Action: func(c *cli.Context) error {
                    group, err := migrator.Migrate(c.Context)
                    if err != nil {
                        return err
                    }
                    if group.IsZero() {
                        fmt.Println("no new migrations")
                        return nil
                    }
                    fmt.Printf("migrated to %s\n", group)
                    return nil
                },
            },
            // ロールバックの実行
            // (グループ単位で管理していて、それを一つ前に戻す。)
            {
                Name: "rollback",
                Usage: "rollback the last migration group",
                Action: func(c *cli.Context) error {
                    group, err := migrator.Rollback(c.Context)
                    if err != nil {
                        return err
                    }
                    if group.IsZero() {
                        fmt.Println("no groups to rollback")
                        return nil
                    }
                    fmt.Printf("rolled back %s\n", group)
                    return nil
                },
            },
        },
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

 

次に以下のコマンドを実行し、go.modを更新します。

$ docker compose exec api go mod tidy

 

次に以下のコマンドを実行し、Dockerコンテナの再ビルドおよび再起動をします。

$ docker compose down -v
$ docker compose build --no-cache
$ docker compose up -d

 

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

次に以下のコマンドを実行し、マイグレーションファイルを作成します。

$ docker compose exec api go run cmd/migrate/main.go create_sql create_update_updated_at_function
$ docker compose exec api go run cmd/migrate/main.go create_sql create_users_table
$ docker compose exec api go run cmd/migrate/main.go create_sql create_orders_table
$ docker compose exec api go run cmd/migrate/main.go create_sql create_order_details_table

 

次に作成したマイグレーションファイルをそれぞれ以下のように記述します。

・「src/internal/infrastructure/migration/migrations/20260119023405_create_update_updated_at_function.up.sql」

※ファイル名の「20260119023405」の部分は作成タイミングで変わります。

-- 更新日時用のカラム「updated_at」の自動更新用共通関数の作成
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

 

・「src/internal/infrastructure/migration/migrations/20260119023405_create_update_updated_at_function.down.sql」

※ファイル名の「20260119023405」の部分は作成タイミングで変わります。

-- 更新日時用のカラム「updated_at」の自動更新用共通関数の削除
DROP FUNCTION IF EXISTS update_updated_at_column();

 

・「src/internal/infrastructure/migration/migrations/20260119024130_create_users_table.up.sql」

※ファイル名の「20260119024130」の部分は作成タイミングで変わります。

-- 1. usersテーブルの作成
CREATE TABLE users (
  id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name VARCHAR NOT NULL,
  email VARCHAR NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 2. usersテーブルの「updated_at」を自動更新するトリガー設定を作成
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

 

・「src/internal/infrastructure/migration/migrations/20260119024130_create_users_table.down.sql」

※ファイル名の「20260119024130」の部分は作成タイミングで変わります。

-- 1. usersテーブルの「updated_at」を自動更新するトリガー設定を削除
DROP TRIGGER IF EXISTS update_users_updated_at ON users;

-- 2. usersテーブルの削除
DROP TABLE IF EXISTS users;

 

・「src/internal/infrastructure/migration/migrations/20260119024450_create_orders_table.up.sql」

※ファイル名の「20260119024450」の部分は作成タイミングで変わります。

-- 1. ordersテーブルの作成
CREATE TABLE orders (
  id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  order_no VARCHAR NOT NULL UNIQUE,
  customer_id BIGINT NOT NULL,
  total_amount NUMERIC(12,2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 2. ordersテーブルの「updated_at」を自動更新するトリガー設定を作成
CREATE TRIGGER update_orders_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

 

・「src/internal/infrastructure/migration/migrations/20260119024450_create_orders_table.down.sql」

※ファイル名の「20260119024450」の部分は作成タイミングで変わります。

-- 1. ordersテーブルの「updated_at」を自動更新するトリガー設定を削除
DROP TRIGGER IF EXISTS update_orders_updated_at ON orders;

-- 2. ordersテーブルの削除
DROP TABLE IF EXISTS orders;

 

・「src/internal/infrastructure/migration/migrations/20260119024955_create_order_details_table.up.sql」

※ファイル名の「20260119024955」の部分は作成タイミングで変わります。

-- 1. order_detailsテーブルの作成
CREATE TABLE order_details (
  id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  order_id BIGINT NOT NULL,
  product_name VARCHAR NOT NULL,
  price NUMERIC(10,2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT fk_order_details_order
    FOREIGN KEY (order_id)
    REFERENCES orders(id)
);

-- 2. order_detailsテーブルの「updated_at」を自動更新するトリガー設定
CREATE TRIGGER update_order_details_updated_at
BEFORE UPDATE ON order_details
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

 

・「src/internal/infrastructure/migration/migrations/20260119024955_create_order_details_table.down.sql」

※ファイル名の「20260119024955」の部分は作成タイミングで変わります。

-- 1. order_detailsテーブルの「updated_at」を自動更新するトリガー設定を削除
DROP TRIGGER IF EXISTS update_order_details_updated_at ON order_details;

-- 2. order_detailsテーブルの削除
DROP TABLE IF EXISTS order_details;

 

次に事前に作成しておいたファイル「src/internal/infrastructure/migration/migrations/.keep.sql」が不要なので削除します。

 

マイグレーションの初期化

次に以下のコマンドを実行し、マイグレーションの初期化をします。

$ docker compose exec api go run cmd/migrate/main.go init

 

マイグレーションの初期化実行後、マイグレーション管理用のテーブル「bun_migrations」、「bun_migration_locks」が作成されます。

 

マイグレーションの実行

次に以下のコマンドを実行し、マイグレーションの状態を確認します。

$ docker compose exec api go run cmd/migrate/main.go status

 

コマンド実行後、以下のようにログが出力されます。

※「unapplied migrations:」の部分が、まだマイグレーションを実行していないファイルです。

 

次に以下のコマンドを実行し、マイグレーションを実行します。

$ docker compose exec api go run cmd/migrate/main.go migrate

 

コマンド実行後、以下のようにログが出力されます。

 

次にもう一度以下のコマンドを実行し、マイグレーションの状態を確認します。

$ docker compose exec api go run cmd/migrate/main.go status

 

コマンド実行後、以下のようにログが出力されます。

 

次に上記で使用した「pgAdmin 4」で作成したテーブルを確認し、以下のようにテーブルが作成されていればOKです。

 

ORM「Bun」用のスキーマ定義を作成

次にORM「Bun」用のスキーマ定義を作成するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/internal/infrastructure/database/schema
$ touch src/internal/infrastructure/database/schema/users.go src/internal/infrastructure/database/schema/orders.go src/internal/infrastructure/database/schema/order_details.go

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/infrastructure/database/schema/users.go」

package schema

import (
    "time"

    "github.com/uptrace/bun"
)

type UsersSchema struct {
    // テーブル名とエイリアスを設定
    bun.BaseModel `bun:"table:users,alias:u"`

    // BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
    ID int64 `bun:"id,pk,autoincrement"`

    // VARCHAR NOT NULL
    Name string `bun:"name,notnull"`

    // VARCHAR NOT NULL UNIQUE
    Email string `bun:"email,notnull,unique"`

    // TIMESTAMPTZ NOT NULL DEFAULT now()
    CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`

    // TIMESTAMPTZ NOT NULL DEFAULT now()
    UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`
}

 

・「src/internal/infrastructure/database/schema/orders.go」

package schema

import (
    "time"

    "github.com/uptrace/bun"
)

type OrdersSchema struct {
    // テーブル名とエイリアスを設定
    bun.BaseModel `bun:"table:orders,alias:o"`

    // id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
    ID int64 `bun:"id,pk,autoincrement"`

    // order_no VARCHAR NOT NULL UNIQUE
    OrderNo string `bun:"order_no,notnull,unique"`

    // customer_id BIGINT NOT NULL
    CustomerID int64 `bun:"customer_id,notnull"`

    // total_amount NUMERIC(12,2) NOT NULL
    TotalAmount float64 `bun:"total_amount,notnull"`

    // created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`

    // updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
    UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`

    // リレーションの設定
    Customer *UsersSchema `bun:"rel:belongs-to,join:customer_id=id"`
    OrderDetails []*OrderDetailsSchema `bun:"rel:has-many,join:id=order_id"`
}

 

・「src/internal/infrastructure/database/schema/order_details.go」

package schema

import (
    "time"

    "github.com/uptrace/bun"
)

type OrderDetailsSchema struct {
    // テーブル名とエイリアスを設定
    bun.BaseModel `bun:"table:order_details,alias:od"`

    // id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
    ID int64 `bun:"id,pk,autoincrement"`

    // order_id BIGINT NOT NULL (外部キー)
    OrderID int64 `bun:"order_id,notnull"`

    // product_name VARCHAR NOT NULL
    ProductName string `bun:"product_name,notnull"`

    // price NUMERIC(10,2) NOT NULL
    Price float64 `bun:"price,notnull"`

    // created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`

    // updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
    UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`

    // リレーションの設定
    Order *OrdersSchema `bun:"rel:belongs-to,join:order_id=id"`
}

 

サンプルAPI(CRUD処理)を追加

次にORM「Bun」を使ったサンプルAPI(CRUD処理)を追加するため、ファイル「src/main.go」を以下のように修正します。

・「src/main.go」

package main

import (
    "database/sql"
    "errors"
    "fmt"
    "log/slog"
    "net/http"

    "github.com/labstack/echo/v4"

    "go-pg-bun/internal/infrastructure/database"
    "go-pg-bun/internal/infrastructure/database/schema"
)

func main() {
    // echoのルーター設定
    e := echo.New()

    e.GET("/", func(c echo.Context) error {
        // レスポンス結果の設定
        res := map[string]string{
            "message": "Hello World !!",
        }

        return c.JSON(http.StatusOK, res)
    })

    // サンプルAPI(CRUD処理)を追加
    apiV1 := e.Group("/api/v1")

    // ユーザー作成
    apiV1.POST("/users", func(c echo.Context) error {
        // リクエストボディの取得
        type CreateUsersRequestBody struct {
            Name string `json:"name"`
            Email string `json:"email"`
        }
        var reqBody CreateUsersRequestBody
        if err := c.Bind(&reqBody); err != nil {
            return err
        }

        // DBインスタンスの取得
        db := database.NewBunDB()
        defer db.Close()

        // ユーザー作成処理
        user := schema.UsersSchema{
            Name: reqBody.Name,
            Email: reqBody.Email,
        }
        _, err := db.NewInsert().Model(&user).Exec(c.Request().Context())
        if err != nil {
            errMsg := fmt.Sprintf("failed to create user: %v", err)
            return echo.NewHTTPError(http.StatusInternalServerError, errMsg)
        }

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

    // 全てのユーザー取得
    apiV1.GET("/users", func(c echo.Context) error {
        // DBインスタンスの取得
        db := database.NewBunDB()
        defer db.Close()

        // 全てのユーザー取得処理
        var users []schema.UsersSchema
        err := db.NewSelect().Model(&users).Scan(c.Request().Context())
        if err != nil {
            return err
        }

        // データが0件の場合、空の配列を設定
        if len(users) == 0 {
            users = []schema.UsersSchema{}
        }

        return c.JSON(http.StatusOK, users)
    })

    // 全てのユーザー取得(SQL版)
    apiV1.GET("/users/sql", func(c echo.Context) error {
        // DBインスタンスの取得
        db := database.NewBunDB()
        defer db.Close()

        // 全てのユーザー取得処理
        var users []schema.UsersSchema
        err := db.NewRaw("SELECT * FROM users").Scan(c.Request().Context(), &users)
        if err != nil {
            return err
        }

        // データが0件の場合、空の配列を設定
        if len(users) == 0 {
            users = []schema.UsersSchema{}
        }

        return c.JSON(http.StatusOK, users)
    })

    // 対象のユーザー取得
    apiV1.GET("/users/:id", func(c echo.Context) error {
        // リクエストパラメータの取得
        id := c.Param("id")
        if id == "" {
            return echo.NewHTTPError(http.StatusBadRequest, "id is required")
        }

        // DBインスタンスの取得
        db := database.NewBunDB()
        defer db.Close()

        // 対象ユーザー取得処理
        var user schema.UsersSchema
        err := db.NewSelect().Model(&user).Where("id = ?", id).Scan(c.Request().Context())
        if err != nil {
            // 対象データが存在しない場合は空のオブジェクトを返す
            if errors.Is(err, sql.ErrNoRows) {
                return c.JSON(http.StatusOK, map[string]interface{}{})
            }
            return err
        }

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

    // 対象ユーザー取得(SQL版)
    apiV1.GET("/users/:id/sql", func(c echo.Context) error {
        // リクエストパラメータの取得
        id := c.Param("id")
        if id == "" {
            return echo.NewHTTPError(http.StatusBadRequest, "id is required")
        }

        // DBインスタンスの取得
        db := database.NewBunDB()
        defer db.Close()

        // 対象ユーザー取得処理
        var user schema.UsersSchema
        err := db.NewRaw("SELECT * FROM users WHERE id = ?", id).Scan(c.Request().Context(), &user)
        if err != nil {
            // 対象データが存在しない場合は空のオブジェクトを返す
            if errors.Is(err, sql.ErrNoRows) {
                return c.JSON(http.StatusOK, map[string]interface{}{})
            }
            return err
        }

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

    // 対象ユーザー更新
    apiV1.PUT("/users/:id", func(c echo.Context) error {
        // リクエストパラメータの取得
        id := c.Param("id")
        if id == "" {
            return echo.NewHTTPError(http.StatusBadRequest, "id is required")
        }

        // リクエストボディの取得
        type UpdateUsersRequestBody struct {
            Name string `json:"name"`
            Email string `json:"email"`
        }
        var reqBody UpdateUsersRequestBody
        if err := c.Bind(&reqBody); err != nil {
            return err
        }

        // DBインスタンスの取得
        db := database.NewBunDB()
        defer db.Close()

        // DB操作(トランザクション有り)
        tx, err := db.Begin()
        if err != nil {
            return err
        }
        defer tx.Rollback()

        // 対象ユーザー取得
        var user schema.UsersSchema
        err = tx.NewSelect().Model(&user).Where("id = ?", id).Scan(c.Request().Context())
        if err != nil {
            // 対象データが存在しない場合
            if errors.Is(err, sql.ErrNoRows) {
                return echo.NewHTTPError(http.StatusNotFound, "user not found")
            }
            return err
        }

        // 更新値の設定
        if reqBody.Name != "" {
            user.Name = reqBody.Name
        }
        if reqBody.Email != "" {
            user.Email = reqBody.Email
        }

        // 更新処理
        _, err = tx.NewUpdate().Model(&user).Where("id = ?", id).Returning("*").Exec(c.Request().Context())
        if err != nil {
            errMsg := fmt.Sprintf("failed to update user: %v", err)
            return echo.NewHTTPError(http.StatusInternalServerError, errMsg)
        }

        // コミット
        err = tx.Commit()
        if err != nil {
            return err
        }

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

    // 対象ユーザー削除
    apiV1.DELETE("/users/:id", func(c echo.Context) error {
        // リクエストパラメータの取得
        id := c.Param("id")
        if id == "" {
            return echo.NewHTTPError(http.StatusBadRequest, "id is required")
        }

        // DBインスタンスの取得
        db := database.NewBunDB()
        defer db.Close()

        // DB操作(トランザクション有り)
        tx, err := db.Begin()
        if err != nil {
            return err
        }
        defer tx.Rollback()

        // 対象ユーザー取得
        var user schema.UsersSchema
        err = tx.NewSelect().Model(&user).Where("id = ?", id).Scan(c.Request().Context())
        if err != nil {
            // 対象データが存在しない場合
            if errors.Is(err, sql.ErrNoRows) {
                return echo.NewHTTPError(http.StatusNotFound, "user not found")
            }
            return err
        }

        // 削除処理
        _, err = tx.NewDelete().Model(&user).Where("id = ?", id).Exec(c.Request().Context())
        if err != nil {
            errMsg := fmt.Sprintf("failed to delete user: %v", err)
            return echo.NewHTTPError(http.StatusInternalServerError, errMsg)
        }

        // コミット
        err = tx.Commit()
        if err != nil {
            return err
        }

        return c.NoContent(http.StatusNoContent)
    })

    // ログ出力
    slog.Info("start go-pg-bun")

    // サーバー起動
    e.Logger.Fatal(e.Start(":8080"))
}

 

次に以下のコマンドを実行し、Dockerコンテナの再ビルドおよび再起動をします。

$ docker compose exec api go mod tidy
$ docker compose down
$ docker compose build --no-cache
$ docker compose up -d

 

サンプルAPIを実行して試す

次に上記で作成したサンプルAPIをPostmanを使って試します。

まずはPOSTメソッドで「http://localhost:8080/api/v1/users」を実行し、下図のようにステータス201で想定通りの結果になればOKです。

 

次にGETメソッドで「http://localhost:8080/api/v1/users」を実行し、下図のようにステータス200で想定通りの結果になればOKです。

 

次にGETメソッドで「http://localhost:8080/api/v1/users/sql」を実行し、下図のようにステータス200で想定通りの結果になればOKです。

 

次にGETメソッドで「http://localhost:8080/api/v1/users/1」を実行し、下図のようにステータス200で想定通りの結果になればOKです。

 

次にGETメソッドで「http://localhost:8080/api/v1/users/1/sql」を実行し、下図のようにステータス200で想定通りの結果になればOKです。

 

次にPUTメソッドで「http://localhost:8080/api/v1/users/1」を実行し、下図のようにステータス200で想定通りの結果になればOKです。

 

次にDELETEメソッドで「http://localhost:8080/api/v1/users/1」を実行し、下図のようにステータス204で想定通りの結果になればOKです。

 

次に再度GETメソッドで「http://localhost:8080/api/v1/users」を実行し、下図のようにステータス200で想定通りの結果になればOKです。

 

スポンサーリンク

最後に

今回はDocker環境とGo言語(Golang)からPostgreSQLやORM「Bun」を使う方法について解説しました。

Go言語用のマイグレーションツールやORMは色々ありますが、Bunを使えばいい感じに実装でき、かつ生のSQL実行もしやすいので、実務で使うのに良さそうです。

今回はPostgreSQLを中心にDB操作周りの基本的な使い方もまとめたので、興味がある方はぜひ参考にしてみて下さい。

 

この記事を書いた人
Tomoyuki

SE→ブロガーを経て、現在はSoftware Engineer(Web/Gopher)をしています!

Tomoyukiをフォローする
3. 応用
スポンサーリンク
Tomoyukiをフォローする

コメント

タイトルとURLをコピーしました