フューチャー技術ブログ

GoのモダンDBアクセスレイヤーRELを触って

はじめに

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トップページ

RELの特徴はエレガントなAPI(チェーンでSQLクエリを組み立てるDSL)を提供しながら、テスタビリティを追求しているところが最大のポイントかと思います。他にも豊富な機能が謳われています。

  • Eager loading
  • ネストしたトランザクション
  • 複合PK
  • ページネーション
  • スキーマ移行
  • 他にも様々

さて、トップページに書いている通り、テスタブル ということがかなり強調されています。reltestという組み込みパッケージを用いることでテストが可能という点です。このあたりは Why relに書かれてている通りなんですが、重要なのでここでも強調します。

Why relについて

GoにおいてO/Rマッパライブラリの多くは次のようなチェーンAPIで提供します。

// チェーン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
);

データは次のようなデータを登録します。

# author
101,Mat Ryer
102,Katherine Cox-Buday
103,Thorsten Ball

# book
301,Go言語によるWebアプリケーション開発,3520,101
302,Go言語による並行処理,3080,102
303,Go言語でつくるインタプリタ,3740,103

さきほどのリポジトリを git clone して、 docker compose up -dするとデータ登録済みのテーブルが存在すると思います。

psqlで接続する際は以下のコマンドです。

# Windows
set PGPASSWORD=postgres123
psql -h localhost -p 5432 -U postgres -d postgres

# Mac, Linux
PGPASSWORD=postgres123 psql -h localhost -p 5432 -U postgres -d postgres

RELのAPIの使い方

まずはアプリケーション側のコードです。カラム名を変更するときは dbタグを利用します。テーブル名を変更するときは、レシーバに Table() を定義します。

モデル部分
// Not Found
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 main

import (
"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 main

import (
"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) {
// create a mocked repository.
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 main

import (
"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) {
// create a mocked repository.
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の使い方は、ExpectFindExpectFindAll でテストで想定する振る舞いを設定しモック化します
  • ちょっと癖があるので、導入前はトラブルシュートやよくハマるミスはチームで共有したほうが良さげ

次は澁川さんのGoとPoatgreSQLでCOPYです。