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

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


  1. vn_sten
    12.10.2022 17:07
    +1

    В VK используете sqlx ? A jackc/pgx рассматривали?


    1. guryanov Автор
      12.10.2022 17:10
      +1

      VK большой и проектов много, я думаю в зависимости от задач используют разные библиотеки :)

      В качестве драйвера используется pgx и еще заполнение таблиц большими объемами данных делается напрямую через pgx CopyFromSource.

      Была идея везде использовать напрямую pgx но что-то не дошли руки, так как довольно много кода пришлось бы переписать.