こんにちは。KOUKIです。
Go言語でアプリケーション開発手法を紹介しています。
前回は、CRUDのUnit Test作成について触れました。
今日は、Transactionについて学びたいと思います。
トランザクションとは
トランザクションを大まかに説明すると「DB操作のいくつかの処理を一つの処理としてまとめたもの」となります。

処理の一単位であり、
多くの場合、複数のdb操作で構成されます。
トランザクションを銀行の残高処理で例えてみましょう。
例として、太郎さんの口座から1万円を花子さんの口座に移すことになったとします。その時必要な処理の流れは、次のようになります。
2. 太郎さんの口座残高から1万円引く
3. 花子さんの口座残高を1万円増やす
4. 花子さんの口座残高が1万円増えたレコードを作成
説明のため簡略化して書いていますが、実際はもっと複雑でしょう。しかし、トランザクションの必要性を知るには十分です。
トランザクションは、1~4の工程を単体で処理するのではなく、一つの処理としてまとめます。なぜなら、1~4の工程が独立して動くとどこかでエラーが発生した時に処理をキャンセルすることができなくなるからです。
1~4の工程をトランザクションとしてまとめてしまえば、不測な事態が発生してもロールバックなどの対策が取れるというわけですね。
また、一単位の処理として扱えるということは、他のプログラムからの独立性が高い(影響を受けない)ことにも繋がります。goroutineを使ってトランザクション単位で処理を並行化し、処理速度を向上させることも可能なので、これもメリットになります。
トランザクション処理の実装
ファイルの準備
実装に必要なファイルを作成しましょう。
1 2 3 4 |
touch db/sqlc/store.go touch db/sqlc/store_test.go touch db/query/entry.sql touch db/query/transfer.sql |
EntryとTransferテーブル操作
事前準備として、entry.sqlとtransfer.sqlにSQL文を追加し、テーブル操作をできるようにしておきます。
entry.sqlに次のSQL文を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
-- name: CreateEntry :one INSERT INTO entries ( account_id, amount ) VALUES ( $1, $2 ) RETURNING *; -- name: GetEntry :one SELECT * FROM entries WHERE id = $1 LIMIT 1; -- name: ListEntries :many SELECT * FROM entries WHERE account_id = $1 ORDER BY id LIMIT $2 OFFSET $3; |
transfer.sqlに次のSQL文を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
-- name: CreateTransfer :one INSERT INTO transfers ( from_account_id, to_account_id, amount ) VALUES ( $1, $2, $3 ) RETURNING *; -- name: GetTransfer :one SELECT * FROM transfers WHERE id = $1 LIMIT 1; -- name: ListTransfers :many SELECT * FROM transfers WHERE from_account_id = $1 OR to_account_id = $2 ORDER BY id LIMIT $3 OFFSET $4; |
次のコマンドで、SQL文からGoファイルを生成します。
1 2 |
$ make sqlc sqlc generate |
このコマンドの詳細については、こちらの記事を参考にしてください。
Store structの作成
トランザクションを処理するためのStructを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// store.go package db import ( "context" "database/sql" "fmt" ) // Store はdbのクエリやトランザクションを実行する全ての処理を提供する type Store struct { *Queries db *sql.DB } // コンストラクタ func NewStore(db *sql.DB) *Store { return &Store{ db: db, Queries: New(db), } } |
トランザクション実行メソッド
先ほど作成したStore Structにトランザクションを実行するためのメソッドを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// store.go // トランザクション処理 func (store *Store) execTx(ctx context.Context, fn func(*Queries) error) error { // トランザクションスタート tx, err := store.db.BeginTx(ctx, nil) if err != nil { return err } // query生成 q := New(tx) err = fn(q) if err != nil { // 処理に失敗したらロールバックする if rbErr := tx.Rollback(); rbErr != nil { return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr) } return err } // 問題なく処理したらトランザクションを完了する return tx.Commit() } |
トランザクションデータのStruct化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// store.go // トランザクションデータをStruct化 type TransferTxParams struct { FromAccountID int64 `json:"from_account_id"` ToAccountID int64 `json:"to_account_id"` Amount int64 `json:"amount"` } // トランザクションの結果を格納 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"` } |
処理をまとめる
最後に、先ほど作成したトランザクション実行メソッド(execTx)に「処理の塊を投げる」ための関数を作成しておきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
// store.go // Aの口座からBの口座へお金を移動する処理 // レコード作成、口座の変更など 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 // 処理1 result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{ FromAccountID: arg.FromAccountID, ToAccountID: arg.ToAccountID, Amount: arg.Amount, }) if err != nil { return err } // 処理2 result.FromEntry, err = q.CreateEntry(ctx, CreateEntryParams{ AccountID: arg.FromAccountID, Amount: -arg.Amount, }) if err != nil { return err } // 処理3 result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{ AccountID: arg.ToAccountID, Amount: arg.Amount, }) if err != nil { return err } // 処理4 // Todo: update account's balance return nil }) return result, err } |
execTxメソッドの第二パラメータが肝です。ここには関数を渡すことができ、トランザクションとしてまとめた処理の塊(処理1~4)を渡しています。
テストコードの実装
前回学習したUnit Testをここでも書いていきましょう。
最初にmain_test.goを少し修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// main_test.go package db import ( "database/sql" "log" "os" "testing" _ "github.com/lib/pq" ) const ( dbDriver = "postgres" dbSource = "postgresql://postgres:secret@localhost:5432/simplebank?sslmode=disable" ) var testQueries *Queries // 修正: testDBを外だし var testDB *sql.DB func TestMain(m *testing.M) { var err error testDB, err = sql.Open(dbDriver, dbSource) if err != nil { log.Fatal("cannot connect to db:", err) } defer testDB.Close() testQueries = New(testDB) os.Exit(m.Run()) } |
DBのコネクション情報(testDB)を外出しして、他のファイルから呼び出すことができるようにします。※testDBと先頭が小文字のためプライベート扱いですが、同パッケージ内からは呼び出すことができます
次に、TransferTxメソッドのテストを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
// store_test.go package db import ( "context" "fmt" "testing" "github.com/stretchr/testify/require" ) func TestTransferTx(t *testing.T) { // Asset --- // main_test.goのtestDB(コネクション情報)を引数として渡す store := NewStore(testDB) // アカウント作成(createRandomAccountは、前回の記事で作成した関数) account1 := createRandomAccount(t) account2 := createRandomAccount(t) fmt.Println(">> before:", account1.Balance, account2.Balance) // 並行処理(goroutine) n := 5 amount := int64(10) // 結果格納用のChannel errs := make(chan error) results := make(chan TransferTxResult) // Act --- for i := 0; i < n; i++ { go func() { result, err := store.TransferTx(context.Background(), TransferTxParams{ FromAccountID: account1.ID, ToAccountID: account2.ID, Amount: amount, }) errs <- err results <- result }() } // Assert --- for i := 0; i < n; i++ { err := <-errs require.NoError(t, err) result := <-results require.NotEmpty(t, result) // Transferのチェック transfer := result.Transfer require.NotEmpty(t, transfer) require.Equal(t, account1.ID, transfer.FromAccountID) require.Equal(t, account2.ID, transfer.ToAccountID) require.Equal(t, amount, transfer.Amount) require.NotZero(t, transfer.ID) require.NotZero(t, transfer.CreatedAt) _, err = store.GetTransfer(context.Background(), transfer.ID) require.NoError(t, err) // Entryのチェック fromEntry := result.FromEntry require.NotEmpty(t, fromEntry) require.Equal(t, account1.ID, fromEntry.AccountID) require.Equal(t, -amount, fromEntry.Amount) require.NotZero(t, fromEntry.ID) require.NotZero(t, fromEntry.CreatedAt) _, err = store.GetEntry(context.Background(), fromEntry.ID) require.NoError(t, err) toEntry := result.ToEntry require.NotEmpty(t, toEntry) require.Equal(t, account2.ID, toEntry.AccountID) require.Equal(t, amount, toEntry.Amount) require.NotZero(t, toEntry.ID) require.NotZero(t, toEntry.CreatedAt) _, err = store.GetEntry(context.Background(), toEntry.ID) require.NoError(t, err) // TODO: 口座チェック } } |
TransferTxのテストにgoroutineを使うところがミソですね。
goroutineで起動すれば実行時間を短くすることができますし、並列で実行しても問題ないことも確認できます。
テストをしてみましょう。
1 2 3 4 5 6 7 8 9 10 11 |
$ make test go test -v -cover ./... ? golang-with-postgres [no test files] ... === RUN TestTransferTx >> before: 39 221 --- PASS: TestTransferTx (0.04s) PASS coverage: 58.8% of statements ok golang-with-postgres/db/sqlc 0.094s coverage: 58.8% of statements ? golang-with-postgres/util [no test files] |
テストがパスしましたね。
長くなったので、ここまでにしましょう。
次回
トランザクション処理を実装できるようになれば、プログラミングスキルも一段階アップした感じがしますよね^^
私も自分のアプリケーションや会社のプログラムにこの技術を導入しようと思います。
次回は、デットロックについて学びましょう。
コメントを残す
コメントを投稿するにはログインしてください。