天天看點

Go 中 ORM 的 Repository(倉儲)模式

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 了談什麼大道至簡。