Golang GORM

Gary Liao
22 min readJun 12, 2023

--

牽線木偶

前言

GORM 是 Golang 操作資料庫的第三方套件,主打 ORM (Object Relational Mapping),光是命名就覺得成大器。

撇開GO不談,身為一個曾經寫Python的分析師,第一次碰到 SqlAlchemy (也是一個實現 ORM 的資料庫包) ,覺得 ORM 好酷,隱含物件導向,我要昇華了… 怪了,我沒昇華,反而查詢執行速度比我直接寫SQL慢,我隱約感覺到,為了實現 ORM,後面有很多 overhead …,這篇文章證實了我的猜想。

後來我漸漸了解,分析師想要的是統計結果,根本不在乎每一個物件的操作,搞 ORM 根本不符合需求。

ORM 的好處是讓程式中的物件與資料庫之間的操作更直覺,資料庫就像程式的牽線木偶一樣,但代價是我得在程式碼中重現資料庫的設計,也就是說,我得把 table schema 在程式碼中抄一份,而表之間的關聯也得照著定義一份。

code-first / schema-first

這裡牽涉到 ORM package 的一個分類: code-first / schema-first。

Code-first 以程式的物件為 data model,以此為本,產生出 database schema,常用於資料庫設計或實作仍未知的情況。

Schema-first 先定義 database schema ,然後才設計程式的物件去搭配,常用於已經有資料庫的情況。

GORM 屬於 Code-first,身為一個只用過 schema-first 的人,踩過下面的坑之後,我覺得 GORM 的設計還是很吸引人的。

一號坑:把 Model 當成 Table Schema

帶著 schema-first 的概念去理解 GORM 會很快,也會很快踩到坑。

GORM 的 Model ,意義上是物件的樣版,而不是 Table Schema,因為用 Model 宣告出來的,並不是 table,而是物件,階級於 table row 相當:

// 這是個 Model,不是 table schema
type User struct {
ID uint
Name string
Email *string
Age uint8
Birthday *time.Time
MemberNumber sql.NullString
ActivatedAt sql.NullTime
CreatedAt time.Time
UpdatedAt time.Time
}

// 這是個 Object,不是 table,比較接近 row
user := User{ Name: "Gary" } // user 比較像是資料表中的一個 row,或是 record

GORM 從頭到尾都沒有去定義過 Table 。

二號坑:以為 Model & field 名稱會對應 table 與 column 名稱

前面提到 GORM 從頭到尾都沒有去定義過 Table ,那麼查詢的時候 Table name 哪來?

GORM貼心的為你產生的 Table name,官方文件的 Model 舉例叫做 User,產生的SQL會幫你自動產生 table name 就叫做 users,你以為它只幫你做了複數轉換?不,它還幫你轉大小寫,從駝峰式改成蛇型,而且連 column name 都這麼做,例如 :

// 這是個Model
type GoodUser struct {
LaAge int
}

// 宣告一個物件
gu := GoodUser{18}

// 執行查詢
db.Select("LaAge").Take(&gu)

// 實際執行SQL
// SELECT "la_age" FROM "good_users" LIMIT 1

// Table name: GoodUser -> good_users ,with 's'
// Column name: LaAge -> la_age

注意無論是 table name 或是 column name,都被大寫改小寫,駝峰改蛇型。

改用自定義 Table name

根據官方文件,對 Model 寫個 function 吐 table name 就可以了:

// 這是個Model
type GoodUser struct {
LaAge int
}

// 自定義 table name
// 需實作介面 : type Tabler interface { TableName() string }
func (GoodUser) TableName() string {
return "GoodUser"
}

// 宣告一個物件
gu := GoodUser{18}

// 執行查詢
db.Select("LaAge").Take(&gu)

// 實際執行SQL
// SELECT "la_age" FROM "GoodUser" LIMIT 1

// Table name: GoodUser -> GoodUser 被定義了
// Column name: LaAge -> la_age

也是有硬幹的做法,但這樣一點都不 ORM:

// 透過 db.Table("寫死Table名稱")
db.Table("GoodUser").Take(&gu)

改用自定義 Column name

根據官方文件,想要自定義 column name,可以在 field 後面加上 tag :

// 這是個Model
type GoodUser struct {
// 注意 tag
LaAge int `gorm:"column:LaAge"`
}

// 宣告一個物件
gu := GoodUser{18}

// 執行查詢
db.Select("LaAge").Take(&gu)

// 實際執行SQL
// SELECT "LaAge" FROM "good_users" LIMIT 1

// Table name: GoodUser -> good_users ,with 's'
// Column name: LaAge -> LaAge 被定義了

卍解

如果現行資料庫有一套命名規則,只是跟 GORM 的規則相左,那麼透過 GORM Config 直接改 NamingStrategy 應該是最通用的辦法了。

三號坑:Field name 必須開頭大寫

有些資料庫的欄位名稱偏好小寫,那我想要 Model 裡面的 Field 也一模一樣行嗎?很抱歉會出錯,因為小寫開頭的 Field 是不會被別的 package 看到的。

四號坑:以為 GORM 的 first() 就是SQL 的 limit 1 或是 top 1

我不懂為何官方文件一直用 first 做例子,因為其實這動作有點耗運算資源。

The First and Last methods will find the first and last record (respectively) as ordered by primary key.

也就是說,我今天如果只是想要拿一筆資料來看看,照著官方的舉例一直用 first ,其實每次都會做排序,也就是 order by,如果有 primary key (PK)它就用PK排序,沒有的話它就用第一個欄位

如果想要 SQL 的 limit 1 或是 top 1 的效果,也就是不論排序給我一個就對了,那得用 take 。

type GoodUser struct {
LaDeSai string
LaAge int
}
gu := GoodUser{"What's up", 18}

db.Select("LaAge").First(&gu)
// SELECT "la_age" FROM "good_users" ORDER BY "good_users"."la_de_sai" LIMIT 1
// 注意那個 ORDER BY

db.Select("LaAge").Take(&gu)
// SELECT "la_age" FROM "good_users" LIMIT 1
// 沒有 ORDER BY

建議任何查詢動作,除非有十足把握,使用 GORM 時都先用 DryRun Mode 看看到底產生了麼SQL code,避免 GORM 太貼心,導致 DBA 衝進來殺人。

Relation? Association?

GORM官方文件使用 Association 這個字來介紹 ORM 的 "Relation" 底下的功能, 我起初不是很諒解,但看過他偷偷摸摸做了一堆事情之後,我猜 GORM 大概想表達他們真的不一樣。

GORM 會依照 Association 的形式,在背後建立額外的 hooks、constraints 等等,甚至會建額外的 table。

接下來我會順著官方文件,介紹 Association 章節的內容,並用 GORM 實際送往 PostgreSQL 執行的指令,來說明表裡差異。

版本: 
go 1.20
gorm.io/gorm v1.25.2

Belongs To & Has One

GORM 使用這兩個名詞,來區分一對一關係中的主從關係,從 DB 實現上來說,差別只是 Foreign Key (FK) 以及 constraint 放在哪張 table 的差別而已,放了 FK 並設定了 constraint 的那張 table ,就是 "從",而被 reference 的 table 就是 "主" 。

Belongs To

一個人屬於一間公司,這種一對一關係,GORM 定義為 Bolongs To。在 Model 設計上,GORM 必須有個 overhead ,如下例,CompanyID & Company 必須同時放在 User 內:

type User struct {
gorm.Model
Name string
CompanyID int // Foreign Key
Company Company // referenced Model
}

type Company struct {
ID int
Name string
}

GORM 在這個範例的 DB 實作是在使用者的表中加個 FK 去 reference 公司,公司的資料並不存放在使用者的表內,跟 Model 設計是不同的:

/* 
db.Session(&gorm.Session{DryRun: true}).AutoMigrate(&User{}, &Company{})
*/

CREATE TABLE "companies" (
"id" bigserial
,"name" text,PRIMARY KEY ("id")
);
CREATE TABLE "users" (
"id" bigserial
,"created_at" timestamptz
,"updated_at" timestamptz
,"deleted_at" timestamptz
,"name" text
,"company_id" bigint,PRIMARY KEY ("id")
,CONSTRAINT "fk_users_company" FOREIGN KEY ("company_id")
REFERENCES "companies"("id")
);
CREATE INDEX IF NOT EXISTS "idx_users_deleted_at"
ON "users" ("deleted_at");

Has One

一個人有一張卡,這種一對一關係,GORM 定義為 Has One。在這種設計中的 Overhead,就是明明 CreditCard 已經被包在 User 中,卻還是得宣告自己屬於誰:

type User struct {
gorm.Model
CreditCard CreditCard
}

type CreditCard struct {
gorm.Model
Number string
UserID uint
}

GORM 在這個範例的 DB 實作就是在信用卡這邊建一個 FK 去 reference 使用者:

/*
db.Session(&gorm.Session{DryRun: true}).AutoMigrate(&User{}, &CreditCard{})
*/

CREATE TABLE "users" (
"id" bigserial
,"created_at" timestamptz
,"updated_at" timestamptz
,"deleted_at" timestamptz
,PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "idx_users_deleted_at"
ON "users" ("deleted_at");
CREATE TABLE "credit_cards" (
"id" bigserial
,"created_at" timestamptz
,"updated_at" timestamptz
,"deleted_at" timestamptz
,"number" text
,"user_id" bigint
,PRIMARY KEY ("id")
,CONSTRAINT "fk_users_credit_card" FOREIGN KEY ("user_id")
REFERENCES "users"("id")
);
CREATE INDEX IF NOT EXISTS "idx_credit_cards_deleted_at"
ON "credit_cards" ("deleted_at");

雖然官方文件用了 one-on-one 這個詞,但這個範例的 DB 實作並沒有限制一個user可以擁有幾張卡。

Has Many

一個人有多張卡,這種關係,GORM 定義為 Has Many。

可以看到在 User 的 Model 中,的確給了個 slice 來存信用卡資訊,信用卡是被包裝在 User 物件中的,Overhead 與 Has One 相同:

type User struct {
gorm.Model
CreditCards []CreditCard
}

type CreditCard struct {
gorm.Model
Number string
UserID uint
}

但在 DB 實作中,users 這張表沒有任何信用卡資訊,反而是信用卡的 table 要自行宣告這卡是屬於誰的。

CREATE TABLE "users" (
"id" bigserial
,"created_at" timestamptz
,"updated_at" timestamptz
,"deleted_at" timestamptz
,PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "idx_users_deleted_at"
ON "users" ("deleted_at");
CREATE TABLE "credit_cards" (
id" bigserial
,"created_at" timestamptz
,"updated_at" timestamptz
,"deleted_at" timestamptz
,"number" text,"user_id" bigint
,PRIMARY KEY ("id")
,CONSTRAINT "fk_users_credit_cards" FOREIGN KEY ("user_id")
REFERENCES "users"("id")
);
CREATE INDEX IF NOT EXISTS "idx_credit_cards_deleted_at"
ON "credit_cards" ("deleted_at");

Many To Many

一個人會多種語言,一種語言被許多人所使用,這就是 GORM 所謂的多對多,所以 Model 的部分就會有兩個:人 & 語言:

// User has and belongs to many languages, `user_languages` is the join table
type User struct {
gorm.Model
Languages []Language `gorm:"many2many:user_languages;"`
}

type Language struct {
gorm.Model
Name string
}

GORM 在這種情況下會多建一張對照表:

2 Models but 3 tables are created.
兩個 Models 卻能建出三張表
/* 
執行 db.Session(&gorm.Session{DryRun: true}).AutoMigrate(&User{}, &Language{})
*/

CREATE TABLE "users" (
"id" bigserial
,"created_at" timestamptz
,"updated_at" timestamptz
,"deleted_at" timestamptz
,PRIMARY KEY ("id")
);

CREATE INDEX IF NOT EXISTS "idx_users_deleted_at"
ON "users" ("deleted_at");

CREATE TABLE "languages" (
"id" bigserial
,"created_at" timestamptz
,"updated_at" timestamptz
,"deleted_at" timestamptz
,"name" text,PRIMARY KEY ("id")
);

CREATE INDEX IF NOT EXISTS "idx_languages_deleted_at"
ON "languages" ("deleted_at");

CREATE TABLE "user_languages" (
"user_id" bigint
,"language_id" bigint
,PRIMARY KEY ("user_id","language_id")
,CONSTRAINT "fk_user_languages_user" FOREIGN KEY ("user_id")
REFERENCES "users"("id")
,CONSTRAINT "fk_user_languages_language" FOREIGN KEY ("language_id")
REFERENCES "languages"("id")
);

這張對照表有時候是個隱患,users * m,languages * n,對照表就有 m * n 的潛力。

早期 DB 沒有 Array 這種欄位,所以會使用這種設計方式;就我所知,MSSQL & PostgreSQL 後來都有 array 類型的欄位,但我目前還不確定 GORM 能否支援。

查詢

我以為痛苦結束了,沒想到是開始。

GORM 在面對查詢的時候,常常必須用"字串"來指名要查詢的欄位,或是查詢的條件;並且文件對於"字串"的寫法出現混亂,有時候使用資料庫中的命名,有時使用 Model 的命名 (驚),ORM 精神在此蕩然無存。

官方文件截圖:Orders 開頭大寫,state 開頭小寫,說好的規則呢?

我也試圖妥協用字串來指名查詢欄位這件事,但查詢只要稍稍複雜,就無法用 GORM 提供的功能以純ORM精神兜出合適的 SQL,不如使用 db.raw(“select col1 from table where col2 = ? ”, "?帶入值").Scan(&save) 直接寫 SQL 代入參數。

 type Company struct {
ID int
Name string
}
type User struct {
gorm.Model
Name string
CompanyID int // Foreign Key
Company Company // referenced Model
}

users := []User{}
companies := []Company{}
//db.InnerJoins("User").Find(&companies)
SELECT
"companies"."id"
,"companies"."name"
FROM "companies" User -- what the ?

//db.InnerJoins("Company").Find(&users)
SELECT "users"."id"
,"users"."created_at"
,"users"."updated_at"
,"users"."deleted_at"
,"users"."name"
,"users"."company_id"
,"Company"."id" AS "Company__id"
,"Company"."name" AS "Company__name"
FROM "users"
INNER JOIN "companies" "Company" ON "users"."company_id" = "Company"."id"
WHERE "users"."deleted_at" IS NULL

//db.InnerJoins("User", db.Where(&User{Name: "Gary"})).Find(&companies)
SELECT
"companies"."id"
,"companies"."name"
FROM "companies" User -- what the ?

//db.InnerJoins("Company", db.Where(&Company{Name: "Edimax"})).Find(&users)
SELECT
"users"."id"
,"users"."created_at"
,"users"."updated_at"
,"users"."deleted_at"
,"users"."name"
,"users"."company_id"
,"Company"."id" AS "Company__id"
,"Company"."name" AS "Company__name"
FROM "users"
INNER JOIN "companies" "Company"
ON "users"."company_id" = "Company"."id" AND "Company"."name" = 'Edimax'
WHERE "users"."deleted_at" IS NULL

//db.Joins("User").Find(&companies)
SELECT
"companies"."id"
,"companies"."name"
FROM "companies" User -- what the ?

//db.Joins("Company").Find(&users)
SELECT
"users"."id"
,"users"."created_at"
,"users"."updated_at"
,"users"."deleted_at"
,"users"."name"
,"users"."company_id"
,"Company"."id" AS "Company__id"
,"Company"."name" AS "Company__name"
FROM "users"
LEFT JOIN "companies" "Company" -- 還會自動幫我變 Left Join
ON "users"."company_id" = "Company"."id"
WHERE "users"."deleted_at" IS NULL

//db.Joins("User", db.Where(&User{Name: "Gary"})).Find(&companies)
SELECT
"companies"."id"
,"companies"."name"
FROM "companies" User -- what the ?

//db.Joins("Company", db.Where(&Company{Name: "Edimax"})).Find(&users)
SELECT
"users"."id"
,"users"."created_at"
,"users"."updated_at"
,"users"."deleted_at"
,"users"."name"
,"users"."company_id"
,"Company"."id" AS "Company__id"
,"Company"."name" AS "Company__name"
FROM "users"
LEFT JOIN "companies" "Company" -- 還會自動幫我變 Left Join
ON "users"."company_id" = "Company"."id" AND "Company"."name" = 'Edimax'
WHERE "users"."deleted_at" IS NULL

在GORM的設計,擁有 Foreign Key 的 Model 才能用比較純的ORM方式去 Join 別張 table;想要反向來查,必須自己多寫SQL的部分 :

db.Table("users").Joins("INNER JOIN companies on users.company_id = users.company_id").Where("users.name = ?", "Gary").Find(&companies)

後記

以我有限的使用經驗,只能體會到這樣。GORM的設計思路就是希望以最低的成本從零建立整套資料庫,所以默默做了很多事,挺精妙的,各種功能也做得很細,值得使用;但若是要對已經有自己一套規則的資料庫使用,必須深入了解 GORM 究竟默默做了哪些事,畢竟GORM 屬於 code-first。

--

--