はじめに TIG DXユニット真野です。GoのORマッパー連載 の1本目です。
テーマはorm-ishなデータベースアクセスレイヤーライブラリであるREL(go-rel/rel) です。他のメジャーなO/Rマッパライブラリに比べるとまだまだGitHub Stars数は少なくマイナーかもしれませんが、いくつか興味深い点があったので最後までお付き合いいただければです。
LICENSEはMIT 、2021.07.23でv0.17.0 までリリースされています。この記事はv0.17.0時点で記事を書いています。
REL概要 ドキュメントサイトも用意されていてこちら です。
RELの特徴はエレガントなAPI(チェーンでSQLクエリを組み立てるDSL)を提供しながら、テスタビリティを追求しているところが最大のポイントかと思います。他にも豊富な機能が謳われています。
Eager loading
ネストしたトランザクション
複合PK
ページネーション
スキーマ移行
他にも様々
さて、トップページに書いている通り、テスタブル ということがかなり強調されています。reltestという組み込みパッケージを用いることでテストが可能という点です。このあたりは Why rel に書かれてている通りなんですが、重要なのでここでも強調します。
Why relについて GoにおいてO/Rマッパライブラリの多くは次のようなチェーンAPIで提供します。
db.Where("id = ?" , 1 ).First(&user)
チェーンAPI自体の表現力・開発生産性・安全性について疑う余地はないと思うのですが、単体テストのときには一工夫を要することが多いと思います。例えば、GoのWeb APIなどのリポジトリでよくある構成が、DBアクセス部分をRepositoryパターンで提供することだと思います。これによってハンドラー(MVCでいうController)の単体テストを、モックで行うということです。
type UserRepository interface { Find(user *User, id int ) error } type mockUserRepository struct {}func (ur mockUserRepository ) Find(user *User, id int ) error { return user {ID:id, Name:"リムル様" } } type userRepository struct { db *DB } func (ur userRepository) Find(user *User, id int ) error { return db.Where("id = ?" , 1 ).First(&user) }
こういったラッパーを作成せずにテスト可能にすることをRELは設計ポリシーに持っています。 reltest パッケージの使い方が気になってきましたね。次章以降で実際に使っていきます。
なおこの記事で使っているコードは、次のリポジトリに配備します。
https://github.com/ma91n/gorel-example
今回利用するスキーマ authorとbookの2テーブルを用います。
create table author( id integer , name varchar (99 ) ); create table book( id integer , title varchar (99 ), price integer , author_id integer );
データは次のようなデータを登録します。
101,Mat Ryer 102,Katherine Cox-Buday 103,Thorsten Ball 301,Go言語によるWebアプリケーション開発,3520,101 302,Go言語による並行処理,3080,102 303,Go言語でつくるインタプリタ,3740,103
さきほどのリポジトリを git clone
して、 docker compose up -d
するとデータ登録済みのテーブルが存在すると思います。
psqlで接続する際は以下のコマンドです。
set PGPASSWORD=postgres123psql -h localhost -p 5432 -U postgres -d postgres PGPASSWORD=postgres123 psql -h localhost -p 5432 -U postgres -d postgres
RELのAPIの使い方 まずはアプリケーション側のコードです。カラム名を変更するときは db
タグを利用します。テーブル名を変更するときは、レシーバに Table()
を定義します。
モデル部分 var NotFoundErr = errors.New("not found" )type Author struct { ID int `db:"id"` Name string `db:"name"` } func (b Author ) Table() string { return "author" } type Book struct { ID int `db:"id"` Title string `db:"title"` Price int `db:"price"` AuthorID int `db:"author_id"` } func (b Book) Table() string { return "book" }
データを1件検索してみます。PostgreSQLを利用しています。
データ1件検索するサンプルコード package mainimport ( "context" "errors" "fmt" "log" "github.com/go-rel/rel" "github.com/go-rel/rel/adapter/postgres" "github.com/go-rel/rel/where" _ "github.com/lib/pq" ) func main () { adapter, err := postgres.Open("postgres://postgres:postgres123@localhost/rel_test?sslmode=disable" ) if err != nil { log.Fatal(err) } defer adapter.Close() repo := rel.New(adapter) book, err := FindBook(context.Background(), repo, 301 ) if err != nil { log.Fatal(err) } fmt.Println(book) } func FindBook (ctx context.Context, r rel.Repository, id int ) (Book, error ) { var b Book if err := r.Find(ctx, &b, where.Eq("id" , id)); err != nil { if errors.Is(err, rel.NotFoundError{}) { return Book{}, NotFoundErr } return Book{}, err } return b, nil }
DBアクセスですが、 rel/adaptor
パッケージのpostgresを利用します。現状だとMSSQL, MySQL, PostgreSQL, SQLite3が利用可能です。
https://go-rel.github.io/adapters/
DB接続情報を引数にした adapor
を生成して rel.New
でrelでメインのDBアクセスAPIを提供するリポジトリ(rel.Repository)を生成します。あとはrelのDSLに沿ってデータ操作します。
このコードを実行すると、レコードが1件取得できたことが分かります。
>go run main.go 2021/07/26 12:45:29 [duration: 38.6956ms op: adapter-query] SELECT * FROM "book" WHERE "id"=$1 LIMIT 1; {301 Go言語によるWebアプリケーション開発 3520 101}
テストコード さきほどのmainパッケージの FindBook
をテストしてみましょう(実際はもう少しビジネスロジックが入ったユースケース相当の関数をテストした方が良いと思います)
お待ちかねの reltest
パッケージを利用します。
ポイントは以下です。
reltest.New
でリポジトリを作成する
そのリポジトリに対して、 ExpectedFind
などで動かしたい挙動になるようにデータを登録する
r.ExpectFind(where.Eq("id", 301)).Result(book)
などがそれにあたる
データが存在しない場合も指定する必要がある
r.ExpectFind(where.Eq("id", 401)).NotFound()
設定済みのリポジトリをテストしたい関数に渡したり、初期化に用いて動作を検証する
package mainimport ( "context" "github.com/go-rel/rel" "github.com/go-rel/rel/reltest" "github.com/go-rel/rel/where" "reflect" "testing" ) func TestFindBook (t *testing.T) { var ( r = reltest.New() book = Book{ ID: 301 , Title: "Go言語によるWebアプリケーション開発" , Price: 3520 , AuthorID: 101 , } ) r.ExpectFind(where.Eq("id" , 301 )).Result(book) r.ExpectFind(where.Eq("id" , 401 )).NotFound() type args struct { r rel.Repository id int } tests := []struct { name string args args want Book wantErr error }{ { name: "1件検索" , args: args{ r: r, id: 301 , }, want: Book{ ID: 301 , Title: "Go言語によるWebアプリケーション開発" , Price: 3520 , AuthorID: 101 , }, wantErr: nil , }, { name: "存在しないキーを指定" , args: args{ r: r, id: 401 , }, want: Book{}, wantErr: NotFoundErr, }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { got, err := FindBook(context.Background(), tt.args.r, tt.args.id) if err != tt.wantErr { t.Errorf("FindBook() error = %v, wantErr %v" , err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("FindBook() got = %v, want %v" , got, tt.want) } }) } }
reltest
パッケージ側でモックの機能が存在するため、自前でラップしなくても良いというのは面白いアプローチですね。
注意としては、NotFoundの場合もExpectFindでリポジトリを指定しないと、rel側でpanicが発生することです。何も指定しないとrel.NotFoundError
が返ってくるのかなと思いましたが、そんなことは無いです。おそらくテーブルが存在するかどうか判断つかないので、一律指定する必要があるのだと思います。
テーブル結合 relでのテーブル結合も試してみます。
結合後の構造体 type AuthorBook struct { ID int `db:"id"` Title string `db:"title"` Price int `db:"price"` AuthorName string `db:"name"` } func (b AuthorBook) Table() string { return "book" }
続いて本体です。
package mainimport ( "context" "fmt" "log" "github.com/go-rel/rel" "github.com/go-rel/rel/adapter/postgres" _ "github.com/lib/pq" ) func main () { adapter, _ := postgres.Open("postgres://postgres:postgres123@localhost/rel_test?sslmode=disable" ) defer adapter.Close() repo := rel.New(adapter) book, err := FindBook(context.Background(), repo, 102 ) if err != nil { log.Fatal(err) } fmt.Println(book) } func FindBook (ctx context.Context, r rel.Repository, id int ) ([]AuthorBook, error ) { var b []AuthorBook if err := r.FindAll(ctx, &b, rel.Eq("author.id" , id), rel.JoinOn("author" , "author.id" , "book.author_id" )); err != nil { return nil , err } return b, nil }
テーブル結合はいくつか手法がありますが、 rel.JoinOn
などで結合キーとなるカラムを指定します。Join句とWhere句はカンマ区切りで引数に渡せばOKです。
go run main.go 2021/07/26 13:43:49 [duration: 38.2732ms op: adapter-query] SELECT * FROM "book" JOIN "author" ON "author" ."id" ="book" ."author_id" WHERE "author" ."id" =$1 ; [{102 Go言語による並行処理 3080 Katherine Cox-Buday}]
慣れれば大丈夫かと思いますが、SQLであれば一瞬で書けるのにrel経由だとどう書くんだろう、という部分に結構悩みがあるかなと思います。チームでよく使いそうなクエリは、relでもどう書くのかショーケース化しておくと良いかなと思いました。
テーブル結合のテスト テーブル結合のテストですが、やることは先程のPK検索と変わりません。relリポジトリを作成し、今度は ExpectedFindAll
に対して振る舞いを設定します。
func TestFindBook (t *testing.T) { var ( r = reltest.New() books = []AuthorBook{ { ID: 301 , Title: "Go言語によるWebアプリケーション開発" , Price: 3520 , AuthorName: "Katherine Cox-Buday" , }, } ) r.ExpectFindAll(rel.JoinOn("author" , "author.id" , "book.author_id" ), rel.Eq("author.id" , 102 )).Result(books) r.ExpectFindAll(rel.JoinOn("author" , "author.id" , "book.author_id" ), rel.Eq("author.id" , 999 )).Result([]AuthorBook{}) type args struct { r rel.Repository id int } tests := []struct { name string args args want []AuthorBook wantErr bool }{ { name: "1件検索" , args: args{ r: r, id: 102 , }, want: []AuthorBook{ { ID: 301 , Title: "Go言語によるWebアプリケーション開発" , Price: 3520 , AuthorName: "Katherine Cox-Buday" , }, }, wantErr: false , }, { name: "存在しないキーを指定" , args: args{ r: r, id: 999 , }, want: []AuthorBook{}, wantErr: false , }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { got, err := FindBook(context.Background(), tt.args.r, tt.args.id) if (err != nil ) != tt.wantErr { t.Errorf("FindBook() error = %v, wantErr %v" , err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("FindBook() got = %v, want %v" , got, tt.want) } }) } }
これもテストで利用するキー全てに対して、ExpectedFindAll
で指定しないとreltest側でpanicになるのでご注意ください。
やってみた所感 いくつか動かしてみた所感ですが、データアクセスしかロジックが入っていない関数のテストはあまり意味がなく、データアクセスを用いて何かしらビジネスロジックを行う部分で用いることが肝要だなと思いました(まさしく、リポジトリのモック化の意図・意味を理解しないとダメですね)
これをちゃんと意識しないと、工数をかけた割に自作自演テストになって品質向上につながらないと思うので、注意しようと思いました。
relおよびreltestの仕様ですが、慣れるまでの学習コストは比較的少ない方だなと感じました。一方で何か reltest側でエラーになった時に、何が原因なのか分からずトラブルシュートに少し時間がかかりました。例えば私は ExpectedFind
に今回のテストで指定する値を設定していなかったことが原因でかなり悩みました。この当たりはチームで導入する時に全員が間違いなく陥ると思うので、注意喚起はした方がベターだなと思います。
個人的には今後自分でモックのラッパーを作らずRELに任せるかは…、まだ様子見としたいと感じました。理由は導入コストと、自前のラッパー層を作らずに済むというバランスの兼ね合いで、薄いラッパーを無くすためにはちょっと大変だなと言う印象を得たからです。こういった技術選定については他の連載記事を見て改めて考えたいと思います。
まとめ
RELの概要をまとめた記事です
RELは単体テスト目的でO/Rマッパを利用者がラップするのではなく、モックパッケージを提供してくれます
reltestの使い方は、ExpectFind
や ExpectFindAll
でテストで想定する振る舞いを設定しモック化します
ちょっと癖があるので、導入前はトラブルシュートやよくハマるミスはチームで共有したほうが良さげ
次は澁川さんのGoとPoatgreSQLでCOPY です。