はじめに
Go言語およびGORMライブラリの初学者である私は、ドキュメントを見ても部分的な情報だけではイマイチ使い方が把握できませんでした。
ですので、実際に書いて理解した内容を備忘録として載せておきます。
今回は次のアソシエーションを試してみました。
- belongs to(多対1 または 1対1)
- has one(1対1)
- has many(1対多)
- many to many(多対多)
検証環境
今回は実行結果をJson形式で確認したかったので、Go+Gin+GORMの構成で進めます。
開発環境の構築は以下を利用しています。
実行条件
Golang v1.22.3
Gin v1.10.0
GORM v1.25.10
air v1.52.0
MySQL 8.0.29
ディレクトリ構成
最終的には以下のようになります。
.
├── Dockerfile
├── controllers
│ └── sample_controller.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── models
│ ├── database.go
│ └── items.go
├── routes
│ └── api_route.go
├── services
│ └── sample.go
├── tmp
└── utilities
データベース
次のようなデータを作成し、これをitemsテーブルを起点にして取得できるように実装を進めていきます。
mysql> select * from db_test.items;
+----+------------+-------------+
| id | company_id | name |
+----+------------+-------------+
| 1 | 1 | item_name_1 |
| 2 | 1 | item_name_2 |
| 3 | 2 | item_name_3 |
+----+------------+-------------+
3 rows in set (0.00 sec)
mysql> select * from db_test.companies;
+----+----------------+
| id | name |
+----+----------------+
| 1 | company_name_1 |
| 2 | company_name_2 |
+----+----------------+
2 rows in set (0.00 sec)
mysql> select * from db_test.players;
+----+----------+--------------+
| id | name | init_item_id |
+----+----------+--------------+
| 1 | player_1 | 1 |
| 2 | player_2 | 2 |
| 3 | player_3 | NULL |
+----+----------+--------------+
3 rows in set (0.00 sec)
mysql> select * from db_test.tags;
+----+---------+-------+
| id | item_id | name |
+----+---------+-------+
| 1 | 1 | tag_1 |
| 2 | 1 | tag_2 |
+----+---------+-------+
2 rows in set (0.00 sec)
mysql> select * from db_test.item_images;
+---------+----------+
| item_id | image_id |
+---------+----------+
| 1 | 1 |
| 1 | 2 |
| 2 | 3 |
+---------+----------+
3 rows in set (0.00 sec)
mysql> select * from db_test.images;
+----+-----------------------+----------------+
| id | data | name |
+----+-----------------------+----------------+
| 1 | product_image_data1-1 | image_name_1-1 |
| 2 | product_image_data1-2 | image_name_1-2 |
| 3 | product_image_data2-1 | image_name_2-1 |
+----+-----------------------+----------------+
3 rows in set (0.00 sec)
itemsテーブルを起点に雑に結合してみるとこんな感じです。
この結果と同じものを取得するわけではありませんが、参考までに。
mysql> select i.*,c.*,p.*,t.*,img.*
-> from items i
-> left join companies c on i.company_id = c.id
-> left join players p on i.id = p.init_item_id
-> left join tags t on i.id = t.item_id
-> left join item_images ii on i.id = ii.item_id
-> left join images img on ii.image_id = img.id;
+----+------------+-------------+------+----------------+------+----------+--------------+------+---------+-------+------+-----------------------+----------------+
| id | company_id | name | id | name | id | name | init_item_id | id | item_id | name | id | data | name |
+----+------------+-------------+------+----------------+------+----------+--------------+------+---------+-------+------+-----------------------+----------------+
| 1 | 1 | item_name_1 | 1 | company_name_1 | 1 | player_1 | 1 | 2 | 1 | tag_2 | 1 | product_image_data1-1 | image_name_1-1 |
| 1 | 1 | item_name_1 | 1 | company_name_1 | 1 | player_1 | 1 | 2 | 1 | tag_2 | 2 | product_image_data1-2 | image_name_1-2 |
| 1 | 1 | item_name_1 | 1 | company_name_1 | 1 | player_1 | 1 | 1 | 1 | tag_1 | 1 | product_image_data1-1 | image_name_1-1 |
| 1 | 1 | item_name_1 | 1 | company_name_1 | 1 | player_1 | 1 | 1 | 1 | tag_1 | 2 | product_image_data1-2 | image_name_1-2 |
| 2 | 1 | item_name_2 | 1 | company_name_1 | 2 | player_2 | 2 | NULL | NULL | NULL | 3 | product_image_data2-1 | image_name_2-1 |
| 3 | 2 | item_name_3 | 2 | company_name_2 | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL |
+----+------------+-------------+------+----------------+------+----------+--------------+------+---------+-------+------+-----------------------+----------------+
6 rows in set (0.00 sec)
うまく取得できることを期待して、やってみましょう。
ルーティング
package main
import (
"go_gin_gorm/routes"
)
func main() {
router := routes.GetApiRouter()
router.Run(":8080")
}
package routes
import (
"github.com/gin-gonic/gin"
"go_gin_gorm/controllers"
)
func GetApiRouter() *gin.Engine {
r := gin.Default()
// /sample?ids=1,2,3の形でリクエストする
r.GET("/sample", controllers.SampleGetDatas)
// /sample/10の形でリクエストする
r.GET("/sample/:id", controllers.SampleGetOne)
r.GET("/sample/all", controllers.SampleGetAll)
return r
}
今回は3つの確認用APIを実装します。
/sample
はクエリパラメータを持ち、指定したアイテムID(複数指定可能)の取得結果を返すことを想定しています。
/sample/all
はすべてのアイテムの取得結果を返します。
/sample/:id
はidに指定したアイテムIDの取得結果を返します。
モデル
package models
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var Db *gorm.DB
func init() {
Db = Connect()
}
func Connect() *gorm.DB {
//DB接続
dsn := "root@tcp(mysql:3306)/db_test?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
return db
}
database.go
ではDB接続を行います。
今回はデータベース名をdb_test
としてします。
package models
type Item struct {
Id int
Name string
CompanyId int
Company Company `gorm:"references:Id"`
Player Player `gorm:"foreignKey:InitItemId"`
Tags []Tag
Images []Image `gorm:"many2many:item_images;"`
}
// Belongs To
type Company struct {
Id int
Name string
}
// Has One
type Player struct {
Id int
Name string
InitItemId int
}
// Has Many
type Tag struct {
Id int
ItemId int
Name string
}
// Many To Many
type Image struct {
Id int
Data string
Name string
}
// 1件取得用
func GetOneItems(id int) (data Item, err error) {
err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").First(&data, id).Error
return
}
// n件取得用
func GetItems(ids []int) (datas []Item, err error) {
err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").Find(&datas, ids).Error
return
}
// 全件取得用
func GetAllItems() (datas []Item, err error) {
err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").Find(&datas).Error
return
}
本題のDB制御部分ですね。順にみていきましょう
Belongs To
type Item struct {
...
CompanyId int
Company Company `gorm:"references:Id"`
...
}
// Belongs To(多対1)
type Company struct {
Id int
Name string
}
Belongs To
は「~属する」という意味です。ここではitemsテーブルが属するcompaniesテーブルのレコードを取得します。
BelongsToを実現するために`gorm:"references:参照フィールド"`
を書きます。(主キーが対象であれば書かなくても動きます)
itemsテーブルのカラムcompany_id
は外部キーです。companies.idを参照しています。
次のように参照フィールドと外部キーの両方を書くこともできます。
Company Company `gorm:"foreignKey:CompanyId;references:Id"`
Has One(1対1)
type Item struct {
Id int
...
Player Player `gorm:"foreignKey:InitItemId"`
...
}
// Has One
type Player struct {
Id int
Name string
InitItemId int
}
Has One
は主テーブルのレコードに対して、従テーブルの1つのレコードが紐付けられるときに用いられます。
HasOneを実現するために`gorm:"foreignKey:外部キー"`
を書きます。
playersテーブルのinit_item_id
は外部キーです。items.idを参照しています。
Has One(1対多)
type Item struct {
Id int
...
Tags []Tag
...
}
// Has Many
type Tag struct {
Id int
ItemId int
Name string
}
Has Many
はHas Oneと非常に良く似ています。複数取得するか単数取得するかの違いだけです。
tagsテーブルのitem_id
は外部キーです。items.idを参照しています。
通常は所有する側のモデルの主キーをリレーションの外部キーの値として使用するため、DB構成次第でタグを指定しなくてもアソシエーションを利用できますが、タグを指定する場合は以下のようになります。
Tags []Tag `gorm:"foreignKey:ItemId"`
Many To Many(多対多)
type Item struct {
Id int
...
Images []Image `gorm:"many2many:item_images;"`
}
// Many To Many
type Image struct {
Id int
Data string
Name string
}
Many To Many
は多対多を取得する場合に用いられます。今回の場合、多対多を実現するための中間テーブルitem_images
を使用します。
中間テーブルを用いてManyToManyを実現するためには、`gorm:"many2many:中間テーブル名;"`
を書きます。
記載していませんが、item_imagesはitems.idとimages.idの外部キーを持ちます。
データの取得方法
リレーションはPreloadを用いることで取得しています。
引数に指定するのは構造体で定義した変数名を指定します。
err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").First(&data, id).Error
Debug()
を使うことで、この処理が実行された際にSQLがログ出力されるようになります。
サービス
ビジネスロジックは適当です。DBアクセスした結果を制御しています。
package services
import (
"go_gin_gorm/models"
"fmt"
)
func SampleGetOne(id int) (models.Item, error) {
data, err := models.GetOneItems(id)
if err != nil {
return data, fmt.Errorf(err.Error())
}
if data.Id == 0 {
return data, fmt.Errorf("データがありません")
}
return data, nil
}
func SampleGetDatas(ids []int) ([]models.Item, error) {
datas, err := models.GetItems(ids)
if err != nil {
return datas, fmt.Errorf(err.Error())
}
if len(datas) == 0 {
return nil, fmt.Errorf("データがありません")
}
return datas, nil
}
func SampleGetAll() ([]models.Item, error) {
datas, err := models.GetAllItems()
if err != nil {
return nil, fmt.Errorf(err.Error())
}
if len(datas) == 0 {
return nil, fmt.Errorf("データがありません")
}
return datas, nil
}
コントローラ
ユーザーからのリクエストを受け取る部分です。
package controllers
import (
"github.com/gin-gonic/gin"
"net/http"
"go_gin_gorm/services"
"strconv"
"strings"
)
func SampleGetOne(c *gin.Context) {
// エンドポイントから取得。取得値はすべて文字列になるため型変換する
id,_ := strconv.Atoi(c.Param("id"))
data, err := services.SampleGetOne(id);
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error" : err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"data" : data,
})
}
func SampleGetDatas(c *gin.Context) {
// クエリパラメータを取得し、分割
ids := strings.Split(c.Query("ids"), ",")
// int型にしたいため変換(ほかにいい方法あるか?)
int_ids := make([]int, len(ids))
for i, v := range ids {
int_ids[i],_ = strconv.Atoi(v)
}
datas, err := services.SampleGetDatas(int_ids);
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error" : err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"datas" : datas,
})
}
func SampleGetAll(c *gin.Context) {
datas, err := services.SampleGetAll();
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error" : err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"datas" : datas,
})
}
いくつか補足します。
id,_ := strconv.Atoi(c.Param("id"))
c.Param("id")
の部分は、エンドポイントで:キー
の形で指定された値を取得している部分です。
エンドポイントを /sample/:id
としているので、/sample/10
の場合は10が取れることになります。
ちなみに取得結果は文字列になりますので、INT型に変換しています。変換できなかった場合は無視します
// クエリパラメータを取得し、分割
ids := strings.Split(c.Query("ids"), ",")
// int型にしたいため変換(ほかにいい方法あるか?)
int_ids := make([]int, len(ids))
for i, v := range ids {
int_ids[i],_ = strconv.Atoi(v)
}
c.Query("ids")
でリクエストのクエリパラメータを取得しています。
/sample?ids=10
と指定された場合は10が取れます。
/sample?ids=10,20
とした場合は10と20が取れます。
クエリパラメータをカンマ区切りで分割し、1つずつINT型に変換して詰め直しています。
実行結果
さて、それではどのように値が取れるか見てみましょう。
1件の取得
$ curl -X GET "http://localhost:8080/sample/1"
{
"data": {
"Id": 1,
"Name": "item_name_1",
"CompanyId": 1,
"Company": {
"Id": 1,
"Name": "company_name_1"
},
"Player": {
"Id": 1,
"Name": "player_1",
"InitItemId": 1
},
"Tags": [
{
"Id": 1,
"ItemId": 1,
"Name": "tag_1"
},
{
"Id": 2,
"ItemId": 1,
"Name": "tag_2"
}
],
"Images": [
{
"Id": 1,
"Data": "product_image_data1-1",
"Name": "image_name_1-1"
},
{
"Id": 2,
"Data": "product_image_data1-2",
"Name": "image_name_1-2"
}
]
}
}
[0.385ms] [rows:2] SELECT * FROM `item_images` WHERE `item_images`.`item_id` = 1
[0.280ms] [rows:1] SELECT * FROM `companies` WHERE `companies`.`id` = 1
[0.470ms] [rows:2] SELECT * FROM `images` WHERE `images`.`id` IN (1,2)
[0.548ms] [rows:1] SELECT * FROM `players` WHERE `players`.`init_item_id` = 1
[3.725ms] [rows:1] SELECT * FROM `items` WHERE `items`.`id` = 1 ORDER BY `items`.`id` LIMIT 1
[0.863ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`item_id` = 1
複数指定による取得
$ curl -X GET "http://localhost:8080/sample?ids=1,3"
{
"datas": [
{
"Id": 1,
"Name": "item_name_1",
"CompanyId": 1,
"Company": {
"Id": 1,
"Name": "company_name_1"
},
"Player": {
"Id": 1,
"Name": "player_1",
"InitItemId": 1
},
"Tags": [
{
"Id": 1,
"ItemId": 1,
"Name": "tag_1"
},
{
"Id": 2,
"ItemId": 1,
"Name": "tag_2"
}
],
"Images": [
{
"Id": 1,
"Data": "product_image_data1-1",
"Name": "image_name_1-1"
},
{
"Id": 2,
"Data": "product_image_data1-2",
"Name": "image_name_1-2"
}
]
},
{
"Id": 3,
"Name": "item_name_3",
"CompanyId": 2,
"Company": {
"Id": 2,
"Name": "company_name_2"
},
"Player": {
"Id": 0,
"Name": "",
"InitItemId": 0
},
"Tags": [],
"Images": []
}
]
}
[0.234ms] [rows:2] SELECT * FROM `companies` WHERE `companies`.`id` IN (1,2)
[0.259ms] [rows:2] SELECT * FROM `item_images` WHERE `item_images`.`item_id` IN (1,3)
[0.436ms] [rows:2] SELECT * FROM `images` WHERE `images`.`id` IN (1,2)
[0.334ms] [rows:1] SELECT * FROM `players` WHERE `players`.`init_item_id` IN (1,3)
[0.346ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`item_id` IN (1,3)
[2.551ms] [rows:2] SELECT * FROM `items` WHERE `items`.`id` IN (1,3)
全件取得
$ curl -X GET "http://localhost:8080/sample/all"
{
"datas": [
{
"Id": 1,
"Name": "item_name_1",
"CompanyId": 1,
"Company": {
"Id": 1,
"Name": "company_name_1"
},
"Player": {
"Id": 1,
"Name": "player_1",
"InitItemId": 1
},
"Tags": [
{
"Id": 1,
"ItemId": 1,
"Name": "tag_1"
},
{
"Id": 2,
"ItemId": 1,
"Name": "tag_2"
}
],
"Images": [
{
"Id": 1,
"Data": "product_image_data1-1",
"Name": "image_name_1-1"
},
{
"Id": 2,
"Data": "product_image_data1-2",
"Name": "image_name_1-2"
}
]
},
{
"Id": 2,
"Name": "item_name_2",
"CompanyId": 1,
"Company": {
"Id": 1,
"Name": "company_name_1"
},
"Player": {
"Id": 2,
"Name": "player_2",
"InitItemId": 2
},
"Tags": [],
"Images": [
{
"Id": 3,
"Data": "product_image_data2-1",
"Name": "image_name_2-1"
}
]
},
{
"Id": 3,
"Name": "item_name_3",
"CompanyId": 2,
"Company": {
"Id": 2,
"Name": "company_name_2"
},
"Player": {
"Id": 0,
"Name": "",
"InitItemId": 0
},
"Tags": [],
"Images": []
}
]
}
[0.511ms] [rows:2] SELECT * FROM `companies` WHERE `companies`.`id` IN (1,2)
[0.578ms] [rows:3] SELECT * FROM `item_images` WHERE `item_images`.`item_id` IN (1,2,3)
[0.399ms] [rows:3] SELECT * FROM `images` WHERE `images`.`id` IN (1,2,3)
[0.454ms] [rows:2] SELECT * FROM `players` WHERE `players`.`init_item_id` IN (1,2,3)
[0.336ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`item_id` IN (1,2,3)
[3.168ms] [rows:3] SELECT * FROM `items`
まとめ
いかがだったでしょうか。
Preloadによって複数のSQLが実行されているので、データ量が多くなるとかなり重くなりそうな気がしますが、使いのこなせると便利そうですね。