ORM 在業務開發中一直扮演着亦正亦邪的角色。很多人贊頌 ORM,認為 ORM 與面向對象的契合度讓代碼簡潔有道。但是不少人厭惡它,因為 ORM 隱藏了太多的細節,埋下了超多的隱患。在 Go 中,我們也或多或少接觸過 ORM,但是,在查閱不少業務代碼后發現,ORM 使用起來頗為滑稽,並且“雷隱隱霧蒙蒙”。
從 Entity Framework 談起
Entity Framework 作為雄踞 Microsoft .NET Framework 以及 .NET Core 的殺手級 ORM 不論在使用上還是效率上都是數一數二的。並且 Entity Framework 自帶 Repository 模式(倉儲模式)可以說降低了開發者的使用門檻。舉幾個實際的例子:
WebAppContext entity = new WebAppContext();
[HttpGet]
public ActionResult Index(String verify, String email)
{
var databasemail = entity.Mails.Find(verify);
//code...
entity.Mails.Add(databasemail);
entity.SaveChanges();
//code...
}
可以看到,通過 Entity Framework 上下文,可以方便地檢索到數據並在隨后的使用中直接訪問數據實體並按照直覺進行 CURD。
Go 里面的 ORM 是怎么做的呢?
Go 里面的 ORM 用法
下面的內容以 go-pg 為例。
Go 里對於 ORM 的用法就百花齊放了。一共見識過 4 種不同的用法:
Raw 查詢式
Raw 查詢實際上是很經典的使用方式,一般出報表、批量更新或者執行數據調整的腳本時非常有用,實際上新手剛剛接觸到 Go,使用 ORM 也會傾向於使用 Raw 查詢(簡單)。所以濫用導致 Raw 查詢實際上在代碼中到處都是,幾乎把 ORM 當作了數據庫驅動在用。
func Query(sql string, params ...interface{}) ([]map[string]interface{}, error) {
rows, err := DB.Raw(sql, params...).Rows()
if err != nil {
return nil, err
}
defer rows.Close()
list := []map[string]interface{}{}
for rows.Next() {
dest := make(map[string]interface{})
if scanErr := MapScan(rows, dest); scanErr != nil {
return nil, scanErr
}
list = append(list, dest)
}
return list, nil
}
這樣做不是說不好,而是數據缺乏組織化,並且 []map[string]interface{}
這種東西在實際使用的時候很容易因為類型不具合翻車(panic)。所以 Raw 查詢不是不好,而是濫用不好。一般使用 CTE、窗口函數之類的前置條件場景,使用 Raw 查詢是合理的,但是需要注意對於 Raw 查詢的復用:
func (service *DBService) cte(arg1, arg2 interface{}, domain ...interface{}) (sql string, args []interface{}) {
//code...
return
}
返回可以服用的 CTE 查詢這樣來降低雷同 Raw 查詢出現的頻次。
基礎查詢式
這種模式在 ORM 使用中相當常見。直接使用 ORM 傳入模型然后執行檢索,操作起來大約是這樣的:
var entity = Entity{}
PostgreSQLConnection.Model(&entity).Where(`ID = $id`).Select()
看上去利用 ORM 的優勢,就是查詢出來的結果是一個結構化的實體,但實際上這樣的模式實際上就是前面 Raw 查詢模式的一個變種,不過相對更安全一些。這樣的查詢方式,利用 ORM 的模型映射,但是由於沒有統一組織管理查詢,使得整體看上去顯得凌亂,也就是說,到處都是 PostgreSQLConnection.Model
。並且,這樣的模式與前面一樣,無法在數據層面上完成邏輯表達。
數據層面的邏輯表達
例如,
Corporation
實體實際上有Staffs
的強關聯數據,如果用這個模式,查詢Corporation.Staffs
應該去構建Staff
模型,然后WHERE
語句中添加CORPORATION_ID
這樣的參數信息。但是理論上我要查詢到該企業的員工信息應該直接在該企業實體的Staffs
屬性或方法訪問到才對。
當然,ORM 或提供改善這樣的問題的能力。go-pg 提供一個關系數據引用檢索的特性(但是這個特性 Issue 比較多...)來提供形如 .Staffs
的方法。不過需要在查詢時顯式聲明檢索,並且需要立即指定條件,最后拿到的 .Staffs
實際上是已經查出來的結果數據,靈活程度比較低(例如,只需要符合條件的 ID 列表)。
半倉儲模式(或曰數據服務模式)
這個模式實際上是我之前用過的一種模式,這種模式將各類數據訪問的邏輯封裝起來成為一個數據服務:
type (
//IService 服務契約定義
IService interface {
Save(*models.Entity) error
Find(interface{}, ...func(*orm.Query)) (*models.Entity, error)
Where(models.Entity, ...func(*orm.Query)) ([]models.Entity, error)
Count(models.Entity, ...func(*orm.Query)) (int, error)
}
service struct {
Pg *pg.DB
}
)
然后去實現對應的:
- Save
- Find
- Where
- Count
然后根據數據的邏輯關系添加其他的數據訪問接口,例如 Corporation
的服務添加一個 Staffs
契約定義。
然后將這些服務集統一注冊到服務對象:
type (
//Services 基礎服務集合
Services struct {
Corporation corporation.IService
}
)
實際上這樣的使用模式已經很接近終極形態了,雖然這樣的模式已經構造了數據訪問的統一入口,並且也嘗試去解決數據層面的邏輯問題,但是這樣的數據訪問最大的問題是,換湯不換葯:
service.Corporation.Staffs(corp, `ID IN (?)`, pg.In(array))
在上面的語句,看上去我通過 Corporation 的信息直接訪問到了 Staffs,但是實際上對應的語義是:
用企業信息數據服務查詢員工信息
而不是:
企業的員工信息
本質上沒有解決前面兩個的問題,大概就是農夫山泉和怡寶的區別。那么,像 Entity Framework 的倉儲模式,Go 里怎么實現才能更加優雅呢?
倉儲模式
我們不妨回到 Entity Framework 上下文聲明:
namespace Tencent.Models
{
public class WebAppContext : DbContext
{
public WebAppContext() : base("name=WebAppContext") {}
public virtual DbSet<Entity> Entities { get; set; }
}
}
注意到了嗎,Entity.Entities
實際上並不是 Entity
類型而是 DbSet<T>
類型。為什么前面三個方法沒有本質區別就在於,它們全是使用了 Plain Ordinary Go Structure(POGS)來推演數據以及提供數據的訪問。
要做到倉儲模式,我們應該構建數據庫上下文結構(Go Structure with Database Context):
type (
//Corporation 應用數據庫模型
Corporation struct {
tableName struct{} `sql:"corporations"`
*models.Corporation
db *pg.DB
}
)
//Save 保存
func (c *Corporation) Save() (err error) {
if c.ID > 0 {
err = c.db.Update(c)
} else {
err = c.db.Insert(c)
}
return
}
//Query 查詢
func (c *Corporation) Query() (query *orm.Query) {
return c.db.Model(c)
}
也就是與數據庫交互,並在實際業務中流動的實例應該隨附關聯的數據庫上下文。這樣的話,可以在 Corporation
的實例方法中去定義 Staffs
方法:
//Staffs 公司員工列表
func (c *Corporation) Staffs(valid ...bool) *orm.Query {
tables := c.db.Model((*User)(nil)).Where(`"corporation_id" = ?`, c.ID)
if len(valid) > 0 {
tables.Where(`"valid" IS ?`, valid[0])
}
q := c.db.Model().With("users", tables).Table("users")
return q
}
注意,這里返回的是一個 CTE 查詢。相當於
.Staffs()
方法並沒有去直接執行查詢而是提供一個“該公司員工數據集”的前置查詢條件。如果需要查詢關聯員工信息的 ID,實際上還需要:
var staffIDs []int
err := corporation.Staffs().Column("id").Select(&staffIDs)
的后繼查詢操作。
為了實現統一的倉儲模式,可以將這些結構統一注冊到一個 Repositories:
type (
//Service 數據庫服務協議
Repository interface {
User(...*models.User) *User
Corporation(...*models.Corporation) *Corporation
}
repository struct {
*pg.DB
}
)
//NewService 在目標連接上新建服務
func NewRepository(db *pg.DB) Repository {
return &repository{db}
}
修改前面 Corporation
定義中的 db *pg.DB
為 db *repository
,然后將 Corporation
的工廠方法注冊到 Repository
:
//Corporation 企業數據庫服務
func (repository *repository) Corporation(corp ...*models.Corporation) (entity *Corporation) {
if len(corp) == 0 {
corp = append(corp, nil)
} else if corp[0] != nil {
defer entity.Clean()
}
entity = &Corporation{Corporation: corp[0], db: repository}
return
}
至此,ORM with Repository in Go 就創建終了。Repository 模式有效隔離開了數據模型、數據庫上下文模型,並且真的簡化了 DB 訪問的同時提供了數據層面的邏輯。如果業務中需要使用到 Go,還用到了 Go 的 ORM 來訪問數據庫,不妨借鑒 .NET 或 Java ORM 的做法。
這不大道至簡。
本篇水文的前提是 ORM,都用 ORM 了談什么大道至簡。