牽線木偶
前言
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
andLast
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 在這種情況下會多建一張對照表:
/*
執行 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 精神在此蕩然無存。
我也試圖妥協用字串來指名查詢欄位這件事,但查詢只要稍稍複雜,就無法用 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。