【Unit4 ブログリレー6日目】
こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 最近まで開発していたm3ラウンジでは、goからRDBを利用していました。 m3ラウンジでは、SQLの組みやすさやテストのしやすさの観点で検討した結果、goquを採用しましたので、 そこで得られた知見とその実装例を紹介します。
これから試してみる方(と将来m3ラウンジの開発に新たに入ることになったメンバー)の参考になるように、サンプルコードも説明も多くなってしまいかなり長いです。 お時間ある時にお読みいただければ。
m3ラウンジ
4日目の記事でも紹介したとおり、m3ラウンジはFacebookの医師版のようなサービスです。去年の8月末くらいにリリースされて、最近まで集中して開発をしていました。
goqu
goquは、RDBを扱うものの中でも、SQLビルダーに分類されるものです。 gormのようないわゆるORM(Object Relational Mapping)のような高級な機能はありません。
ORMではなくて、goquを選んだ理由は3つあります。
1つめは、ORMは複雑だったり特殊なSQLを実現するための労力が大きくなりやすい。 m3では日常的にSQLで集計やレポートを作ることが多いため、割と複雑だったり大きなSQLも抵抗なく作れます。 しかし、手で組み立てたクエリがORMの標準的な機能で組み立てられなかったり、不可能ではないけど時間をかけてドキュメントを探しまくってなんとか作ったりと、 目的のクエリはすでにあるのに、それをコードに落としこむまでの労力が大きくなりやすいのがストレスでした。 元のSQLに近い構造になるようなSQLビルダーの方が使いやすいと思っています。 また、m3ではレビューする側もSQLは読み慣れているので、SQLビルダーの方がなじみやすいと思います。
2つめは、IDEやフォーマッタ(gofmt)のサポートが受けられる点です。 mybatis(JVMだけど)のXMLや sqlcのような素のSQLを利用すると、 SQLの組み立ての点は解決するのですが、SQLの書き方もフォーマットも人によってバラバラで、 チーム開発だと変更時に不要なdiffも生まれやすいのが難点です。 これは、CIとかでSQLのフォーマットや書き方を統一できれば解決するかもしれません。 goのコードとしてSQLを組めることで、IDEによるサポートやgofmtによるフォーマッタを適用できるので、ソースコードと同じようにSQLを書けます。
3つめは、クエリをプログラムとして構築できる点です。
クエリをプログラムとして構築しているので、ベースのクエリを再利用して別のクエリを生成したり、
条件によってクエリを変化させたりすることがgoのコードとして可能です。
例えばlimit
に1以上が指定されていたらLIMIT
句をつけたクエリにする、などがプログラムとして構築できます。
func (m *MapperImpl) GetComments(ctx context.Context, offset, limit int, comments *[]DbComment) error { // ベースとなるクエリ q := m.selectComments() // OFFSET句 if offset > 0 { q = q.Offset(uint(offset)) } // LIMIT句 if limit > 0 { q = q.Limit(uint(limit)) } return q.ScanStructsContext(ctx, comments) }
実例
簡単な例で実装例を紹介します。
以下のような 本book
, 著者author
, 画像image
があるテーブル構成を想定します。
本1冊につき、著者が1人存在し、本、著者それぞれに画像があります。
画像の path
はS3のkeyとか保存場所のパスとかのイメージです。
CREATE TABLE book ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL, author_id UUID NOT NULL, image_id UUID NOT NULL, insert_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), delete_timestamp TIMESTAMP WITH TIME ZONE ); CREATE TABLE author ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, image_id UUID NOT NULL, insert_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), delete_timestamp TIMESTAMP WITH TIME ZONE ); CREATE TABLE image ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), path TEXT NOT NULL, insert_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), delete_timestamp TIMESTAMP WITH TIME ZONE );
modelの構造体
まずは、DBから取得した結果をマッピングする構造体の定義です。
DBから取得してマッピングする構造体はすべて Db**
と命名しています。
理由はClean ArchitecureのDB層だけで使うモデルと分かりやすくするためです。詳しくは以下の記事を見てください。
package db import ( "database/sql" "time" "github.com/google/uuid" ) type DbBook struct { Id uuid.UUID `db:"id"` Title string `db:"title"` Author DbAuthor `db:"book_author"` Image DbImage `db:"book_image"` InsertTimestamp time.Time `db:"insert_timestamp"` DeleteTimestamp sql.NullTime `db:"delete_timestamp"` } type DbAuthor struct { Id uuid.UUID `db:"id"` Name string `db:"name"` Image DbImage `db:"author_image"` InsertTimestamp time.Time `db:"insert_timestamp"` DeleteTimestamp sql.NullTime `db:"delete_timestamp"` } type DbImage struct { Id uuid.UUID `db:"id"` Path string `db:"path"` InsertTimestamp time.Time `db:"insert_timestamp"` DeleteTimestamp sql.NullTime `db:"delete_timestamp"` }
`db:"**"`
はSQLの結果を構造体にマッピングするためのタグです。
特に理由がなければDBのカラム名と一致させておけばよいです。
DbBook
の中に DbAuthor
や DbImage
などがありますが、
構造体の中にさらに構造体が含まれていてもマッピングできます。
mapper
mapperはDBの操作を定義するinterfaceです。
Clean Architectureに従って、DB層の実装が外に影響しないようにinterfaceで定義します。
Get**
は基本的に引数の最後にポインタを渡して、渡されたポインタにDBから取得した結果の構造体を指すアドレスを代入させます。
json.Unmarshal
などと同じです。
Get**ById
は、指定したIDで存在しなかった場合は false
を返します。
また Insert
は、insertした結果として、本/著者/画像のIDを返します。
package db import ( "context" "github.com/google/uuid" ) type BookMapper interface { // GetBookById 本を取得する GetBookById(ctx context.Context, bid uuid.UUID, book *DbBook) (bool, error) // GetBooksOfAuthor 著者を指定して本を取得する GetBooksOfAuthor(ctx context.Context, aid uuid.UUID, books *[]DbBook) error // Insert 本を追加する Insert(ctx context.Context, title string, aid uuid.UUID, iid uuid.UUID) (*uuid.UUID, error) } type AuthorMapper interface { // GetAuthorById 著者を取得する GetAuthorById(ctx context.Context, aid uuid.UUID, author *DbAuthor) (bool, error) // Insert 著者を追加する Insert(ctx context.Context, name string, iid uuid.UUID) (*uuid.UUID, error) } type ImageMapper interface { // GetImageById 画像を取得する GetImageById(ctx context.Context, iid uuid.UUID, image *DbImage) (bool, error) // Insert 画像を追加する Insert(ctx context.Context, path string) (*uuid.UUID, error) }
mapperの実装
説明がしやすいので ImageMapper
の実装を例に説明します。
type ImageMapperImpl struct { primaryDb *goqu.Database readerDb *goqu.Database // txForTest テスト時にtransactionを設定するために使う txForTest *goqu.TxDatabase } func NewImageMapperImpl( dialect goqu.DialectWrapper, primaryDb *sql.DB, readerDb *sql.DB, ) *ImageMapperImpl { return &ImageMapperImpl{ primaryDb: dialect.DB(primaryDb), readerDb: dialect.DB(readerDb), } }
primaryDb
と readerDb
は、両方ともDBそのものです。
m3ラウンジでは、AWSのAuroraを使っており、書き込みはprimaryに、
読み込みはprimaryかreaderの両方から*1読みます。
そのため、primaryとreaderを分けています。
ただし、primaryの変更がreaderにreplacateされるまで数十msはかかり、
内容に違いが生じることがあるので、必要があればprimaryを指定して読めるようにしています
*2。
dialect
は直訳すれば「方言」で使用するDBを指定します。
import ( "github.com/doug-martin/goqu/v9" _ "github.com/doug-martin/goqu/v9/dialect/postgres" ) dialect := goqu.Dialect("postgres")
txForText
はテストのために用意されたフィールドです。テスト時以外は常に nil
です。後ほど説明します。
goquのSQLの結果から構造体へのマッピング
goqu では構造体の db
タグで指定されたフィールドと、SELECT
文のカラム名をマッピングすることで、
クエリの結果を構造体にマッピングします。
DbImage
の場合は、
id
, path
, insert_timestamp
, delete_timestamp
があればよいので、
例えば以下のようなSQLが発行されればマッピングできます。
SELECT "image"."id", "image"."path", "image"."insert_timestamp", "image"."delete_timestamp" FROM "image" WHERE (("image"."id" = $1) AND ("image"."delete_timestamp" IS NULL)) LIMIT 1
構造体が入れ子になっている場合は、db
タグで指定した項目名を .
でつなげるとマッピングできます。
DbAuthor
の場合は以下のようなSQLになります。
SELECT "author"."id", "author"."name", -- DbAuthor.Image (=author_image) にマッピング "author_image"."id" AS "author_image.id", "author_image"."path" AS "author_image.path", "author_image"."insert_timestamp" AS "author_image.insert_timestamp", "author_image"."delete_timestamp" AS "author_image.delete_timestamp", "author"."insert_timestamp", "author"."delete_timestamp" FROM "author" INNER JOIN "image" AS "author_image" ON ("author"."image_id" = "author_image"."id") WHERE (("author"."id" = $1) AND ("author"."delete_timestamp" IS NULL)) LIMIT 1
author
テーブルと image
テーブルをJOINするクエリはSQLとしては特に難しくないと思います。
DbAuthor
の Image
にマッピングさせるために AS
でカラム名を明示しています。
"author_image.id"
は DbAuthor.Image.Id
にマッピングされます。
ここで注意するのは "author_image"."id"
ではありません。
.
も含めてカラム名(識別子)なのでPostgreSQLの場合は全体を "
で囲います。
DbBook
も同様です。
SELECT "book"."id", "book"."title", "book"."insert_timestamp", "book"."delete_timestamp", -- DbBookのAuthor (=book_author) "book_author"."id" AS "book_author.id", "book_author"."name" AS "book_author.name", "book_author"."insert_timestamp" AS "book_author.insert_timestamp", "book_author"."delete_timestamp" AS "book_author.delete_timestamp", -- DbBookのAuthorのImage (=author_image) "book_author"."author_image.id" AS "book_author.author_image.id", "book_author"."author_image.path" AS "book_author.author_image.path", "book_author"."author_image.insert_timestamp" AS "book_author.author_image.insert_timestamp", "book_author"."author_image.delete_timestamp" AS "book_author.author_image.delete_timestamp", -- DbBookのImage (=book_image) "book_image"."id" AS "book_image.id", "book_image"."path" AS "book_image.path", "book_image"."insert_timestamp" AS "book_image.insert_timestamp", "book_image"."delete_timestamp" AS "book_image.delete_timestamp" FROM "book" INNER JOIN (SELECT "author"."id", "author"."name", "author_image"."id" AS "author_image.id", "author_image"."path" AS "author_image.path", "author_image"."insert_timestamp" AS "author_image.insert_timestamp", "author_image"."delete_timestamp" AS "author_image.delete_timestamp", "author"."insert_timestamp", "author"."delete_timestamp" FROM "author" INNER JOIN "image" AS "author_image" ON ("author"."image_id" = "author_image"."id")) AS "book_author" ON ("book"."author_id" = "book_author"."id") INNER JOIN "image" AS "book_image" ON ("book"."image_id" = "book_image"."id") WHERE (("book"."id" = $1) AND ("book"."delete_timestamp" IS NULL)) LIMIT 1
DbBook.Author.Image.Id
は "book_author.author_image.id"
にマッピングされます。
構造が深くなるとマッピングが複雑になりますが、ネストされた構造体でも1クエリでマッピングできます。
select文の構築
DbImage
DbImage
を取得するクエリを作ってみます。まずは、ベースとなるクエリです。
条件を何も指定しないでSELECTだけするクエリです。
var tblImage = goqu.T("image") func selectImage(sd *goqu.SelectDataset) *goqu.SelectDataset { return sd.From(tblImage).Prepared(true). Select( tblImage.Col("id"), tblImage.Col("path"), tblImage.Col("insert_timestamp"), tblImage.Col("delete_timestamp"), ) }
特に難しくないと思います。goqu.T(table string)
はテーブルを返します。
SQLの生成時にいい感じに変換してくれます。
goqu.T("table") -> "table" goqu.T("table").Col("col") -> "table"."col"
これをSQLにすると、以下になります。
SELECT "image"."id", "image"."path", "image"."insert_timestamp", "image"."delete_timestamp" FROM "image"
GetImageById
は、このベースのクエリに条件を指定して、実行、マッピングするだけです。
SQLを知っていれば自然と読めるコードだと思います。
func (m *ImageMapperImpl) GetImageById(ctx context.Context, iid uuid.UUID, image *DbImage) (bool, error) { return selectImage(m.readerSelectDataset()). Where(goqu.And( tblImage.Col("id").Eq(iid), tblImage.Col("delete_timestamp").IsNull(), )). ScanStructContext(ctx, image) }
ScanStructContext(ctx context.Context, i interface{})
は1件だけ取得して、
指定したポインタ (image
) に結果をマッピングした構造体のアドレスを設定します。
複数件の結果が返る場合は ScanStructsContext(ctx context.Context, i interface{})
を使用します。
構造体でなく値単体の場合に使える ScanValContext
, ScanValsContext
もあります。
このコードから生成されるSQLは以下です。1件だけに限定されるため $2
には 1
が指定されます。
SELECT "image"."id", "image"."path", "image"."insert_timestamp", "image"."delete_timestamp" FROM "image" WHERE (("image"."id" = $1) AND ("image"."delete_timestamp" IS NULL)) LIMIT $2
m.readerSelectDataset()
は、読み込み用のDBを取得するメソッドです。
テスト以外では txForTest
は常に nil
なので、常に m.readerDb.From().Prepared(true)
が返ります。
func (m *ImageMapperImpl) readerSelectDataset() *goqu.SelectDataset { // テスト時にtransaction内で実行させるため if m.txForTest == nil { return m.readerDb.From().Prepared(true) } return m.txForTest.From().Prepared(true) }
m3ラウンジでは、primaryDbとreaderDbから同じ割合で読ませるため、50%の確率でどちらかを返すようにしています。
// READER_DB_RATIO 読み専用DBの負荷分散のための割合 const READER_DB_RATIO = 50 func (m *ImageMapperImpl) readerSelectDataset() *goqu.SelectDataset { // テスト時にtransaction内で実行させるため if mapper.txForTest == nil { // DBの負荷分散のために振り分け if rand.Intn(100) <= READER_DB_RATIO { return mapper.readerDb.From().Prepared(true) } else { return mapper.primaryDb.From().Prepared(true) } } return mapper.txForTest.From().Prepared(true) }
DbAuthor
DbAuthor
は先にSQLを示します。
SELECT author: SELECT "author"."id", "author"."name", "author_image"."id" AS "author_image.id", "author_image"."path" AS "author_image.path", "author_image"."insert_timestamp" AS "author_image.insert_timestamp", "author_image"."delete_timestamp" AS "author_image.delete_timestamp", "author"."insert_timestamp", "author"."delete_timestamp" FROM "author" INNER JOIN "image" AS "author_image" ON ("author"."image_id" = "author_image"."id")
このSQLを生成するためのコードが以下です。
var ( tblAuthor = goqu.T("author") subqAuthorImage = goqu.T("author_image") ) func selectAuthor(sd *goqu.SelectDataset) *goqu.SelectDataset { authorImage := F("author_image") return sd.From(tblAuthor).Prepared(true). Join(tblImage.As(subqAuthorImage.GetTable()), goqu.On(tblAuthor.Col("image_id").Eq(subqAuthorImage.Col("id")))). Select( tblAuthor.Col("id"), tblAuthor.Col("name"), subqAuthorImage.Col("id").As(authorImage.AsC("id")), subqAuthorImage.Col("path").As(authorImage.AsC("path")), subqAuthorImage.Col("insert_timestamp").As(authorImage.AsC("insert_timestamp")), subqAuthorImage.Col("delete_timestamp").As(authorImage.AsC("delete_timestamp")), tblAuthor.Col("insert_timestamp"), tblAuthor.Col("delete_timestamp"), ) }
ここでポイントとなるのが、"author_image.xxx"
を生成する部分です。
goquにはカラムを表す goqu.C(col string)
があるので、本来は以下のように書きます。
subqAuthorImage.Col("id").As(goqu.C("author_image.id")), subqAuthorImage.Col("path").As(goqu.C("author_image.path")), subqAuthorImage.Col("insert_timestamp").As(goqu.C("author_image.insert_timestamp")), subqAuthorImage.Col("delete_timestamp").As(goqu.C("author_image.delete_timestamp")),
しかし author_image.
を何回も繰り返し書くのはプログラムとしては冗長なので、
この .
で連結したカラム名を効率的に生成できるように独自の構造体を定義しています。
type structField struct { names []string } func F(n string) structField { return structField{names: []string{n}} } func (s structField) F(n string) structField { s.names = append(s.names, n) return s } func (s structField) AsC(n string) exp.IdentifierExpression { return s.F(n).asC() } func (s structField) AsStr(n string) string { return s.F(n).asStr() } func (s structField) asC() exp.IdentifierExpression { return goqu.C(s.asStr()) } func (s structField) asStr() string { return strings.Join(s.names, ".") }
これを使えば、.
で連結したカラム名を生成しやすくなります。
abc := F("a").F("b").F("c") abc.AsC("d") -> goqu.C("a.b.c.d") abc.AsC("e") -> goqu.C("a.b.c.e") abc.AsStr("f") -> "a.b.c.f"
ここまで用意できれば、GetAuthorById
はImageと同様です。
func (m *AuthorMapperImpl) GetAuthorById(ctx context.Context, aid uuid.UUID, author *DbAuthor) (bool, error) { return selectAuthor(m.readerSelectDataset()). Where(goqu.And( tblAuthor.Col("id").Eq(aid), tblAuthor.Col("delete_timestamp").IsNull(), )). ScanStructContext(ctx, author) }
DbBook
DbBook
も先にゴールとなるSQLを示します。
author
と image
をJOINしたサブクエリ book_author
を book
にJOINしている箇所がちょっと複雑です。
SELECT book: SELECT "book"."id", "book"."title", "book"."insert_timestamp", "book"."delete_timestamp", "book_author"."id" AS "book_author.id", "book_author"."name" AS "book_author.name", "book_author"."insert_timestamp" AS "book_author.insert_timestamp", "book_author"."delete_timestamp" AS "book_author.delete_timestamp", "book_author"."author_image.id" AS "book_author.author_image.id", "book_author"."author_image.path" AS "book_author.author_image.path", "book_author"."author_image.insert_timestamp" AS "book_author.author_image.insert_timestamp", "book_author"."author_image.delete_timestamp" AS "book_author.author_image.delete_timestamp", "book_image"."id" AS "book_image.id", "book_image"."path" AS "book_image.path", "book_image"."insert_timestamp" AS "book_image.insert_timestamp", "book_image"."delete_timestamp" AS "book_image.delete_timestamp" FROM "book" INNER JOIN (SELECT "author"."id", "author"."name", "author_image"."id" AS "author_image.id", "author_image"."path" AS "author_image.path", "author_image"."insert_timestamp" AS "author_image.insert_timestamp", "author_image"."delete_timestamp" AS "author_image.delete_timestamp", "author"."insert_timestamp", "author"."delete_timestamp" FROM "author" INNER JOIN "image" AS "author_image" ON ("author"."image_id" = "author_image"."id")) AS "book_author" ON ("book"."author_id" = "book_author"."id") INNER JOIN "image" AS "book_image" ON ("book"."image_id" = "book_image"."id")
コードは以下のとおりです。
var ( tblBook = goqu.T("book") subqBookAuthor = goqu.T("book_author") subqBookImage = goqu.T("book_image") ) func selectBook(sd *goqu.SelectDataset) *goqu.SelectDataset { bookAuthor := F("book_author") authorImage := F("author_image") bookAuthorImage := bookAuthor.F("author_image") bookImage := F("book_image") return sd.From(tblBook).Prepared(true). Join(selectAuthor(sd).As(subqBookAuthor.GetTable()), goqu.On(tblBook.Col("author_id").Eq(subqBookAuthor.Col("id")))). Join(tblImage.As(subqBookImage.GetTable()), goqu.On(tblBook.Col("image_id").Eq(subqBookImage.Col("id")))). Select( // DbBook tblBook.Col("id"), tblBook.Col("title"), tblBook.Col("insert_timestamp"), tblBook.Col("delete_timestamp"), // DbAuthor subqBookAuthor.Col("id").As(bookAuthor.AsC("id")), subqBookAuthor.Col("name").As(bookAuthor.AsC("name")), subqBookAuthor.Col("insert_timestamp").As(bookAuthor.AsC("insert_timestamp")), subqBookAuthor.Col("delete_timestamp").As(bookAuthor.AsC("delete_timestamp")), //DbAuthor.DbImage subqBookAuthor.Col(authorImage.AsStr("id")).As(bookAuthorImage.AsC("id")), subqBookAuthor.Col(authorImage.AsStr("path")).As(bookAuthorImage.AsC("path")), subqBookAuthor.Col(authorImage.AsStr("insert_timestamp")).As(bookAuthorImage.AsC("insert_timestamp")), subqBookAuthor.Col(authorImage.AsStr("delete_timestamp")).As(bookAuthorImage.AsC("delete_timestamp")), // DbImage subqBookImage.Col("id").As(bookImage.AsC("id")), subqBookImage.Col("path").As(bookImage.AsC("path")), subqBookImage.Col("insert_timestamp").As(bookImage.AsC("insert_timestamp")), subqBookImage.Col("delete_timestamp").As(bookImage.AsC("delete_timestamp")), ) }
サブクエリをJOINしている箇所は以下です。
Join(selectAuthor(sd).As(subqBookAuthor.GetTable()), goqu.On(tblBook.Col("author_id").Eq(subqBookAuthor.Col("id")))).
Join()
はテーブルだけでなく、サブクエリも指定できます。
ここでは、DbAuthor
のベースのクエリ selectAuthor()
をそのまま使っています。
ベースのクエリを定義しておくことで、サブクエリにもそのまま使えるようになっています。
このベースのクエリを使えばGetBookById
, GetBooksOfAuthor
は以下のとおり書けます。
func (m *BookMapperImpl) GetBookById(ctx context.Context, bid uuid.UUID, book *DbBook) (bool, error) { return selectBook(m.readerSelectDataset()).Where( goqu.And( tblBook.Col("id").Eq(bid), tblBook.Col("delete_timestamp").IsNull(), )). ScanStructContext(ctx, book) } func (m *BookMapperImpl) GetBooksOfAuthor(ctx context.Context, aid uuid.UUID, books *[]DbBook) error { return selectBook(m.readerSelectDataset()).Where( goqu.And( tblBook.Col("author_id").Eq(aid), tblBook.Col("delete_timestamp").IsNull(), )). Order(tblBook.Col("insert_timestamp").Desc()). ScanStructsContext(ctx, books) }
insert文の構成
insert文は単純なのでBookだけ示します。
func (m *BookMapperImpl) Insert(ctx context.Context, title string, aid uuid.UUID, iid uuid.UUID) (*uuid.UUID, error) { // 本ID bid, err := uuid.NewRandom() if err != nil { return nil, err } // transaction tx, err := m.begin() if err != nil { return nil, err } book := goqu.Record{ "id": bid, "title": title, "author_id": aid, "image_id": iid, "insert_timestamp": goqu.L("current_timestamp"), } if _, err := tx.dataset().From(tblBook).Prepared(true). Insert().Rows(book).Executor().ExecContext(ctx); err != nil { if rollbackErr := tx.doRollback(); rollbackErr != nil { return nil, rollbackErr } return nil, err } // commit if err := tx.doCommit(); err != nil { return nil, err } return &bid, nil }
処理の流れとしては、新しいIDの採番、トランザクションの開始、insert、commitだけです。 1件insertするだけなので、トランザクションが必要な処理ではないですが説明のために入れています。
goqu.L(sql String, args ...interface{})
はリテラルを表します。
主にSQL内の関数などを呼び出す時や、どうしても構成できないクエリを直接書いてしまう時に使います。
?
でプレースホルダーを指定できます。
goqu.L("current_timestamp") -> current_timestamp goqu.L("a = ?", "b") -> a = 'b'
m.begin()
はトランザクションを開始する時に呼び出します。
以下のような構造体を返すので、以降のDB操作はこの構造体を使って実行します。
m.primaryDb
を直接使わないでラップした構造体を使うのはテストを実行しやすくするためです。後ほど説明します。
type mapperTx struct { // dataset dataset func() *goqu.SelectDataset // doCommit コミットする doCommit func() error // doRollback ロールバックする doRollback func() error }
DBを使ったテスト(SELECT)
DBに関わるテストはdockerで起動したDBに対して実際にクエリを投げてテストします。
Springであれば、@Transactional
をつけたテストは、トランザクション内で実行され、
テスト完了後に自動的にロールバックされることで、実際にはテーブルには何も変更をしない状態でテストを実行できます。
これをgoで実現するためにの仕組みを実装しました。
ImageMapperImpl
のテストを例に説明していきます。
テストケースは
- (1)DBに接続
- (2)テスト開始時にトランザクションを開始
- (3)テスト内のクエリはすべてそのトランザクション内で実行
- (4)テストが終わったらロールバック、DBから切断
の流れで実施します。
func TestImageMapperImpl(t *testing.T) { t.Run("GetImageById", func(t *testing.T) { // given db, mapper := getImageMapper() tx, _ := mapper.beginTestTransaction() defer tx.Rollback() defer db.Close() // insert image exptPath := "img-path" exptIid, err := uuid.NewRandom() if err != nil { t.Fatal(err) } tx.Exec(` INSERT INTO image (id, path, insert_timestamp) VALUES ($1, $2, current_timestamp)`, exptIid, exptPath) // when var actlImage DbImage actlFound, actlErr := mapper.GetImageById(context.Background(), exptIid, &actlImage) // then assert.New(t).NoError(actlErr) assert.New(t).True(actlFound) assert.New(t).Equal(exptIid, actlImage.Id) assert.New(t).Equal(exptPath, actlImage.Path) }) ... }
(1)DBに接続
getImageMapper
はテスト用のDBに接続して、*sql.DB
と、*ImageMapperImpl
を返します。
テストは読み込みと書き込みは同じDBでよいので、両方 db
を渡しています。
func getImageMapper() (*sql.DB, *ImageMapperImpl) { conn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", "localhost", "7543", "test", "test", "testdb") db, err := sql.Open("pgx", conn) if err != nil { panic(err) } dialect := goqu.Dialect("postgres") return db, NewImageMapperImpl(dialect, db, db) }
(2)トランザクションを開始
beginTestTransaction()
は、トランザクションを開始し、mapperの txForTest
をテスト用トランザクションに置き換えます。
このコードは *_test.go
に書かれているので、実際にデプロイされるコードには含まれません。
// テストコードだけで有効になるメソッド func (m *ImageMapperImpl) beginTestTransaction() (*goqu.TxDatabase, error) { tx, err := m.primaryDb.Begin() m.txForTest = tx return tx, err }
(3)トランザクション内でクエリを実行
テスト用のレコードは、beginTestTransaction()
で生成したトランザクション tx
を使って直接insertしておきます。
mapperの機能でinsertするとテストの意味がないので、直接SQLでデータを投入します。
テスト対象である GetImageById
も同一トランザクション tx
内でSELECTを実行されます。
テスト時にだけ、テスト用に用意したトランザクション内でSELECTが実行されるのは、
先述した readerSelectDataset()
がテスト時にだけ、テスト用のトランザクションを返すためです。
// 再掲 func (m *ImageMapperImpl) readerSelectDataset() *goqu.SelectDataset { // テスト時にtransaction内で実行させるため if m.txForTest == nil { return m.readerDb.From().Prepared(true) } return m.txForTest.From().Prepared(true) }
実行した結果をassertすればテストは完了です。
(4)ロールバック、DBから切断
ロールバック、DBからの切断は、冒頭に defer
で指定しているのでテストケース終了後に自動で実行されます。
PostgreSQLでクエリログをすべて表示させる設定でテストを実行させると、 想定どおりクエリが実行されているのが分かります。
docker run --rm -p 7543:5432 \ -e POSTGRES_USER=test \ -e POSTGRES_PASSWORD=test \ -e POSTGRES_DB=testdb \ postgres:15.4-alpine3.18 \ -c log_destination=stderr \ -c log_statement=all 2023-08-25 11:59:19.431 UTC [67] LOG: statement: begin 2023-08-25 11:59:19.434 UTC [67] LOG: execute stmtcache_19: INSERT INTO image (id, path, insert_timestamp) VALUES ($1, $2, current_timestamp) 2023-08-25 11:59:19.434 UTC [67] DETAIL: parameters: $1 = '17f08c10-1926-48cf-ba11-35c344671caf', $2 = 'img-path' 2023-08-25 11:59:19.436 UTC [67] LOG: execute stmtcache_20: SELECT "image"."id", "image"."path", "image"."insert_timestamp", "image"."delete_timestamp" FROM "image" WHERE (("image"."id" = $1) AND ("image"."delete_timestamp" IS NULL)) LIMIT $2 2023-08-25 11:59:19.436 UTC [67] DETAIL: parameters: $1 = '17f08c10-1926-48cf-ba11-35c344671caf', $2 = '1' 2023-08-25 11:59:19.437 UTC [67] LOG: statement: rollback
トランザクションを含むDBを使ったテスト
トランザクションを含むクエリを同じようにテストすると、
テストのために用意したトランザクション内でさらにトランザクションを生成することになります。
こうしたネストしたトランザクションのようなものは、RDBには一般的にはなさそうなので、
SAVEPOINT
を活用して実現します。
例えば、
処理1; BEGIN; 処理2; ROLLBACK; 処理3;
このSQLをテスト用トランザクション内で再現させるには、処理1
と 処理3
だけ実行されるようになればよいので、
テスト用のトランザクション内で、BEGIN
のところでSAVEPOINTを開始し、ROLLBACK
が呼ばれたらSAVEPOINTまで戻るようにすればよいはずです。
BEGIN; -- テスト用のトランザクション 処理1; SAVEPOINT point; -- BEGIN; 処理2; ROLLBACK TO SAVEPONT point; -- ROLLBACK; 処理3; ROLLBACK; -- テスト用のトランザクションを戻す
テストの場合とそれ以外でこのように処理を分けるために、
先述した begin()
はテスト時とそれ以外で違う mapperTx
を返すようにします。
func (m *ImageMapperImpl) begin() (*mapperTx, error) { // テストではない時にはここでtransaction開始 if m.txForTest == nil { tx, err := m.primaryDb.Begin() return &mapperTx{ dataset: func() *goqu.SelectDataset { return tx.From().Prepared(true) }, doCommit: tx.Commit, doRollback: tx.Rollback, }, err } // テスト時にはすでにtransaction内なので、savepointを保存 _, err := m.txForTest.Exec("SAVEPOINT image_mapper") return &mapperTx{ dataset: func() *goqu.SelectDataset { return m.txForTest.From().Prepared(true) }, doCommit: func() error { // 何もしなくていいが、ログで追いやすいようにコメントだけ残す _, err := m.txForTest.Exec("-- commit") return err }, doRollback: func() error { _, err := m.txForTest.Exec("ROLLBACK TO SAVEPOINT image_mapper") return err }, }, err }
SQLの実行の流れが追いやすいように、Insert
にログ的なクエリを追加しておきます。
func (m *ImageMapperImpl) Insert(ctx context.Context, path string) (*uuid.UUID, error) { // 画像ID iid, err := uuid.NewRandom() if err != nil { return nil, err } // transaction tx, err := m.begin() if err != nil { return nil, err } // ログ if m.txForTest != nil { m.txForTest.Exec("-- start insert image") } image := goqu.Record{ "id": iid, "path": path, "insert_timestamp": goqu.L("current_timestamp"), } if _, err := tx.dataset().From(tblImage).Prepared(true). Insert().Rows(image).Executor().ExecContext(ctx); err != nil { if rollbackErr := tx.doRollback(); rollbackErr != nil { return nil, rollbackErr } return nil, err } // ログ if m.txForTest != nil { m.txForTest.Exec("-- finish insert image") } // commit if err := tx.doCommit(); err != nil { return nil, err } return &iid, nil }
insertのテストの例を示します。1件insertして、レコードが存在するか確認します。
t.Run("Insert", func(t *testing.T) { t.Run("success", func(t *testing.T) { // given db, mapper := getImageMapper() tx, _ := mapper.beginTestTransaction() defer tx.Rollback() defer db.Close() exptPath := "img-path" // when actlIid, actlErr := mapper.Insert(context.Background(), exptPath) // then assert.New(t).NoError(actlErr) assert.New(t).NotEqual(uuid.Nil, actlIid) var actlPath string tx.QueryRow(`SELECT path from image where id = $1`, actlIid).Scan(&actlPath) assert.New(t).Equal(exptPath, actlPath) }) ...
この場合はROLLBACKさせないので、SAVEPOINTは設定しますが、戻ることなく終了します。
2023-08-25 11:59:19.446 UTC [68] LOG: statement: begin 2023-08-25 11:59:19.447 UTC [68] LOG: statement: SAVEPOINT image_mapper 2023-08-25 11:59:19.448 UTC [68] LOG: statement: -- start insert image 2023-08-25 11:59:19.450 UTC [68] LOG: execute stmtcache_21: INSERT INTO "image" ("id", "insert_timestamp", "path") VALUES ($1, current_timestamp, $2) 2023-08-25 11:59:19.450 UTC [68] DETAIL: parameters: $1 = '451552f4-17bb-4498-9a2f-599f78617b46', $2 = 'img-path' 2023-08-25 11:59:19.451 UTC [68] LOG: statement: -- finish insert image 2023-08-25 11:59:19.451 UTC [68] LOG: statement: -- commit 2023-08-25 11:59:19.453 UTC [68] LOG: execute stmtcache_22: SELECT path from image where id = $1 2023-08-25 11:59:19.453 UTC [68] DETAIL: parameters: $1 = '451552f4-17bb-4498-9a2f-599f78617b46' 2023-08-25 11:59:19.454 UTC [68] LOG: statement: rollback
ここで、INSERT時にわざとエラーが発生するようにしたテストを実施します。
t.Run("error on insert", func(t *testing.T) { // given db, mapper := getImageMapper() tx, _ := mapper.beginTestTransaction() defer tx.Rollback() defer db.Close() // when ctx, cancel := context.WithCancel(context.Background()) cancel() // 先にキャンセルしておくとエラーにできる exptPath := "img-path" actlIid, actlErr := mapper.Insert(ctx, exptPath) // then assert.New(t).Error(actlErr) assert.New(t).Nil(actlIid) var count int tx.QueryRow(`SELECT count(1) from image`).Scan(&count) assert.New(t).Equal(0, count) })
INSERT時にエラーが発生した場合にはROLLBACKを実行してそこで終了するので、SAVEPOINTまで戻り、 それ以降のクエリやcommitは実行されないで終了します。
2023-08-25 11:59:19.464 UTC [69] LOG: statement: begin 2023-08-25 11:59:19.464 UTC [69] LOG: statement: SAVEPOINT image_mapper 2023-08-25 11:59:19.465 UTC [69] LOG: statement: -- start insert image 2023-08-25 11:59:19.466 UTC [69] LOG: statement: ROLLBACK TO SAVEPOINT image_mapper 2023-08-25 11:59:19.468 UTC [69] LOG: execute stmtcache_23: SELECT count(1) from image 2023-08-25 11:59:19.469 UTC [69] LOG: statement: rollback
まとめ
- goquでRDBを操作する方法を紹介しました
- SQLをプログラムとして構築できます
- SQLのクエリの結果を構造体にマッピングできます
- RDBのトランザクションを使ったテストの実例を紹介しました
We are hiring!
今回紹介したm3ラウンジを含め、m3の多様なサービスを一緒に開発してくれる仲間を募集中です。お気軽にお問い合わせください。