
Писать 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 запроса и аргументов — это вопрос для отдельной статьи.
