iT邦幫忙

2023 iThome 鐵人賽

DAY 8
0
自我挑戰組

Techschool Goalng Backend Master Class 的學習記錄系列 第 8

[Day 08] A clean way to implement database transaction in Golang Part 2

  • 分享至 

  • xImage
  •  

Simple Bank Transaction

Transfer 10 USD from back account 1 to bank account2

https://ithelp.ithome.com.tw/upload/images/20230923/20121746BVK9JTzvnd.png

Step 1

Create a transfer record with amount = 10

Step 2

Create an account entry for account 1 with amount = -10

Step 3

Create an account entry for account 2 with amount =+10

Step 4

Subtract 10 from the balance of account 1

Step 5

Add 10 to the balance of account 2

Implement DB transaction in Go

stroe.go

package db

import (
	"context"
	"database/sql"
)

type Store struct {
	*Queries
	db *sql.DB
}

func NewStore(db *sql.DB) *Store {
	return &Store{
		db:      db,
		Queries: New(db),
	}
}

func (store *Store) execTx(ctx context.Context, fn func(*Queries) error) error {
	tx, err := store.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	queries := New(tx)
	err = fn(queries)
	if err != nil {
		if rbErr := tx.Rollback(); rbErr != nil {
			return rbErr
		}
		return err
	}
	return tx.Commit()
}

Use composition to extend Queries' functionality

  1. Queries:這表示**Store結構體包含了一個Queries指標。這是Go語言的composition特性,使得Store可以使用Queries中的所有方法。透過這種組合的方式,Store結構體既可以利用Queries**的所有功能,又保留了擴展和封裝的能力。
  2. db *sql.DB:這表示**Store結構體也有一個指向sql.DB的指標,它是Go標準庫database/sql**中的結構體,代表一個數據庫連接池。
store.go

type Store struct {
    *Queries
    db *sql.DB
}

Create a new Store

  • 這裡New實際使用的是db.go中所定義的New func,但差別在這裡只會傳入sql.DB。
func NewStore(db *sql.DB) *Store {
	return &Store{
		db:      db,
		Queries: New(db),
	}
}

Execute a generic DB transaction

  • 這個函數更像是一個包裹。你給它一些Queries的操作,它會自動幫你開啟transaction、執行這些操作,然後確定是否提交或回滾事務。

  • 這裡New實際使用的是db.go中所定義的New func,但差別在這裡只會傳入sql.Tx (DBTX 可以接段sql.DB 或 sql.Tx)。

    type DBTX interface {
        ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
        PrepareContext(context.Context, string) (*sql.Stmt, error)
        QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
        QueryRowContext(context.Context, string, ...interface{}) *sql.Row
    }
    
    func New(db DBTX) *Queries {
        return &Queries{db: db}
    }
    
  • 這種實現方式常被稱為「回調模式」(Callback Pattern)或「函數作為參數」。

  • 在這段代碼中,execTx 函數接受一個函數 fn 作為參數。這個 fn 函數接受一個 *Queries 類型的參數並返回一個 error

  • 當我們在 execTx 函數中呼叫 fn(queries) 時,我們其實是在執行傳入的回調函數,並將剛剛建立的事務相關的 queries 作為該函數的參數。

  • 此方法允許我們在 execTx 內部進行事務的管理,同時提供了一個靈活的方式讓呼叫者可以在事務中執行他們自己定義的操作。

func (store *Store) execTx(ctx context.Context, fn func(*Queries) error) error {
	tx, err := store.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	queries := New(tx)
	err = fn(queries)
	if err != nil {
		if rbErr := tx.Rollback(); rbErr != nil {
			return rbErr
		}
		return err
	}
	return tx.Commit()
}

Q & A:

  1. Composition 是什麼 和OOP的inheritance是相同的嗎?

    1. 當你在一個結構體(Store)內嵌入另一個結構體(Queries)時,這稱為組合(Composition)。在這段代碼中,**Store結構體通過嵌入Queries**繼承了其所有的方法。

    目的可能有以下幾個優點:

    1. 簡化方法的調用Store可以直接調用Queries中的方法,而不需要再透過一個中間的成員。例如,如果Queries有一個名為GetAccount的方法,你可以直接透過Store實例調用此方法,而不需要像這樣:store.Queries.GetAccount()
    2. 擴展性:將來,如果你想要為**Store添加更多的方法或者狀態,你可以在不改變原始Queries的情況下直接在Store**中進行擴展。
    3. 封裝:透過這種方式,你可以在**Store中提供一些高級或者複合的方法,這些方法背後可能涉及對多個Queries方法的調用。這樣,對於使用Store的開發者來說,他們不必關心背後的詳細實現,只需關心Store**提供的接口。
  2. NewStore的Object只能操作sql.DB嗎?

    1. Store結構體中確實有一個*sql.DB的指標作為其屬性,這意味著它可以直接進行與sql.DB相關的操作。但這並不意味著Store只能操作sql.DB
    2. 更重要的是,Store結構體中還包含了Queries的指標,而Queries結構體的db屬性是DBTX接口類型。根據先前的描述,這個DBTX接口是設計來允許操作sql.DBsql.Tx(事務)的。這意味著,通過**QueriesStore**實際上可以操作普通的數據庫連接或者是transaction。
  3. Store 為何不同時包裝sql.DBsql.Tx呢?

    1. 總結來說,包裝 sql.DB 而不是 sql.Tx 主要是因為它們的使用情境和生命週期不同。當您需要操作交易時,您可以從已包裝的 sql.DB 中動態生成一個 sql.Tx
    2. 生命週期:交易的生命週期通常比較短。當您開始一個交易,您可能會想要在很短的時間內完成它,以確保資源的釋放和資料的一致性。與此相對,sql.DB 的生命週期相對較長,它可能會在應用程序的整個生命週期中存在。
    3. 動態生成:通常,我們會根據需要動態地從 sql.DB 中建立 sql.Tx。每次我們需要一個新的交易時,我們都會從連接池中獲取一個連接並開始交易。這意味著,事實上我們不需要「存儲」一個 sql.Tx,因為它是臨時的。
    4. 靈活性:當我們將 sql.DB 包裝在 Store 中時,我們仍然可以有一個方法來開始新的交易(並回傳相應的 sql.Tx),或者提供其他方法來操作這些交易。
  4. execTxWithTx的關係和使用時機為何?

    1. WithTx 函數

      這個函數就像是給你一個工具,可以讓你告訴 Queries:「嘿,我現在要進行一個特定的事務,請使用我給你的這個事務來執行操作。」

      範例

      goCopy code
      tx, _ := db.Begin() // 假設你已經開始了一個事務
      q := queries.WithTx(tx) // 現在,q 是關聯到這個事務的 Queries
      
      
    2. execTx 函數

      這個函數更像是一個包裹。你給它一些操作,它會自動幫你開啟事務、執行這些操作,然後確定是否提交或回滾事務。

      範例

      goCopy code
      err := store.execTx(ctx, func(q *Queries) error {
          // 這裡的 q 是自動為我們關聯到事務的 Queries
          // 我們可以執行一些資料庫操作...
          return nil // 如果沒有錯誤,事務將被提交
      })
      
      

    所以,WithTx 是一個手動工具,你可以用它來指定事務。而 execTx 則是一個自動工具,它會幫你管理整個事務的生命週期。

Implement money transfer transaction

  • TransferTx() function to perform the money transfer transaction

  • store.execTx is a closure that can access result.

  • Transaction:

    1. We create a transfer record with amount equals to 10.
    2. We create an entry record for account 1 with amount equals to -10, since money is moving out of this account.
    3. We create another entry record for account 2, but with amount equals to 10, because money is moving in to this account.
    4. Then we update the balance of account 1 by subtracting 10 from it.
    5. And finally we update the balance of account 2 by adding 10 to it.
    
func (store *Store) execTx(ctx context.Context, fn func(*Queries) error) error {
	tx, err := store.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	queries := New(tx)
	err = fn(queries)
	if err != nil {
		if rbErr := tx.Rollback(); rbErr != nil {
			return rbErr
		}
		return err
	}
	return tx.Commit()
}

type TransferTxParams struct {
	FromAccountID int64 `json:"from_account_id"`
	ToAccountID   int64 `json:"to_account_id"`
	Amount        int64 `json:"amount"`
}

// TransferTxResult contains the result of the TransferTx function.
// The created Transfer record.
// The FromAccount after its balance is subtracted.
// The ToAccount after its its balance is added.
// The FromEntry of the account which records that money is moving out of the FromAccount.
// And the ToEntry of the account which records that money is moving in to the ToAccount.
type TransferTxResult struct {
	Transfer    Transfer `json:"transfer"`
	FromAccount Account  `json:"from_account"`
	ToAccount   Account  `json:"to_account"`
	FromEntry   Entry    `json:"from_entry"`
	ToEntry     Entry    `json:"to_entry"`
}

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
	var result TransferTxResult
	err := store.execTx(ctx, func(q *Queries) error {
		var err error

		result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{
			FromAccountID: arg.FromAccountID,
			ToAccountID:   arg.ToAccountID,
			Amount:        arg.Amount,
		})

		if err != nil {
			return err
		}

		result.FromEntry, err = q.CreateEntry(ctx, CreateEntryParams{
			AccountID: arg.FromAccountID,
			Amount:    -arg.Amount,
		})

		if err != nil {
			return err
		}

		result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{
			AccountID: arg.ToAccountID,
			Amount:    arg.Amount,
		})

		if err != nil {
			return err
		}
		return nil

		// TODO: update accounts' balance
	})

	return result, err
}

Q & A:

  1. store.execTx是否何對應到execTx ?

    1. 當你在 TransferTx 函數中呼叫 store.execTx 時,你實際上是將一個匿名函數(lambda 函數)作為參數傳遞給 execTx。這個匿名函數描述了一系列的 SQL 操作,這些操作必須在同一個事務中執行。

    接下來,讓我們看看 store.execTx 的實現:

    1. tx, err := store.db.BeginTx(ctx, nil):
      這裡開始了一個新的 SQL 事務並賦值給 tx。如果開始事務時出現錯誤,它將返回這個錯誤。
    2. queries := New(tx):
      這裡創建了一個新的 Queries 物件,其內部使用的數據庫連接實際上是一個事務 tx 而不是原始的 sql.DB
    3. err = fn(queries):
      這裡實際上是執行你傳入的那個匿名函數。當 fn 被呼叫並傳入 queries 時,它執行的所有 SQL 操作都會在事務 tx 中執行。換句話說,你之前在 TransferTx 中定義的所有 CreateTransferCreateEntry 等操作都是在這個 tx 事務中執行的。
    4. 接下來的部分就是檢查事務中的操作是否成功:
      • 如果操作中有任何一個出錯 (err != nil),則回滾事務 tx.Rollback()
      • 如果操作都成功了,則提交事務 tx.Commit()

    所以,你在 TransferTx 中傳入的那個匿名函數實際上是在 store.execTxerr = fn(queries) 這一步被執行的,並且所有的操作都在同一個事務 tx 中執行。

  2. store.execTx 是用到closure嗎 ?

    1. TransferTx 函數中,當傳遞一個匿名函數給 store.execTx 時,這個匿名函數會捕捉到它外部的變數,如 result。這種行為被稱為「閉包」(closure), 這裡的 result.Transfer 是外部 TransferTx 函數的一個變數,但在匿名函數中被修改了。這就是閉包的概念。

上一篇
[Day 07] A clean way to implement database transaction in Golang Part 1
下一篇
[Day 09] A clean way to implement database transaction in Golang Part 3
系列文
Techschool Goalng Backend Master Class 的學習記錄31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言