Писать 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 запроса и аргументов — это вопрос для отдельной статьи.
vn_sten
В VK используете sqlx ? A jackc/pgx рассматривали?
guryanov Автор
VK большой и проектов много, я думаю в зависимости от задач используют разные библиотеки :)
В качестве драйвера используется pgx и еще заполнение таблиц большими объемами данных делается напрямую через pgx CopyFromSource.
Была идея везде использовать напрямую pgx но что-то не дошли руки, так как довольно много кода пришлось бы переписать.