👁

GORM入門

この章では、Go向けの代表的なORMであるGORMの基本について学び、ハンズオン形式で学習します。これにより、モデル定義・CRUD操作・リレーションなど、GORMでデータベースを扱う基本ができるようになります。

1. 本章の概要

1.1 本章の目的

データベースを操作する方法には、SQLを直接記述する方法と、ORM(Object-Relational Mapping)を使う方法の2つがあります。前章で扱った database/sql はSQLを直接書く方法で、プレースホルダで安全性は担保できるものの、構造体への詰め替えを毎回自分で書く必要があり、書きやすさとコンパイル時のミス検出のしやすさの両面で不利になりがちです。ORMを使うと、Goの構造体をそのままDBのテーブルにマッピングでき、CRUD操作を「オブジェクトの操作」として書けます。

Goのデファクトスタンダード(事実上の標準)なORMが GORM です。本章では、GORMの位置づけと基本セットアップ、構造体によるモデル定義とマイグレーションに加えて、CRUD操作、条件付きクエリ、1対多のリレーションを扱います。前章と同じ users テーブルを対象にすることで、database/sql とGORMでの書き方の違いを対比しながら進めます。

1.2 ハンズオンの流れ

MySQL側の起動確認とデータベース作成を済ませたうえで、GORMからMySQLに接続し、User モデルを定義して AutoMigrate でテーブルを作成します。そのうえで、Create / Find / First / Save / Delete のCRUDと1対多のリレーションを段階的に扱います。各CRUD操作は前章と同じく、その操作だけを実行するスタンドアロンな main.go に書き換えていく形で進めます。

1.3 事前準備

前提となる講座

この章では、以下の知識を前提としています。自信がない場合は先に関連講座を実施してみましょう。

講座名 必要な知識
Goでデータベースを操作しよう database/sql によるMySQL接続とCRUD操作、.env からの接続情報読み込み
SQL基本文法 SELECT・INSERT・UPDATE・DELETEなど、SQLの基本操作

必要なツール

この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。

ツール名 関連箇所 理由
Visual Studio Code Visual Studio Codeのインストール GORMを使ったコードを記述するエディタとして使用する
Go Goのインストール GORMを利用するコードのランタイムとして使用する
MySQL MySQLのインストール GORMから接続するデータベースとして使用する

2. 準備と接続

まずは新しいプロジェクトフォルダを用意し、GORM・MySQLドライバ・godotenv を扱える状態を整えます。MySQL側の起動確認と対象データベースの作成を行ってから、GORMからMySQLに接続し、モデル定義とテーブル作成までを行います。

2.1 プロジェクトの準備

Goでデータベースを扱うにあたって、まずは新しいプロジェクトフォルダを用意し、GORM・MySQLドライバ・godotenv を扱える状態を整えます。

フォルダの作成

任意の場所に gorm-intro フォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」から、作成した gorm-intro フォルダを開きます。続けて、「ターミナル」→「新しいターミナル」からVisual Studio Codeのターミナルを開きます。以降の操作は、このターミナルから行います。

gorm-intro/  ← このフォルダを作成

Go のインストール確認

以下のコマンドで、Go がインストールされていることを確認します。

go version

以下のように Go のバージョンが表示されれば、インストールは確認できています。

go version go1.x.x darwin/arm64

バージョンが表示されない場合は、Goのインストール を先に実施してください。

Go モジュールの初期化

作成した gorm-intro フォルダをGoモジュールとして初期化するために、go mod init を実行します。この初期化により、以降のパッケージ導入や依存関係の管理の起点となる go.mod ファイルが作成されます。

go mod init gorm-intro

以下のような実行結果が表示されます。

go: creating new go.mod: module gorm-intro

パッケージの導入

続いて、本章で使うパッケージを導入します。まず下記のコマンドを実行します。

go get gorm.io/gorm

以下のような実行結果が表示されます。GORMが依存関係を持つ複数のパッケージも一緒にダウンロード・追加されます。

go: downloading gorm.io/gorm v1.x.x
go: downloading github.com/jinzhu/now v1.x.x
go: downloading github.com/jinzhu/inflection v1.x.x
go: downloading golang.org/x/text v0.x.x
go: added github.com/jinzhu/inflection v1.x.x
go: added github.com/jinzhu/now v1.x.x
go: added golang.org/x/text v0.x.x
go: added gorm.io/gorm v1.x.x

続いて下記のコマンドを実行します。

go get gorm.io/driver/mysql

以下のような実行結果が表示されます。

go: downloading gorm.io/driver/mysql v1.x.x
go: added filippo.io/edwards25519 v1.x.x
go: added github.com/go-sql-driver/mysql v1.x.x
go: added gorm.io/driver/mysql v1.x.x

続いて下記のコマンドを実行します。

go get github.com/joho/godotenv

以下のような実行結果が表示されます。

go: downloading github.com/joho/godotenv v1.x.x
go: added github.com/joho/godotenv v1.x.x

3つのパッケージが go.mod に依存関係として追加されました。中身を確認すると、require ディレクティブに導入したパッケージと、その依存関係のあるパッケージが並んでいます。

module gorm-intro

go 1.x

require (
    filippo.io/edwards25519 v1.x.x // indirect
    github.com/go-sql-driver/mysql v1.x.x // indirect
    github.com/jinzhu/inflection v1.x.x // indirect
    github.com/jinzhu/now v1.x.x // indirect
    github.com/joho/godotenv v1.x.x // indirect
    golang.org/x/text v0.x.x // indirect
    gorm.io/driver/mysql v1.x.x // indirect
    gorm.io/gorm v1.x.x // indirect
)

この時点ではまだ main.go を書いていないため、go get で導入したパッケージもコード上で import していない状態です。そのため、すべてのパッケージに // indirect(間接的な依存関係)というマーカーが付いています。次のステップで main.goimport を書くと、直接使うパッケージからは // indirect が外れます。

2.2 MySQL の起動確認

本章では、GoからMySQLへの接続を試す前に、MySQL自体が起動していて、想定するユーザでログインできることをCLIから確認します。

MySQL のインストール確認

まずは、MySQLがインストールされていることをバージョン表示で確認します。

mysql --version

以下のようにMySQLのバージョンが表示されれば、インストールは確認できています。

mysql  Ver 8.x.x for macos on arm64 (Homebrew)

バージョンが表示されない場合は、MySQLのインストール を先に実施してください。

MySQL へのログイン確認

次に、root ユーザでMySQLにログインできることを確認します。

mysql -u root -p

パスワードを求められるので、MySQLインストール時に設定したパスワードを入力します。以下のようにMySQLのプロンプトに切り替われば、ログインは成功しています。

mysql>

ログインできない場合は、MySQLサーバが起動しているか、パスワードが正しいかを確認してください。

2.3 データベースの作成

ハンズオンで使う gorm_intro データベースを作成します。GORMは既存のデータベースへ接続してその中のテーブル操作を担うライブラリで、データベースそのものの作成(CREATE DATABASE)はカバーしていません。そのため、この段階だけはMySQL CLIから直接SQLを発行します。GORMではなく素のSQLで作りたい場合は、前章で扱った database/sqldb.Exec("CREATE DATABASE ...") でも同じことができます。

先ほどログインしたMySQLのプロンプトで、以下のSQLを実行します。

CREATE DATABASE gorm_intro;

以下のような実行結果が表示されます。

Query OK, 1 row affected (0.01 sec)

続いて、gorm_intro データベースが作成されたことを SHOW DATABASES で確認します。

SHOW DATABASES;

以下のように、gorm_intro が一覧に含まれていれば作成できています。

+--------------------+
| Database           |
+--------------------+
| information_schema |
| gorm_intro         |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

以降のハンズオンではこのデータベースにテーブルを追加していきます。作成が確認できたら、exit; でMySQLのプロンプトを抜けます。

exit;

2.4 GORM への接続

データベースの用意ができたので、GORMからそのデータベースに接続できることを確認します。まず接続情報をまとめた .env ファイルを用意し、そのうえで最小限のコードで接続を試します。

.env ファイルの作成

Goのコード内にパスワードを直接書くと、コードを変更するたびに書き換える必要があり、うっかりGitに含めてしまうリスクもあります。ここでは、接続情報を .env ファイルにまとめて、Goのコードからは環境変数として読み込む形にします。

Visual Studio Codeのエクスプローラーで gorm-intro フォルダを右クリックし、「新しいファイル」を選択して .env という名前のファイルを作成します。

gorm-intro/
├── .env  ← このファイルを作成
├── go.mod
└── go.sum

作成したファイルに以下の内容を記述して保存します。MYSQL_PASSWORD の値は、先ほど mysql -u root -p で入力した自分のMySQLパスワードに書き換えてください。

MYSQL_USER=root
MYSQL_PASSWORD=あなたのパスワード
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=gorm_intro

.envKEY=VALUE の形式で1行1変数を書くシンプルなテキストファイルです。以降の main.go では、先ほど導入した godotenv パッケージがこの内容を読み込み、Goから環境変数として取り出せるようになります。

main.go の作成

Visual Studio Codeのエクスプローラーで gorm-intro フォルダを右クリックし、「新しいファイル」を選択して main.go という名前で作成します。

gorm-intro/
├── .env
├── go.mod
├── go.sum
└── main.go  ← このファイルを作成

書き方は以下のとおりです。gorm.Open にドライバ設定と DSN を渡すと *gorm.DB が返ります。以降のGORM操作はすべてこの *gorm.DB に対して行います。

db, err := gorm.Open(mysql.Open(DSN), &gorm.Config{})   // GORM で接続を開く

作成したファイルに以下の内容を記述して保存します。これは、.env から接続情報を読み込み、GORMでMySQLに接続を試み、成功したら 接続成功 と表示するだけの最小コードです。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    sqlDB, err := db.DB()
    if err != nil {
        fmt.Println("DB取得失敗:", err)
        return
    }
    if err := sqlDB.Ping(); err != nil {
        fmt.Println("Ping失敗:", err)
        return
    }
    fmt.Println("接続成功")
}

コードを解説します。

import (
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

gorm.io/gorm はGORM本体、gorm.io/driver/mysql はGORMからMySQLに接続するためのドライバです。GORMは複数のRDBに対応しており、使用するデータベースごとに専用のドライバをインポートします。今回はMySQLに接続するため、この2つをセットで読み込みます。

db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

gorm.Open はドライバ設定とDSNを受け取り、*gorm.DB を返します。以降のCRUD操作はすべてこの db に対して行います。第2引数の &gorm.Config{} にはロガーやマイグレーション時のオプションを渡せますが、ここではデフォルトのままにしています。

sqlDB, err := db.DB()

db.DB() は、GORMの *gorm.DB が内部に保持している database/sql*sql.DB を取り出すメソッドです。GORMは接続管理を *sql.DB に委ねているため、標準ライブラリの機能を使いたいときはこの *sql.DB 経由で操作します。

if err := sqlDB.Ping(); err != nil {
    ...
}

Ping() は、実際にデータベースへ到達できるかを確認するメソッドです。ここで接続確認をしておくことで、後続の処理に進む前に接続エラーを検知できます。

実行して接続を確認します。

go run main.go

以下のような実行結果が表示されます。

接続成功

「接続成功」と表示されれば、GoからMySQLに接続できています。エラーが表示される場合は、以下のトラブルシューティングを確認してください。

⚠️ 「Ping失敗: Error 1045 (28000): Access denied for user 'root'@...」というエラーが出る場合
.envMYSQL_PASSWORD が実際のMySQLパスワードと一致していない可能性が高いです。.env を、先ほど mysql -u root -p でログインできたパスワードに書き換えてから、再度 go run main.go を実行してください。
⚠️ 「dial tcp 127.0.0.1:3306: connect: connection refused」というエラーが出る場合
MySQLサーバが起動していない、または3306以外のポートで動作している可能性があります。まず mysql -u root -p でCLIからログインできるかを確認し、ログインできない場合はMySQLサーバを起動してください。ポートを変更している場合は、.envMYSQL_HOSTMYSQL_PORT を実際の値に書き換えます。
⚠️ 「Error 1049 (42000): Unknown database 'gorm_intro'」というエラーが出る場合
.envMYSQL_DATABASE で指定したデータベースが存在しません。先ほどの「データベースの作成」でMySQL CLIから CREATE DATABASE gorm_intro; を実行したかを確認してください。

2.5 モデル定義とテーブル作成

接続確認ができたので、テーブルの1行分を表す構造体(モデル)を定義し、AutoMigrate でその構造体からテーブルを自動作成します。前章では CREATE TABLE users (...) のSQLを自分で書きましたが、GORMではモデル定義に沿ってGORM側がSQLを組み立ててくれます。

モデル定義とマイグレーションの実行

書き方は以下のとおりです。モデルのフィールドにタグ `gorm:"..."` で GORM 向けの追加情報を、AutoMigrate の引数にモデルのポインタを渡してテーブル作成を指示します。

type モデル名 struct {
    ID       uint   `gorm:"primaryKey"`
    カラム名 型    `gorm:"size:50;not null;uniqueIndex"`
}

db.AutoMigrate(&モデル名{})   // モデル定義からテーブルを自動作成

main.go を以下のように書き換えます。これは、User モデルを定義し、GORMで users テーブルを自動作成するコードです。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"size:50;not null"`
    Email string `gorm:"size:100;not null;uniqueIndex"`
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    if err := db.AutoMigrate(&User{}); err != nil {
        fmt.Println("マイグレーション失敗:", err)
        return
    }

    fmt.Println("マイグレーション完了")
}

実行してテーブル作成を確認します。

go run main.go

以下のような実行結果が表示されます。

マイグレーション完了

コードを解説します。

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"size:50;not null"`
    Email string `gorm:"size:100;not null;uniqueIndex"`
}

タグ `gorm:"..."` で、GORMに対する追加情報を指定します。primaryKey は主キー、size:50 は VARCHAR(50)、not null は NOT NULL 制約、uniqueIndex は一意インデックスです。GORMは自動的に構造体名 User を複数形の users テーブルにマッピングします。

if err := db.AutoMigrate(&User{}); err != nil {
    ...
}

AutoMigrate は、モデルの定義から CREATE TABLE 相当のSQLを自動生成し、テーブルがなければ作ります。既にある場合は、必要なカラムを追加します(削除はしません)。

作成されたテーブルの確認

Goのコードから作られたテーブルが実際にMySQLに存在することを、MySQL CLIから確認します。まず root ユーザでログインします。

mysql -u root -p

ログイン後、gorm_intro データベースを使用します。

USE gorm_intro;

続いて、テーブル一覧を表示します。

SHOW TABLES;

以下のように、users テーブルが一覧に表示されます。

+----------------------+
| Tables_in_gorm_intro |
+----------------------+
| users                |
+----------------------+

続いて、users テーブルの構造を確認します。

DESCRIBE users;

以下のように、モデル定義で指定したフィールドがそのままカラムとして作られています。id は主キーで自動採番、name は VARCHAR(50) の NOT NULL、email は VARCHAR(100) の NOT NULL に加えて一意インデックス(UNI)として反映されています。

+-------+-----------------+------+-----+---------+----------------+
| Field | Type            | Null | Key | Default | Extra          |
+-------+-----------------+------+-----+---------+----------------+
| id    | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| name  | varchar(50)     | NO   |     | NULL    |                |
| email | varchar(100)    | NO   | UNI | NULL    |                |
+-------+-----------------+------+-----+---------+----------------+

Goで書いた User 構造体の定義が、MySQL側のテーブル構造にそのまま反映されていることが確認できました。確認できたら、exit; でMySQLのプロンプトを抜けます。

exit;

3. CRUD 操作

users テーブルに対して、Create によるレコードの追加、Find による全件取得、First / Where による1件取得、Save / Updates による更新、Delete による削除までを順に扱います。前章と同じく、各操作の main.go はその操作だけを実行するスタンドアロン形式で書き換えていきます。

3.1 ユーザの追加(Create)

INSERTに相当するのが db.Create(&u) です。前章の database/sql では db.Exec("INSERT INTO ...", ...) と書きましたが、GORMではSQLを書かずに構造体を渡すだけでINSERTを発行できます。

書き方は以下のとおりです。GORMは自動採番された ID を構造体の ID に書き戻すため、Create 後に構造体の ID を読むと、DB側で振られたIDを取得できます。エラーは戻り値の .Error フィールドから取り出します。

u := モデル名{フィールド: 値, ...}
if err := db.Create(&u).Error; err != nil {
    // エラー処理
}
// u.ID に自動採番された ID が入る

main.go を以下のように書き換えます。これは、User を1件作成して、自動採番されたIDを表示するコードです。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"size:50;not null"`
    Email string `gorm:"size:100;not null;uniqueIndex"`
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    u := User{Name: "Taro", Email: "taro@example.com"}
    if err := db.Create(&u).Error; err != nil {
        fmt.Println("Create失敗:", err)
        return
    }
    fmt.Println("作成したユーザID:", u.ID)
}

実行してユーザを追加します。

go run main.go

以下のような実行結果が表示されます。

作成したユーザID: 1

AUTO_INCREMENT が自動で 1 を割り振り、そのIDが u.ID に書き戻されて表示されました。

コードを解説します。

u := User{Name: "Taro", Email: "taro@example.com"}
if err := db.Create(&u).Error; err != nil {
    ...
}
fmt.Println("作成したユーザID:", u.ID)

User のインスタンスを作り、db.Create(&u) でINSERTします。GORMは自動採番されたIDを u.ID に書き戻すため、Create 後に u.ID を読むと、DB側で振られたIDを取得できます。db.Create*gorm.DB を返し、その .Error フィールドでエラーの有無を確認します。

3.2 ユーザ一覧の取得(Find)

SELECTの結果を全件取得するのが db.Find(&users) です。前章の database/sql では db.Query + rows.Next + rows.Scan のループを書いていましたが、GORMではGoのスライスを渡すだけで、各行を構造体として詰めてくれます。

書き方は以下のとおりです。スライスを渡すと SELECT * FROM テーブル名 相当のSQLが自動発行され、各行が構造体として詰められます。

var スライス []モデル名
if err := db.Find(&スライス).Error; err != nil {
    // エラー処理
}

main.go を以下のように書き換えます。これは、users テーブルの全レコードを取り出して1件ずつ表示するコードです。前ステップで登録した1件がそのまま残っている前提です。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"size:50;not null"`
    Email string `gorm:"size:100;not null;uniqueIndex"`
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    var users []User
    if err := db.Find(&users).Error; err != nil {
        fmt.Println("Find失敗:", err)
        return
    }

    fmt.Println("--- ユーザ一覧 ---")
    for _, u := range users {
        fmt.Printf("[%d] %s <%s>\n", u.ID, u.Name, u.Email)
    }
}

実行してユーザ一覧を確認します。

go run main.go

以下のような実行結果が表示されます。

--- ユーザ一覧 ---
[1] Taro <taro@example.com>

前ステップの Create で追加された1件のユーザが [ID] Name <email> の形で表示されました。

コードを解説します。

var users []User
if err := db.Find(&users).Error; err != nil {
    ...
}

db.Find(&users) は「テーブルの全レコードを users スライスに詰める」動きをします。SELECT * FROM users に相当するSQLが自動生成され、各行が User構造体にマッピングされます。前章のように for rows.Next() { rows.Scan(...) } を書く必要はありません。

3.3 1件の取得(First / Where)

主キーで1件だけ取り出すのが db.First(&u, id) です。前章の database/sql では QueryRow + Scan + sql.ErrNoRows の分岐を書いていましたが、GORMでは First の戻り値のエラーが gorm.ErrRecordNotFound かどうかで判定します。

書き方は以下のとおりです。主キーで1件取り出し、見つからないときは gorm.ErrRecordNotFound が返ります。

var 変数 モデル名
err := db.First(&変数, 主キー値).Error   // 主キーで1件取得。見つからないと gorm.ErrRecordNotFound

main.go を以下のように書き換えます。これは、id = 1 のユーザを First で1件だけ取得し、存在するかどうかで分岐して表示するコードです。前ステップで登録した id = 1 のユーザが残っている前提です。

package main

import (
    "errors"
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"size:50;not null"`
    Email string `gorm:"size:100;not null;uniqueIndex"`
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    var one User
    if err := db.First(&one, 1).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            fmt.Println("ID=1 のユーザは見つかりませんでした")
        } else {
            fmt.Println("First失敗:", err)
        }
        return
    }
    fmt.Printf("取得: [%d] %s <%s>\n", one.ID, one.Name, one.Email)
}

実行して1件取得できることを確認します。

go run main.go

以下のような実行結果が表示されます。

取得: [1] Taro <taro@example.com>

主キー 1 のレコードが取得され、one に詰め込まれた内容がそのまま表示されました。

コードを解説します。

if err := db.First(&one, 1).Error; err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        fmt.Println("ID=1 のユーザは見つかりませんでした")
    } else {
        ...
    }
    return
}

db.First(&one, 1) は「主キー 1 のレコードを one に詰める」動きをします。見つからない場合は gorm.ErrRecordNotFound が返るので、errors.Is で他のエラーと区別して扱います。前章の sql.ErrNoRows と同じ役割です。

主キー以外の条件で取り出したい場合は Where を組み合わせます。書き方は以下のとおりです。プレースホルダに値を渡すため、SQLインジェクション対策も自動で行われます。

db.Where("カラム名 = ?", 値).First(&変数)   // 条件にマッチする最初の1件を取得

main.goFirst の呼び出しの後ろに、以下のコードを追記します。これは、email = "taro@example.com" のユーザを取り出す例です。

    var byEmail User
    if err := db.Where("email = ?", "taro@example.com").First(&byEmail).Error; err != nil {
        fmt.Println("Where失敗:", err)
        return
    }
    fmt.Println("メール検索:", byEmail.Name)

go run main.go コマンドで実行します。以下のような実行結果が表示されます。

取得: [1] Taro <taro@example.com>
メール検索: Taro

email = 'taro@example.com' に一致するレコードの NameTaro)が取得できました。

db.Where("email = ?", "taro@example.com").First(&byEmail)

Where の第一引数はSQLの式(プレースホルダ付き)、第二引数以降が値です。GORM内部でプリペアドステートメントに変換されるため、SQLインジェクション対策は自動で行われます。.First(&byEmail) で条件にマッチする最初の1件を取り出します。

3.4 ユーザの更新(Save / Updates)

db.Save(&u) は構造体の全カラムをUPDATEします。前章の database/sql では UPDATE users SET カラム = ? WHERE id = ? のSQLを組み立てていましたが、GORMでは構造体のフィールドを書き換えて Save に渡すだけで、GORM側がSQLを組み立てます。

書き方は以下のとおりです。First などで取り出したレコードのフィールドを書き換えたあと、db.Save(&u) を呼ぶと、構造体の全カラムがそのままUPDATEされます。

変数.カラム名 = 新しい値
db.Save(&変数)   //構造体の全カラムを UPDATE

main.go を以下のように書き換えます。これは、id = 1 のユーザを取得したうえで、NameTaro Yamada に更新するコードです。前ステップまでで登録した id = 1 のユーザが残っている前提です。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"size:50;not null"`
    Email string `gorm:"size:100;not null;uniqueIndex"`
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    var one User
    if err := db.First(&one, 1).Error; err != nil {
        fmt.Println("First失敗:", err)
        return
    }

    one.Name = "Taro Yamada"
    if err := db.Save(&one).Error; err != nil {
        fmt.Println("Save失敗:", err)
        return
    }
    fmt.Println("更新完了:", one.Name)
}

実行して更新を確認します。

go run main.go

以下のような実行結果が表示されます。

更新完了: Taro Yamada

Name カラムが Taro Yamada にUPDATEされ、書き換え後の値が表示されました。今回は Find の実行を省略していますが、先ほどの Find のコードをもう一度実行すると、id = 1NameTaro から Taro Yamada に変わっていることが確認できます。

コードを解説します。

var one User
if err := db.First(&one, 1).Error; err != nil { ... }

one.Name = "Taro Yamada"
if err := db.Save(&one).Error; err != nil { ... }

まず Firstid = 1 のレコードを one に取り出し、そのフィールドを書き換えてから db.Save(&one) で永続化します。Save は全カラムをUPDATEするので、フィールド変更を伴わないカラムも一緒に上書きされます。

一部のカラムだけ更新したい場合は Updates を使います。書き方は以下のとおりです。

db.Model(&対象).Updates(モデル名{カラム名: 新しい値})   // 指定カラムだけ UPDATE(ゼロ値のフィールドは除外)

main.goSave の呼び出しの後ろに、以下を追記します。これは、Name カラムだけを Taro T. に更新する例です。

    if err := db.Model(&one).Updates(User{Name: "Taro T."}).Error; err != nil {
        fmt.Println("Updates失敗:", err)
        return
    }
    fmt.Println("Updates後:", one.Name)

go run main.go コマンドで実行します。以下のような実行結果が表示されます。

更新完了: Taro Yamada
Updates後: Taro T.

Name カラムだけが Taro T. にUPDATEされ、Email は元の値のまま残ります。

db.Model(&one).Updates(User{Name: "Taro T."})

Model(&one) で対象レコードを指定し、Updates(...) に渡した構造体のフィールドだけを更新します。Updates に構造体を渡すと、ゼロ値のフィールドは更新対象から除外されます。空文字で更新したい場合はマップを使うなどの工夫が必要です(詳しくは GORM Update に記載があります)。

3.5 ユーザの削除(Delete)

db.Delete(&u) でDELETE文を発行します。前章の database/sql では db.Exec("DELETE FROM users WHERE id = ?", 1) と書きましたが、GORMでは対象レコードの構造体を渡すだけで、その ID を主キーとするDELETEを発行してくれます。

gorm.Model が持つ DeletedAt のような「削除日時カラム」がある場合は論理削除になりますが、今回のモデルには入れていないため物理削除になります。書き方は以下のとおりです。

db.Delete(&変数)   // 変数.ID を主キーとして DELETE

main.go を以下のように書き換えます。これは、id = 1 のユーザを取得したうえで削除するコードです。前ステップまでで登録した id = 1 のユーザが残っている前提です。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"size:50;not null"`
    Email string `gorm:"size:100;not null;uniqueIndex"`
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    var one User
    if err := db.First(&one, 1).Error; err != nil {
        fmt.Println("First失敗:", err)
        return
    }

    if err := db.Delete(&one).Error; err != nil {
        fmt.Println("Delete失敗:", err)
        return
    }
    fmt.Println("削除完了")
}

実行して削除を確認します。

go run main.go

以下のような実行結果が表示されます。

削除完了

one.ID を主キーとするDELETE文が発行され、users テーブルから該当レコードが物理的に削除されました。今回は Find の実行を省略していますが、先ほどの Find のコードをもう一度実行すると、id = 1 のレコードが一覧に表示されなくなっていることが確認できます。

コードを解説します。

var one User
if err := db.First(&one, 1).Error; err != nil { ... }

if err := db.Delete(&one).Error; err != nil { ... }

db.First(&one, 1) で削除対象を取り出したあと、db.Delete(&one)one.ID の値を主キーとしてレコードを削除します。今回のモデルには論理削除用のフィールドがないため、DB上から物理的に削除されます。

4. リレーション

実際のスキーマでは、ユーザ1人が複数の予約を持つ、といったテーブル間の関係が頻出します。GORMではリレーションを構造体のフィールドで表現でき、Preload を使えば LEFT JOIN 相当の取得を簡潔に書けます。database/sql では、親レコードと子レコードで別々にクエリを組み立てて詰め替える必要がありますが、GORMではフィールド定義とメソッドチェーンだけで済ませられるのが違いです。

4.1 1対多の定義

GORMではリレーションを構造体のフィールドで表現します。親側は子のスライスを持ち、子側は「親テーブル名 + ID」の外部キーカラムを持たせます。書き方は以下のとおりです。

type 親モデル struct {
    ID       uint
    子スライス []子モデル   // 1対多を示すフィールド
}

type 子モデル struct {
    ID   uint
    親ID uint       // 外部キー(GORMの慣例: 親テーブル名 + ID)
    親   親モデル    // 逆参照(子から親を辿れるようにするフィールド)
}

これは、User が複数の Reservation を持つ関係を、構造体のフィールドで表現する例です。次のステップで扱うので、この時点ではまだ main.go に貼り付けなくて構いません。

type User struct {
    ID           uint   `gorm:"primaryKey"`
    Name         string `gorm:"size:50;not null"`
    Email        string `gorm:"size:100;not null;uniqueIndex"`
    Reservations []Reservation
}

type Reservation struct {
    ID     uint   `gorm:"primaryKey"`
    RoomID uint   `gorm:"not null"`
    UserID uint   `gorm:"not null"`
    User   User
}

コードを解説します。

Reservations []Reservation

User.Reservations フィールドは「1人の User が複数の Reservation を持つ」ことを表します。スライス型にすることで「1対多」の関係が示されます。

UserID uint   `gorm:"not null"`

Reservation 側の UserID は、外部キーになるカラムです。GORMの慣例(親テーブル名 + ID)に従って命名すると、User テーブルへの外部キーとして自動で認識されます。

User   User

User User を書いておくと「Reservation から User を参照できる」関係も表せます。予約から予約者情報を取り出したいときに使えるフィールドです。

4.2 予約テーブルの追加と予約の登録

続いて、上で定義した Reservation モデルを追加して reservations テーブルを作り、user_id = 1 の予約を1件登録します。前ステップで id = 1 のユーザを削除しているので、まずユーザを1件作り直したうえで、予約を1件登録するコードにします。

main.go を以下のように書き換えます。これは、UserReservation の両方をマイグレーションしたうえで、ユーザを1件作成し、続けてそのユーザに紐づく予約を1件作成するコードです。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID           uint   `gorm:"primaryKey"`
    Name         string `gorm:"size:50;not null"`
    Email        string `gorm:"size:100;not null;uniqueIndex"`
    Reservations []Reservation
}

type Reservation struct {
    ID     uint `gorm:"primaryKey"`
    RoomID uint `gorm:"not null"`
    UserID uint `gorm:"not null"`
    User   User
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    if err := db.AutoMigrate(&User{}, &Reservation{}); err != nil {
        fmt.Println("マイグレーション失敗:", err)
        return
    }

    u := User{Name: "Taro", Email: "taro@example.com"}
    if err := db.Create(&u).Error; err != nil {
        fmt.Println("User作成失敗:", err)
        return
    }
    if err := db.Create(&Reservation{UserID: u.ID, RoomID: 10}).Error; err != nil {
        fmt.Println("Reservation作成失敗:", err)
        return
    }
    fmt.Println("UserとReservationを作成しました UserID:", u.ID)
}

実行してテーブル作成とレコード登録を確認します。

go run main.go

以下のような実行結果が表示されます(UserID は実行のたびに変わります)。

UserとReservationを作成しました UserID: 2

reservations テーブルが新規に作成され、指定した user_idroom_id = 10 の予約が1件登録されました。

コードを解説します。

if err := db.AutoMigrate(&User{}, &Reservation{}); err != nil {
    ...
}

AutoMigrate は複数のモデルを可変長引数で受け取れます。ここでは UserReservation の両方をまとめて渡し、usersreservations のテーブル定義を最新の構造体定義に揃えます。

if err := db.Create(&Reservation{UserID: u.ID, RoomID: 10}).Error; err != nil {
    ...
}

Reservation の構造体に UserIDRoomID を詰めて Create を呼ぶと、INSERTが発行されます。この時点で reservations テーブルにユーザとの紐付きが記録されました。

4.3 Preload での関連取得

ユーザと紐づく予約を一緒に取得するには Preload を使います。database/sql では、ユーザを1件取り出したうえで別途 reservations テーブルにクエリを打ち、Goのコードでスライスに詰め直す必要がありますが、GORMでは1メソッドで書けます。書き方は以下のとおりです。

db.Preload("関連フィールド名").First(&変数, 主キー値)   // 関連レコードも自動で埋めて取得

main.go を以下のように書き換えます。これは、直前のステップで作成したユーザとその予約を、Preload を使って一緒に取り出して表示するコードです。id は直前の実行時に表示されたIDに置き換えてください。

package main

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID           uint   `gorm:"primaryKey"`
    Name         string `gorm:"size:50;not null"`
    Email        string `gorm:"size:100;not null;uniqueIndex"`
    Reservations []Reservation
}

type Reservation struct {
    ID     uint `gorm:"primaryKey"`
    RoomID uint `gorm:"not null"`
    UserID uint `gorm:"not null"`
    User   User
}

func main() {
    if err := godotenv.Load(); err != nil {
        fmt.Println(".envファイル読み込み失敗:", err)
        return
    }

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("MYSQL_USER"),
        os.Getenv("MYSQL_PASSWORD"),
        os.Getenv("MYSQL_HOST"),
        os.Getenv("MYSQL_PORT"),
        os.Getenv("MYSQL_DATABASE"),
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("Open失敗:", err)
        return
    }

    var u User
    if err := db.Preload("Reservations").First(&u, 2).Error; err != nil {
        fmt.Println("Preload失敗:", err)
        return
    }

    fmt.Printf("ユーザ: [%d] %s\n", u.ID, u.Name)
    for _, r := range u.Reservations {
        fmt.Printf("  Reservation: ID=%d Room=%d\n", r.ID, r.RoomID)
    }
}

実行してユーザと予約を一緒に取得できることを確認します。

go run main.go

以下のような実行結果が表示されます。

ユーザ: [2] Taro
  Reservation: ID=1 Room=10

Preload によりUserの取得と同時に紐づく Reservations が埋め込まれ、ユーザの情報と予約の IDRoomID が並んで表示されました。

コードを解説します。

var u User
if err := db.Preload("Reservations").First(&u, 2).Error; err != nil { ... }

Preload("Reservations") を挟むと、Userの取得と同時に、そのUserに紐づく Reservations も自動で埋めてくれます。内部的には2回のクエリ(ユーザ本体と、そのIDに紐づく予約)が発行されます。

for _, r := range u.Reservations {
    fmt.Printf("  Reservation: ID=%d Room=%d\n", r.ID, r.RoomID)
}

u.Reservations が予約のスライスとして埋まっているので、そのままループで回して表示できます。

JOINしたい場合は Joins("Reservations") を使いますが、Preloadのほうが N+1問題(関連レコードを取得するために、親レコードの件数分だけ子レコードのクエリを発行してしまう問題)を避けつつシンプルに書けるため、多くの場合はPreloadが選ばれます。

5. 不要リソースの削除

ハンズオンで作成した gorm_intro データベースを削除します。MySQL CLI から以下のSQLを実行します。

DROP DATABASE gorm_intro;

以下のような実行結果が表示されます。

Query OK, 1 row affected (0.02 sec)

gorm_intro データベースが削除され、users テーブルと reservations テーブルもまとめて消えたことが確認できます。

6. まとめ

この章では、GORMの基本を学びつつ、実際にモデル定義とCRUD操作、リレーションの取得を体験しました。

  • GORMは、構造体をそのままテーブルにマッピングするORMで、Goのデファクトスタンダードである
  • gorm.Open で接続し、db.DB().Ping() で内部の *sql.DB から接続確認ができる
  • AutoMigrate により、モデル定義から CREATE TABLE / ALTER TABLE 相当を自動生成できる
  • CRUDは、Create / Find / First / Save / Delete の直感的なメソッドで書ける
  • 条件は Where("email = ?", value) の形で書き、内部でプリペアドステートメントに変換される
  • リレーションは構造体のフィールドで表現し、Preload で関連レコードを一緒に取得できる

次の章では、net/http を使ったWebサーバの基礎を学びつつ、実際にハンドラ・ルーティング・ミドルウェアの実装を体験します。

この教材は役に立ちましたか?

いいねをたくさんいただけると、制作者の励みになり、より多くのセクションが作れるようになります。

感想を一言(任意)

いただいたコメントは次の制作のヒントになります。ぜひお気軽にご投稿ください。

このコメントは他の受講生には公開されません。DevOps Camp運営が、教材改善のために確認します。

0 / 2000