GolangのジェネリクスでWriterDB/ReaderDBの型を分ける

アプリケーションからDBを使うときに読み込み専用のノードを作ってトランザクションの不要なクエリや重いクエリを読み込み専用のノードに投げるようにすることがよくある。

アプリケーションの全体の情報を保持する構造体があるとして、Writerノード・Readerノードの両方へのコネクションプールを持つ場合、以下のような構造になると思う。

type App struct {
  WriterDB *sql.DB
  ReaderDB *sql.DB
}

ジェネリクスを使わないで*sql.DBの型を分ける

ReaderDBしか使わない関数に対してWriterDBを渡されたくないので、WriterDB/ReaderDBの型を分けたい。 Embeddingすると同じインターフェースを持ちながら異なる型を定義できる。

type Writer struct {
  *sql.DB
}
type Reader struct {
  *sql.DB
}
type App struct {
  WriterDB *Writer
  ReaderDB *Reader
}

// `*Writer`は受け取れない
func procForReader(db *Reader) {
    // ...
}

WriterDB/ReaderDBの両方で使うような関数がある場合は、*sql.DBと同じメソッドを持つインターフェースを定義して引数の型にすれば*Writer*Readerの両方を渡すことができる。

type DB interface {
  Begin() (*sql.Tx, error)
  BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
  // ...
}

// `*Writer`と`*Reader`を受け取れる
func procForDB(db DB) {
  // ...
}

ReaderDBについては、読み込み処理しか行わないのでExec()Begin()は禁止したい。

QueryXXX()しか持たないインターフェースを定義すれば一応、制限することはできる。 しかしそれだと、*Writer*Reader両方を渡すことができてしまう。

type Queryer interface {
  Query(query string, args ...any) (*sql.Rows, error)
}

// `*Writer`も受け取れてしまう
func procForReader(db Queryer) {
  // ...
}

ジェネリクスで*sql.DBの型を分ける

…という要件をここ数日悶々と考えていて、ジェネリクスを使えばできそうなのでライブラリを書いてみた。

github.com

まずマーカーにする型を定義する。 型の実態はstruct{}でもintでも何でもいい。

type WriterDB struct{}
type ReaderDB struct{}

type MyDB interface {
  WriterDB | ReaderDB
}

そのマーカーを使って*dbtyp.DB[T]を生成する。

  writer, _ := dbtyp.New2[WriterDB](sql.Open("sqlite", "file::memory:"))
  reader, _ := dbtyp.New2[ReaderDB](sql.Open("sqlite", "file::memory:"))

*dbtyp.DB[T]*sql.DBを埋め込んでいるので同じメソッドを持つ。

type DB[T any] struct {
  *sql.DB
}

*dbtyp.DB[WriterDB]*dbtyp.DB[ReaderDB]は型が違うので当然、代入はできない。

  writer = reader // COMPILE ERROR!

DBを使う関数では型パラメーターで使うDBを制限できる。

// `*dbtyp.DB[WriterDB]`は受け取れない
func procForRaeder(db *dbtyp.DB[ReaderDB]) {
  // ...
}

もし、WriterDBとReaderDBの両方を受け取りたかったら型制約MyDBを使う。

// `*dbtyp.DB[WriterDB]`と`*dbtyp.DB[ReaderDB]`を受け取れる
func procForRW[T MyDB](db *dbtyp.DB[T]) {
  // ...
}

さらに*dbtyp.DB[T]はメソッドを制限した*dbtyp.Queryer[T]を生成できる。

*dbtyp.Queryer[T]を使えば、ReaderDBに対してQueryXXX()以外のメソッドの呼び出しを禁止することができる。

func main() {
   reader, _ := dbtyp.New2[ReaderDB](sql.Open("sqlite", "file::memory:"))
   q := reader.Queryer()
   procReader(q)
}

// `*dbtyp.Queryer[WriterDB]`は受け取れない
func procReader(q *dbtyp.Queryer[ReaderDB]) {
   q.Query("select 1") // QueryXXX()しか呼び出せない
}

既存の*sql.DB*sql.Txとの相互運用も踏まえて、双方に互換性のあるインターフェースも用意してみた。

iface package - github.com/winebarrel/dbtyp/iface - Go Packages

type DB interface {
    Begin() (*sql.Tx, error)
    BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
    Close() error
    Conn(ctx context.Context) (*sql.Conn, error)
    Driver() driver.Driver
    Exec(query string, args ...any) (sql.Result, error)
    ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
    Ping() error
    PingContext(ctx context.Context) error
    Prepare(query string) (*sql.Stmt, error)
    PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
    Query(query string, args ...any) (*sql.Rows, error)
    QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
    QueryRow(query string, args ...any) *sql.Row
    QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
    SetConnMaxIdleTime(d time.Duration)
    SetConnMaxLifetime(d time.Duration)
    SetMaxIdleConns(n int)
    SetMaxOpenConns(n int)
    Stats() sql.DBStats
}