Знать, как запрашивать базы данных SQL в Go. Правильный путь

Эта история основана на отличном ресурсе об использовании SQL в Go: https://go-database-sql.org/. Я рекомендую вам ознакомиться с ним, чтобы получить полное представление о подходе Go к базам данных SQL.

Обзор

Идиоматический способ использования базы данных SQL или SQL-подобной базы данных в Go — через файл database/sql package. Он предоставляет облегченный интерфейс для базы данных, ориентированной на строки. Документация пакета говорит вам, что все делает, но не говорит вам, как использовать пакет. Многие из нас обнаруживают, что хотят получить краткую справку и ориентацию для начала работы, которая рассказывает истории, а не перечисляет факты. Эта история как раз об этом. Пойдем!

sql.DB

Для доступа к базам данных в Go вы используете sql.DB. Вы используете этот тип для создания операторов и транзакций, выполнения запросов и выборки результатов.

Первое, что вы должны знать, это то, что sql.DB не является соединением с базой данных. Это также не соответствует понятию «база данных» или «схема» какого-либо конкретного программного обеспечения базы данных. Это абстракция базы данных, которая может быть такой же разнообразной, как локальный файл, доступ к которому осуществляется через сетевое соединение, или в памяти и в процессе.

sql.DB выполняет для вас некоторые важные задачи за кулисами:

  • Он открывает и закрывает соединения с фактической базовой базой данных через драйвер.
  • Он управляет пулом соединений по мере необходимости, что может быть различным, как уже упоминалось.

Абстракция sql.DB предназначена для того, чтобы вы не беспокоились о том, как управлять одновременным доступом к базовому хранилищу данных. Это безопасно для одновременного использования несколькими горутинами.

Соединение помечается как используемое, когда вы используете его для выполнения задачи, а затем возвращается в доступный пул, когда оно больше не используется. Одним из следствий этого является то, что если вы не сможете разблокировать соединения обратно в пул, вы можете заставить sql.DB открыть множество соединений, потенциально исчерпав ресурсы (слишком много соединений, слишком много открытых файловых дескрипторов, отсутствие доступных сетевых портов, и т. д). Мы обсудим это позже.

Драйвер базы данных

Чтобы использовать database/sql, вам понадобится сам пакет, а также драйвер для конкретной базы данных, которую вы хотите использовать.

Как правило, вам не следует использовать пакеты драйверов напрямую, хотя некоторые драйверы рекомендуют вам это делать. (По нашему мнению, обычно это плохая идея.) Вместо этого ваш код должен ссылаться только на типы, определенные в database/sql, если это возможно. Это помогает избежать зависимости вашего кода от драйвера, так что вы можете изменить базовый драйвер (и, следовательно, базу данных, к которой вы обращаетесь) с минимальными изменениями кода. Это также заставляет вас использовать идиомы Go вместо специальных идиом, которые мог предоставить конкретный автор драйвера.

Драйверы баз данных не включены в стандартную библиотеку Go. Но их много, они реализованы сторонними, см. https://golang.org/s/sqldrivers.

В демонстрационных целях воспользуемся отличными драйверами MySQL.

Добавьте следующее в начало исходного файла Go:

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql"
)

Обратите внимание, что мы загружаем драйвер анонимно, присвоив его квалификатору пакета _, чтобы ни одно из его экспортированных имен не было видно нашему коду. Под капотом драйвер регистрирует себя как доступный для пакета database/sql, но в целом больше ничего не происходит, за исключением запуска функции init.

Теперь вы готовы получить доступ к базе данных.

Доступ к базе данных

Теперь, когда вы загрузили пакет драйверов, вы готовы создать объект базы данных, файл sql.DB.

Чтобы создать sql.DB, вы используете sql.Open(). Это возвращает *sql.DB:

func main() {
	db, err := sql.Open("mysql",
		"user:password@tcp(127.0.0.1:3306)/hello")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
}

В показанном примере мы иллюстрируем несколько вещей:

  1. Первый аргумент sql.Open — это имя драйвера. Это строка, которую драйвер использовал для регистрации с помощью database/sql, и обычно совпадает с именем пакета, чтобы избежать путаницы. Например, это mysql для github.com/go-sql-driver/mysql. Некоторые драйверы не следуют соглашению и используют имя базы данных, например. sqlite3 для github.com/mattn/go-sqlite3 и postgres для github.com/lib/pq.
  2. Второй аргумент — это специфичный для драйвера синтаксис, который сообщает драйверу, как получить доступ к базовому хранилищу данных. В этом примере мы подключаемся к базе данных «hello» внутри локального экземпляра сервера MySQL.
  3. Вы всегда должны проверять и обрабатывать ошибки, возвращаемые всеми database/sql операциями.
  4. Это идиоматично для defer db.Close(), если sql.DB не должно иметь время жизни, выходящее за рамки функции.

Возможно, вопреки здравому смыслу sql.Open() не устанавливает никаких подключений к базе данных и не проверяет параметры подключения драйвера. Вместо этого он просто подготавливает абстракцию базы данных для последующего использования. Первое фактическое подключение к базовому хранилищу данных будет установлено лениво, когда оно потребуется в первый раз. Если вы хотите сразу проверить, что база данных доступна и доступна (например, проверить, что вы можете установить сетевое соединение и войти в систему), используйте для этого db.Ping() и не забудьте проверить наличие ошибок:

err = db.Ping()
if err != nil {
	// do something here
}

Хотя это идиоматично Close() базу данных, когда вы закончите с ней работать, объект sql.DB рассчитан на долгую жизнь. Не выполняйте Open() и Close() базы данных слишком часто. Вместо этого создайте по одному объекту sql.DB для каждого отдельного хранилища данных, к которому вам нужно получить доступ, и сохраняйте его до тех пор, пока программа не завершит доступ к этому хранилищу данных. Передавайте его по мере необходимости или сделайте его доступным как-то глобально, но держите его открытым. И не Open() и Close() из недолговечной функции. Вместо этого передайте sql.DB в эту недолговечную функцию в качестве аргумента.

Получение наборов результатов

Имена database/sql функций в Go имеют большое значение. Если имя функции включает Query, оно предназначено для запроса базы данных и возвращает набор строк, даже если он пуст. Операторы, которые не возвращают строки, не должны использовать функции Query; они должны использовать Exec().

Получение данных из базы данных

Давайте рассмотрим пример того, как делать запросы к базе данных, работая с результатами. Мы запросим таблицу users для пользователя, чье id равно 1, и распечатаем пользовательские id и name. Мы будем назначать результаты переменным, построчно, с rows.Scan().

Вот что происходит в приведенном выше коде:

  1. Мы используем db.Query() для отправки запроса в базу данных. Проверяем ошибку, как обычно.
  2. Мы откладываем rows.Close(). Это очень важно.
  3. Мы перебираем строки с rows.Next().
  4. Мы читаем столбцы в каждой строке в переменные с rows.Scan().
  5. Мы проверяем наличие ошибок после того, как закончим перебор строк.

Это почти единственный способ сделать это в Go. Например, вы не можете получить строку как карту. Это потому, что все строго типизировано. Вам нужно создать переменные правильного типа и передать им указатели, как показано.

В паре частей этого легко ошибиться, и это может иметь плохие последствия.

  • rows.Next() указывает, доступна ли следующая строка из набора результатов, и будет возвращать true до тех пор, пока какой-либо набор результатов не будет исчерпан или во время выборки данных не произойдет ошибка. По этой причине вы всегда должны проверять наличие ошибки в конце цикла for rows.Next() (это делается при вызове rows.Err()). Если во время цикла произошла ошибка, вы должны знать об этом. Не думайте, что цикл повторяется до тех пор, пока вы не обработаете все строки.
  • Во-вторых, пока существует открытый набор результатов (представленный rows), базовое соединение занято и не может использоваться для каких-либо других запросов, пока не будет вызвано rows.Close(). Это означает, что он недоступен в пуле соединений. Преимущество database/sql заключается в том, что он неявно вызовет rows.Close() для вас, когда rows.Next() вернет false, но если вы преждевременно выйдете из цикла, вы обязаны закрыть строки, иначе соединение останется занятым и недоступным для других операций, приводит к утечке соединения. Таким образом, как правило, вы всегда должны defer rows.Close(), чтобы избежать утечки соединения и исчерпания ресурсов.
  • rows.Close() — безобидная пустая операция, если она уже закрыта, поэтому можно вызывать ее несколько раз. Однако обратите внимание, что сначала мы проверяем ошибку из db.Query() и откладываем rows.Close() только в том случае, если ошибки нет, чтобы избежать паники во время выполнения (например, когда ошибка возвращается из метода db.Query(), объект rows будет nil).

Как работает сканирование()

Когда вы перебираете строки и сканируете их в целевые переменные, Go выполняет преобразование типов данных за вас, за кулисами. Он основан на типе целевой переменной. Знание этого может очистить ваш код и помочь избежать повторяющейся работы.

Например, предположим, что вы выбираете несколько строк из таблицы, которая определена строковыми столбцами, такими как VARCHAR(45) или подобными. Вы, однако, знаете, что таблица всегда содержит числа. Если вы передадите указатель на строку, Go скопирует байты в строку. Теперь вы можете использовать strconv.ParseInt() или подобное для преобразования значения в число. Вам нужно будет проверить наличие ошибок в операциях SQL, а также ошибок при синтаксическом анализе целого числа. Это грязно и утомительно.

Или вы можете просто передать Scan() указатель на целое число. Go обнаружит это и вызовет для вас strconv.ParseInt(). Если в преобразовании есть ошибка, вызов Scan() вернет ее. Теперь ваш код стал аккуратнее и меньше. Это рекомендуемый способ использования database/sql.

Однострочные запросы

Если запрос возвращает не более одной строки, вы можете использовать ярлык для обхода длинного шаблонного кода:

Ошибки из запроса откладываются до вызова Scan(), а затем возвращаются из него.

Изменение данных

Используйте Exec() для выполнения INSERT, UPDATE, DELETE или другого оператора (вероятно, специфичного для базы данных), который не возвращает строки. В следующем примере показано, как вставить строку и проверить метаданные об операции:

Выполнение оператора создает sql.Result, который дает доступ к метаданным оператора: последний вставленный идентификатор и количество затронутых строк.

Что делать, если вам не важен результат? Что, если вы просто хотите выполнить оператор и проверить, не было ли ошибок, но проигнорировать результат? Разве следующие два утверждения не делают одно и то же?

_, err := db.Exec("DELETE FROM users")  // OK
_, err := db.Query("DELETE FROM users") // BAD

Ответ - нет. Они делают разные вещи, и вы никогда не должны использовать Query() таким образом. Query() вернет sql.Rows, который резервирует соединение с базой данных до закрытия sql.Rows. Поскольку могут быть непрочитанные данные (например, больше строк данных), соединение использовать нельзя. В приведенном выше примере соединение никогда снова не будет разорвано. Таким образом, этот антишаблон — хороший способ исчерпать ресурсы (например, слишком много соединений).

Краткое содержание

Мы изучили идиоматические способы работы с базами данных SQL на языке программирования Go с использованием стандартного пакета database/sql. Расширенные концепции транзакций и подготовленных отчетов были оставлены в стороне для краткости.

Если вам нужны дополнительные сведения о том, как работать с транзакциями и подготовленными операторами в database/sql, я рекомендую вам ознакомиться с отличным учебником по golang SQL: https://go-database-sql.org/index.html.