フューチャー技術ブログ

DBスキーマを駆動にした開発のためのライブラリ調査

はじめに

Goでデータベースを扱う場合、Gorm, Gorp, sqlx といった O/Rマッパ がよく使われます。多くの場合は構造体のフィールドとデータベースのカラム名のマッピングをするタグ付きの構造体を実装します。例えば Gorm を用いて users テーブルにアクセスする場合、以下のような構造体が一例として考えられます。

model_user.go
type User struct {
UserID string `gorm:"column:user_id;primary_key"`
UserName string `gorm:"column:user_name"`
}

func (u *User) TableName() string {
return "users"
}

GormXorm といった O/Rマッパ を用いる場合、スキーマから構造体を生成できず、構造体の生成が面倒という問題があります。複数人で開発していたり、テーブルのカラム数が多かったり、テーブルの数が多い場合、スキーマから構造体を自動生成できるとよいな、と考えることでしょう。 1

また、当社のメンバーと開発するとき SQL が書け適切に index を貼ったり、実行計画からチューニングできるメンバーが多いです。素の SQL を書く or 書きたいケースが多く、メソッドチェーンでクエリを生成する Gorm などの O/Rマッパ と相性が悪いと感じていました。

  • スキーマからテーブルのカラムとマッピングできる構造体を自動生成したい
  • 任意のクエリから結果セットのカラムとマッピングできる構造体を自動生成したい

上記を満たすようなライブラリを探しつつ、今回は既に存在するデータベースのスキーマやクエリから構造体を生成できる xogen を調査してみました。

SQLBoilerXorm は O/Rマッパ として使われることが多いので対象から外しました。SQLBoilerXorm の所感は Go言語のDBレイヤーライブラリの評価 の記事に記載しておりますので、あわせてご確認ください。

概要サマリ

今回調査するライブラリの2020/07/12時点のデータをもとにした概要サマリです。どちらのライブラリも継続的にメンテナンスされています。

[object Object] undefined
  • バージョン

今回調査したバージョン情報は以下になります。xo はセマンティクスバージョニングされていなかったため、コミット ID を記載しています。

[object Object] undefined

サンプルプロジェクト

本記事では xo のライブラリにサンプルとして記載されている booktest をサンプルのプロジェクトとして考えてみます。データベースは PostgreSQL を採用することとし、Docker を用いて以下のようにローカルに起動させておきます。

docker run --rm --name my-postgres -e POSTGRES_USER=booktest -e POSTGRES_PASSWORD=pass -e POSTGRES_DB=testdb -d -p 15432:5432 postgres

データベースの ER 図は以下のようになります。

スキーマを作成する SQL は以下になります。

-- The MIT License (MIT)
--
-- Copyright (c) 2015-2016 Kenneth Shaw
--
-- https://github.com/xo/xo/blob/master/LICENSE

DROP SCHEMA IF EXISTS booktest CASCADE;
DROP TABLE IF EXISTS books CASCADE;
DROP TYPE IF EXISTS book_type CASCADE;
DROP TABLE IF EXISTS authors CASCADE;
DROP FUNCTION IF EXISTS say_hello(text) CASCADE;

CREATE SCHEMA booktest;

CREATE TABLE authors (
author_id SERIAL PRIMARY KEY,
name text NOT NULL DEFAULT ''
);

CREATE INDEX authors_name_idx ON authors(name);

CREATE TYPE book_type AS ENUM (
'FICTION',
'NONFICTION'
);

CREATE TABLE books (
book_id SERIAL PRIMARY KEY,
author_id integer NOT NULL REFERENCES authors(author_id),
isbn text NOT NULL DEFAULT '' UNIQUE,
booktype book_type NOT NULL DEFAULT 'FICTION',
title text NOT NULL DEFAULT '',
year integer NOT NULL DEFAULT 2000,
available timestamp with time zone NOT NULL DEFAULT 'NOW()',
tags varchar[] NOT NULL DEFAULT '{}',
memo text
);

CREATE INDEX books_title_idx ON books(title, year);

CREATE FUNCTION say_hello(text) RETURNS text AS $$
BEGIN
RETURN CONCAT('hello ', $1);
END;
$$ LANGUAGE plpgsql;

CREATE INDEX books_title_lower_idx ON books(title);

xo

xo はデータベースのスキーマや任意の SQL からデータベースにアクセスする Go のコードを生成するライブラリです。生成するコードはテンプレートファイルを使って柔軟にカスタマイズできます。O/Rマッパ を目指したライブラリではなく、静的に型付けされた Go らしいコードを生成することを目的の 1 つとしています。

まずは xo を試してみます。ディレクトリ構成は以下です。

.
├── go.mod
├── main.go
└── generated

さっそく xo をインストールします。2020/07/13現在、セマンティクスバージョニングはされていないため、master ブランチの最新のコミット(7818603ff52bc0b96122715f9c7df7559aeef82d) を用います。

go get -u github.com/xo/xo

生成先のディレクトリ名は generated とします。xo コマンドを用いて、構造体を生成します。

xo pgsql://booktest:pass@localhost:15432/testdb?sslmode=disable --schema booktest --out generated

generated ディレクトリ配下にいくつかファイルが生成されました。

.
├── generated
│   ├── author.xo.go
│   ├── book.xo.go
│   ├── booktype.xo.go
│   ├── sp_sayhello.xo.go
│   └── xo_db.xo.go
├── go.mod
└── main.go

生成されたファイル author.xo.go を確認します。ファイルの構成は、大きく SELECT を扱うクエリ用の関数と、それ以外の INSERT, UPDATE, DELETE といったクエリを扱うメソッドと 2 種類に大別できます。

SELECT系

SELECT を扱うクエリでは以下の 2 つの関数が生成されています。

  • AuthorsByName(db XODB, name string) ([]*Author, error)
  • AuthorByAuthorID(db XODB, authorID int) (*Author, error)

それぞれ関数のドキュメントからデータベースに貼られた index をもとに生成されています。

// AuthorsByName retrieves a row from 'booktest.authors' as a Author.
//
// Generated from index 'authors_name_idx'.
func AuthorsByName(db XODB, name string) ([]*Author, error) {
// ...
}

// AuthorByAuthorID retrieves a row from 'booktest.authors' as a Author.
//
// Generated from index 'authors_pkey'.
func AuthorByAuthorID(db XODB, authorID int) (*Author, error) {
// ...
}

関数内に SQL が明示的に記述されていて安心感があります。また index から生成されているので、概ねクエリは高速に動作するでしょう。ただし関数のシグネチャや、データベースアクセスするメソッドのシグネチャから分かるように Context を引数に受け取ることができません。多くのケースでは Context を引き回すケースが一般的ですので、Context 対応はテンプレートなどを用いて拡張する必要がありそうです。

// AuthorByAuthorID retrieves a row from 'booktest.authors' as a Author.
//
// Generated from index 'authors_pkey'.
func AuthorByAuthorID(db XODB, authorID int) (*Author, error) {
var err error

// sql query
const sqlstr = `SELECT ` +
`author_id, name ` +
`FROM booktest.authors ` +
`WHERE author_id = $1`

// run query
XOLog(sqlstr, authorID)
a := Author{
_exists: true,
}

err = db.QueryRow(sqlstr, authorID).Scan(&a.AuthorID, &a.Name)
if err != nil {
return nil, err
}

return &a, nil
}

INSERT/UPDATE/DELETE系

INSERT のクエリも SELECT と同様に、INSERT するメソッドが生成されます。

// ...

// Author represents a row from 'booktest.authors'.
type Author struct {
AuthorID int `json:"author_id"` // author_id
Name string `json:"name"` // name

// xo fields
_exists, _deleted bool
}

// ...

// Insert inserts the Author to the database.
func (a *Author) Insert(db XODB) error {
var err error

// if already exist, bail
if a._exists {
return errors.New("insert failed: already exists")
}

// sql insert query, primary key provided by sequence
const sqlstr = `INSERT INTO booktest.authors (` +
`name` +
`) VALUES (` +
`$1` +
`) RETURNING author_id`

// run query
XOLog(sqlstr, a.Name)
err = db.QueryRow(sqlstr, a.Name).Scan(&a.AuthorID)
if err != nil {
return err
}

// set existence
a._exists = true

return nil
}

UPDATE, UPSERT, DELETE 用のメソッドも生成されます。本記事では割愛します。

その他特徴

xo で特徴的な機能は、任意のクエリをもとに構造体を生成できる点です。books テーブルと authors テーブルを JOIN して bookstitle で文字列検索するクエリの例を考えてみます。以下は xo のコマンド例です。SQL を記述して構造体を生成できます。オプションの詳細は README.md を参照ください。

xo pgsql://booktest:pass@localhost:15432/testdb?sslmode=disable -N -M -B -T AuthorBookResult --query-type-comment='AuthorBookResult is the result of a search.' -o generated << ENDSQL
SELECT
a.author_id AS author_id,
a.name AS author_name,
b.book_id AS book_id,
b.isbn AS book_isbn,
b.title AS book_title,
b.tags AS book_tags,
b.memo AS book_memo
FROM books b
JOIN authors a ON a.author_id = b.author_id
WHERE b.title LIKE %%title string%%
ENDSQL

コマンドを実装すると authorbookresult.xo.go というファイルが生成されます。以下のようにコマンドに記述したクエリとデータベースから取得するカラムのレコードを保持する構造体が生成されます。SQL にパラメータとして渡す変数の型は、xo のコマンドに記述する SQL の内に int, string などとして明示的に記述する必要があります。

// Package generated contains the types for schema 'public'.
package generated

// Code generated by xo. DO NOT EDIT.

// AuthorBookResult is the result of a search.
type AuthorBookResult struct {
AuthorID int // author_id
AuthorName string // author_name
BookID int // book_id
BookIsbn string // book_isbn
BookTitle string // book_title
BookTags StringSlice // book_tags
BookMemo string // book_memo
}

// AuthorBookResultsByTitle runs a custom query, returning results as AuthorBookResult.
func AuthorBookResultsByTitle(db XODB, title string) ([]*AuthorBookResult, error) {
var err error

// sql query
const sqlstr = `SELECT ` +
`a.author_id AS author_id, ` +
`a.name AS author_name, ` +
`b.book_id AS book_id, ` +
`b.isbn AS book_isbn, ` +
`b.title AS book_title, ` +
`b.tags AS book_tags, ` +
`b.memo AS book_memo ` +
`FROM books b ` +
`JOIN authors a ON a.author_id = b.author_id ` +
`WHERE b.title LIKE $1`

// run query
XOLog(sqlstr, title)
q, err := db.Query(sqlstr, title)
if err != nil {
return nil, err
}
defer q.Close()

// load results
res := []*AuthorBookResult{}
for q.Next() {
abr := AuthorBookResult{}

// scan
err = q.Scan(&abr.AuthorID, &abr.AuthorName, &abr.BookID, &abr.BookIsbn, &abr.BookTitle, &abr.BookTags, &abr.BookMemo)
if err != nil {
return nil, err
}

res = append(res, &abr)
}

return res, nil
}

余談ですが、カスタム SQL から構造体を生成する場合、-- hogehoge というコメントの記述はできません。生成される SQL が壊れます。以下のように、実行時のログを以下のようにして出力すると明らかです。

generated.XOLog = log.Printf
  • 実行結果例

文字列結合した SQL ですが、-- によって意図しない範囲の記述までコメントアウトされてしまいます。/* hogehoge */ とコメントを記述すれば OK です。

2020/07/14 21:59:00 SELECT a.author_id AS author_id, a.name AS author_name, b.book_id AS book_id, b.isbn AS book_isbn, b.title AS book_title, b.tags AS book_tags, -- memo is memo b.memo AS book_memo FROM books b JOIN authors a ON a.author_id = b.author_id WHERE
b.title LIKE $1%!(EXTRA string=hoge)

所感

  • Good
    • SQL が文字列として明示されていて、どのようなクエリが発行されるか分かる
    • クエリを扱う関数やメソッドが扱う型を明示していて扱いやすい
  • Bad
    • デフォルトで生成されたクエリは Context を扱うことができず、Context を使う場合、テンプレートの実装が必要
    • 生成されたクエリを psql などで接続してコンソールから直接実行したい場合、文字列結合されたクエリを連結させる必要があり手間
    • カスタムクエリでパラメータを使う場合、独自の記述方法が必要。またパラメータ内に Go の型を埋め込む必要があり、煩雑
    • カスタムクエリを使う場合、SQL にコメント -- hogehoge が記載されていると、生成されるクエリが壊れる

gen

続いて gen です。gen はデータベースのスキーマメタデータをもとにしてデータベース(特に Gorm)とマッピング可能な構造体のコードを生成できます。

gen をインストールします。

go get -u github.com/smallnest/gen@v0.9.24

プロジェクト構成は xo を試したときと同様に以下のようにしておきます。

.
├── go.mod
├── main.go
└── generated

gen コマンドで構造体を生成します。

$ gen --connstr="host=localhost port=15432 user=booktest password=pass dbname=testdb sslmode=disable" --out generated --sqltype postgres --database testdb --no-json --overwrite
table: books unable to generate struct field: tags type: _VARCHAR error: unknown sql type: _varchar
Generating code for the following tables (2)
[0] authors
[1] books

generated ディレクトリ配下に model というディレクトリが生成され、ディレクトリの中に authors.gobooks.gomodel_base.go という 3 つのファイルが生成されました。books テーブルの tags カラムのデータ型は varchar[] 型でしたが、gen で生成するとエラーになり、生成された構造体にはフィールドが存在しませんでした。

.
├── generated
│   └── model
│   ├── authors.go
│   ├── books.go
│   └── model_base.go
├── go.mod
└── go.sum

gen ではデータベースのデータ型と構造体の型をマッピングする json ファイルを用意し、コマンド実行時にパラメータ --mapping= でファイルを指定することで任意の型のマッピングが可能になります。お手軽かつ柔軟にカスタマイズできる点は非常に良いと感じています。カラムが NULL を許容するかどうかで NULL を扱える型が生成できる点も嬉しいポイントです。ただし mapping.json をローカルにダウンロードし、以下のマッピング情報を追加して gen コマンドを実行時にファイルを指定しても

{
"sql_type": "varchar[]",
"go_type": "[]string",
"json_type": "Text",
"protobuf_type": "[]string",
"guregu_type": "null.String",
"go_nullable_type": "[]sql.NullString",
"swagger_type": "string"
},

table: books unable to generate struct field: tags type: _VARCHAR error: unknown sql type: _varchar というエラーは出力されてしまい、フィールドは生成されませんでした。謎のハックですが、エラーメッセージを確認し、以下のように _varchar 型とするとうまく生成できます。

    {
- "sql_type": "varchar[]",
+ "sql_type": "_varchar",
"go_type": "[]string",
"json_type": "Text",
"protobuf_type": "[]string",
"guregu_type": "null.String",
"go_nullable_type": "[]sql.NullString",
"swagger_type": "string"
},

さて books.go は以下のようなファイルです。デフォルトだと guregu/nullsatori/go.uuid といったモジュールを要求されます。テンプレートで拡張の余地がありそうです。

books.go
package model

import (
"database/sql"
"time"

"github.com/guregu/null"
"github.com/satori/go.uuid"
)

var (
_ = time.Second
_ = sql.LevelDefault
_ = null.Bool{}
_ = uuid.UUID{}
)

/*
DB Table Details
-------------------------------------


Table: books
[ 0] book_id INT4 null: false primary: true isArray: false auto: false col: INT4 len: -1 default: []
[ 1] author_id INT4 null: false primary: false isArray: false auto: false col: INT4 len: -1 default: []
[ 2] isbn TEXT null: false primary: false isArray: false auto: false col: TEXT len: -1 default: ['']
[ 3] booktype USER_DEFINED null: false primary: false isArray: false auto: false col: USER_DEFINED len: -1 default: ['FICTION']
[ 4] title TEXT null: false primary: false isArray: false auto: false col: TEXT len: -1 default: ['']
[ 5] year INT4 null: false primary: false isArray: false auto: false col: INT4 len: -1 default: [2000]
[ 6] available TIMESTAMPTZ null: false primary: false isArray: false auto: false col: TIMESTAMPTZ len: -1 default: ['2020-07-14 00:11:49.494152+00']
[ 7] tags _VARCHAR null: false primary: false isArray: false auto: false col: _VARCHAR len: -1 default: ['{}']
[ 8] memo TEXT null: true primary: false isArray: false auto: false col: TEXT len: -1 default: []


JSON Sample
-------------------------------------
{ "booktype": 46, "title": "EBVYCwqfRlayFOFOmrVgcynTE", "year": 11, "available": "2272-12-29T02:27:30.083660182+09:00", "tags": "VHTlEZoMqhtKpSMUrcdhBiUxj", "book_id": 14, "author_id": 44, "isbn": "gxvRbsFhNSFstWgOZBWVeGWvX"}



*/

// Books struct is a row record of the books table in the testdb database
type Books struct {
//[ 0] book_id INT4 null: false primary: true isArray: false auto: false col: INT4 len: -1 default: []
BookID int32
//[ 1] author_id INT4 null: false primary: false isArray: false auto: false col: INT4 len: -1 default: []
AuthorID int32
//[ 2] isbn TEXT null: false primary: false isArray: false auto: false col: TEXT len: -1 default: ['']
Isbn string
//[ 3] booktype USER_DEFINED null: false primary: false isArray: false auto: false col: USER_DEFINED len: -1 default: ['FICTION']
Booktype interface{}
//[ 4] title TEXT null: false primary: false isArray: false auto: false col: TEXT len: -1 default: ['']
Title string
//[ 5] year INT4 null: false primary: false isArray: false auto: false col: INT4 len: -1 default: [2000]
Year int32
//[ 6] available TIMESTAMPTZ null: false primary: false isArray: false auto: false col: TIMESTAMPTZ len: -1 default: ['2020-07-14 00:11:49.494152+00']
Available time.Time
//[ 8] memo TEXT null: true primary: false isArray: false auto: false col: TEXT len: -1 default: []
Memo sql.NullString
}

var booksTableInfo = &TableInfo{
Name: "books",
Columns: []*ColumnInfo{

&ColumnInfo{
Index: 0,
Name: "book_id",
Comment: ``,
Notes: ``,
Nullable: false,
DatabaseTypeName: "INT4",
DatabaseTypePretty: "INT4",
IsPrimaryKey: true,
IsAutoIncrement: false,
IsArray: false,
ColumnType: "INT4",
ColumnLength: -1,
GoFieldName: "BookID",
GoFieldType: "int32",
JSONFieldName: "book_id",
ProtobufFieldName: "book_id",
ProtobufType: "int32",
ProtobufPos: 1,
},

// ... 省略

&ColumnInfo{
Index: 8,
Name: "memo",
Comment: ``,
Notes: ``,
Nullable: true,
DatabaseTypeName: "TEXT",
DatabaseTypePretty: "TEXT",
IsPrimaryKey: false,
IsAutoIncrement: false,
IsArray: false,
ColumnType: "TEXT",
ColumnLength: -1,
GoFieldName: "Memo",
GoFieldType: "sql.NullString",
JSONFieldName: "memo",
ProtobufFieldName: "memo",
ProtobufType: "string",
ProtobufPos: 9,
},
},
}

// TableName sets the insert table name for this struct type
func (b *Books) TableName() string {
return "books"
}

// BeforeSave invoked before saving, return an error if field is not populated.
func (b *Books) BeforeSave() error {
return nil
}

// Prepare invoked before saving, can be used to populate fields etc.
func (b *Books) Prepare() {
}

// Validate invoked before performing action, return an error if field is not populated.
func (b *Books) Validate(action Action) error {
return nil
}

// TableInfo return table meta data
func (b *Books) TableInfo() *TableInfo {
return booksTableInfo
}

Gorm を意識したライブラリになっており、gen コマンドに --gorm というパラメータを付与すると生成する構造体に Gorm のタグが付与されます。authors.go ファイルに付与されたタグは以下のようなものです。

// Authors struct is a row record of the authors table in the testdb database
type Authors struct {
//[ 0] author_id INT4 null: false primary: true isArray: false auto: false col: INT4 len: -1 default: []
AuthorID int32 `gorm:"primary_key;column:author_id;type:INT4;"`
//[ 1] name TEXT null: false primary: false isArray: false auto: false col: TEXT len: -1 default: ['']
Name string `gorm:"column:name;type:TEXT;default:'';"`
}

gorm:"primary_key;column:author_id;type:INT4;" といったタグが付与されています。構造体のメソッドとして付与されている TableName() メソッドもおそらく Gorm を意識したものでしょう。以下のような TableName() メソッドを実装すると Gorm のテーブル名として string の文字列が使用されます。以下の場合テーブル名は authors です。

// TableName sets the insert table name for this struct type
func (a *Authors) TableName() string {
return "authors"
}

ちなみに Gorm の構造体として生成されるタグですが、外部キーは付与されませんでした。books テーブルにおける author_idauthors の外部キーですが、Gorm の構造体を見ると、外部キーであるタグは付与されていません。

// Books struct is a row record of the books table in the testdb database
type Books struct {
//[ 0] book_id INT4 null: false primary: true isArray: false auto: false col: INT4 len: -1 default: []
BookID int32 `gorm:"primary_key;column:book_id;type:INT4;"`
//[ 1] author_id INT4 null: false primary: false isArray: false auto: false col: INT4 len: -1 default: []
AuthorID int32 `gorm:"column:author_id;type:INT4;"`
//[ 2] isbn TEXT null: false primary: false isArray: false auto: false col: TEXT len: -1 default: ['']
Isbn string `gorm:"column:isbn;type:TEXT;default:'';"`
//[ 3] booktype USER_DEFINED null: false primary: false isArray: false auto: false col: USER_DEFINED len: -1 default: ['FICTION']
Booktype interface{} `gorm:"column:booktype;"`
//[ 4] title TEXT null: false primary: false isArray: false auto: false col: TEXT len: -1 default: ['']
Title string `gorm:"column:title;type:TEXT;default:'';"`
//[ 5] year INT4 null: false primary: false isArray: false auto: false col: INT4 len: -1 default: [2000]
Year int32 `gorm:"column:year;type:INT4;default:2000;"`
//[ 6] available TIMESTAMPTZ null: false primary: false isArray: false auto: false col: TIMESTAMPTZ len: -1 default: ['2020-07-14 00:11:49.494152+00']
Available time.Time `gorm:"column:available;type:TIMESTAMPTZ;default:'2020-07-14 00:11:49.494152+00';"`
//[ 8] memo TEXT null: true primary: false isArray: false auto: false col: TEXT len: -1 default: []
Memo sql.NullString `gorm:"column:memo;type:TEXT;"`
}

所感

  • Good
    • Gormdb といった O/Rマッパ と連携できるタグが付与された構造体を生成できる
    • 拡張できる点が多く、柔軟にカスタマイズできる
  • Bad
    • デフォルトだと生成される構造体がやや重厚
    • varchar[] 型があるテーブルから構造体を生成した場合にエラーが発生するなど、デフォルトでは扱えないデータ型がある

まとめ

スキーマからクエリを扱う関数やメソッド、構造体を生成するライブラリを調査しました

xoSQLBoiler と同じような系統で、スキーマや SQL をからデータベースアクセス可能な関数やメソッドを生成します。gen はスキーマから構造体を生成するライブラリです。Gormsqlx といった O/Rマッパ などと一緒に使う必要があるでしょう。

いずれにしてもスキーマから構造体を生成できる点は開発体験として良いと感じています。xogen も特徴的で、興味深いライブラリです。ただし、プロジェクトの開発に組み込んでいくにはテンプレートの拡張などいくつか工夫は必要だと感じました。


  1. 1.スキーマを変更した場合、構造体の生成だけでなくマイグレーション管理が必要ですが、本記事ではマイグレーションの話は割愛します。