Генератор клиента к базе данных на Golang на основе интерфейса
Генератор клиента к базе данных на Golang на основе интерфейса.
Для работы с базами данных Golang предлагает пакет database/sql
, который является абстракцией над программным интерфейсом реляционной базы данных. С одной стороны пакет включает мощную функциональность по управлению пулом соединений, работе с prepared statements, транзакциями, интерфейсом запросов к базе. С другой стороны приходится написать немалое кол-во однотипного кода в веб приложении для взаимодействия с базой данных. Библиотека go-gad/sal предлагает решение в виде генерации однотипного кода на основе описанного интерфейса.
Motivation
Сегодня существует достаточное количество библиотек, которые предлагают решения в виде ORM, помощников построения запросов, генерации хелперов на основе схемы базы данных.
Когда я переходил несколько лет назад на язык Golang, у меня уже был опыт работы с базами данных на разных языках. С использованием ORM, например ActiveRecord, и без. Пройдя путь от любви до ненависти, не имея проблем с написанием нескольких дополнительных строчек кода, взаимодействие с базой данных в Golang пришло к нечто похожему на repository pattern. Описываем интерфейс работы с базой данных, реализуем с помощью стандартных db.Query, row.Scan. Использовать дополнительные обертки просто не имело смысла, это было непрозрачно, заставляло бы быть на чеку.
Сам язык SQL уже представляет из себя абстракцию между вашей программой и данными в хранилище. Мне всегда казалось нелогичным пытаться описать схему данных, а потом строить сложные запросы. Структура ответа в таком случае отличается от схемы данных. Получается что контракт надо описывать не на уровне схемы данных, а на уровне запроса и ответа. Этот подход в веб разработке мы используем, когда описываем структуры данных запросов и ответов API. При обращении к сервису по RESTful JSON или gRPC, мы декларируем контракт на уровне запроса и ответа с помощью JSON Schema или Protobuf, а не схемы данных сущностей внутри сервисов.
То есть взаимодействие с базой данных свелось к подобному способу:
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)
//...
}
Такой способ делает вашу программу предсказуемой. Но будем честны, это не мечта поэта. Мы хотим сократить количество шаблонного кода для составления запроса, наполнения структур данных, использовать связывание переменных и так далее. Я попробовал сформулировать список требований, которым должна удовлетворять желанный набор утилит.
Requirements
- Описание взаимодействия в виде интерфейса.
- Интерфейс описывается методами и сообщениями запросов и ответов.
- Поддержка связывания переменных и подготовленных выражений (prepared statements).
- Поддержка именованных аргументов.
- Связывание ответа базы данных с полями структуры данных сообщения.
- Поддержка нетипичных структур данных (массив, json).
- Прозрачная работа с транзакциями.
- Встроенная поддержка промежуточных обработчиков (middleware).
Имплементацию взаимодействия с базой данных мы хотим абстрагировать с помощью интерфейса. Это позволит нам реализовать нечто похожее на такой шаблон проектирования как репозиторий. В примере выше мы описали интерфейс Store. Теперь можем использовать его как зависимость. На этапе тестирования мы можем передать сгенерированный на базе этого интерфейса объект заглушку, а в продуктиве будем использовать нашу имплементацию на базе структуры Postgres.
Каждый метод интерфейса описывает один запрос к базе данных. Входные и выходные параметры метода должны быть частью контракта для запроса. Строку запроса нужно уметь форматировать в зависимости от входных параметров. Это особенно актуально при составлении запросов со сложным условием выборки.
При составлении запроса мы хотим использовать подстановки и связывание переменных. Например в PostgreSQL вместо значения вы пишите $1
, и вместе с запросом передаёте массив аргументов. Первый аргумент будет использован в качестве значения в преобразованном запросе. Поддержка подготовленных выражений позволит не заботиться об организации хранения этих самых выражений. Библиотека database/sql предоставляет мощный инструмент поддержки подготовленных выражений, сама заботится о пулле соединений, закрытых соединениях. Но со стороны пользователя необходимо совершить дополнительное действие для повторного использования подготовленного выражения в транзакции.
Базы данных, такие как PostgreSQL и MySQL, используют разный синтаксис для использования подстановок и связывания переменных. PostgreSQL использует формат $1
, $2
, … MySQL использует ?
вне зависимости от расположения значения. Библиотека database/sql предложила универсальный формат именованных аргументов https://golang.org/pkg/database/sql/#NamedArg. Пример использования:
db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime))
Поддержка такого формата предпочтительнее в использовании по сравнению с решениями PostgreSQL или MySQL.
Ответ от базы данных, который обрабатывает программный драйвер условно можно представить в следующем виде:
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)
С точки зрения пользователя на уровне интерфейса удобно описать выходной параметр как массив из структур вида:
type GetRubricsResp struct {
ID int
CreatedAt time.Time
Title string
URL string
}
Далее спроецировать значение id
на resp.ID
и так далее. В общем случае этот функционал закрывает большинство потребностей.
При декларации сообщений через внутренние структуры данных, возникает вопрос о способе поддержки нестандартных типов данных. Например массив. Если при работе с PostgreSQL вы использует драйвер github.com/lib/pq, то при передаче аргументов запроса или сканировании ответа вы можете использовать вспомогательные функции, как pq.Array(&x)
. Пример из документации:
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))
Соответственно должны быть способы подготовки структур данных.
При выполнении любого из методов интерфейса может быть использовано соединение с базой данных, в виде объекта *sql.DB
. При необходимости выполнить несколько методов в рамках одной транзакции хочется использовать прозрачный функционал с аналогичным подходом работы вне транзакции, не передавать дополнительные аргументы.
При работе с реализаций интерфейса нам жизненно необходимо иметь возможность встроить инструментарий. Например логирование всех запросов. Инструментарий должен получить доступ к переменным запроса, ошибке ответа, времени выполнения, названию метода интерфейса.
По большей части требования были сформулированы как систематизация сценариев работы с базой данных.
Solution: go-gad/sal
Один из способов борьбы с шаблонным кодом — это сгенерировать его. Благо в Golang есть инструментарий и примеры для этого https://blog.golang.org/generate. В качестве архитектурного решения для генерации был позаимствован подход GoMock https://github.com/golang/mock, где анализ интерфейса осуществляется с помощью рефлексии. На основе этого подхода согласно требованиям была написана утилита salgen и библиотека sal, которые генерируют код реализации интерфейса и предоставляют набор вспомогательных функций.
Для того, чтобы начать использовать это решение необходимо описать интерфейс, который описывает поведение слоя взаимодействия с базой данных. Указать директиву go:generate
с набором аргументов и запустить генерацию. Будет получен конструктор и куча шаблонного кода, готовые к использованию.
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
Всё начинается с декларирования интерфейса и специальной команды для утилиты 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 {
...
Здесь описывается, что для нашего интерфейса Store
из пакета будет вызвана консольная утилита salgen
, с двумя опциями и двумя аргументами. Первая опция -destination
определяет в какой файл будет записан сгенерированный код. Вторая опция -package
определяет полный путь (import path) библиотеки для сгенерированной имплементации. Далее указаны два аргумента. Первый описывает полный путь пакета (github.com/go-gad/sal/examples/profile/storage
), где расположен искомый интерфейс, второй указывает собственно название интерфейса. Отметим, что команда для go generate
может быть расположена в любом месте, не обязательно рядом с целевым интерфейсом.
После выполнения команды go generate
мы получим конструктор, имя которого строится путем добавления префикса New
к названию интерфейса. Конструктор принимает обязательный параметр, соответствующий интерфейсу 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)
}
Этому интерфейсу соответствует объект *sql.DB
.
connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full"
db, err := sql.Open("postgres", connStr)
client := storage.NewStore(db)
Methods
Методы интерфейса определяют набор доступных запросов к базе данных.
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
}
- Количество аргументов всегда строго два.
- Первый аргумент это контекст.
- Второй аргумент содержит данные для связывания переменных и определяет строку запроса.
- Первый выходной параметр может быть объектом, массивом объектов или отсутствовать.
- Последний выходной параметр всегда ошибка.
Первым аргументом всегда ожидается объект context.Context
. Этот контекст будет передан при вызовах базы данных и инструментария. Вторым аргументом ожидается параметр с базовым типом struct
(или указатель на struct
). Параметр обязан удовлетворять следующему интерфейсу:
type Queryer interface {
Query() string
}
Метод Query()
будет вызван перед выполнением запроса к базе данных. Полученная строка будет преобразована к специфичному для базы данных формату. То есть для PostgreSQL @end
будет заменено на $1
, и в массив аргументов будет передано значение &req.End
В зависимости от выходных параметров определяется какой из методов (Query/Exec) будет вызван:
- Если первый параметр с базовым типом
struct
(или указатель наstruct
), то будет вызван методQueryContext
. Если ответ от базы данных не содержит ни одной строки, то вернется ошибкаsql.ErrNoRows
. То есть поведение схожее сdb.QueryRow
. - Если первый параметр с базовым типом
slice
, то будет вызван методQueryContext
. Если ответ от базы данных не содержит строк, то вернется пустой список. Базовый тип элемента списка должен бытьstuct
(или указатель наstruct
). - Если выходной параметр один, с типом
error
, то будет вызван методExecContext
.
Prepared statements
Сгенерированный код поддерживает подготовленные выражения. Подготовленные выражения кэшируются. После первой подготовки выражения оно помещается в кэш. Библиотека database/sql сама заботится о том, чтобы подготовленные выражения прозрачно применялись на нужное соединение с базой данных, включая обработку закрытых соединений. В свою очередь библиотека go-gad/sal
забоится о повторном использовании подготовленного выражения в контексте транзакции. При выполнении подготовленного выражения аргументы передаются с помощью связывания переменных, прозрачно для разработчика.
Для поддержки именованных аргументов на стороне библиотеки go-gad/sal
запрос преобразуется в вид, пригодный для базы данных. Сейчас есть поддержка преобразования для PostgreSQL. Имена полей объекта запроса используются для подстановки в именованных аргументах. Чтобы указать вместо имени поля объекта другое название, необходимо использовать тэг sql
для полей структуры. Рассмотрим пример:
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`
}
Строка запроса будет преобразована, а с помощью таблицы соответствия и связывания переменных в аргументы выполнения запроса будет передан список:
// generated code:
db.Query("DELETE FROM orders WHERE user_id=$1 AND created_at<$2", &req.UserID, &req.CreatedAt)
Map structs to request’s arguments and response messages
Библиотека go-gad/sal
забоится о том, чтобы связать строки ответа базы данных со структурами ответа, колонки таблиц с полями структур:
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)
}
И если ответ базы данных будет:
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)
То нам вернется список GetRubricsResp, элементами которого будут указатели на Rubric, где поля заполнены значениями из колонок, которые соответствуют названиям тегов.
Если в ответе базы данных будут колонки с одинаковыми именами, то соответствующие поля структуры будут выбраны в порядке объявления.
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
}
Non-standard data types
Пакет database/sql
предоставляет поддержку базовым типам данных (строки, числа). Для того, чтобы обработать такие типы данных как массив или json в запросе или ответе, необходимо поддержать интерфейсы driver.Valuer
и sql.Scanner
. В различных реализациях драйверов есть специальные вспомогательные функции. Например lib/pq.Array
(https://godoc.org/github.com/lib/pq#Array):
func Array(a interface{}) interface {
driver.Valuer
sql.Scanner
}
По умолчанию библиотека go-gad/sql
для полей структуры вида
type DeleteAuthrosReq struct {
Tags []int64 `sql:"tags"`
}
будет использовать значение &req.Tags
. Если же структура будет удовлетворять интерфейсу sal.ProcessRower
,
type ProcessRower interface {
ProcessRow(rowMap RowMap)
}
то используемое значение можно скорректировать
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[])`
}
Этот обработчик можно использовать для аргументов запроса и ответа. В случае со списком в ответе, метод должен принадлежать элементу списка.
Transactions
Для поддержки транзакций интерфейс (Store) должен быть расширен следующими методами:
type Store interface {
BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error)
sal.Txer
...
Реализация методов будет сгенерирована. Метод BeginTx
использует соединение из текущего объекта sal.QueryHandler
и открывает транзакцию db.BeginTx(...)
; возвращает новый объект реализации интерфейса Store
, но в качестве хэндлера использует полученный объект *sql.Tx
Middleware
Для встраивания инструментария предусмотрены хуки.
type BeforeQueryFunc func(ctx context.Context, query string, req interface{}) (context.Context, FinalizerFunc)
type FinalizerFunc func(ctx context.Context, err error)
Хук BeforeQueryFunc
будет вызван до выполнения db.PrepareContext
или db.Query
. То есть на старте программы, когда кэш подготовленных выражений пуст, при вызове store.GetAuthors
, хук BeforeQueryFunc
будет вызван дважды. Хук BeforeQueryFunc
может вернуть хук FinalizerFunc
, который будет вызван перед выходом из пользовательского метода, в нашем случае store.GetAuthors
, с помощью defer
.
В момент выполнения хуков контекст наполнен служебными ключами со следующими значениями:
ctx.Value(sal.ContextKeyTxOpened)
булево значение определяет, что метод вызван в контексте транзакции или нет.ctx.Value(sal.ContextKeyOperationType)
, строковое значение типа операции,"QueryRow"
,"Query"
,"Exec"
,"Commit"
и т.д.ctx.Value(sal.ContextKeyMethodName)
строковое значение метода интерфейса, например"GetAuthors"
.
В качестве аргументов хук BeforeQueryFunc
принимает строку sql запроса и аргумент req
метода пользовательского запроса. Хук FinalizerFunc
в качестве аргумента принимает переменную 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))
Примеров вывода:
"CreateAuthor" > Opeartion "Prepare": "INSERT INTO authors (Name, Desc, CreatedAt) VALUES($1, $2, now()) RETURNING ID, CreatedAt" with req took [50.819µs] inTx[false] Error:
"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:
What’s next
- Поддержка связывания переменных и подготовленных выражений для MySQL.
- Хук RowAppender для корректировки ответа.
- Возврат значения
Exec.Result
.