Interface do Gerador de Cliente Baseada em Banco de Dados Golang

Gerador de cliente de banco de dados Golang com base na interface.



Para trabalhar com bancos de dados, a Golang oferece o pacote database/sql , que é uma abstração da interface de programação de banco de dados relacional. Por um lado, o pacote inclui funcionalidades poderosas para gerenciar o pool de conexões, trabalhar com instruções preparadas, transações e a interface de consulta ao banco de dados. Por outro lado, você deve escrever uma quantidade considerável do mesmo tipo de código em um aplicativo Web para interagir com um banco de dados. A biblioteca go-gad / sal oferece uma solução na forma de gerar o mesmo tipo de código com base na interface descrita.


Motivação


Hoje, existe um número suficiente de bibliotecas que oferecem soluções na forma de ORMs, auxiliares para criar consultas, gerando auxiliares com base em um esquema de banco de dados.



Quando mudei para o idioma Golang há vários anos, eu já tinha experiência em trabalhar com bancos de dados em diferentes idiomas. Usando ORM, como ActiveRecord, e sem. Tendo passado do amor ao ódio, sem problemas para escrever algumas linhas extras de código, a interação com o banco de dados em Golang surgiu com um padrão de repositório. Descrevemos a interface para trabalhar com o banco de dados, implementamos-na usando o padrão db.Query, row.Scan. Usar invólucros adicionais simplesmente não fazia sentido, era opaco, obrigaria a estar em alerta.


A própria linguagem SQL já é uma abstração entre o seu programa e os dados no repositório. Sempre me pareceu ilógico tentar descrever um esquema de dados e criar consultas complexas. A estrutura de resposta neste caso é diferente do esquema de dados. Acontece que o contrato precisa ser descrito não no nível do esquema de dados, mas no nível de solicitação e resposta. Usamos essa abordagem no desenvolvimento da Web quando descrevemos as estruturas de dados das solicitações e respostas da API. Ao acessar o serviço usando RESTful JSON ou gRPC, declaramos o contrato no nível de solicitação e resposta usando JSON Schema ou Protobuf, e não o esquema de dados das entidades nos serviços.


Ou seja, a interação com o banco de dados se resumiu a um método semelhante:


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

Dessa forma, seu programa é previsível. Mas, para ser sincero, este não é o sonho de um poeta. Queremos reduzir a quantidade de código padrão para compor uma consulta, preencher estruturas de dados, usar ligação de variável e assim por diante. Tentei formular uma lista de requisitos que o conjunto de utilitários desejado deveria atender.


Exigências


  • Descrição da interação na forma de uma interface.
  • A interface é descrita por métodos e mensagens de solicitações e respostas.
  • Suporte para variáveis ​​de ligação e instruções preparadas.
  • Suporte para argumentos nomeados.
  • Vinculando a resposta do banco de dados aos campos da estrutura de dados da mensagem.
  • Suporte para estruturas de dados atípicas (array, json).
  • Trabalho transparente com transações.
  • Suporte nativo para middleware.

Queremos abstrair a implementação da interação com o banco de dados usando a interface. Isso nos permitirá implementar algo semelhante a um padrão de design, como um repositório. No exemplo acima, descrevemos a interface da loja. Agora podemos usá-lo como uma dependência. No estágio de teste, podemos passar um stub gerado com base nessa interface e, no produto, usaremos nossa implementação com base na estrutura do Postgres.


Cada método de interface descreve uma consulta ao banco de dados. Os parâmetros de entrada e saída do método devem fazer parte do contrato para a solicitação. A string de consulta precisa poder formatar, dependendo dos parâmetros de entrada. Isso é especialmente verdade ao compilar consultas com uma condição de amostragem complexa.


Ao compilar uma consulta, queremos usar substituição e ligação de variável. Por exemplo, no PostgreSQL, você escreve $1 vez de um valor e, junto com a consulta, passa uma matriz de argumentos. O primeiro argumento será usado como o valor na consulta convertida. O suporte para expressões preparadas permite que você não se preocupe em organizar o armazenamento dessas mesmas expressões. A biblioteca database / sql fornece uma ferramenta poderosa para suportar expressões preparadas; ela mesma cuida do pool de conexões, conexões fechadas. Mas, por parte do usuário, é necessária uma ação adicional para reutilizar a expressão preparada na transação.


Bancos de dados, como PostgreSQL e MySQL, usam sintaxe diferente para usar substituições e ligações de variáveis. O PostgreSQL usa o formato $1 , $2 , ... O MySQL usa ? independentemente da localização do valor. A biblioteca database / sql propôs um formato universal para argumentos nomeados https://golang.org/pkg/database/sql/#NamedArg . Exemplo de uso:


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

É preferível usar esse formato em comparação com as soluções PostgreSQL ou MySQL.


A resposta do banco de dados que processa o driver do software pode ser representada condicionalmente da seguinte maneira:


 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) 

Do ponto de vista do usuário no nível da interface, é conveniente descrever o parâmetro de saída como uma matriz de estruturas do formulário:


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

Em seguida, projete o valor do id em resp.ID e assim por diante. Em geral, essa funcionalidade cobre a maioria das necessidades.


Ao declarar mensagens através de estruturas de dados internas, surge a questão de como oferecer suporte a tipos de dados não padrão. Por exemplo, uma matriz. Se você usar o driver github.com/lib/pq ao trabalhar com o PostgreSQL, poderá usar funções auxiliares como pq.Array(&x) ao passar argumentos de consulta ou verificar uma resposta. Exemplo da documentação:


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

Por conseguinte, deve haver maneiras de preparar estruturas de dados.


Ao executar qualquer um dos métodos de interface, uma conexão com o banco de dados pode ser usada na forma de um *sql.DB Se você precisar executar vários métodos em uma única transação, desejo usar a funcionalidade transparente com uma abordagem semelhante para trabalhar fora de uma transação, e não passar argumentos adicionais.


Ao trabalhar com implementações de interface, é vital poder incorporar o kit de ferramentas. Por exemplo, registrando todos os pedidos. O kit de ferramentas deve obter acesso às variáveis ​​de solicitação, erro de resposta, tempo de execução, nome do método de interface.


Para a maior parte, os requisitos foram formulados como uma sistematização de cenários de banco de dados.


Solução: go-gad / sal


Uma maneira de lidar com o código padrão é gerá-lo. Felizmente, Golang tem ferramentas e exemplos para este https://blog.golang.org/generate . O GoMock https://github.com/golang/mock , onde a análise da interface é realizada usando reflexão, foi emprestado como uma solução arquitetural para a geração. Com base nessa abordagem, de acordo com os requisitos, foram escritos o utilitário salgen e a biblioteca sal, que geram código de implementação de interface e fornecem um conjunto de funções auxiliares.


Para começar a usar esta solução, é necessário descrever uma interface que descreva o comportamento da camada de interação com o banco de dados. Especifique a diretiva go:generate com um conjunto de argumentos e inicie a geração. Você receberá um construtor e um monte de código padrão, pronto para uso.


 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


Tudo começa com a declaração da interface e um comando especial para o utilitário 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 { ... 

Aqui está descrito que, para nossa interface da Store , o utilitário de console salgen será chamado a partir do pacote, com duas opções e dois argumentos. A primeira opção -destination determina em qual arquivo o código gerado será gravado. A segunda opção -package define o caminho completo (caminho de importação) da biblioteca para a implementação gerada. A seguir, dois argumentos. O primeiro descreve o caminho completo do pacote ( github.com/go-gad/sal/examples/profile/storage ) onde a interface está localizada, o segundo indica o nome da própria interface. Observe que o comando para go generate pode estar localizado em qualquer lugar, não necessariamente próximo à interface de destino.


Depois de executar o comando go generate , obtemos um construtor cujo nome é construído adicionando o prefixo New ao nome da interface. O construtor usa um parâmetro necessário correspondente à 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) } 

Essa interface corresponde ao objeto *sql.DB


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

Métodos


Os métodos de interface determinam o conjunto de consultas de banco de dados disponíveis.


 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 } 

  • O número de argumentos é sempre estritamente dois.
  • O primeiro argumento é o contexto.
  • O segundo argumento contém dados para variáveis ​​de ligação e define a string de consulta.
  • O primeiro parâmetro de saída pode ser um objeto, uma matriz de objetos ou ausente.
  • O último parâmetro de saída é sempre um erro.

O primeiro argumento é sempre o objeto context.Context . Esse contexto será transmitido ao chamar o banco de dados e o kit de ferramentas. O segundo argumento espera um parâmetro com o tipo de base struct (ou um ponteiro para struct ). O parâmetro deve satisfazer a seguinte interface:


 type Queryer interface { Query() string } 

O método Query() será chamado antes de executar uma consulta ao banco de dados. A sequência resultante será convertida para um formato específico do banco de dados. Ou seja, para o PostgreSQL, @end será substituído por $1 e o valor &req.End será passado para a matriz de argumentos


Dependendo dos parâmetros de saída, é determinado qual dos métodos (Consulta / Exec) será chamado:


  • Se o primeiro parâmetro for do tipo base struct (ou um ponteiro para struct ), o método QueryContext será chamado. Se a resposta do banco de dados não contiver uma única linha, o erro sql.ErrNoRows será sql.ErrNoRows . Ou seja, o comportamento é semelhante ao db.QueryRow .
  • Se o primeiro parâmetro estiver com a slice tipo base, o método QueryContext será chamado. Se a resposta do banco de dados não contiver linhas, uma lista vazia será retornada. O tipo base do item da lista deve ser stuct (ou um ponteiro para uma struct ).
  • Se o parâmetro de saída for um com o tipo de error , o método ExecContext será chamado.

Declarações preparadas


O código gerado suporta expressões preparadas. Expressões preparadas são armazenadas em cache. Após a primeira preparação da expressão, ela é armazenada em cache. A própria biblioteca de banco de dados / sql garante que as expressões preparadas sejam aplicadas de forma transparente à conexão de banco de dados desejada, incluindo o processamento de conexões fechadas. Por sua vez, a biblioteca go-gad/sal cuida da reutilização da instrução preparada no contexto da transação. Quando a expressão preparada é executada, os argumentos são transmitidos usando ligação variável, transparente para o desenvolvedor.


Para suportar argumentos nomeados no lado da biblioteca go-gad/sal , a solicitação é convertida em uma visualização adequada para o banco de dados. Agora existe suporte de conversão para o PostgreSQL. Os nomes dos campos do objeto de consulta são usados ​​para substituir os argumentos nomeados. Para especificar um nome diferente em vez do nome do campo do objeto, você deve usar a tag sql para os campos da estrutura. Considere um exemplo:


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

A string de consulta será convertida e, usando a tabela de correspondência e a associação de variáveis, uma lista será passada para os argumentos de execução da consulta:


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

Mapeie estruturas para argumentos de solicitação e mensagens de resposta


A biblioteca go-gad/sal cuida da associação de linhas de resposta do banco de dados a estruturas de resposta, colunas de tabela com campos de estrutura:


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

E se a resposta do banco de dados for:


 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) 

Em seguida, a lista GetRubricsResp retornará para nós, cujos elementos serão ponteiros para a Rubrica, onde os campos são preenchidos com valores das colunas que correspondem aos nomes das marcas.


Se a resposta do banco de dados contiver colunas com o mesmo nome, os campos de estrutura correspondentes serão selecionados na ordem da declaração.


 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 } 

Tipos de dados não padrão


O pacote database/sql fornece suporte para tipos de dados básicos (seqüências de caracteres, números). Para processar tipos de dados como array ou json em uma solicitação ou resposta, é necessário oferecer suporte às sql.Scanner e sql.Scanner . Diferentes implementações de driver têm funções auxiliares especiais. Por exemplo lib/pq.Array ( https://godoc.org/github.com/lib/pq#Array ):


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

Por padrão, a biblioteca go-gad/sql para visualizar os campos da estrutura


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

usará o valor &req.Tags . Se a estrutura sal.ProcessRower interface sal.ProcessRower ,


 type ProcessRower interface { ProcessRow(rowMap RowMap) } 

então o valor usado pode ser ajustado


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

Este manipulador pode ser usado para argumentos de solicitação e resposta. No caso de uma lista na resposta, o método deve pertencer ao item da lista.


Transações


Para oferecer suporte a transações, a interface (Store) deve ser expandida com os seguintes métodos:


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

A implementação dos métodos será gerada. O método BeginTx usa a conexão do objeto sal.QueryHandler atual e abre a transação db.BeginTx(...) ; retorna um novo objeto de implementação da interface Store , mas usa o objeto *sql.Tx recebido como *sql.Tx


Middleware


Ganchos são fornecidos para incorporar ferramentas.


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

O gancho BeforeQueryFunc será chamado antes da db.PrepareContext ou db.Query . Ou seja, no início do programa, quando o cache de expressão preparado estiver vazio, quando store.GetAuthors chamado, o gancho BeforeQueryFunc será chamado duas vezes. O gancho BeforeQueryFunc pode retornar um gancho FinalizerFunc , que será chamado antes de sair do método do usuário, em nosso caso store.GetAuthors , usando store.GetAuthors .


No momento da execução dos ganchos, o contexto é preenchido com chaves de serviço com os seguintes valores:


  • ctx.Value(sal.ContextKeyTxOpened) valor booleano determina se o método é chamado no contexto da transação ou não.
  • ctx.Value(sal.ContextKeyOperationType) , o valor da cadeia de caracteres do tipo de operação, "QueryRow" , "Query" , "Exec" , "Commit" , etc.
  • ctx.Value(sal.ContextKeyMethodName) valor da string do método da interface, como "GetAuthors" .

Como argumentos, o gancho BeforeQueryFunc aceita a string sql da consulta e o argumento req do método de consulta do usuário. O gancho FinalizerFunc aceita uma variável de err como argumento.


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

Exemplos de saída:


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

O que vem a seguir


  • Suporte para variáveis ​​de ligação e expressões preparadas para MySQL.
  • Gancho RowAppender para ajustar a resposta.
  • Retorna o valor de Exec.Result .

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


All Articles