Generador de cliente de base de datos Golang basado en la interfaz.

Para trabajar con bases de datos, Golang ofrece el paquete database/sql
, que es una abstracción de la interfaz de programación de bases de datos relacionales. Por un lado, el paquete incluye una potente funcionalidad para administrar el grupo de conexiones, trabajar con declaraciones preparadas, transacciones y la interfaz de consulta de la base de datos. Por otro lado, debe escribir una cantidad considerable del mismo tipo de código en una aplicación web para interactuar con una base de datos. La biblioteca go-gad / sal ofrece una solución en forma de generar el mismo tipo de código basado en la interfaz descrita.
Motivación
Hoy en día, hay un número suficiente de bibliotecas que ofrecen soluciones en forma de ORM, ayudantes para generar consultas, generando ayudantes basados en un esquema de base de datos.
Cuando cambié al idioma Golang hace varios años, ya tenía experiencia trabajando con bases de datos en diferentes idiomas. Usando ORM, como ActiveRecord, y sin. Después de pasar del amor al odio, no tener problemas para escribir algunas líneas de código adicionales, interactuar con la base de datos en Golang surgió algo así como un patrón de repositorio. Describimos la interfaz para trabajar con la base de datos, la implementamos usando db.Query, row.Scan estándar. Usar envoltorios adicionales simplemente no tenía sentido, era opaco, obligaría a estar alerta.
El lenguaje SQL en sí mismo ya es una abstracción entre su programa y los datos en el repositorio. Siempre me pareció ilógico tratar de describir un esquema de datos y luego crear consultas complejas. La estructura de respuesta en este caso es diferente del esquema de datos. Resulta que el contrato debe describirse no a nivel del esquema de datos, sino a nivel de solicitud y respuesta. Utilizamos este enfoque en el desarrollo web cuando describimos las estructuras de datos de las solicitudes y respuestas API. Al acceder al servicio usando RESTful JSON o gRPC, declaramos el contrato a nivel de solicitud y respuesta usando JSON Schema o Protobuf, y no el esquema de datos de las entidades dentro de los servicios.
Es decir, interactuar con la base de datos se redujo a un método similar:
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) {
De esta manera, su programa es predecible. Pero para ser sincero, este no es el sueño de un poeta. Queremos reducir la cantidad de código repetitivo para componer una consulta, completar estructuras de datos, usar enlace variable, etc. Traté de formular una lista de requisitos que el conjunto deseado de utilidades debería satisfacer.
Requisitos
- Descripción de la interacción en forma de interfaz.
- La interfaz se describe mediante métodos y mensajes de solicitudes y respuestas.
- Soporte para variables vinculantes y declaraciones preparadas.
- Soporte para argumentos con nombre.
- Vinculación de la respuesta de la base de datos a los campos de la estructura de datos del mensaje.
- Soporte para estructuras de datos atípicos (array, json).
- Trabajo transparente con transacciones.
- Soporte nativo para middleware.
Queremos abstraer la implementación de la interacción con la base de datos utilizando la interfaz. Esto nos permitirá implementar algo similar a un patrón de diseño, como un repositorio. En el ejemplo anterior, describimos la interfaz de la Tienda. Ahora podemos usarlo como una dependencia. En la etapa de prueba, podemos pasar un código auxiliar generado en base a esta interfaz, y en el producto usaremos nuestra implementación basada en la estructura de Postgres.
Cada método de interfaz describe una consulta de base de datos. Los parámetros de entrada y salida del método deben ser parte del contrato para la solicitud. La cadena de consulta debe poder formatear según los parámetros de entrada. Esto es especialmente cierto cuando se compilan consultas con una condición de muestreo compleja.
Al compilar una consulta, queremos usar sustitución y enlace variable. Por ejemplo, en PostgreSQL, escribe $1
lugar de un valor y, junto con la consulta, pasa una matriz de argumentos. El primer argumento se usará como el valor en la consulta convertida. La compatibilidad con expresiones preparadas le permite no preocuparse por organizar el almacenamiento de estas mismas expresiones. La base de datos / biblioteca sql proporciona una herramienta poderosa para soportar expresiones preparadas; en sí misma se encarga del grupo de conexiones, conexiones cerradas. Pero por parte del usuario, es necesaria una acción adicional para reutilizar la expresión preparada en la transacción.
Las bases de datos, como PostgreSQL y MySQL, usan una sintaxis diferente para usar sustituciones y enlaces variables. PostgreSQL usa el formato $1
, $2
, ... ¿MySQL usa ?
independientemente de la ubicación del valor. La base de datos / biblioteca sql propuso un formato universal para argumentos con nombre https://golang.org/pkg/database/sql/#NamedArg . Ejemplo de uso:
db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime))
Es preferible utilizar el soporte para este formato en comparación con las soluciones PostgreSQL o MySQL.
La respuesta de la base de datos que procesa el controlador de software se puede representar condicionalmente de la siguiente manera:
dev > SELECT * FROM rubrics; id | created_at | title | url
Desde el punto de vista del usuario a nivel de interfaz, es conveniente describir el parámetro de salida como una matriz de estructuras de la forma:
type GetRubricsResp struct { ID int CreatedAt time.Time Title string URL string }
A continuación, proyecte el valor de id
en resp.ID
y así sucesivamente. En general, esta funcionalidad cubre la mayoría de las necesidades.
Al declarar mensajes a través de estructuras de datos internas, surge la pregunta de cómo admitir tipos de datos no estándar. Por ejemplo, una matriz. Si usa el controlador github.com/lib/pq cuando trabaja con PostgreSQL, puede usar funciones auxiliares como pq.Array(&x)
al pasar argumentos de consulta o escanear una respuesta. Ejemplo de la documentación:
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 consecuencia, debe haber formas de preparar estructuras de datos.
Al ejecutar cualquiera de los métodos de interfaz, se puede utilizar una conexión de base de datos en forma de un *sql.DB
Si necesita ejecutar varios métodos dentro de una sola transacción, quiero usar una funcionalidad transparente con un enfoque similar para trabajar fuera de una transacción, no pasar argumentos adicionales.
Al trabajar con implementaciones de interfaz, es vital para nosotros poder incrustar el kit de herramientas. Por ejemplo, registrar todas las solicitudes. El kit de herramientas debe tener acceso a las variables de solicitud, error de respuesta, tiempo de ejecución, nombre del método de interfaz.
En su mayor parte, los requisitos se formularon como una sistematización de escenarios de bases de datos.
Solución: go-gad / sal
Una forma de lidiar con el código repetitivo es generarlo. Afortunadamente, Golang tiene herramientas y ejemplos para este https://blog.golang.org/generate . El enfoque https://github.com/golang/mock de GoMock se tomó como una solución arquitectónica para la generación, donde el análisis de la interfaz se realiza mediante la reflexión. Según este enfoque, de acuerdo con los requisitos, se escribieron la utilidad salgen y la biblioteca sal, que generan código de implementación de interfaz y proporcionan un conjunto de funciones auxiliares.
Para comenzar a usar esta solución, es necesario describir una interfaz que describa el comportamiento de la capa de interacción con la base de datos. Especifique la directiva go:generate
con un conjunto de argumentos y comience la generación. Obtendrá un constructor y un montón de código repetitivo, listo para usar.
package repo import "context"
Interfaz
Todo comienza con la declaración de la interfaz y un comando especial para la utilidad go generate
:
Aquí se describe que para nuestra interfaz de Store
, la utilidad de consola salgen
se llamará desde el paquete, con dos opciones y dos argumentos. La primera opción -destination
determina en qué archivo se escribirá el código generado. La segunda opción -package
define la ruta completa (ruta de importación) de la biblioteca para la implementación generada. Los siguientes son dos argumentos. El primero describe la ruta completa del paquete ( github.com/go-gad/sal/examples/profile/storage
) donde se encuentra la interfaz, el segundo indica el nombre de la interfaz en sí. Tenga en cuenta que el comando para go generate
puede ubicarse en cualquier lugar, no necesariamente al lado de la interfaz de destino.
Después de ejecutar el comando go generate
, obtenemos un constructor cuyo nombre se construye agregando el prefijo New
al nombre de la interfaz. El constructor toma un parámetro requerido correspondiente a la interfaz 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) }
Esta interfaz corresponde al objeto *sql.DB
connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full" db, err := sql.Open("postgres", connStr) client := storage.NewStore(db)
Métodos
Los métodos de interfaz determinan el conjunto de consultas de bases de datos 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 }
- El número de argumentos siempre es estrictamente dos.
- El primer argumento es el contexto.
- El segundo argumento contiene datos para variables de enlace y define la cadena de consulta.
- El primer parámetro de salida puede ser un objeto, una matriz de objetos o estar ausente.
- El último parámetro de salida es siempre un error.
El primer argumento es siempre el objeto context.Context
. Este contexto se transmitirá al invocar la base de datos y el kit de herramientas. El segundo argumento espera un parámetro con el tipo base struct
(o un puntero a struct
). El parámetro debe satisfacer la siguiente interfaz:
type Queryer interface { Query() string }
Se llamará al método Query()
antes de ejecutar una consulta de base de datos. La cadena resultante se convertirá a un formato específico de la base de datos. Es decir, para PostgreSQL, @end
se reemplazará con $1
, y el valor &req.End
pasará a la matriz de argumentos
Dependiendo de los parámetros de salida, se determina a cuál de los métodos (Query / Exec) se llamará:
- Si el primer parámetro es del tipo base
struct
(o un puntero a struct
), se QueryContext
método QueryContext
. Si la respuesta de la base de datos no contiene una sola fila, se sql.ErrNoRows
error sql.ErrNoRows
. Es decir, el comportamiento es similar a db.QueryRow
. - Si el primer parámetro es con el
slice
tipo base, se QueryContext
método QueryContext
. Si la respuesta de la base de datos no contiene filas, se devolverá una lista vacía. El tipo base del elemento de la lista debe ser stuct
(o un puntero a una struct
). - Si el parámetro de salida es uno con el tipo de
error
, se ExecContext
método ExecContext
.
Declaraciones preparadas
El código generado admite expresiones preparadas. Las expresiones preparadas se almacenan en caché. Después de la primera preparación de la expresión, se almacena en caché. La propia base de datos / biblioteca sql garantiza que las expresiones preparadas se apliquen de forma transparente a la conexión de base de datos deseada, incluido el procesamiento de conexiones cerradas. A su vez, la biblioteca go-gad/sal
se encarga de reutilizar la declaración preparada en el contexto de la transacción. Cuando se ejecuta la expresión preparada, los argumentos se pasan utilizando el enlace variable, transparente para el desarrollador.
Para admitir argumentos con nombre en el lado de la biblioteca go-gad/sal
, la solicitud se convierte en una vista adecuada para la base de datos. Ahora hay soporte de conversión para PostgreSQL. Los nombres de campo del objeto de consulta se utilizan para sustituir en argumentos con nombre. Para especificar un nombre diferente en lugar del nombre del campo del objeto, debe usar la etiqueta sql
para los campos de estructura. Considere un ejemplo:
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 cadena de consulta se convertirá y, utilizando la tabla de correspondencia y el enlace variable, se pasará una lista a los argumentos de ejecución de la consulta:
Asignar estructuras a los argumentos de solicitud y mensajes de respuesta
La biblioteca go-gad/sal
se encarga de asociar las líneas de respuesta de la base de datos con las estructuras de respuesta, las columnas de la tabla con los campos de estructura:
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) }
Y si la respuesta de la base de datos es:
dev > SELECT * FROM rubrics; id | created_at | title
Luego, la lista GetRubricsResp volverá a nosotros, cuyos elementos serán punteros a Rubric, donde los campos se rellenan con valores de las columnas que corresponden a los nombres de las etiquetas.
Si la respuesta de la base de datos contiene columnas con el mismo nombre, los campos de estructura correspondientes se seleccionarán en el orden de la declaración.
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 }
Tipos de datos no estándar
El paquete de database/sql
proporciona soporte para tipos de datos básicos (cadenas, números). Para procesar tipos de datos como array o json en una solicitud o respuesta, es necesario admitir las sql.Scanner
driver.Valuer
y sql.Scanner
. Las diferentes implementaciones de controladores tienen funciones auxiliares especiales. Por ejemplo lib/pq.Array
( https://godoc.org/github.com/lib/pq#Array ):
func Array(a interface{}) interface { driver.Valuer sql.Scanner }
Por defecto, la biblioteca go-gad/sql
para ver los campos de estructura
type DeleteAuthrosReq struct { Tags []int64 `sql:"tags"` }
utilizará el valor &req.Tags
. Si la estructura satisface la interfaz sal.ProcessRower
,
type ProcessRower interface { ProcessRow(rowMap RowMap) }
entonces el valor utilizado se puede ajustar
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 controlador se puede usar para argumentos de solicitud y respuesta. En el caso de una lista en la respuesta, el método debe pertenecer al elemento de la lista.
Transacciones
Para admitir transacciones, la interfaz (Tienda) debe ampliarse con los siguientes métodos:
type Store interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error) sal.Txer ...
Se generará la implementación de los métodos. El método BeginTx
usa la conexión desde el objeto actual sal.QueryHandler
y abre la transacción db.BeginTx(...)
; devuelve un nuevo objeto de implementación de la interfaz de la Store
, pero usa el objeto recibido *sql.Tx
como *sql.Tx
Middleware
Se proporcionan ganchos para incrustar herramientas.
type BeforeQueryFunc func(ctx context.Context, query string, req interface{}) (context.Context, FinalizerFunc) type FinalizerFunc func(ctx context.Context, err error)
Se BeforeQueryFunc
gancho BeforeQueryFunc
antes de db.PrepareContext
o db.Query
. Es decir, al comienzo del programa, cuando la memoria caché de expresiones preparada está vacía, cuando store.GetAuthors
llama a store.GetAuthors
llamará al gancho BeforeQueryFunc
dos veces. El BeforeQueryFunc
puede devolver un BeforeQueryFunc
FinalizerFunc
, que se llamará antes de salir del método de usuario, en nuestra store.GetAuthors
casos. store.GetAuthors
, usando defer
.
En el momento de la ejecución de los ganchos, el contexto se llena con claves de servicio con los siguientes valores:
ctx.Value(sal.ContextKeyTxOpened)
valor booleano determina si el método se llama en el contexto de la transacción o no.ctx.Value(sal.ContextKeyOperationType)
, el valor de cadena del tipo de operación, "QueryRow"
, "Query"
, "Exec"
, "Commit"
, etc.ctx.Value(sal.ContextKeyMethodName)
valor de cadena del método de interfaz, como "GetAuthors"
.
Como argumentos, el BeforeQueryFunc
acepta la cadena sql de la consulta y el argumento req
del método de consulta del usuario. El gancho FinalizerFunc
toma una variable 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))
Ejemplos de salida:
"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>
Que sigue
- Soporte para variables de enlace y expresiones preparadas para MySQL.
- Gancho RowAppender para ajustar la respuesta.
- Devuelve el valor de
Exec.Result
.