Генератор клиента к базе данных на 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 <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>

What's next


  • Поддержка связывания переменных и подготовленных выражений для MySQL.
  • Хук RowAppender для корректировки ответа.
  • Возврат значения Exec.Result.

Комментарии (8)


  1. helgihabr
    04.12.2018 13:56

    Т.е. каждый раз описывать структуру БД?
    Почему не сделать, например, метод Load, который загрузит строку из любой таблицы и разложит в map данные, согласно названиям полей:

    map[string]interface{}{
        "id": 1,
        "name": "Some"
    ...
    }
    

    И потом иметь Set и Get для работы с этими полями.


    1. zaurio Автор
      04.12.2018 15:58

      В том то и дело, что не структуру БД. Подход к описанию контракта в статически типизированном виде предпочтительнее работы с моделями базы данных, это типичная практика. Например потому что ответ от базы не всегда соответствует таблицам в базе.


      1. helgihabr
        04.12.2018 16:15

        Начал смотреть код на гитхаб, я правильно понял, что его нельзя в параллельных горутинах использовать?


        1. zaurio Автор
          04.12.2018 16:23

          Конкурентный доступ к объекту DB обеспечивает драйвер, конкурентный доступ к кэшу prepared statements обеспечен библиотекой на базе mutex.

          Можно.


          1. helgihabr
            04.12.2018 16:30

            Понял, спасибо. Просто закралось сомнение, что эта часть безопасна для гоурутин:
            https://github.com/go-gad/sal/blob/master/looker/reflect.go#L133


  1. zelenin
    05.12.2018 21:47

        GetRubrics(ctx context.Context, req GetRubricsReq) (GetRubricsResp, error)

    при запросе 100000 строк здесь будет слайс из 10000 инстансов? кажется здесь курсор/итератор должен возвращаться.


    1. zaurio Автор
      06.12.2018 12:15

      На 100_000 строк из базы данных будет слайс из 100_000 элементов. Курсор должен запрашиваться явно. В данном случае поведение соответствует ожиданию.


      1. zelenin
        06.12.2018 16:53

        Курсор должен запрашиваться явно.

        GetRubricsResp — если это будет курсором, то явность его запрашивания будет зашкаливать. Не в этом дело, а в апи, сгенерированном тулзой. Из курсора я могу получить слайс, из слайса курсор нет. Таким образом тулза покрывает один кейс, а могла бы два.
        db-слой по умолчанию должен возвращать итератор, а вот что с ним уже делать, решит конечный пользователь.