Как запускать SQL в Go с максимальным комфортом

от автора

Писать SQL руками или использовать ORM — тема очень спорная, и я опишу, как использовать первый подход максимально эффективно. А какой из подходов выбрать, думаю, каждый сам для себя уже решил.

Я не буду описывать то, что уже описано в документации, речь пойдёт про использование встроенного пакета для работы с базами данных database/sql и надстройки над этим пакетом под названием sqlx. Также будет пара примеров для работы только с PostgreSQL.

Всё описанное ниже хорошо работает в большом проекте, в котором очень активно используется SQL, когда мест, где запускаются запросы, больше 100, к примеру.

Примеры кода можно посмотреть в репозитории. Этот код я написал специально для статьи, так что, возможно, допустил где-то ошибки. Но он очень похож на то, что используется у нас в проекте.

Используйте Query только в случае крайней необходимости

Query, QueryContext, NamedQuery — это самая «общая» функция для запуска запросов, с её помощью можно наиболее полно управлять процессом запуска и получения результатов, но пользоваться ею крайне неудобно. Вот пример считывания строк в структуры:

var users []user q = `SELECT * FROM test_users` rows, err := dbh.QueryxContext(ctx, q) if err != nil {     return err }  defer func() {     err = multierr.Combine(err, rows.Close()) }()  for rows.Next() {     var u user     if err := rows.StructScan(&u); err != nil {         return err     }      users = append(users, u) }  if rows.Err() != nil {     return rows.Err() }

А если воспользоваться функцией SelectContext, то код будет выглядеть вот так:

var users []user q = `SELECT * FROM test_users` if err = dbh.SelectContext(ctx, &users, q); err != nil {     return err }

В первом примере можно много где ошибиться так, что в целом код будет работать, но ошибка где-то не обработается или rows не закроется; а во втором примере ошибиться невозможно. Да и смотреть на второй пример гораздо приятнее, код не засорён.

Но самая большая проблема с первым примером заключается в том, что все люди пишут это немного по-разному и проверять такой код очень сложно, приходится каждый раз вникать в эти 15 строк кода, пытаясь понять, нет ли там ошибки.

Query надо использовать только в крайних случаях, например, когда нужно прочитать миллион строк и обработать их в потоковом режиме. А когда данных ожидается не слишком много, то даже если требуется какая-то обработка, лучше её сделать над всем блоком данных уже после завершения запроса. Например, если пользователей из предыдущего примера надо положить в map:

var users []user q = `SELECT * FROM test_users` if err = dbh.SelectContext(ctx, &users, q); err != nil {     return err }  usersMap := make(map[string]user, len(users)) for _, u := range users {     usersMap[u.Login] = u }

Да, памяти так будет использовано побольше, но зато код получается гораздо проще.

Напишите обёртки

Я считаю, что авторы каждого проекта должны себе написать небольшой пакет с обёртками по трём причинам.

Во-первых, все делают какую-то особенную творческую обработку ошибок, то есть будет удобно обернуть их в обёртки, чтобы при вызове снаружи просто возвращать err.

Во-вторых, Named-версии есть только для Exec и Query (который лучше не использовать). Named-версии крайне полезны, когда аргументов запроса много или когда они собираются из разных функций. Для тех, кто всё-таки не прочитал документацию по sqlx, выглядит запуск запроса вот так:

arg := map[string]interface{}{     "login": "ivanov", } q = `UPDATE test_users SET enabled = false WHERE login = :login` if _, err := dbh.NamedExecContext(ctx, q, arg); err != nil {     return err }

В-третьих, в нашем проекте, к примеру, есть необходимость делать выборку данных в слайс из map‘ов. либо получать одну строку в виде map. В sqlx такие функции отсутствуют. В итоге получаем примерно такой набор обёрток:

Exec(ctx context.Context, db sqlx.ExecerContext, query string, args ...interface{}) (sql.Result, error) Select(ctx context.Context, db sqlx.QueryerContext, dest interface{}, query string, args ...interface{}) error Get(ctx context.Context, db sqlx.QueryerContext, dest interface{}, query string, args ...interface{}) error SelectMaps(ctx context.Context, db sqlx.QueryerContext, query string, args ...interface{}) (ret []map[string]interface{}, err error) GetMap(ctx context.Context, db sqlx.QueryerContext, query string, args ...interface{}) (ret map[string]interface{}, err error)

Плюс Named-версии этих функций.

Используйте ANY вместо IN

В PostgreSQL, к примеру, есть возможность передавать массивы в качестве аргументов и проверять на принадлежность массиву с помощью оператора ANY:

q = `SELECT * FROM test_users WHERE login = ANY($1)` if err := dbutils.Select(ctx, dbh, &users, q, []string{"ivanov", "petrov"}); err != nil {     return err }

Можно разворачивать массивы с помощью sqlx.In, но у этого подхода есть несколько проблем:

  • Максимальное количество аргументов в PostgreSQL — 65536.

  • Раз уж договорились делать обёртки, то их количество умножится на 2, для каждой функции понадобится ещё написать WithIn-версию.

  • Нельзя будет использовать массивы в аргументах. К примеру, если в таблице пользователей будет колонка phones типа text[], то нельзя будет выполнить запрос UPDATE test_users SET phones = $1 WHERE login IN ($2), да и использовать одновременно операторы IN и ANY с аргументами извне не получится.

  • По скорости ANY чуть быстрее, но не критично.

Обёртки для запуска транзакций

С запуском транзакций та же проблема, что и с Query: каждый пишет код как-то по-своему и невозможно договориться, чтобы писать 15 строк кода каким-то особым, фиксированным образом.

К сожалению, тут никак не получается написать красивый код, но серьёзно упростить и уменьшить вероятность ошибки всё-таки можно. Предлагаю такую обёртку:

type TxFunc func(tx *sqlx.Tx) error  type TxRunner interface { BeginTxx(context.Context, *sql.TxOptions) (*sqlx.Tx, error) }  func RunTx(ctx context.Context, db TxRunner, f TxFunc) (err error)

И пример использования:

func updateUser(ctx context.Context, dbh *sqlx.DB, login string, newName string) (u user, err error) { err = dbutils.RunTx(ctx, dbh, func(tx *sqlx.Tx) error { u, err = updateUserTx(ctx, tx, login, newName) return err })  return u, err }  func updateUserTx(ctx context.Context, tx sqlx.ExtContext, login string, newName string) (u user, err error) { q := `SELECT * FROM test_users WHERE login = $1` if err := dbutils.Get(ctx, tx, &u, q, login); err != nil { return user{}, err }  q = `UPDATE test_users SET name = $1 WHERE login = $2 RETURNING *` if err := dbutils.Get(ctx, tx, &u, q, newName, login); err != nil { return user{}, err }  return u, nil }

Здесь updateUser — вспомогательная функция, на которую можно не обращать особого внимания, вся логика находится в функции updateUserTx, которая максимально очищена от вспомогательного кода. В моём примере всегда используется один и тот же уровень изоляции, но каждый проект должен сам решить, будут они задавать уровень изоляции в конфигурации на всё сразу или же задавать при каждом запуске транзакции.

Выводы

Пользуясь этими простыми правилами можно существенно очистить свой код, облегчить проверку и понизить вероятность ошибки. С использованием таких обёрток код становится простой и предсказуемый.

Подготовка кода SQL запроса и аргументов — это вопрос для отдельной статьи.


ссылка на оригинал статьи https://habr.com/ru/company/vk/blog/692062/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *