エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

goquを駆使してgoでSQL構築も構造体マッピングもRDBテストもやる

【Unit4 ブログリレー6日目】

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 最近まで開発していたm3ラウンジでは、goからRDBを利用していました。 m3ラウンジでは、SQLの組みやすさやテストのしやすさの観点で検討した結果、goquを採用しましたので、 そこで得られた知見とその実装例を紹介します。

これから試してみる方(と将来m3ラウンジの開発に新たに入ることになったメンバー)の参考になるように、サンプルコードも説明も多くなってしまいかなり長いです。 お時間ある時にお読みいただければ。

名古屋城は、日本の城のひとつ。尾張国愛知郡名古屋(現在の愛知県名古屋市中区本丸・北区名城)にある。本文には特に関係ありません。

m3ラウンジ

4日目の記事でも紹介したとおり、m3ラウンジはFacebookの医師版のようなサービスです。去年の8月末くらいにリリースされて、最近まで集中して開発をしていました。

goqu

goquは、RDBを扱うものの中でも、SQLビルダーに分類されるものです。 gormのようないわゆるORM(Object Relational Mapping)のような高級な機能はありません。

github.com

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層だけで使うモデルと分かりやすくするためです。詳しくは以下の記事を見てください。

www.m3tech.blog

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 の中に DbAuthorDbImage などがありますが、 構造体の中にさらに構造体が含まれていてもマッピングできます。

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),
    }
}

primaryDbreaderDb は、両方とも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としては特に難しくないと思います。 DbAuthorImage にマッピングさせるために 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を示します。 authorimage をJOINしたサブクエリ book_authorbook に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 をつけたテストは、トランザクション内で実行され、 テスト完了後に自動的にロールバックされることで、実際にはテーブルには何も変更をしない状態でテストを実行できます。

spring.pleiades.io

これを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 を活用して実現します。

qiita.com

例えば、

処理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の多様なサービスを一緒に開発してくれる仲間を募集中です。お気軽にお問い合わせください。

jobs.m3.com

*1:readerだけにすると、readerだけ負荷が高くなって、負荷がアンバランスになってもったいないから

*2:primaryに書いた直後にreaderに反映される前に読んでしまいバグになったことがあります