数か月前まで、あるオンラインサロンでチーム開発に参加していました。
その優勝チームと準優勝チームが合体し、そこにサロン主も加わり、継続部隊を結成して新規開発を行うことになりました。
私は再びバックエンド側メンバーに入りました。
継続部隊のバックエンドは、Go+Echoを使用することになりました。
チーム開発に参加しながら、私の学習の軌跡を記事にしていきたいと思います。
本シリーズのリンク
- チーム開発参加の記録【2023-10~2024-03】(1) Go言語用ORM「Bun」をDBファーストで使う試み(SQLite使用)
- チーム開発参加の記録【2023-10~2024-03】(2) Go言語からTursoを使ってみた
- チーム開発参加の記録【2023-10~2024-03】(3) slogを使ってリクエスト・レスポンス情報をログ出力してみた
- チーム開発参加の記録【2023-10~2024-03】(4) Go言語用ORM「Bun」をDBファーストで使う試み(PostgreSQL使用)
- チーム開発参加の記録【2023-10~2024-03】(5) Go言語用ORM「Bun」でトランザクション、UPSERT、JOINを使ってみた
- チーム開発参加の記録【2023-10~2024-03】(6) Go言語用ORM「Bun」で複数のクエリーをまとめてDBサーバーで実行
- チーム開発参加の記録【2023-10~2024-03】(7) slogでErrorレベルのログをSlackに飛ばしてみた
本記事で行うこと
前回(6月~8月)のチーム開発ではPostgreSQLを使用しましたが、今回はSQLiteが最有力候補となりました。
それに合わせて、DBクライアントライブラリも見直すことにしました。
候補に挙がったのは、SQLファーストを謳うORM「Bun」です。
前回はsqlcを使用して、私は大変気に入りましたが、Bunは気に入るでしょうか?
DBファーストで開発したい
前回使ったsqlcはDBファーストでした。
DBファーストって何?
大雑把にコードファーストと比較すると:
- コードファースト:アプリ側でORMのModelを記述し、アプリを動かしてテーブルを作成する
- DBファースト:ERモデリングツールにDDLを生成してもらうなり、人がDDLを書くなりして、それをDB側で実行する
今回もsqlc同様、DBファーストにしたいのです。
本記事は、ORM「Bun」をDBファーストで使う試みです。
何故DBファーストか
理由が2つあります。
- SQLiteのテーブルをSTRICTモードで作成したいから
Bunを使って、試しにSQLiteのテーブルをコードファーストで作成したところ、STRICTでないテーブルが作成されました。
SQLiteの世界観を否定する気はありませんが、私はSTRICTテーブルを使いたいです。
厳格な型付けのテーブルを作成するために、自分でDDLを書いてSTRICTテーブルを作りたい、というのが理由の一つです。
- DBとアプリを疎結合にしたいから
コードファーストでDBを作ると、DBとアプリが相互に依存して密結合になります。
一方でDBファーストで作れば、DBはアプリのコードに依存しません。
DBはアプリよりずっと長く使われるので、相対的に短命なアプリに依存させたくありません。
選択したORMがオワコン化する可能性もありますので、DBを特定のORMに依存させたくもありません。
また、DBはシステムの基盤的存在で、様々なアプリから共有されることもありますので、一つのアプリに依存させず疎結合にしたい、というのがもう一つの理由となります。
テーブルを作る
それでは作業に移ります。
DBファーストですので、まずDDLを書きます。
CREATE TABLE foo (
col_text TEXT NOT NULL,
col_text_null TEXT,
col_int INTEGER NOT NULL,
col_int_null INTEGER,
col_real REAL NOT NULL,
col_real_null REAL,
col_timestamp REAL NOT NULL,
col_timestamp_null REAL,
col_json TEXT NOT NULL,
col_json_null TEXT,
col_array TEXT NOT NULL,
col_array_null TEXT
) STRICT;
上記DDLの意図
STRICTテーブルで使える型は以下に限られています。
- INT
- INTEGER
- REAL
- TEXT
- BLOB
- ANY
このうち、プロジェクトで使う可能性が高い以下の型を試します。
- TEXT
- INTEGER
- REAL
加えて、日付・時刻も試します。
日付・時刻は、以下の型で保存できます。
- TEXT:ISO-8601形式
- REAL:ユリウス通日
- INTEGER:1970年からの秒数
本記事では日付・時刻をREAL型で保存することにしました。理由は、
- INTEGER型で持つと精度が秒になるが、REAL型やTEXT型は秒の小数の精度まで持てる
- TEXT型は保存フォーマットに気を遣う必要があって面倒だし、バイト数もREAL型より大きい
日付・時刻に加えて、jsonと配列の保存も試します。
これらはTEXT型で保存します。
以上が上記DDLの意図です。
テスト用データをINSERTする
作成したfooテーブルに、以下のSQLでテスト用データを2件INSERTしました。
INSERT INTO foo
(
col_text,
col_text_null,
col_int,
col_int_null,
col_real,
col_real_null,
col_timestamp,
col_timestamp_null,
col_json,
col_json_null,
col_array,
col_array_null
)
VALUES
(
'text_data',
NULL,
1234567890123456789,
NULL,
3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
NULL,
JULIANDAY('now'),
NULL,
'{"a":100,"b":"bbb"}',
NULL,
'[10,null]',
NULL
),
(
'text_data',
'text_data',
1234567890123456789,
1234567890123456789,
3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
JULIANDAY('now'),
JULIANDAY('now'),
'{"a":200,"b":null}',
'{"a":300,"z":"zzz"}',
'[30,null,50]',
'[]'
);
INSERTしたら、fooテーブルにSELECT文を発行してみます。
SELECT
col_text,
col_text_null,
col_int,
col_int_null,
col_real,
col_real_null,
STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp,
STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null,
col_json,
col_json_null,
col_array,
col_array_null
FROM
foo;
画像が小さいですが、SELECT文の結果です。
EchoとBunを使って、fooテーブルにCRUD操作するWeb APIサーバーを作ってみる
それでは、Go言語からBunを使っていきます。
最初に、今回書いたソースコード全体を載せてしまいます。
ディレクトリ構造とファイル一覧
Project Root
├── app.go
└── go.mod
app.go
package main
import (
"context"
"database/sql"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/driver/sqliteshim"
"github.com/uptrace/bun/extra/bundebug"
)
type (
httpError struct {
Error string `json:"error"`
}
httpMessage struct {
Message string `json:"message"`
}
)
var (
db *bun.DB
)
func selectFoo(c echo.Context) error {
type (
jsonType struct {
A *int `json:"a"`
B *string `json:"b"`
}
resultSetType struct {
ColText string `json:"colText"`
ColTextNull *string `json:"colTextNull"`
ColInt int64 `json:"colInt"`
ColIntNull *int64 `json:"colIntNull"`
ColReal float64 `json:"colReal"`
ColRealNull *float64 `json:"colRealNull"`
ColTimestamp time.Time `json:"colTimestamp"`
ColTimestampNull bun.NullTime `json:"colTimestampNull"`
ColJson jsonType `json:"colJson"`
ColJsonNull *jsonType `json:"colJsonNull"`
ColArray []*int64 `json:"colArray"`
ColArrayNull *[]*int64 `json:"colArrayNull"`
}
)
ctx := context.TODO()
resultSetFoo := make([]resultSetType, 0)
if err := db.NewSelect().
Column("col_text").
Column("col_text_null").
Column("col_int").
Column("col_int_null").
Column("col_real").
Column("col_real_null").
ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp").
ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null").
Column("col_json").
Column("col_json_null").
Column("col_array").
Column("col_array_null").
Table("foo").
Scan(ctx, &resultSetFoo); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
return c.JSON(http.StatusOK, resultSetFoo)
}
func updateFoo(c echo.Context) error {
ctx := context.TODO()
if _, err := db.NewUpdate().
Table("foo").
SetColumn("col_text_null", "?", "ABC").
Where("col_text_null IS NULL").
Exec(ctx); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
return c.JSON(http.StatusOK, httpMessage{Message: "ok"})
}
func insertFoo(c echo.Context) error {
type dummyModel struct{}
ctx := context.TODO()
if _, err := db.NewInsert().
Model((*dummyModel)(nil)).
ModelTableExpr("foo").
Value("col_text", "?", "XYZ").
Value("col_text_null", "?", nil).
Value("col_int", "?", 123).
Value("col_int_null", "?", nil).
Value("col_real", "?", 3.14).
Value("col_real_null", "?", nil).
Value("col_timestamp", "JULIANDAY('now')").
Value("col_timestamp_null", "?", nil).
Value("col_json", "?", "{}").
Value("col_json_null", "?", nil).
Value("col_array", "?", "[]").
Value("col_array_null", "?", nil).
Exec(ctx); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
return c.JSON(http.StatusOK, httpMessage{Message: "ok"})
}
func deleteFoo(c echo.Context) error {
ctx := context.TODO()
if _, err := db.NewDelete().
Table("foo").
Where("col_text = ?", "XYZ").
Exec(ctx); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
return c.JSON(http.StatusOK, httpMessage{Message: "ok"})
}
func main() {
sqlite, err := sql.Open(sqliteshim.ShimName, "/home/user/DataGripProjects/sqlite/exercise.sqlite3")
if err != nil {
panic(err)
}
sqlite.SetMaxOpenConns(1)
db = bun.NewDB(sqlite, sqlitedialect.New())
db.AddQueryHook(bundebug.NewQueryHook(
bundebug.WithVerbose(true),
bundebug.FromEnv("BUNDEBUG"),
))
// EchoでAPIサーバー
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Routing
api := e.Group("/api")
api.GET("/selectFoo", selectFoo)
api.PUT("/updateFoo", updateFoo)
api.POST("/insertFoo", insertFoo)
api.DELETE("/deleteFoo", deleteFoo)
e.Logger.Fatal(e.Start(":1323"))
}
go.mod
module exercise_bun
go 1.21
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/labstack/echo/v4 v4.11.3 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/uptrace/bun v1.1.16 // indirect
github.com/uptrace/bun/dialect/sqlitedialect v1.1.16 // indirect
github.com/uptrace/bun/driver/sqliteshim v1.1.16 // indirect
github.com/uptrace/bun/extra/bundebug v1.1.16 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.13.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.1 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.25.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
CRUD操作
それでは、CRUD操作について一つ一つ見ていきます。
SELECT文
まずはSELECT文です。
selectFooハンドラの中身
ハンドラの中身を見ていきます。
ます、クエリーの結果表(リザルトセット)を格納する受け皿の型を定義しています。
NULLが入る可能性のある変数は、日付・時刻はbun.NullTime型で定義し、それ以外はポインタ型で定義します。
type (
jsonType struct {
A *int `json:"a"`
B *string `json:"b"`
}
resultSetType struct {
ColText string `json:"colText"`
ColTextNull *string `json:"colTextNull"`
ColInt int64 `json:"colInt"`
ColIntNull *int64 `json:"colIntNull"`
ColReal float64 `json:"colReal"`
ColRealNull *float64 `json:"colRealNull"`
ColTimestamp time.Time `json:"colTimestamp"`
ColTimestampNull bun.NullTime `json:"colTimestampNull"`
ColJson jsonType `json:"colJson"`
ColJsonNull *jsonType `json:"colJsonNull"`
ColArray []*int64 `json:"colArray"`
ColArrayNull *[]*int64 `json:"colArrayNull"`
}
)
この型をモデルとみなすかどうかですが、広義ではモデルだと思いますが、私はORMのModelとは別物と認識しています。
本記事のResultSetと、ORMのModelの違い
ORMのModelは、コードファーストで必要です。
RDBの実テーブルとアプリのModelが密結合する形になります。
一方、本記事のようなDBファーストでは、「SELECT文では」実テーブルと結合したModelは不要と私は考えています。
代わりに必要なのが、クエリーの結果表に相当するResultSetです。
ResultSetとModelの違いですが、ここでデータベーススペシャリスト試験でも出題される「ANSI/SPARC 3層スキーマ」を思い出しましょう。
3層スキーマは、データベースのスキーマを「外部スキーマ/概念スキーマ/内部スキーマ」の3階層で定義する方式ですね。
ResultSetは外部スキーマに相当し、Modelは概念スキーマに相当します。
3層スキーマでは、ユーザー(アプリも含む)は「外部スキーマを介してデータにアクセスせよ」と言っています。
3層スキーマに従うなら、SELECT文において、概念スキーマと結合したModelをアプリで使うのは、行儀が悪いというのが私の意見です。
従って、本記事のselectFooハンドラではModelは登場せず、代わりにResultSetが登場しています。
アプリケーションは本来は、SQLの結果表をモデルとして扱うべきだと思います。
クエリー~レスポンスを返す部分
続きのソースコードを見てみます。
BunはSQLファーストを謳っているだけあって、SQL文が透けて見えますね。
本記事では単純なクエリーしか生成してもらっていませんが、Bunでは複雑なクエリーも生成してくれるそうです。
私みたいにSQLを書きたい人、N+1問題を気にしながらORMを使うなんて馬鹿らしいと思っている人、必要に応じてSQLチューニングをしたい人にとって、Bunは嬉しいORMだと思います。
resultSetFoo := make([]resultSetType, 0)
if err := db.NewSelect().
Column("col_text").
Column("col_text_null").
Column("col_int").
Column("col_int_null").
Column("col_real").
Column("col_real_null").
ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp").
ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null").
Column("col_json").
Column("col_json_null").
Column("col_array").
Column("col_array_null").
Table("foo").
Scan(ctx, &resultSetFoo); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
return c.JSON(http.StatusOK, resultSetFoo)
Bunが生成したSQL
上記コードからBunが生成したSQLを、整形して見やすくしました。
SELECT
"col_text",
"col_text_null",
"col_int",
"col_int_null",
"col_real",
"col_real_null",
STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp,
STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null,
"col_json",
"col_json_null",
"col_array",
"col_array_null"
FROM
"foo"
selectFoo APIの実行結果
selectFoo APIを呼び出した結果です。
レスポンスは、ハンドラの最後の一行で、結果表をそのままjsonにして返しているだけですが、きれいなレスポンスが生成できていますね。
シンプルなコードできれいなレスポンスを返せているのはポイントが高いです。
[
{
"colText": "text_data",
"colTextNull": null,
"colInt": 1234567890123456789,
"colIntNull": null,
"colReal": 3.141592653589793,
"colRealNull": null,
"colTimestamp": "2023-12-13T10:41:18.524Z",
"colTimestampNull": null,
"colJson": {
"a": 100,
"b": "bbb"
},
"colJsonNull": null,
"colArray": [
10,
null
],
"colArrayNull": null
},
{
"colText": "text_data",
"colTextNull": "text_data",
"colInt": 1234567890123456789,
"colIntNull": 1234567890123456789,
"colReal": 3.141592653589793,
"colRealNull": 3.141592653589793,
"colTimestamp": "2023-12-13T10:41:18.524Z",
"colTimestampNull": "2023-12-13T10:41:18.524Z",
"colJson": {
"a": 200,
"b": null
},
"colJsonNull": {
"a": 300,
"b": null
},
"colArray": [
30,
null,
50
],
"colArrayNull": []
}
]
SELECT文振り返り
ORMはコードファースト前提で開発されていることが多く、Bunも例外ではないと思います。
でもBunをDBファーストで試したら、問題なく動いてくれました。
jsonや配列を格納した列もGoの型にマッピングできて、とても良いと思います。
UPDATE文
次はUPDATE文を見ていきます。
updateFooハンドラの中身
ハンドラのクエリー部分を見ます。
col_text_null列がNULLの行について、同じ列を"ABC"に更新する内容です。
if _, err := db.NewUpdate().
Table("foo").
SetColumn("col_text_null", "?", "ABC").
Where("col_text_null IS NULL").
Exec(ctx); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
Bunが生成したSQL
上記コードからBunが生成したSQLを、整形して見やすくしました。
UPDATE "foo" SET
col_text_null = 'ABC'
WHERE
(col_text_null IS NULL)
updateFoo APIの実行結果
updateFoo APIを呼び出した結果です。
col_text_nullがNULLだった行の意図した列が、意図した値に更新されました。
Bunとsqlcを比較:Bunは動的SQLを組み立てやすそう
ここでBunとsqlcを比較してみます。
UPDATE文では、テーブルの一部の列を更新したいケース、それもどの列を更新するか実行時に決まるケースがしばしば出てきます。
その場合、事前にSQL文を用意しようとすると大量のSQLが必要になるため、そうせずに実行時に動的にSQL文を組み立てることが多いです。
sqlcは事前にSQL文を用意する方式であり、動的にSQL文を組み立てる機能はなさそうです。
その点Bunは、updateFooハンドラのコードで言えば、必要なSetColumn()を実行時に付け加えていけば良いので、簡単に動的SQL文を組み立てられそうです。
そんな訳で、私はsqlcよりもBunを使っていきたいと思いました。
INSERT文
次はINSERT文を見ていきます。
insertFooハンドラの中身
ハンドラのクエリー部分を見ます。
NULLを入れたい列にnilを設定しています。
type dummyModel struct{}
ctx := context.TODO()
if _, err := db.NewInsert().
Model((*dummyModel)(nil)).
ModelTableExpr("foo").
Value("col_text", "?", "XYZ").
Value("col_text_null", "?", nil).
Value("col_int", "?", 123).
Value("col_int_null", "?", nil).
Value("col_real", "?", 3.14).
Value("col_real_null", "?", nil).
Value("col_timestamp", "JULIANDAY('now')").
Value("col_timestamp_null", "?", nil).
Value("col_json", "?", "{}").
Value("col_json_null", "?", nil).
Value("col_array", "?", "[]").
Value("col_array_null", "?", nil).
Exec(ctx); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
このコード、Model周りの記述が変ですみません。
本当はUPDATE文の時みたいに「Table("foo")」と書きたかったのですが、Bunが「"bun: Model(nil)"」とエラーメッセージを吐いて受け付けてくれませんでした。
やり方を変えれば、変じゃないコードを書くことが可能でしょう。
Bunが生成したSQL
上記コードからBunが生成したSQLを、整形して見やすくしました。
INSERT INTO foo (
"col_text",
"col_text_null",
"col_int",
"col_int_null",
"col_real",
"col_real_null",
"col_timestamp",
"col_timestamp_null",
"col_json",
"col_json_null",
"col_array",
"col_array_null"
)
VALUES (
'XYZ',
NULL,
123,
NULL,
3.14,
NULL,
JULIANDAY('now'),
NULL,
'{}',
NULL,
'[]',
NULL
)
insertFoo APIの実行結果
insertFoo APIを呼び出した結果です。
3行目に、意図した行がINSERTされたことを確認できました。
DELETE文
次はDELETE文を見ていきます。
deleteFooハンドラの中身
ハンドラのクエリー部分を見ます。
col_text列が"XYZ"の行を削除する内容です。
if _, err := db.NewDelete().
Table("foo").
Where("col_text = ?", "XYZ").
Exec(ctx); err != nil {
return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
}
Bunが生成したSQL
上記コードからBunが生成したSQLを、整形して見やすくしました。
DELETE FROM
"foo"
WHERE
(col_text = 'XYZ')
deleteFoo APIの実行結果
deleteFoo APIを呼び出した結果です。
意図した行(3行目)が正常に削除されました。
まとめ
BunをDBファーストで使ってみたところ、問題なく動いてくれました。
BunはSQLファーストを謳っているだけあって、SQLを書きたい私でも使いたいと思わせてくれるORMでした。
記事中でBunとsqlcを比較しましたが、私はsqlcからBunに乗り換えたいと思いました。
おまけ
BunでRelationという名のメソッドがありますが、これはRelationshipと命名してほしかったです。
リレーショナルモデルのリレーション(関係、集合)と、ERモデルのリレーションシップ(関連、関係性)は全く別物ですので。