Golang Datenbank Client Generator basierend auf Schnittstelle.

Für die Arbeit mit Datenbanken bietet Golang das database/sql
Paket an, eine Abstraktion der relationalen Datenbankprogrammierschnittstelle. Einerseits enthält das Paket leistungsstarke Funktionen zum Verwalten des Verbindungspools, zum Arbeiten mit vorbereiteten Anweisungen, Transaktionen und zur Datenbankabfrageschnittstelle. Andererseits müssen Sie für die Interaktion mit einer Datenbank eine beträchtliche Menge des gleichen Codetyps in eine Webanwendung schreiben. Die go-gad / sal-Bibliothek bietet eine Lösung in Form der Generierung des gleichen Codetyps basierend auf der beschriebenen Schnittstelle.
Motivation
Heutzutage gibt es eine ausreichende Anzahl von Bibliotheken, die Lösungen in Form von ORMs anbieten, Helfer zum Erstellen von Abfragen und Generieren von Helfern basierend auf einem Datenbankschema.
Als ich vor einigen Jahren zur Sprache Golang wechselte, hatte ich bereits Erfahrung mit Datenbanken in verschiedenen Sprachen. Verwenden von ORM wie ActiveRecord und ohne. Nachdem ich von der Liebe zum Hass übergegangen war und keine Probleme hatte, ein paar zusätzliche Codezeilen zu schreiben, kam die Interaktion mit der Datenbank in Golang zu einem Repository-Muster. Wir beschreiben die Schnittstelle für die Arbeit mit der Datenbank und implementieren sie mit dem Standard db.Query, row.Scan. Zusätzliche Wrapper zu verwenden war einfach nicht sinnvoll, es war undurchsichtig, es würde zwingen, auf der Hut zu sein.
Die SQL-Sprache selbst ist bereits eine Abstraktion zwischen Ihrem Programm und den Daten im Repository. Es erschien mir immer unlogisch, zu versuchen, ein Datenschema zu beschreiben und dann komplexe Abfragen zu erstellen. Die Antwortstruktur unterscheidet sich in diesem Fall vom Datenschema. Es stellt sich heraus, dass der Vertrag nicht auf der Ebene des Datenschemas, sondern auf der Ebene der Anforderung und Antwort beschrieben werden muss. Wir verwenden diesen Ansatz in der Webentwicklung, wenn wir die Datenstrukturen von API-Anforderungen und -Antworten beschreiben. Beim Zugriff auf den Service mit RESTful JSON oder gRPC deklarieren wir den Vertrag auf Anforderungs- und Antwortebene mit JSON-Schema oder Protobuf und nicht mit dem Datenschema von Entitäten innerhalb der Services.
Das heißt, die Interaktion mit der Datenbank ergab eine ähnliche Methode:
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) {
Auf diese Weise wird Ihr Programm vorhersehbar. Aber um ehrlich zu sein, ist dies kein Traum eines Dichters. Wir möchten die Menge an Boilerplate-Code reduzieren, um eine Abfrage zu erstellen, Datenstrukturen zu füllen, Variablenbindungen zu verwenden und so weiter. Ich habe versucht, eine Liste von Anforderungen zu formulieren, die die gewünschten Dienstprogramme erfüllen sollten.
Anforderungen
- Beschreibung der Interaktion in Form einer Schnittstelle.
- Die Schnittstelle wird durch Methoden und Nachrichten von Anforderungen und Antworten beschrieben.
- Unterstützung für Bindungsvariablen und vorbereitete Anweisungen.
- Unterstützung für benannte Argumente.
- Verknüpfen der Datenbankantwort mit den Feldern der Nachrichtendatenstruktur.
- Unterstützung für atypische Datenstrukturen (Array, JSON).
- Transparente Arbeit mit Transaktionen.
- Native Unterstützung für Middleware.
Wir wollen die Implementierung der Interaktion mit der Datenbank über die Schnittstelle abstrahieren. Auf diese Weise können wir etwas implementieren, das einem Entwurfsmuster wie einem Repository ähnelt. Im obigen Beispiel haben wir die Store-Oberfläche beschrieben. Jetzt können wir es als Abhängigkeit verwenden. In der Testphase können wir einen auf der Grundlage dieser Schnittstelle generierten Stub übergeben, und im Produkt werden wir unsere Implementierung basierend auf der Postgres-Struktur verwenden.
Jede Schnittstellenmethode beschreibt eine Datenbankabfrage. Die Eingabe- und Ausgabeparameter der Methode müssen Bestandteil des Vertrags für die Anforderung sein. Die Abfragezeichenfolge muss in Abhängigkeit von den Eingabeparametern formatiert werden können. Dies gilt insbesondere beim Kompilieren von Abfragen mit einer komplexen Stichprobenbedingung.
Beim Kompilieren einer Abfrage möchten wir Substitution und Variablenbindung verwenden. In PostgreSQL schreiben Sie beispielsweise $1
anstelle eines Werts und übergeben zusammen mit der Abfrage ein Array von Argumenten. Das erste Argument wird als Wert in der konvertierten Abfrage verwendet. Durch die Unterstützung vorbereiteter Ausdrücke müssen Sie sich keine Gedanken über die Organisation der Speicherung derselben Ausdrücke machen. Die Datenbank- / SQL-Bibliothek bietet ein leistungsstarkes Tool zur Unterstützung vorbereiteter Ausdrücke. Sie selbst kümmert sich um den Verbindungspool und geschlossene Verbindungen. Seitens des Benutzers ist jedoch eine zusätzliche Aktion erforderlich, um den vorbereiteten Ausdruck in der Transaktion wiederzuverwenden.
Datenbanken wie PostgreSQL und MySQL verwenden unterschiedliche Syntax für die Verwendung von Substitutionen und Variablenbindungen. PostgreSQL verwendet das Format $1
, $2
, ... MySQL verwendet ?
unabhängig vom Ort des Wertes. Die Datenbank- / SQL-Bibliothek schlug ein universelles Format für benannte Argumente vor: https://golang.org/pkg/database/sql/#NamedArg . Anwendungsbeispiel:
db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime))
Die Unterstützung dieses Formats ist im Vergleich zu PostgreSQL- oder MySQL-Lösungen vorzuziehen.
Die Antwort aus der Datenbank, die den Softwaretreiber verarbeitet, kann bedingt wie folgt dargestellt werden:
dev > SELECT * FROM rubrics; id | created_at | title | url
Aus Sicht des Benutzers auf Schnittstellenebene ist es zweckmäßig, den Ausgabeparameter als Array von Strukturen des Formulars zu beschreiben:
type GetRubricsResp struct { ID int CreatedAt time.Time Title string URL string }
resp.ID
als Nächstes den id
Wert auf resp.ID
und so weiter. Im Allgemeinen deckt diese Funktionalität die meisten Anforderungen ab.
Bei der Deklaration von Nachrichten über interne Datenstrukturen stellt sich die Frage, wie nicht standardmäßige Datentypen unterstützt werden können. Zum Beispiel ein Array. Wenn Sie bei der Arbeit mit PostgreSQL den Treiber github.com/lib/pq verwenden, können Sie beim Übergeben von Abfrageargumenten oder beim Scannen einer Antwort Zusatzfunktionen wie pq.Array(&x)
. Beispiel aus der Dokumentation:
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))
Dementsprechend muss es Möglichkeiten geben, Datenstrukturen vorzubereiten.
Bei der Ausführung einer der Schnittstellenmethoden kann eine Datenbankverbindung in Form eines *sql.DB
. Wenn Sie mehrere Methoden innerhalb einer einzelnen Transaktion ausführen müssen, möchte ich transparente Funktionen mit einem ähnlichen Ansatz wie das Arbeiten außerhalb einer Transaktion verwenden und keine zusätzlichen Argumente übergeben.
Bei der Arbeit mit Schnittstellenimplementierungen ist es wichtig, dass wir das Toolkit einbetten können. Beispiel: Protokollieren aller Anforderungen. Das Toolkit muss Zugriff auf die Anforderungsvariablen, den Antwortfehler, die Laufzeit und den Namen der Schnittstellenmethode erhalten.
Die Anforderungen wurden größtenteils als Systematisierung von Datenbankszenarien formuliert.
Lösung: go-gad / sal
Eine Möglichkeit, mit Boilerplate-Code umzugehen, besteht darin, ihn zu generieren. Glücklicherweise hat Golang Tools und Beispiele für dieses https://blog.golang.org/generate . GoMock https://github.com/golang/mock , bei dem die Analyse der Schnittstelle mithilfe von Reflexion durchgeführt wird, wurde als Architekturlösung für die Generation ausgeliehen. Basierend auf diesem Ansatz wurden gemäß den Anforderungen das Dienstprogramm salgen und die Bibliothek sal geschrieben, die Schnittstellenimplementierungscode generieren und eine Reihe von Hilfsfunktionen bereitstellen.
Um diese Lösung verwenden zu können, muss eine Schnittstelle beschrieben werden, die das Verhalten der Interaktionsschicht mit der Datenbank beschreibt. Geben go:generate
Anweisung go:generate
mit einer Reihe von Argumenten an und starten Sie die Generierung. Sie erhalten einen Konstruktor und eine Reihe von Boilerplate-Code, die sofort einsatzbereit sind.
package repo import "context"
Schnittstelle
Alles beginnt mit der Deklaration der Schnittstelle und einem speziellen Befehl für das Dienstprogramm go generate
:
Hier wird beschrieben, dass für unsere Store
Oberfläche das Konsolendienstprogramm salgen
aus dem Paket mit zwei Optionen und zwei Argumenten salgen
wird. Die erste Option -destination
bestimmt, in welche Datei der generierte Code geschrieben wird. Das zweite Optionspaket definiert den vollständigen Pfad (Importpfad) der Bibliothek für die generierte Implementierung. Das Folgende sind zwei Argumente. Der erste beschreibt den vollständigen Paketpfad ( github.com/go-gad/sal/examples/profile/storage
), in dem sich die Schnittstelle befindet, der zweite gibt den Schnittstellennamen selbst an. Beachten Sie, dass sich der Befehl für go generate
beliebigen Stelle befinden kann, nicht unbedingt neben der Zielschnittstelle.
Nach dem Ausführen des Befehls go generate
wir einen Konstruktor, dessen Name durch Hinzufügen des Präfixes New
zum Schnittstellennamen erstellt wird. Der Konstruktor verwendet einen erforderlichen Parameter, der der Schnittstelle 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) }
Diese Schnittstelle entspricht dem Objekt *sql.DB
connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full" db, err := sql.Open("postgres", connStr) client := storage.NewStore(db)
Methoden
Schnittstellenmethoden bestimmen den Satz verfügbarer Datenbankabfragen.
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 }
- Die Anzahl der Argumente beträgt immer streng zwei.
- Das erste Argument ist der Kontext.
- Das zweite Argument enthält Daten zum Binden von Variablen und definiert die Abfragezeichenfolge.
- Der erste Ausgabeparameter kann ein Objekt, ein Array von Objekten oder nicht vorhanden sein.
- Der letzte Ausgabeparameter ist immer ein Fehler.
Das erste Argument ist immer das context.Context
Objekt. Dieser Kontext wird beim Aufrufen der Datenbank und des Toolkits weitergegeben. Das zweite Argument erwartet einen Parameter mit dem Basistyp struct
(oder einem Zeiger auf struct
). Der Parameter muss die folgende Schnittstelle erfüllen:
type Queryer interface { Query() string }
Die Query()
-Methode wird aufgerufen, bevor eine Datenbankabfrage ausgeführt wird. Die resultierende Zeichenfolge wird in ein datenbankspezifisches Format konvertiert. Das heißt, für PostgreSQL wird @end
durch $1
und der Wert &req.End
wird an das Array von Argumenten übergeben
Abhängig von den Ausgabeparametern wird festgelegt, welche der Methoden (Query / Exec) aufgerufen wird:
- Wenn der erste Parameter vom Basistyp
struct
(oder ein Zeiger auf struct
) ist, wird die QueryContext
Methode aufgerufen. Wenn die Antwort aus der Datenbank keine einzelne Zeile enthält, wird der Fehler sql.ErrNoRows
. Das heißt, das Verhalten ist ähnlich wie bei db.QueryRow
. - Wenn sich der erste Parameter mit dem
slice
vom QueryContext
, wird die QueryContext
Methode aufgerufen. Wenn die Antwort aus der Datenbank keine Zeilen enthält, wird eine leere Liste zurückgegeben. Der Basistyp des Listenelements muss stuct
(oder ein Zeiger auf eine struct
) sein. - Wenn der Ausgabeparameter eins mit dem
error
, wird die ExecContext
Methode aufgerufen.
Vorbereitete Aussagen
Der generierte Code unterstützt vorbereitete Ausdrücke. Vorbereitete Ausdrücke werden zwischengespeichert. Nach der ersten Vorbereitung des Ausdrucks wird er zwischengespeichert. Die Datenbank- / SQL-Bibliothek selbst stellt sicher, dass vorbereitete Ausdrücke transparent auf die gewünschte Datenbankverbindung angewendet werden, einschließlich der Verarbeitung geschlossener Verbindungen. Die go-gad/sal
Bibliothek sorgt wiederum dafür, dass die vorbereitete Anweisung im Kontext der Transaktion wiederverwendet wird. Wenn der vorbereitete Ausdruck ausgeführt wird, werden die Argumente mithilfe einer Variablenbindung übergeben, die für den Entwickler transparent ist.
Um benannte Argumente auf der Seite der go-gad/sal
Bibliothek zu unterstützen, wird die Anforderung in eine für die Datenbank geeignete Ansicht konvertiert. Es gibt jetzt Konvertierungsunterstützung für PostgreSQL. Die Feldnamen des Abfrageobjekts werden verwendet, um benannte Argumente zu ersetzen. Um einen anderen Namen anstelle des Objektfeldnamens anzugeben, müssen Sie das sql
Tag für Strukturfelder verwenden. Betrachten Sie ein Beispiel:
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` }
Die Abfragezeichenfolge wird konvertiert und unter Verwendung der Korrespondenztabelle und der Variablenbindung wird eine Liste an die Ausführungsargumente der Abfrage übergeben:
Ordnen Sie Strukturen den Argumenten und Antwortnachrichten der Anforderung zu
Die go-gad/sal
Bibliothek kümmert sich um die Zuordnung von Datenbankantwortzeilen zu Antwortstrukturen, Tabellenspalten zu Strukturfeldern:
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) }
Und wenn die Datenbankantwort lautet:
dev > SELECT * FROM rubrics; id | created_at | title
Dann kehrt die GetRubricsResp-Liste zu uns zurück, deren Elemente Zeiger auf die Rubrik sind, in der die Felder mit Werten aus den Spalten gefüllt sind, die den Tag-Namen entsprechen.
Wenn die Datenbankantwort Spalten mit demselben Namen enthält, werden die entsprechenden Strukturfelder in der Deklarationsreihenfolge ausgewählt.
dev > select * from rubrics, subrubrics; id | title | id | title
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 }
Nicht standardmäßige Datentypen
Das database/sql
Paket bietet Unterstützung für grundlegende Datentypen (Zeichenfolgen, Zahlen). Um Datentypen wie Array oder JSON in einer Anforderung oder Antwort verarbeiten zu können, sql.Scanner
driver.Valuer
und sql.Scanner
. Verschiedene Treiberimplementierungen haben spezielle Hilfsfunktionen. Zum Beispiel lib/pq.Array
( https://godoc.org/github.com/lib/pq#Array ):
func Array(a interface{}) interface { driver.Valuer sql.Scanner }
Standardmäßig die go-gad/sql
Bibliothek für Ansichtsstrukturfelder
type DeleteAuthrosReq struct { Tags []int64 `sql:"tags"` }
verwendet den Wert &req.Tags
. Wenn die Struktur die sal.ProcessRower
Schnittstelle erfüllt,
type ProcessRower interface { ProcessRow(rowMap RowMap) }
dann kann der verwendete Wert angepasst werden
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[])` }
Dieser Handler kann für Anforderungs- und Antwortargumente verwendet werden. Bei einer Liste in der Antwort muss die Methode zum Listenelement gehören.
Transaktionen
Zur Unterstützung von Transaktionen sollte die Schnittstelle (Store) mit den folgenden Methoden erweitert werden:
type Store interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error) sal.Txer ...
Die Implementierung der Methoden wird generiert. Die BeginTx
Methode verwendet die Verbindung vom aktuellen sal.QueryHandler
Objekt und öffnet die Transaktion db.BeginTx(...)
. Gibt ein neues Implementierungsobjekt der Store
Schnittstelle zurück, verwendet jedoch das empfangene *sql.Tx
Objekt als *sql.Tx
Middleware
Zum Einbetten von Werkzeugen sind Haken vorgesehen.
type BeforeQueryFunc func(ctx context.Context, query string, req interface{}) (context.Context, FinalizerFunc) type FinalizerFunc func(ctx context.Context, err error)
Der BeforeQueryFunc
Hook wird aufgerufen, bevor db.PrepareContext
oder db.Query
. Das heißt, zu Beginn des Programms, wenn der Cache für vorbereitete Ausdrücke leer ist und store.GetAuthors
aufgerufen wird, wird der BeforeQueryFunc
Hook zweimal aufgerufen. Der BeforeQueryFunc
Hook kann einen FinalizerFunc
Hook zurückgeben, der vor dem Beenden der Benutzermethode in unserem Fall store.GetAuthors
mit store.GetAuthors
wird.
Zum Zeitpunkt der Ausführung der Hooks wird der Kontext mit Dienstschlüsseln mit den folgenden Werten gefüllt:
ctx.Value(sal.ContextKeyTxOpened)
boolescher Wert bestimmt, ob die Methode im Kontext der Transaktion aufgerufen wird oder nicht.ctx.Value(sal.ContextKeyOperationType)
, Zeichenfolgenwert des Operationstyps, "QueryRow"
, "Query"
, "Exec"
, "Commit"
usw.ctx.Value(sal.ContextKeyMethodName)
Zeichenfolgenwert der Schnittstellenmethode, z. B. "GetAuthors"
.
Als Argumente akzeptiert der BeforeQueryFunc
Hook die SQL-Zeichenfolge der Abfrage und das req
Argument der Benutzerabfragemethode. Der FinalizerFunc
Hook verwendet eine err
Variable als Argument.
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))
Ausgabebeispiele:
"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>
Was kommt als nächstes?
- Unterstützung für Bindungsvariablen und vorbereitete Ausdrücke für MySQL.
- RowAppender-Hook zum Anpassen der Antwort.
- Gibt den Wert von
Exec.Result
.