Generator-Client auf Datenbank basierend auf Golang basiert

    Generator-Client zur Datenbank auf Golang-basierter Schnittstelle.



    Für die Arbeit mit Datenbanken bietet Golang ein Paket an database/sql, das eine Abstraktion über die Softwareschnittstelle einer relationalen Datenbank darstellt. Zum einen enthält das Paket leistungsstarke Funktionen zur Verwaltung des Verbindungspools, zum Arbeiten mit den vorbereiteten Anweisungen, Transaktionen und der Datenbankabfrageschnittstelle. Andererseits müssen Sie eine beträchtliche Menge desselben Typs in eine Webanwendung schreiben, um mit der Datenbank zu interagieren. 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 zum Generieren von Helfern basierend auf dem Datenbankschema.



    Als ich vor einigen Jahren zur Golang-Sprache wechselte, hatte ich bereits Erfahrung mit Datenbanken in verschiedenen Sprachen. Verwenden Sie ORM, zum Beispiel ActiveRecord, und ohne. Von der Liebe zum Hass gegangen, ohne Probleme mit dem Schreiben einiger zusätzlicher Codezeilen zu haben, wurde die Interaktion mit der Datenbank in Golang ähnlich wie das Repository-Muster. Wir beschreiben die Schnittstelle mit der Datenbank, die wir mit dem Standard db.Query, row.Scan implementieren. Die Verwendung zusätzlicher Verpackungen machte einfach keinen Sinn, es war undurchsichtig und musste in Alarmbereitschaft versetzt werden.


    Die SQL-Sprache selbst ist bereits eine Abstraktion zwischen Ihrem Programm und den Daten im Repository. Es schien mir immer unlogisch zu versuchen, das Datenschema zu beschreiben und dann komplexe Abfragen zu erstellen. Der Aufbau der Antwort unterscheidet sich in diesem Fall vom Datenschema. Es stellt sich heraus, dass der Vertrag nicht auf der Ebene des Datenschemas, sondern auf der Anfrage- und Antwortebene beschrieben werden sollte. Wir verwenden diesen Ansatz in der Webentwicklung, wenn wir die Datenstrukturen von Anforderungen und API-Antworten beschreiben. Beim Zugriff auf den Dienst über RESTful JSON oder gRPC erklären wir den Vertrag auf der Anforderungs- und Antwortebene unter Verwendung des JSON-Schemas oder Protobufs und nicht des Entitätsdatenschemas innerhalb der Dienste.


    Das heißt, die Interaktion mit der Datenbank wurde auf eine ähnliche Methode reduziert:


    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 {
            returnnil, err
        }
        return &resp, nil
    }
    funcHanlderFindUser(s Store, id int)(*User, error) {
        // logic of service object
        user, err := s.FindUser(id)
        //...
    }

    Diese Methode macht Ihr Programm vorhersehbar. Aber seien wir ehrlich, das ist kein Traum eines Dichters. Wir möchten die Anzahl des Vorlagencodes reduzieren, um eine Abfrage zu erstellen, Datenstrukturen zu füllen, die Variablenbindung 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 Meldungen von Anforderungen und Antworten beschrieben.
    • Unterstützung für das Verknüpfen von Variablen und vorbereiteten Ausdrücken (vorbereitete Anweisungen).
    • Benannte Argumentunterstützung.
    • Verknüpfen Sie eine Datenbankantwort mit Nachrichtendatenstrukturfeldern.
    • Unterstützung für atypische Datenstrukturen (Array, Json).
    • Transparente Arbeit mit Transaktionen.
    • Integrierte Unterstützung für Zwischenprozessoren (Middleware).

    Implementierung der Interaktion mit der Datenbank wollen wir über die Schnittstelle abstrahieren. Dies ermöglicht uns die Implementierung eines solchen Entwurfsmusters als Repository. Im obigen Beispiel haben wir die Store-Schnittstelle beschrieben. Jetzt können wir es als Sucht verwenden. In der Testphase können wir das auf der Basis dieser Schnittstelle erzeugte Stub-Objekt übergeben. In der Produktion werden wir unsere Implementierung basierend auf der Postgres-Struktur verwenden.


    Jede Schnittstellenmethode beschreibt eine Datenbankanforderung. Ein- und Ausgabeparameter der Methode sollten Bestandteil des Vertrags für die Anforderung sein. Die Abfragezeichenfolge muss abhängig von den Eingabeparametern formatiert werden können. Dies gilt insbesondere, wenn Abfragen mit einer komplexen Beispielbedingung erstellt werden.


    Beim Erstellen einer Abfrage möchten wir Ersetzungen und Variablenbindungen verwenden. In PostgreSQL schreiben Sie beispielsweise anstelle eines Werts, $1und zusammen mit der Anforderung übergeben Sie ein Array von Argumenten. Das erste Argument wird als Wert in der konvertierten Abfrage verwendet. Durch die Unterstützung vorbereiteter Ausdrücke können Sie sich keine Gedanken über die Organisation der Speicherung dieser Ausdrücke machen. Die Datenbank- / SQL-Bibliothek bietet ein leistungsfähiges Werkzeug für die Unterstützung vorbereiteter Ausdrücke. Sie kümmert sich um den Verbindungspool und um geschlossene Verbindungen. Es ist jedoch seitens des Benutzers erforderlich, eine zusätzliche Maßnahme zu ergreifen, 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... die MySQL verwendet , ?unabhängig vom Standort Wert. Die Datenbank- / SQL-Bibliothek bot das universelle Format benannter Argumente 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 zu bevorzugen.


    Die Antwort aus der Datenbank, die den Softwaretreiber verarbeitet, kann wie folgt dargestellt werden:


    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)

    Aus Sicht des Benutzers ist es auf der Schnittstellenebene zweckmäßig, den Ausgabeparameter als ein Array von Strukturen des Formulars zu beschreiben:


    type GetRubricsResp struct {
        ID        int
        CreatedAt time.Time
        Title     string
        URL       string
    }

    Als nächstes projizieren Sie den Wert idauf resp.IDund 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 sollen. Zum Beispiel ein Array. Wenn Sie bei der Arbeit mit PostgreSQL den Treiber github.com/lib/pq verwenden, können Sie Hilfsfunktionen verwenden, beispielsweise wenn Sie Argumente an die Abfrage übergeben oder die Antwort scannen 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 sollte es Möglichkeiten geben, Datenstrukturen vorzubereiten.


    Bei der Ausführung einer der Schnittstellenmethoden kann eine Verbindung zur Datenbank als Objekt verwendet werden *sql.DB. Wenn innerhalb einer Transaktion mehrere Methoden ausgeführt werden müssen, möchte ich eine transparente Funktionalität mit einem ähnlichen Ansatz verwenden, außerhalb der Transaktion zu arbeiten, und keine zusätzlichen Argumente übergeben.


    Bei der Implementierung von Schnittstellenimplementierungen ist es für uns wichtig, das Toolkit einbetten zu können. Zum Beispiel das Protokollieren aller Anforderungen. Das Toolkit sollte auf die Anforderungsvariablen, den Antwortfehler, die Laufzeit und den Namen der Schnittstellenmethode zugreifen.


    Die Anforderungen wurden im Wesentlichen als Systematisierung von Szenarien für das Arbeiten mit einer Datenbank formuliert.


    Lösung: go-gad / sal


    Eine Möglichkeit, mit dem Vorlagencode umzugehen, besteht darin, ihn zu generieren. Zum Glück hat Golang Werkzeuge und Beispiele für dieses https://blog.golang.org/generate . Der GoMock-Ansatz https://github.com/golang/mock wurde als architektonische Lösung für die Generierung verwendet , bei der die Analyse der Schnittstelle mithilfe von Reflexion durchgeführt wird. Basierend auf diesem Ansatz wurden je nach Anforderung das Dienstprogramm salgen und die Bibliothek sal erstellt, die Schnittstellenimplementierungscode generieren und eine Reihe von Hilfsfunktionen bereitstellen.


    Um diese Lösung verwenden zu können, muss die Schnittstelle beschrieben werden, die das Verhalten der Interaktionsschicht mit der Datenbank beschreibt. Geben Sie eine Anweisung go:generatemit einer Reihe von Argumenten an und starten Sie die Generierung. Ein Konstruktor und eine Reihe von Vorlagencode werden empfangen und können sofort verwendet werden.


    package repo
    import"context"//go:generate salgen -destination=./postgres_client.go -package=dev/taxi/repo dev/taxi/repo Postgrestype 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)`
    }

    Schnittstelle


    Alles beginnt mit der Deklaration der Schnittstelle und einem speziellen Befehl für das Dienstprogramm 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 Storetype Store interface {
        ...

    Hier wird beschrieben, dass für unsere Schnittstelle Storeaus dem Paket ein Konsolendienstprogramm salgenmit zwei Optionen und zwei Argumenten aufgerufen wird. Die erste Option -destinationbestimmt, in welcher Datei der generierte Code geschrieben wird. Die zweite Option -packagedefiniert 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 Namen der Schnittstelle an. Beachten Sie, dass sich der Befehl für eine go generatebeliebige Stelle befinden kann, nicht notwendigerweise neben der Zielschnittstelle.


    Nach der Ausführung des Befehls erhalten go generatewir einen Konstruktor, dessen Name durch Hinzufügen eines Präfixes Newzum Schnittstellennamen erstellt wird. Der Konstruktor akzeptiert einen der Schnittstelle entsprechenden erforderlichen Parameter 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 definieren einen 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 ist immer streng zwei.
    • Das erste Argument ist der Kontext.
    • Das zweite Argument enthält die Daten zum Binden der Variablen und definiert die Abfragezeichenfolge.
    • Der erste Ausgabeparameter kann ein Objekt, ein Array von Objekten sein oder fehlt.
    • Der letzte Ausgabeparameter ist immer ein Fehler.

    Das erste Argument ist immer das erwartete Objekt context.Context. Dieser Kontext wird an die Aufrufe der Datenbank und des Toolkits weitergeleitet. Das zweite Argument erwartet einen Parameter mit einem Basistyp struct(oder einem Zeiger auf struct). Der Parameter muss die folgende Schnittstelle erfüllen:


    type Queryer interface {
        Query() string
    }

    Die Methode Query()wird vor dem Ausführen der Datenbankabfrage aufgerufen. Die resultierende Zeichenfolge wird in ein datenbankspezifisches Format konvertiert. Das heißt, PostgreSQL @endwird durch ersetzt $1, und der Wert wird an das Array von Argumenten übergeben&req.End


    Abhängig von den Ausgabeparametern wird festgelegt, welche der Methoden (Query / Exec) aufgerufen wird:


    • Wenn der erste Parameter einen Basistyp struct(oder einen Zeiger auf struct) hat, wird die Methode aufgerufen QueryContext. Wenn die Antwort aus der Datenbank keine Zeilen enthält, wird ein Fehler zurückgegeben sql.ErrNoRows. Das Verhalten ist also ähnlich db.QueryRow.
    • Wenn der erste Parameter vom Basistyp ist slice, wird die Methode aufgerufen QueryContext. 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 struct) sein.
    • Wenn der Ausgabeparameter Eins mit Typ ist error, wird die Methode aufgerufen ExecContext.

    Vorbereitete Anweisungen


    Der generierte Code unterstützt vorbereitete Ausdrücke. Vorbereitete Ausdrücke werden zwischengespeichert. Nach der ersten Vorbereitung des Ausdrucks wird er im Cache abgelegt. Die Datenbank- / SQL-Bibliothek selbst stellt sicher, dass vorbereitete Ausdrücke transparent auf die erforderliche Datenbankverbindung angewendet werden, einschließlich der Verarbeitung geschlossener Verbindungen. Die Bibliothek go-gad/salkümmert sich wiederum um die Wiederverwendung des vorbereiteten Ausdrucks im Kontext einer Transaktion. Wenn der vorbereitete Ausdruck ausgeführt wird, werden die Argumente mit variabler Bindung übergeben, die für den Entwickler transparent ist.


    Um benannte Argumente auf der Bibliothekseite zu unterstützen, wird die go-gad/salAbfrage in ein für eine Datenbank geeignetes Formular konvertiert. Nun gibt es eine Konvertierungsunterstützung für PostgreSQL. Die Feldnamen des Abfrageobjekts werden zur Ersetzung in benannten Argumenten verwendet. Um einen anderen Namen anstelle des Objektfeldnamens anzugeben, müssen Sie eine Variable sqlfür die 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 eine Liste wird mit der übereinstimmenden Tabelle und Variablenbindung an die Abfrageausführungsargumente übergeben:


    // generated code:
    db.Query("DELETE FROM orders WHERE user_id=$1 AND created_at<$2", &req.UserID, &req.CreatedAt)

    Zuordnen von Strukturen zum Anfordern von Argumenten und Antwortnachrichten


    Die Bibliothek go-gad/salkümmert sich um das Verknüpfen von Datenbankantwortzeilen mit Antwortstrukturen, Tabellenspalten mit 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 ist:


    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)

    Dann wird die GetRubricsResp-Liste an uns zurückgegeben, deren Elemente Zeiger auf Rubrik sind, wobei die Felder mit Werten aus den Spalten gefüllt werden, die den Namen der Tags entsprechen.


    Wenn die Datenbankantwort Spalten mit demselben Namen enthält, werden die entsprechenden Strukturfelder in der Reihenfolge der Deklaration ausgewählt.


    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
    }

    Nicht standardmäßige Datentypen


    Das Paket database/sqlbietet Unterstützung für grundlegende Datentypen (Zeichenfolgen, Zahlen). Um solche Datentypen als Array oder Json in einer Anforderung oder Antwort zu behandeln, müssen die Schnittstellen driver.Valuerund unterstützt werden sql.Scanner. In verschiedenen Implementierungen von Treibern gibt es spezielle Hilfsfunktionen. Zum Beispiel lib/pq.Array( https://godoc.org/github.com/lib/pq#Array ):


    funcArray(a interface{})interface {
        driver.Valuer
        sql.Scanner
    }

    Die Standardbibliothek go-gad/sqlfür Felder der Struktur des Formulars


    type DeleteAuthrosReq struct {
        Tags []int64`sql:"tags"`
    }

    wird den Wert verwenden &req.Tags. Wenn die Struktur die Schnittstelle erfüllt sal.ProcessRower,


    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


    Um Transaktionen zu unterstützen, muss 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 Methode BeginTxverwendet eine Verbindung vom aktuellen Objekt sal.QueryHandlerund öffnet eine Transaktion db.BeginTx(...). gibt ein neues Interface-Implementierungsobjekt zurück Store, verwendet jedoch das resultierende Objekt als Handler.*sql.Tx


    Middleware


    Für das Einbetten von Werkzeugen werden Haken bereitgestellt.


    type BeforeQueryFunc func(ctx context.Context, query string, req interface{})(context.Context, FinalizerFunc)typeFinalizerFuncfunc(ctx context.Context, err error)

    Der Hook BeforeQueryFuncwird vor der Ausführung von db.PrepareContextoder aufgerufen db.Query. Das heißt, zu Beginn des Programms store.GetAuthorswird der Hook BeforeQueryFunczweimal aufgerufen , wenn der Cache der vorbereiteten Ausdrücke leer ist . Ein Hook BeforeQueryFunckann einen Hook zurückgeben FinalizerFunc, der aufgerufen wird, bevor die Benutzermethode in unserem Fall store.GetAuthorsmit verlassen wird defer.


    Wenn die Hooks ausgeführt werden, wird der Kontext mit Dienstprogrammschlüsseln mit den folgenden Werten gefüllt:


    • ctx.Value(sal.ContextKeyTxOpened) Ein boolescher Wert bestimmt, ob die Methode im Kontext einer Transaktion aufgerufen wird oder nicht.
    • ctx.Value(sal.ContextKeyOperationType), String-Typ Operation "QueryRow", "Query", "Exec", "Commit"usw.
    • ctx.Value(sal.ContextKeyMethodName)Zum Beispiel der String-Wert der Schnittstellenmethode "GetAuthors".

    Als Argument nimmt der Hook BeforeQueryFuncdie SQL-Zeichenfolge der Abfrage und das Argument reqder Benutzerabfragemethode an. Der Haken FinalizerFuncnimmt eine Variable als Argument an 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))

    Beispiele für die Ausgabe:


    "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.
    • Haken Sie RowAppender an, um die Antwort anzupassen.
    • Rückgabewert Exec.Result.

    Jetzt auch beliebt: