一個非侵入的Go事務管理庫——工作原理

倚天碼農發表於2020-06-22

在上一篇文章“一個非侵入的Go事務管理庫——如何使用”中,我講述瞭如何使用事務庫。有些讀者可能讀過"清晰架構(Clean Architecture)的Go微服務: 事物管理" ,其中描述了事務管理系統的舊版本。那篇文章和本文之間會有一些重疊。因為大多數人可能還沒有讀過那篇文章或者即使讀了也忘記了它的內容。因此為了照顧多數讀者,本文還是從頭開始(假設你沒有讀過前文)。如果你讀過,那你可以直接跳過熟悉的部分。

好的事務庫對於使用它的應用程式是透明的。在Go的“sql”庫中,有兩種型別的資料庫連結,“sql.DB”和“sql.Tx”。當你不需要事務支援時,使用“sql.DB”;否則使用“sql.Tx”。為了讓這兩種不同場景共享相同的持久層程式碼,我們需要對資料庫連結進行一個封裝來同時支援這兩種場景。我從"db transaction in golang" 裡得到了這個想法。

資料庫層的介面

資料庫層是事務管理庫中處理資料庫訪問的最低層。應用程式不需要修改該層,只有事務管理庫需要這樣做。

資料庫訪問封裝

下面是可同時支援事務和非事務操作的共享資料庫訪問介面, 它在“gdbc.go”中定義。

// SqlGdbc (SQL Go database connection) is a wrapper for SQL database handler ( can be *sql.DB or *sql.Tx)
// It should be able to work with all SQL data that follows SQL standard.
type SqlGdbc interface {
	Exec(query string, args ...interface{}) (sql.Result, error)
	Prepare(query string) (*sql.Stmt, error)
	Query(query string, args ...interface{}) (*sql.Rows, error)
	QueryRow(query string, args ...interface{}) *sql.Row
	// If need transaction support, add this interface
	Transactioner
}

// Transactioner is the transaction interface for database handler
// It should only be applicable to SQL database
type Transactioner interface {
	// Rollback a transaction
	Rollback() error
	// Commit a transaction
	Commit() error
	// TxEnd commits a transaction if no errors, otherwise rollback
	// txFunc is the operations wrapped in a transaction
	TxEnd(txFunc func() error) error

}

它有兩部分。一個是資料庫介面,它包含了常規的資料庫操作,如查詢表、更新表記錄。另一個事務介面,它包含裡支援事務所需要的函式,如“提交”和“回滾”。“SqlGdbc”介面是兩者的結合。該介面將用於連線資料庫。

資料庫訪問介面的實現

下面是資料庫訪問介面的程式碼實現。它在“sqlConnWrapper.go”檔案中。它定義了兩個結構體,“SqlDBTx”是對“sql.DB”的封裝,將被非事務函式使用。“SqlConnTx”是對“sql.Tx”的封裝,將被事務函式使用。

// SqlDBTx is the concrete implementation of sqlGdbc by using *sql.DB
type SqlDBTx struct {
	DB *sql.DB
}

// SqlConnTx is the concrete implementation of sqlGdbc by using *sql.Tx
type SqlConnTx struct {
	DB *sql.Tx
}

func (sdt *SqlDBTx) Exec(query string, args ...interface{}) (sql.Result, error) {
	return sdt.DB.Exec(query, args...)
}

func (sdt *SqlDBTx) Prepare(query string) (*sql.Stmt, error) {
	return sdt.DB.Prepare(query)
}

func (sdt *SqlDBTx) Query(query string, args ...interface{}) (*sql.Rows, error) {
	return sdt.DB.Query(query, args...)
}

func (sdt *SqlDBTx) QueryRow(query string, args ...interface{}) *sql.Row {
	return sdt.DB.QueryRow(query, args...)
}

func (sdb *SqlConnTx) Exec(query string, args ...interface{}) (sql.Result, error) {
	return sdb.DB.Exec(query, args...)
}

func (sdb *SqlConnTx) Prepare(query string) (*sql.Stmt, error) {
	return sdb.DB.Prepare(query)
}

func (sdb *SqlConnTx) Query(query string, args ...interface{}) (*sql.Rows, error) {
	return sdb.DB.Query(query, args...)
}

func (sdb *SqlConnTx) QueryRow(query string, args ...interface{}) *sql.Row {
	return sdb.DB.QueryRow(query, args...)
}

事務介面的實現

下面是“Transactioner”介面的程式碼實現,它在檔案 "txConn.go"中。我從"database/sql Tx — detecting Commit or Rollback"中得到這個想法。

因為“SqlDBTx”不支援事務,所以它的所有函式都返回“nil"。

// DB doesn't rollback, do nothing here
func (cdt *SqlDBTx) Rollback() error {
	return nil
}

//DB doesnt commit, do nothing here
func (cdt *SqlDBTx) Commit() error {
	return nil
}

// DB doesnt rollback, do nothing here
func (cdt *SqlDBTx) TxEnd(txFunc func() error) error {
	return nil
}

func (sct *SqlConnTx) TxEnd(txFunc func() error) error {
	var err error
	tx := sct.DB

	defer func() {
		if p := recover(); p != nil {
			log.Println("found p and rollback:", p)
			tx.Rollback()
			panic(p) // re-throw panic after Rollback
		} else if err != nil {
			log.Println("found error and rollback:", err)
			tx.Rollback() // err is non-nil; don't change it
		} else {
			log.Println("commit:")
			err = tx.Commit() // if Commit returns error update err with commit err
		}
	}()
	err = txFunc()
	return err
}

func (sct *SqlConnTx) Rollback() error {
	return sct.DB.Rollback()
}

func (sct *SqlConnTx) Commit() error {
	return sct.DB.Commit()
}

持久層的介面

在資料庫層之上是持久層,應用程式使用持久層來訪問資料庫表中的記錄。你需要定義一個函式在本層中實現對事務的支援。下面是持久層的事務介面,它位於“txDataService.go”檔案中。

// TxDataInterface represents operations needed for transaction support.
type TxDataInterface interface {
	// EnableTx is called at the end of a transaction and based on whether there is an error, it commits or rollback the
	// transaction.
	// txFunc is the business function wrapped in a transaction
	EnableTx(txFunc func() error) error
}

以下是它的實現程式碼。它只是呼叫下層資料庫中的函式“TxEnd()”,該函式已在資料庫層實現。下面的程式碼不是事務庫的程式碼(它是本文中惟一的不是事務庫中的程式碼),你需要在應用程式中實現它。

func (uds *UserDataSql) EnableTx(txFunc func() error) error {
	return uds.DB.TxEnd(txFunc)
}

獲取資料庫連結的程式碼

除了我們上面描述的呼叫介面之外,在應用程式中你還需要先獲得資料庫連結。事務庫中有兩個函式可以完成這個任務。

返回"SqlGdbc"介面的函式

函式"Build()"(在"factory.go"中)將返回"SqlGdbc"介面。根據傳入的引數,它講返回滿足"SqlGdbc"介面的結構,如果需要事務支援就是“SqlConnTx”,不需要就是“SqlDBTx”。如果你不需要在應用程式中直接使用資料庫連結,那麼呼叫它是最好的。

// Build returns the SqlGdbc interface. This is the interface that you can use directly in your persistence layer
// If you don't need to cache sql.DB connection, you can call this function because you won't be able to get the sql.DB
// in SqlGdbc interface (if you need to do it, call BuildSqlDB()
func Build(dsc *config.DatabaseConfig) (gdbc.SqlGdbc, error) {
	db, err := sql.Open(dsc.DriverName, dsc.DataSourceName)
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	// check the connection
	err = db.Ping()
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	dt, err := buildGdbc(db, dsc)
	if err != nil {
		return nil, err
	}
	return dt, nil
}

func buildGdbc(sdb *sql.DB,dsc *config.DatabaseConfig) (gdbc.SqlGdbc, error){
	var sdt gdbc.SqlGdbc
	if dsc.Tx {
		tx, err := sdb.Begin()
		if err != nil {
			return nil, err
		}
		sdt = &gdbc.SqlConnTx{DB: tx}
		log.Println("buildGdbc(), create TX:")
	} else {
		sdt = &gdbc.SqlDBTx{sdb}
		log.Println("buildGdbc(), create DB:")
	}
	return sdt, nil
}

返回資料庫連結的函式

函式"BuildSqlDB()"(在"factory.go"中)將返回"sql.DB"。它會忽略傳入的事務標識引數。應用程式在呼叫這個函式獲得資料庫連結後,還需要根據事務標識自己生成“SqlConnTx”或“SqlDBTx”。如果你需要在應用程式裡快取"sql.DB",那麼你必須呼叫這個函式。

// BuildSqlDB returns the sql.DB. The calling function need to generate corresponding gdbc.SqlGdbc struct based on
// sql.DB in order to use it in your persistence layer
// If you need to cache sql.DB connection, you need to call this function
func BuildSqlDB(dsc *config.DatabaseConfig) (*sql.DB, error) {
	db, err := sql.Open(dsc.DriverName, dsc.DataSourceName)
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	// check the connection
	err = db.Ping()
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	return db, nil

}

侷限性

首先,它只支援SQL資料庫的事務。如果你有一個NoSql資料庫,那麼它不支援(大多數NoSql資料庫不支援事務)。

其次,如果你的事務跨越資料庫(例如在不同的微服務之間),那麼它將無法工作。常用的做法是使用“Saga Pattern”。你可以為事務中的每個操作編寫一個補償操作,並在回滾階段逐個執行補償操作。在應用程式中新增“Saga”解決方案並不困難。你可能會問,為什麼不把“Saga”加到事務庫中呢? 這是一個有趣的問題。我覺得還是單獨為“Saga”建一個庫比較合適。

第三,它不支援巢狀事務(Nested Transaction),因此你需要手動確保在程式碼中沒有巢狀事務。如果程式碼庫不是太複雜,這很容易做到。如果你有一個非常複雜的程式碼庫,其中有很多事務和非事務程式碼混在一起,那麼你需要一個支援巢狀事務的解決方案。我沒有花時間研究如何新增巢狀事務,但它應該有一定的工作量。如果你對此感興趣,可以從"database/sql: nested transaction or save point support"開始。到目前為止,對於大多數場景,當前的解決方案可能是在代價不大的情況下的最佳方案。

如何擴充套件庫的功能

“SqlGdbc”介面沒有列出“sql”包中的所有函式,只列出我的應用程式中需要的函式。你可以輕鬆地擴充套件該介面以包含其他函式。

例如,如果需要將全鏈路跟蹤(詳情請見"Go微服務全鏈路跟蹤詳解")擴充套件到資料庫中,則可能需要在上下文中傳遞到資料庫函式中。“sql”庫已經支援具有上下文的資料庫函式。你只需要找到它們並將它們新增到"SqlGdbc"介面中,然後在"sqlConnWrapper "中實現它們。然後在持久層中,需要使用上下文作為引數呼叫函式。

原始碼:

完整原始碼: "jfeng45/gtransaction"

索引:

1 "一個非侵入的Go事務管理庫——如何使用"

2 "清晰架構(Clean Architecture)的Go微服務: 事物管理"

3 "db transaction in golang"

4 "database/sql Tx — detecting Commit or Rollback"

5 "Applying the Saga Pattern - GOTO Conference"

6 "database/sql: nested transaction or save point support"

7 "Go微服務全鏈路跟蹤詳解"

相關文章