インターフェイスに基づくGolangデータベースクライアントジェネレーター 。
データベースを操作するために、Golangはdatabase/sql
パッケージを提供しdatabase/sql
。これは、リレーショナルデータベースプログラミングインターフェイスの抽象化です。 一方では、パッケージには、接続プールの管理、準備済みステートメント、トランザクション、およびデータベースクエリインターフェイスの操作のための強力な機能が含まれています。 一方、データベースとやり取りするためには、Webアプリケーションに同じタイプのコードを相当量書く必要があります。 go-gad / salライブラリは、説明されているインターフェイスに基づいて同じタイプのコードを生成するという形でソリューションを提供します。
やる気
現在、ORMの形式でソリューションを提供する十分な数のライブラリ、クエリを構築するためのヘルパー、データベーススキーマに基づいてヘルパーを生成するライブラリがあります。
- https://github.com/jmoiron/sqlx
- https://github.com/go-reform/reform
- https://github.com/jinzhu/gorm
- https://github.com/Masterminds/squirrel
- https://github.com/volatiletech/sqlboiler
- https://github.com/drone/sqlgen
- https://github.com/gocraft/dbr
- https://github.com/go-gorp/gorp
- https://github.com/doug-martin/goqu
- https://github.com/src-d/go-kallax
- https://github.com/go-pg/pg
数年前にGolang言語に切り替えたとき、私はすでにさまざまな言語のデータベースを操作した経験がありました。 ActiveRecordなどのORMを使用する場合と使用しない場合。 愛から憎しみへと進み、数行のコードを追加することで問題なく、Golangのデータベースと対話することで、リポジトリパターンに似たものを思い付きました。 データベースを操作するインターフェイスについて説明し、標準のdb.Query、row.Scanを使用して実装します。 追加のラッパーを使用するのは意味がありませんでした。不透明で、注意を喚起します。
SQL言語自体は、すでにプログラムとリポジトリ内のデータの間の抽象化です。 データスキームを記述してから、複雑なクエリを作成しようとすることは、常に非論理的に思えました。 この場合の応答構造は、データスキームとは異なります。 契約は、データスキーマレベルではなく、要求および応答レベルで記述する必要があることがわかりました。 APIリクエストとレスポンスのデータ構造を説明するとき、Web開発でこのアプローチを使用します。 RESTful JSONまたはgRPCを使用してサービスにアクセスする場合、サービス内のエンティティのデータスキーマではなく、JSONスキーマまたはProtobufを使用して要求および応答レベルでコントラクトを宣言します。
つまり、データベースとの対話は、同様の方法になりました。
type User struct { ID int64 Name string } type Store interface { FindUser(id int64) (*User, error) } type Postgres struct { DB *sql.DB } func (pg *Postgres) FindUser(id int64) (*User, error) { var resp User err := pg.DB.QueryRow("SELECT id, name FROM users WHERE id=$1", id).Scan(&resp.ID, &resp.Name) if err != nil { return nil, err } return &resp, nil } func HanlderFindUser(s Store, id int) (*User, error) { // logic of service object user, err := s.FindUser(id) //... }
これにより、プログラムが予測可能になります。 しかし、正直に言って、これは詩人の夢ではありません。 ボイラープレートコードの量を減らして、クエリを構成し、データ構造にデータを追加し、変数バインディングを使用します。 目的のユーティリティセットが満たすべき要件のリストを作成しようとしました。
必要条件
- インターフェイスの形での相互作用の説明。
- インターフェースは、要求と応答のメソッドとメッセージによって記述されます。
- バインド変数と準備済みステートメントのサポート。
- 名前付き引数のサポート。
- データベース応答をメッセージデータ構造のフィールドにリンクします。
- 非定型データ構造(配列、json)のサポート。
- トランザクションを使用した透過的な作業。
- ミドルウェアのネイティブサポート。
インターフェイスを使用して、データベースとの対話の実装を抽象化します。 これにより、リポジトリなどの設計パターンに似たものを実装できます。 上記の例では、Storeインターフェースについて説明しました。 これで、依存関係として使用できます。 テスト段階では、このインターフェイスに基づいて生成されたスタブを渡すことができ、製品ではPostgres構造に基づいた実装を使用します。
各インターフェイスメソッドは、1つのデータベースクエリを記述します。 メソッドの入力および出力パラメーターは、要求のコントラクトの一部である必要があります。 クエリ文字列は、入力パラメータに応じてフォーマットできる必要があります。 これは、複雑なサンプリング条件でクエリをコンパイルする場合に特に当てはまります。
クエリをコンパイルするとき、置換と変数バインディングを使用します。 たとえば、PostgreSQLでは、値の代わりに$1
を記述し、クエリとともに引数の配列を渡します。 最初の引数は、変換されたクエリの値として使用されます。 準備された式のサポートにより、これらの同じ式のストレージの整理について心配する必要がなくなります。 データベース/ SQLライブラリは、準備された式をサポートするための強力なツールを提供し、それ自体が接続プール、閉じられた接続を処理します。 ただし、ユーザー側では、準備された式をトランザクションで再利用するために追加のアクションが必要です。
PostgreSQLやMySQLなどのデータベースは、置換と変数バインディングを使用するために異なる構文を使用します。 PostgreSQLは$1
、 $2
、...という形式を使用し?
値の場所に関係なく。 データベース/ SQLライブラリは、名前付き引数https://golang.org/pkg/database/sql/#NamedArgの汎用形式を提案しました。 使用例:
db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime))
この形式のサポートは、PostgreSQLまたはMySQLソリューションと比較して使用することをお勧めします。
ソフトウェアドライバーを処理するデータベースからの応答は、次のように条件付きで表すことができます。
dev > SELECT * FROM rubrics; id | created_at | title | url ----+-------------------------+-------+------------ 1 | 2012-03-13 11:17:23.609 | Tech | technology 2 | 2015-07-21 18:05:43.412 | Style | fashion (2 rows)
インターフェイスレベルでのユーザーの観点から、出力パラメーターを次の形式の構造体の配列として記述すると便利です。
type GetRubricsResp struct { ID int CreatedAt time.Time Title string URL string }
次に、 resp.ID
などにid
値をresp.ID
します。 一般に、この機能はほとんどのニーズに対応します。
内部データ構造を介してメッセージを宣言するとき、非標準のデータ型をサポートする方法の問題が生じます。 たとえば、配列。 PostgreSQLで作業するときにgithub.com/lib/pqドライバーを使用する場合、クエリ引数を渡すとき、または応答をスキャンするときにpq.Array(&x)
などの補助関数を使用できます。 ドキュメントの例:
db.Query(`SELECT * FROM t WHERE id = ANY($1)`, pq.Array([]int{235, 401})) var x []sql.NullInt64 db.QueryRow('SELECT ARRAY[235, 401]').Scan(pq.Array(&x))
したがって、データ構造を準備する方法が必要です。
インターフェースメソッドのいずれかを実行する場合、データベース接続は*sql.DB
形式で使用でき*sql.DB
。 単一のトランザクション内で複数のメソッドを実行する必要がある場合は、追加の引数を渡すのではなく、トランザクションの外部での作業と同様のアプローチで透過的な機能を使用します。
インターフェイス実装を使用する場合、ツールキットを組み込むことが重要です。 たとえば、すべてのリクエストを記録します。 ツールキットは、要求変数、応答エラー、ランタイム、インターフェイスメソッド名にアクセスする必要があります。
ほとんどの場合、要件は、データベースを操作するためのシナリオの体系化として策定されました。
解決策:go-gad / sal
定型コードを処理する1つの方法は、それを生成することです。 幸い、Golangにはこのhttps://blog.golang.org/generateのツールと例があります 。 GoMockのhttps://github.com/golang/mockアプローチは、インターフェース分析がリフレクションを使用して実行される世代のアーキテクチャソリューションとして採用されました。 このアプローチに基づいて、要件に従って、インターフェイス実装コードを生成し、一連の補助機能を提供するsalgenユーティリティとsalライブラリが作成されました。
このソリューションの使用を開始するには、データベースとの対話層の動作を記述するインターフェイスを記述する必要があります。 引数のセットでgo:generate
ディレクティブを指定し、 go:generate
を開始します。 コンストラクターと一連のボイラープレートコードが用意されており、すぐに使用できます。
package repo import "context" //go:generate salgen -destination=./postgres_client.go -package=dev/taxi/repo dev/taxi/repo Postgres type Postgres interface { CreateDriver(ctx context.Context, r *CreateDriverReq) error } type CreateDriverReq struct { taxi.Driver } func (r *CreateDriverReq) Query() string { return `INSERT INTO drivers(id, name) VALUES(@id, @name)` }
インターフェース
すべては、インターフェイスとgo generate
ユーティリティの特別なコマンドを宣言することから始まります。
//go:generate salgen -destination=./client.go -package=github.com/go-gad/sal/examples/profile/storage github.com/go-gad/sal/examples/profile/storage Store type Store interface { ...
ここでは、 Store
インターフェースの場合、コンソールユーティリティsalgen
がパッケージから呼び出され、2つのオプションと2つの引数があることを説明します。 最初のオプション-destination
は、生成されたコードが書き込まれるファイルを決定します。 2番目のオプション-package
は、生成された実装のライブラリのフルパス(インポートパス)を定義します。 以下は2つの引数です。 最初はインターフェースが配置されている完全なパッケージパス( github.com/go-gad/sal/examples/profile/storage
)を記述し、2番目はインターフェース名自体を示します。 go generate
のコマンドはどこにでも配置できることに注意してください。必ずしもターゲットインターフェイスの横にある必要はありません。
go generate
コマンドを実行した後、 New
プレフィックスをインターフェイス名に追加することで名前が作成されるコンストラクターを取得します。 コンストラクターは、 sal.QueryHandler
インターフェースに対応する必須パラメーターをsal.QueryHandler
ます。
type QueryHandler interface { QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) }
このインターフェイスは、 *sql.DB
オブジェクトに対応します。
connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full" db, err := sql.Open("postgres", connStr) client := storage.NewStore(db)
方法
インターフェイスメソッドは、使用可能なデータベースクエリのセットを決定します。
type Store interface { CreateAuthor(ctx context.Context, req CreateAuthorReq) (CreateAuthorResp, error) GetAuthors(ctx context.Context, req GetAuthorsReq) ([]*GetAuthorsResp, error) UpdateAuthor(ctx context.Context, req *UpdateAuthorReq) error }
- 引数の数は常に厳密に2です。
- 最初の引数はコンテキストです。
- 2番目の引数には、変数をバインドするためのデータが含まれ、クエリ文字列を定義します。
- 最初の出力パラメーターは、オブジェクト、オブジェクトの配列、または不在です。
- 最後の出力パラメーターは常にエラーです。
最初の引数は常にcontext.Context
オブジェクトです。 このコンテキストは、データベースとツールキットを呼び出すときに渡されます。 2番目の引数には、基本型struct
(またはstruct
へのポインター)を持つパラメーターが必要です。 パラメーターは、次のインターフェースを満たす必要があります。
type Queryer interface { Query() string }
Query()
メソッドは、データベースクエリを実行する前に呼び出されます。 結果の文字列は、データベース固有の形式に変換されます。 つまり、PostgreSQLの場合、 &req.End
は$1
に置き換えられ、値&req.End
が引数の配列に渡されます
出力パラメーターに応じて、どのメソッド(Query / Exec)が呼び出されるかが決定されます。
- 最初のパラメーターが基本型の
struct
(またはstruct
へのポインター)の場合、QueryContext
メソッドが呼び出されます。 データベースからの応答に単一の行が含まれていない場合、sql.ErrNoRows
エラーがsql.ErrNoRows
ます。 つまり、動作はdb.QueryRow
似ていdb.QueryRow
。 - 最初のパラメーターが基本タイプ
slice
場合、QueryContext
メソッドが呼び出されます。 データベースからの応答に行が含まれていない場合、空のリストが返されます。 リスト項目の基本型はstuct
(またはstruct
へのポインター)でなければなりません。 - 出力パラメーターが
error
タイプのパラメーターである場合、ExecContext
メソッドが呼び出されます。
準備されたステートメント
生成されたコードは、準備された式をサポートします。 準備された式はキャッシュされます。 式の最初の準備後、キャッシュされます。 データベース/ SQLライブラリ自体は、閉じられた接続の処理を含め、準備された式が目的のデータベース接続に透過的に適用されることを保証します。 順番に、 go-gad/sal
ライブラリは、トランザクションのコンテキストで準備された式を再利用します。 準備された式が実行されると、引数は開発者に透過的な変数バインディングを使用して渡されます。
go-gad/sal
ライブラリ側で名前付き引数をサポートするために、リクエストはデータベースに適したビューに変換されます。 現在、PostgreSQLの変換サポートがあります。 クエリオブジェクトのフィールド名は、名前付き引数の代わりに使用されます。 オブジェクトフィールド名の代わりに別の名前を指定するには、構造体フィールドにsql
タグを使用する必要があります。 例を考えてみましょう:
type DeleteOrdersRequest struct { UserID int64 `sql:"user_id"` CreateAt time.Time `sql:"created_at"` } func (r * DeleteOrdersRequest) Query() string { return `DELETE FROM orders WHERE user_id=@user_id AND created_at<@end` }
クエリ文字列が変換され、対応テーブルと変数バインディングを使用して、クエリ実行引数にリストが渡されます。
// generated code: db.Query("DELETE FROM orders WHERE user_id=$1 AND created_at<$2", &req.UserID, &req.CreatedAt)
構造体を要求の引数と応答メッセージにマップします
go-gad/sal
ライブラリは、データベースの応答行と応答構造、テーブルの列と構造フィールドの関連付けを処理します。
type GetRubricsReq struct {} func (r GetRubricReq) Query() string { return `SELECT * FROM rubrics` } type Rubric struct { ID int64 `sql:"id"` CreateAt time.Time `sql:"created_at"` Title string `sql:"title"` } type GetRubricsResp []*Rubric type Store interface { GetRubrics(ctx context.Context, req GetRubricsReq) (GetRubricsResp, error) }
データベースの応答が次の場合:
dev > SELECT * FROM rubrics; id | created_at | title ----+-------------------------+------- 1 | 2012-03-13 11:17:23.609 | Tech 2 | 2015-07-21 18:05:43.412 | Style (2 rows)
次に、GetRubricsRespリストが返されます。その要素はRubricポインターになり、タグ名に対応する列の値がフィールドに入力されます。
データベース応答に同じ名前の列が含まれている場合、対応する構造フィールドが宣言順に選択されます。
dev > select * from rubrics, subrubrics; id | title | id | title ----+-------+----+---------- 1 | Tech | 3 | Politics
type Rubric struct { ID int64 `sql:"id"` Title string `sql:"title"` } type Subrubric struct { ID int64 `sql:"id"` Title string `sql:"title"` } type GetCategoryResp struct { Rubric Subrubric }
非標準のデータ型
database/sql
パッケージは、基本的なデータ型(文字列、数値)のサポートを提供します。 要求または応答で配列やjsonなどのデータ型を処理するには、 driver.Valuer
およびsql.Scanner
をサポートする必要がありsql.Scanner
。 さまざまなドライバー実装には、特別なヘルパー関数があります。 たとえば、 lib/pq.Array
( https://godoc.org/github.com/lib/pq#Array ):
func Array(a interface{}) interface { driver.Valuer sql.Scanner }
デフォルトでは、ビュー構造フィールドのgo-gad/sql
ライブラリ
type DeleteAuthrosReq struct { Tags []int64 `sql:"tags"` }
値&req.Tags
を使用します。 構造がsal.ProcessRower
インターフェースを満たす場合、
type ProcessRower interface { ProcessRow(rowMap RowMap) }
その後、使用値を調整できます
func (r *DeleteAuthorsReq) ProcessRow(rowMap sal.RowMap) { rowMap.Set("tags", pq.Array(r.Tags)) } func (r *DeleteAuthorsReq) Query() string { return `DELETE FROM authors WHERE tags=ANY(@tags::UUID[])` }
このハンドラーは、要求および応答の引数に使用できます。 応答のリストの場合、メソッドはリスト項目に属している必要があります。
取引
トランザクションをサポートするには、インターフェース(ストア)を次のメソッドで拡張する必要があります。
type Store interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error) sal.Txer ...
メソッドの実装が生成されます。 BeginTx
メソッドは、現在のsal.QueryHandler
オブジェクトからの接続を使用して、トランザクションdb.BeginTx(...)
を開きます。 Store
インターフェースの新しい実装オブジェクトを返しますが、受信した*sql.Tx
オブジェクトを*sql.Tx
として使用します
ミドルウェア
ツールを埋め込むためのフックが用意されています。
type BeforeQueryFunc func(ctx context.Context, query string, req interface{}) (context.Context, FinalizerFunc) type FinalizerFunc func(ctx context.Context, err error)
BeforeQueryFunc
フックは、 db.PrepareContext
またはdb.Query
れる前にdb.PrepareContext
db.Query
ます。 つまり、プログラムの開始時、準備された式キャッシュが空の場合、 store.GetAuthors
呼び出されると、 BeforeQueryFunc
フックが2回呼び出されます。 BeforeQueryFunc
フックはFinalizerFunc
フックを返すことができます。このメソッドは、 defer
を使用して、ユーザーメソッド(この場合はstore.GetAuthors
)を終了する前に呼び出されます。
フックの実行時に、コンテキストには次の値を持つサービスキーが入力されます。
-
ctx.Value(sal.ContextKeyTxOpened)
ブール値は、メソッドがトランザクションのコンテキストで呼び出されるかどうかを決定します。 -
ctx.Value(sal.ContextKeyOperationType)
、操作タイプのストリング値、"QueryRow"
、"Query"
、"Exec"
、"Commit"
など -
ctx.Value(sal.ContextKeyMethodName)
"GetAuthors"
などのインターフェイスメソッドctx.Value(sal.ContextKeyMethodName)
文字列値。
引数として、 BeforeQueryFunc
フックは、クエリのsql文字列とユーザークエリメソッドのreq
引数を受け入れます。 FinalizerFunc
フックは、 err
変数を引数として受け取ります。
beforeHook := func(ctx context.Context, query string, req interface{}) (context.Context, sal.FinalizerFunc) { start := time.Now() return ctx, func(ctx context.Context, err error) { log.Printf( "%q > Opeartion %q: %q with req %#v took [%v] inTx[%v] Error: %+v", ctx.Value(sal.ContextKeyMethodName), ctx.Value(sal.ContextKeyOperationType), query, req, time.Since(start), ctx.Value(sal.ContextKeyTxOpened), err, ) } } client := NewStore(db, sal.BeforeQuery(beforeHook))
出力例:
"CreateAuthor" > Opeartion "Prepare": "INSERT INTO authors (Name, Desc, CreatedAt) VALUES($1, $2, now()) RETURNING ID, CreatedAt" with req <nil> took [50.819µs] inTx[false] Error: <nil> "CreateAuthor" > Opeartion "QueryRow": "INSERT INTO authors (Name, Desc, CreatedAt) VALUES(@Name, @Desc, now()) RETURNING ID, CreatedAt" with req bookstore.CreateAuthorReq{BaseAuthor:bookstore.BaseAuthor{Name:"foo", Desc:"Bar"}} took [150.994µs] inTx[false] Error: <nil>
次は何ですか
- MySQLのバインド変数と準備された式のサポート。
- 応答を調整するRowAppenderフック。
-
Exec.Result
の値を返します。