Interface de génération de client basée sur la base de données Golang

Générateur de client de base de données Golang basé sur l'interface.



Pour travailler avec des bases de donnĂ©es, Golang propose le package database/sql , qui est une abstraction de l'interface de programmation de base de donnĂ©es relationnelle. D'une part, le package comprend de puissantes fonctionnalitĂ©s pour gĂ©rer le pool de connexions, travailler avec des instructions prĂ©parĂ©es, des transactions et l'interface de requĂȘte de base de donnĂ©es. D'autre part, vous devez Ă©crire une quantitĂ© considĂ©rable du mĂȘme type de code dans une application Web pour interagir avec une base de donnĂ©es. La bibliothĂšque go-gad / sal propose une solution sous forme de gĂ©nĂ©ration du mĂȘme type de code basĂ© sur l'interface dĂ©crite.


La motivation


Aujourd'hui, il existe un nombre suffisant de bibliothĂšques qui proposent des solutions sous la forme d'ORM, d'aides pour la crĂ©ation de requĂȘtes, de gĂ©nĂ©ration d'aides basĂ©es sur un schĂ©ma de base de donnĂ©es.



Lorsque je suis passĂ© Ă  la langue Golang il y a plusieurs annĂ©es, j'avais dĂ©jĂ  de l'expĂ©rience avec des bases de donnĂ©es dans diffĂ©rentes langues. Utiliser ORM, comme ActiveRecord, et sans. AprĂšs ĂȘtre passĂ© de l'amour Ă  la haine, n'ayant aucun problĂšme Ă  Ă©crire quelques lignes de code supplĂ©mentaires, l'interaction avec la base de donnĂ©es de Golang a abouti Ă  quelque chose comme un modĂšle de rĂ©fĂ©rentiel. Nous dĂ©crivons l'interface pour travailler avec la base de donnĂ©es, nous l'implĂ©mentons en utilisant la norme db.Query, row.Scan. Utiliser des emballages supplĂ©mentaires n'avait tout simplement pas de sens, c'Ă©tait opaque, cela obligerait Ă  ĂȘtre en alerte.


Le langage SQL lui-mĂȘme est dĂ©jĂ  une abstraction entre votre programme et les donnĂ©es du rĂ©fĂ©rentiel. Il m'a toujours semblĂ© illogique d'essayer de dĂ©crire un schĂ©ma de donnĂ©es, puis de crĂ©er des requĂȘtes complexes. Dans ce cas, la structure de rĂ©ponse est diffĂ©rente du schĂ©ma de donnĂ©es. Il s'avĂšre que le contrat doit ĂȘtre dĂ©crit non pas au niveau du schĂ©ma de donnĂ©es, mais au niveau de la demande et de la rĂ©ponse. Nous utilisons cette approche dans le dĂ©veloppement Web lorsque nous dĂ©crivons les structures de donnĂ©es des demandes et des rĂ©ponses d'API. Lors de l'accĂšs au service en utilisant RESTful JSON ou gRPC, nous dĂ©clarons le contrat au niveau de la demande et de la rĂ©ponse en utilisant le schĂ©ma JSON ou Protobuf, et non le schĂ©ma de donnĂ©es des entitĂ©s Ă  l'intĂ©rieur des services.


Autrement dit, l'interaction avec la base de données se résumait à une méthode similaire:


 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) //... } 

De cette façon, votre programme est prĂ©visible. Mais pour ĂȘtre honnĂȘte, ce n'est pas le rĂȘve d'un poĂšte. Nous voulons rĂ©duire la quantitĂ© de code standard pour composer une requĂȘte, remplir des structures de donnĂ©es, utiliser la liaison de variables, etc. J'ai essayĂ© de formuler une liste d'exigences auxquelles l'ensemble d'utilitaires souhaitĂ© devrait satisfaire.


Prérequis


  • Description de l'interaction sous forme d'interface.
  • L'interface est dĂ©crite par des mĂ©thodes et des messages de demandes et de rĂ©ponses.
  • Prise en charge des variables de liaison et des instructions prĂ©parĂ©es.
  • Prise en charge des arguments nommĂ©s.
  • Lier la rĂ©ponse de la base de donnĂ©es aux champs de la structure de donnĂ©es du message.
  • Prise en charge des structures de donnĂ©es atypiques (array, json).
  • Travail transparent avec les transactions.
  • Prise en charge native du middleware.

Nous voulons résumer l'implémentation de l'interaction avec la base de données à l'aide de l'interface. Cela nous permettra d'implémenter quelque chose de similaire à un modÚle de conception tel qu'un référentiel. Dans l'exemple ci-dessus, nous avons décrit l'interface Store. Maintenant, nous pouvons l'utiliser comme dépendance. Au stade du test, nous pouvons passer un stub généré sur la base de cette interface, et dans le produit, nous utiliserons notre implémentation basée sur la structure Postgres.


Chaque mĂ©thode d'interface dĂ©crit une requĂȘte de base de donnĂ©es. Les paramĂštres d'entrĂ©e et de sortie de la mĂ©thode doivent faire partie du contrat de la demande. La chaĂźne de requĂȘte doit pouvoir ĂȘtre formatĂ©e en fonction des paramĂštres d'entrĂ©e. Cela est particuliĂšrement vrai lors de la compilation de requĂȘtes avec une condition d'Ă©chantillonnage complexe.


Lors de la compilation d'une requĂȘte, nous voulons utiliser la substitution et la liaison de variables. Par exemple, dans PostgreSQL, vous Ă©crivez $1 au lieu d'une valeur et, avec la requĂȘte, passez un tableau d'arguments. Le premier argument sera utilisĂ© comme valeur dans la requĂȘte convertie. La prise en charge des expressions prĂ©parĂ©es vous permet de ne pas vous soucier d'organiser le stockage de ces mĂȘmes expressions. La bibliothĂšque de base de donnĂ©es / sql fournit un outil puissant pour prendre en charge les expressions prĂ©parĂ©es; elle s'occupe elle-mĂȘme du pool de connexions, des connexions fermĂ©es. Mais de la part de l'utilisateur, une action supplĂ©mentaire est nĂ©cessaire pour rĂ©utiliser l'expression prĂ©parĂ©e dans la transaction.


Les bases de données, telles que PostgreSQL et MySQL, utilisent une syntaxe différente pour utiliser des substitutions et des liaisons de variables. PostgreSQL utilise le format $1 , $2 , ... MySQL utilise ? quel que soit l'emplacement de la valeur. La bibliothÚque de base de données / sql a proposé un format universel pour les arguments nommés https://golang.org/pkg/database/sql/#NamedArg . Exemple d'utilisation:


 db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime)) 

La prise en charge de ce format est préférable à utiliser par rapport aux solutions PostgreSQL ou MySQL.


La rĂ©ponse de la base de donnĂ©es qui traite le pilote logiciel peut ĂȘtre conditionnellement reprĂ©sentĂ©e comme suit:


 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) 

Du point de vue de l'utilisateur au niveau de l'interface, il est commode de décrire le paramÚtre de sortie comme un tableau de structures de la forme:


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

Ensuite, resp.ID valeur id sur resp.ID et ainsi de suite. En général, cette fonctionnalité couvre la plupart des besoins.


Lors de la dĂ©claration de messages via des structures de donnĂ©es internes, la question se pose de savoir comment prendre en charge les types de donnĂ©es non standard. Par exemple, un tableau. Si vous utilisez le pilote github.com/lib/pq lorsque vous travaillez avec PostgreSQL, vous pouvez utiliser des fonctions auxiliaires comme pq.Array(&x) lors du passage d'arguments de requĂȘte ou de l'analyse d'une rĂ©ponse. Exemple tirĂ© de la documentation:


 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)) 

En conséquence, il doit exister des moyens de préparer des structures de données.


Lors de l'exĂ©cution de l'une des mĂ©thodes d'interface, une connexion Ă  la base de donnĂ©es peut ĂȘtre utilisĂ©e sous la forme d'un *sql.DB Si vous devez exĂ©cuter plusieurs mĂ©thodes au sein d'une mĂȘme transaction, je souhaite utiliser une fonctionnalitĂ© transparente avec une approche similaire pour travailler en dehors d'une transaction, sans passer d'arguments supplĂ©mentaires.


Lorsque vous travaillez avec des implémentations d'interface, il est essentiel pour nous de pouvoir intégrer la boßte à outils. Par exemple, consigner toutes les demandes. La boßte à outils doit avoir accÚs aux variables de demande, à l'erreur de réponse, à l'exécution et au nom de la méthode d'interface.


Pour la plupart, les exigences ont été formulées comme une systématisation des scénarios de base de données.


Solution: go-gad / sal


Une façon de gĂ©rer le code passe-partout est de le gĂ©nĂ©rer. Heureusement, Golang a des outils et des exemples pour ce https://blog.golang.org/generate . L'approche https://github.com/golang/mock de GoMock a Ă©tĂ© considĂ©rĂ©e comme une solution architecturale pour la gĂ©nĂ©ration, oĂč l'analyse d'interface est effectuĂ©e par rĂ©flexion. Sur la base de cette approche, selon les exigences, l'utilitaire salgen et la bibliothĂšque sal ont Ă©tĂ© Ă©crits, qui gĂ©nĂšrent du code d'implĂ©mentation d'interface et fournissent un ensemble de fonctions auxiliaires.


Afin de commencer Ă  utiliser cette solution, il est nĂ©cessaire de dĂ©crire une interface qui dĂ©crit le comportement de la couche d'interaction avec la base de donnĂ©es. SpĂ©cifiez la directive go:generate avec un ensemble d'arguments et lancez la gĂ©nĂ©ration. Vous obtiendrez un constructeur et un tas de code passe-partout, prĂȘts Ă  l'emploi.


 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)` } 

Interface


Tout commence par déclarer l'interface et une commande spéciale pour l'utilitaire 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 { ... 

Ici, il est dĂ©crit que pour notre interface Store , l'utilitaire de console salgen sera appelĂ© Ă  partir du package, avec deux options et deux arguments. La premiĂšre option -destination dĂ©termine dans quel fichier le code gĂ©nĂ©rĂ© sera Ă©crit. La deuxiĂšme option -package dĂ©finit le chemin complet (chemin d'importation) de la bibliothĂšque pour l'implĂ©mentation gĂ©nĂ©rĂ©e. Voici deux arguments. Le premier dĂ©crit le chemin complet du package ( github.com/go-gad/sal/examples/profile/storage ) oĂč se trouve l'interface, le second indique le nom de l'interface elle-mĂȘme. Notez que la commande go generate peut ĂȘtre situĂ©e n'importe oĂč, pas nĂ©cessairement Ă  cĂŽtĂ© de l'interface cible.


AprÚs avoir exécuté la commande go generate , nous obtenons un constructeur dont le nom est construit en ajoutant le New préfixe au nom de l'interface. Le constructeur prend un paramÚtre requis correspondant à l'interface 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) } 

Cette interface correspond Ă  l'objet *sql.DB


 connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full" db, err := sql.Open("postgres", connStr) client := storage.NewStore(db) 

Les méthodes


Les mĂ©thodes d'interface dĂ©terminent l'ensemble des requĂȘtes de base de donnĂ©es disponibles.


 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 } 

  • Le nombre d'arguments est toujours strictement deux.
  • Le premier argument est le contexte.
  • Le deuxiĂšme argument contient des donnĂ©es pour les variables de liaison et dĂ©finit la chaĂźne de requĂȘte.
  • Le premier paramĂštre de sortie peut ĂȘtre un objet, un tableau d'objets ou absent.
  • Le dernier paramĂštre de sortie est toujours une erreur.

Le premier argument est toujours l'objet context.Context . Ce contexte sera transmis lors de l'appel de la base de données et de la boßte à outils. Le deuxiÚme argument attend un paramÚtre avec le type de struct (ou un pointeur vers struct ). Le paramÚtre doit satisfaire l'interface suivante:


 type Queryer interface { Query() string } 

La mĂ©thode Query() sera appelĂ©e avant d'exĂ©cuter une requĂȘte de base de donnĂ©es. La chaĂźne rĂ©sultante sera convertie dans un format spĂ©cifique Ă  la base de donnĂ©es. Autrement dit, pour PostgreSQL, @end sera remplacĂ© par $1 , et la valeur &req.End sera transmise au tableau d'arguments


En fonction des paramÚtres de sortie, il est déterminé laquelle des méthodes (Query / Exec) sera appelée:


  • Si le premier paramĂštre est de type struct (ou un pointeur vers struct ), la mĂ©thode QueryContext sera appelĂ©e. Si la rĂ©ponse de la base de donnĂ©es ne contient pas une seule ligne, l'erreur sql.ErrNoRows sera sql.ErrNoRows . Autrement dit, le comportement est similaire Ă  db.QueryRow .
  • Si le premier paramĂštre est avec la slice type de base, la mĂ©thode QueryContext sera appelĂ©e. Si la rĂ©ponse de la base de donnĂ©es ne contient pas de lignes, une liste vide sera retournĂ©e. Le type de base de l'Ă©lĂ©ment de liste doit ĂȘtre stuct (ou un pointeur vers une struct ).
  • Si le paramĂštre de sortie est un avec le type d' error , la mĂ©thode ExecContext sera appelĂ©e.

Déclarations préparées


Le code gĂ©nĂ©rĂ© prend en charge les expressions prĂ©parĂ©es. Les expressions prĂ©parĂ©es sont mises en cache. AprĂšs la premiĂšre prĂ©paration de l'expression, elle est mise en cache. La bibliothĂšque de base de donnĂ©es / sql elle-mĂȘme garantit que les expressions prĂ©parĂ©es sont appliquĂ©es de maniĂšre transparente Ă  la connexion de base de donnĂ©es souhaitĂ©e, y compris le traitement des connexions fermĂ©es. À son tour, la bibliothĂšque go-gad/sal se charge de rĂ©utiliser l'instruction prĂ©parĂ©e dans le contexte de la transaction. Lorsque l'expression prĂ©parĂ©e est exĂ©cutĂ©e, les arguments sont transmis Ă  l'aide d'une liaison de variable, transparente pour le dĂ©veloppeur.


Pour prendre en charge les arguments nommĂ©s du cĂŽtĂ© de la bibliothĂšque go-gad/sal , la demande est convertie en une vue adaptĂ©e Ă  la base de donnĂ©es. Il existe dĂ©sormais un support de conversion pour PostgreSQL. Les noms de champ de l'objet de requĂȘte sont utilisĂ©s pour se substituer aux arguments nommĂ©s. Pour spĂ©cifier un nom diffĂ©rent au lieu du nom de champ d'objet, vous devez utiliser la balise sql pour les champs de structure. Prenons un exemple:


 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` } 

La chaĂźne de requĂȘte sera convertie et en utilisant la table de correspondance et la liaison de variable, une liste sera transmise aux arguments d'exĂ©cution de la requĂȘte:


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

Mapper les structures aux arguments de la requĂȘte et aux messages de rĂ©ponse


La bibliothÚque go-gad/sal s'occupe d'associer les lignes de réponse de la base de données aux structures de réponse, les colonnes de table aux champs de structure:


 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) } 

Et si la réponse de la base de données est:


 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) 

Ensuite, la liste GetRubricsResp nous reviendra, dont les Ă©lĂ©ments seront des pointeurs vers Rubric, oĂč les champs sont remplis avec des valeurs des colonnes qui correspondent aux noms des balises.


Si la rĂ©ponse de la base de donnĂ©es contient des colonnes du mĂȘme nom, les champs de structure correspondants seront sĂ©lectionnĂ©s dans l'ordre de dĂ©claration.


 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 } 

Types de données non standard


Le package database/sql en charge les types de données de base (chaßnes, nombres). Afin de traiter des types de données tels que tableau ou json dans une demande ou une réponse, il est nécessaire de prendre en charge les sql.Scanner driver.Valuer et sql.Scanner . Différentes implémentations de pilotes ont des fonctions d'assistance spéciales. Par exemple, lib/pq.Array ( https://godoc.org/github.com/lib/pq#Array ):


 func Array(a interface{}) interface { driver.Valuer sql.Scanner } 

Par défaut, la bibliothÚque go-gad/sql pour les champs de structure de vue


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

utilisera la valeur &req.Tags . Si la structure satisfait l'interface sal.ProcessRower ,


 type ProcessRower interface { ProcessRow(rowMap RowMap) } 

alors la valeur utilisĂ©e peut ĂȘtre ajustĂ©e


 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[])` } 

Ce gestionnaire peut ĂȘtre utilisĂ© pour les arguments de demande et de rĂ©ponse. Dans le cas d'une liste dans la rĂ©ponse, la mĂ©thode doit appartenir Ă  l'Ă©lĂ©ment de liste.


Les transactions


Pour prendre en charge les transactions, l'interface (Store) doit ĂȘtre Ă©tendue avec les mĂ©thodes suivantes:


 type Store interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error) sal.Txer ... 

L'implémentation des méthodes sera générée. La méthode BeginTx utilise la connexion de l'objet sal.QueryHandler actuel et ouvre la transaction db.BeginTx(...) ; renvoie un nouvel objet d'implémentation de l'interface Store , mais utilise l'objet *sql.Tx reçu comme *sql.Tx


Middleware


Des crochets sont fournis pour les outils d'intégration.


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

Le hook BeforeQueryFunc sera appelé avant l' db.PrepareContext ou db.Query . Autrement dit, au début du programme, lorsque le cache d'expression préparé est vide, lorsque store.GetAuthors appelé, le crochet BeforeQueryFunc sera appelé deux fois. Le hook BeforeQueryFunc peut renvoyer un hook FinalizerFunc , qui sera appelé avant de quitter la méthode utilisateur, dans notre case store.GetAuthors , en utilisant store.GetAuthors .


Au moment de l'exécution des hooks, le contexte est rempli de clés de service avec les valeurs suivantes:


  • ctx.Value(sal.ContextKeyTxOpened) valeur boolĂ©enne dĂ©termine si la mĂ©thode est appelĂ©e ou non dans le contexte de la transaction.
  • ctx.Value(sal.ContextKeyOperationType) , la valeur de chaĂźne du type d'opĂ©ration, "QueryRow" , "Query" , "Exec" , "Commit" , etc.
  • ctx.Value(sal.ContextKeyMethodName) valeur de chaĂźne de la mĂ©thode d'interface, telle que "GetAuthors" .

En tant qu'arguments, le hook BeforeQueryFunc accepte la chaĂźne sql de la requĂȘte et l'argument req de la mĂ©thode de requĂȘte utilisateur. Le hook FinalizerFunc prend une variable err comme 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)) 

Exemples de sortie:


 "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> 

Et ensuite


  • Prise en charge des variables de liaison et des expressions prĂ©parĂ©es pour MySQL.
  • Crochet RowAppender pour ajuster la rĂ©ponse.
  • Renvoie la valeur de Exec.Result .

Source: https://habr.com/ru/post/fr431984/


All Articles