Работа с базами данных
Go предоставляет универсальный интерфейс database/sql для работы с любыми SQL базами данных. Рассмотрим на примере PostgreSQL.
Подключение к БД
Установка драйвера
go get -u github.com/lib/pq
Базовое подключение
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq" // импорт драйвера
)
func main() {
// Строка подключения
dsn := "postgres://user:password@localhost/dbname?sslmode=disable"
// Открываем соединение
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal("Ошибка подключения:", err)
}
defer db.Close()
// Проверяем соединение
if err := db.Ping(); err != nil {
log.Fatal("Не могу подключиться к БД:", err)
}
fmt.Println("Успешно подключились к БД!")
}
Настройка пула соединений
// Максимум открытых соединений
db.SetMaxOpenConns(25)
// Максимум idle соединений
db.SetMaxIdleConns(5)
// Время жизни соединения
db.SetConnMaxLifetime(5 * time.Minute)
// Максимальное время idle
db.SetConnMaxIdleTime(10 * time.Minute)
Выполнение запросов
Query - SELECT запросы
Строки из БД обычно сканируются в структуру:
type User struct {
ID int
Name string
Email string
CreatedAt time.Time
}
func getUsers(db *sql.DB) ([]User, error) {
query := `
SELECT id, name, email, created_at
FROM users
WHERE active = true
ORDER BY created_at DESC
`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close() // ВАЖНО: всегда закрывайте rows!
var users []User
for rows.Next() {
var user User
err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
users = append(users, user)
}
// Проверяем ошибки после итерации
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return users, nil
}
QueryRow - один результат
func getUserByID(db *sql.DB, id int) (*User, error) {
var user User
query := `
SELECT id, name, email, created_at
FROM users
WHERE id = $1
`
err := db.QueryRow(query, id).Scan(
&user.ID,
&user.Name,
&user.Email,
&user.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found")
}
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
return &user, nil
}
Exec - INSERT, UPDATE, DELETE
func createUser(db *sql.DB, name, email string) (int64, error) {
query := `
INSERT INTO users (name, email, created_at)
VALUES ($1, $2, $3)
RETURNING id
`
var id int64
err := db.QueryRow(query, name, email, time.Now()).Scan(&id)
if err != nil {
return 0, fmt.Errorf("insert failed: %w", err)
}
return id, nil
}
func updateUser(db *sql.DB, id int, name, email string) error {
query := `
UPDATE users
SET name = $1, email = $2, updated_at = $3
WHERE id = $4
`
result, err := db.Exec(query, name, email, time.Now(), id)
if err != nil {
return fmt.Errorf("update failed: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected error: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
Prepared Statements
func getUsersByAge(db *sql.DB, minAge, maxAge int) ([]User, error) {
// Подготавливаем запрос
stmt, err := db.Prepare(`
SELECT id, name, email, created_at
FROM users
WHERE age BETWEEN $1 AND $2
`)
if err != nil {
return nil, err
}
defer stmt.Close()
// Используем многократно
rows, err := stmt.Query(minAge, maxAge)
if err != nil {
return nil, err
}
defer rows.Close()
// ... обработка результатов
}
Транзакции
Что такое ACID и почему перевод денег должен пройти целиком либо не пройти вовсе - в уроке SQL: транзакции.
func transferMoney(db *sql.DB, fromID, toID int, amount float64) error {
// Начинаем транзакцию
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // откатится если не было commit
// Списываем с одного счета
_, err = tx.Exec(`
UPDATE accounts
SET balance = balance - $1
WHERE user_id = $2 AND balance >= $1
`, amount, fromID)
if err != nil {
return fmt.Errorf("debit failed: %w", err)
}
// Пополняем другой счет
_, err = tx.Exec(`
UPDATE accounts
SET balance = balance + $1
WHERE user_id = $2
`, amount, toID)
if err != nil {
return fmt.Errorf("credit failed: %w", err)
}
// Фиксируем транзакцию
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
return nil
}
Транзакции с контекстом
func createOrder(ctx context.Context, db *sql.DB, userID int, items []Item) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: false,
})
if err != nil {
return err
}
defer tx.Rollback()
// Создаем заказ
var orderID int
err = tx.QueryRowContext(ctx, `
INSERT INTO orders (user_id, status, created_at)
VALUES ($1, $2, $3)
RETURNING id
`, userID, "pending", time.Now()).Scan(&orderID)
if err != nil {
return err
}
// Добавляем товары
for _, item := range items {
_, err = tx.ExecContext(ctx, `
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES ($1, $2, $3, $4)
`, orderID, item.ProductID, item.Quantity, item.Price)
if err != nil {
return err
}
}
return tx.Commit()
}
NULL значения
type UserProfile struct {
ID int
Name string
Email string
Phone sql.NullString // может быть NULL
Age sql.NullInt64 // может быть NULL
LastLoginAt sql.NullTime // может быть NULL
}
func getUserProfile(db *sql.DB, id int) (*UserProfile, error) {
var p UserProfile
err := db.QueryRow(`
SELECT id, name, email, phone, age, last_login_at
FROM user_profiles
WHERE id = $1
`, id).Scan(
&p.ID,
&p.Name,
&p.Email,
&p.Phone,
&p.Age,
&p.LastLoginAt,
)
if err != nil {
return nil, err
}
return &p, nil
}
// Использование NULL значений
func printUserPhone(p *UserProfile) {
if p.Phone.Valid {
fmt.Printf("Телефон: %s\n", p.Phone.String)
} else {
fmt.Println("Телефон не указан")
}
}
Миграции
Простая система миграций
type Migration struct {
Version int
Name string
Up string
Down string
}
var migrations = []Migration{
{
Version: 1,
Name: "create_users_table",
Up: `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`,
Down: `DROP TABLE IF EXISTS users;`,
},
{
Version: 2,
Name: "add_phone_to_users",
Up: `ALTER TABLE users ADD COLUMN phone VARCHAR(20);`,
Down: `ALTER TABLE users DROP COLUMN phone;`,
},
}
func migrate(db *sql.DB) error {
// Создаем таблицу миграций
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INT PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// Применяем миграции
for _, m := range migrations {
var exists bool
err := db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)",
m.Version,
).Scan(&exists)
if err != nil {
return err
}
if !exists {
log.Printf("Applying migration %d: %s", m.Version, m.Name)
if _, err := db.Exec(m.Up); err != nil {
return fmt.Errorf("migration %d failed: %w", m.Version, err)
}
if _, err := db.Exec(
"INSERT INTO schema_migrations (version) VALUES ($1)",
m.Version,
); err != nil {
return err
}
}
}
return nil
}
Repository - это уже архитектура, а не database/sql
На практике запросы из database/sql редко вызывают прямо из бизнес-логики - их прячут за репозиторием: тип, который скрывает SQL за методами уровня домена (Create, FindByEmail, Update). Это даёт две вещи: бизнес-код не зависит от драйвера, а в тестах легко подменить репозиторий моком.
Но Repository - это уже паттерн архитектурного слоя, а не часть database/sql. Полный разбор - в соответствующих треках:
- Hex Architecture · Репозитории и интерфейсы: где им жить - где определять интерфейс репозитория и почему его место в домене, а не рядом с БД
- DDD Lite · Repositories в DDD: что они должны делать - что репозиторий должен и чего не должен делать (никакой бизнес-логики внутри)
- Hex Architecture · Тестирование use-case без БД - как мокать репозиторий через интерфейс
В этом уроке достаточно запомнить: всё, что вы видите выше (Query, QueryRow, Exec, транзакции, NULL-значения, миграции) - это низкоуровневые операции с БД. Репозиторий - это слой, который их инкапсулирует.
Best practices
<ComparisonTable data={{ headers: ["Практика", "Плохо", "Хорошо"], rows: [ ["Закрытие rows", "// забыли rows.Close()", "defer rows.Close()"], ["Обработка ошибок", "if err != nil { panic(err) }", "if err != nil { return fmt.Errorf("context: %w", err) }"], ["SQL инъекции", "fmt.Sprintf("WHERE id = %d", id)", ""WHERE id = $1" с параметром"], ["Контекст", "db.Query(query)", "db.QueryContext(ctx, query)"], ["Транзакции", "отдельные запросы", "BEGIN/COMMIT для связанных операций"] ] }} />
Итоги
- database/sql - универсальный интерфейс
- Всегда используйте параметризованные запросы
- Закрывайте rows после использования
- Транзакции для атомарности
- Repository паттерн для организации кода
В следующем уроке научимся тестировать наш код!
Типичная ошибка
Собирать SQL через fmt.Sprintf с пользовательскими данными. Привет, инъекции.
- PHP - PDO и безопасная работа с БД - prepared statements, fetchAssoc, транзакции и NULL в PHP
Мини-практика
Напиши функцию GetUserByID(ctx, db, id) с параметризованным запросом и таймаутом 2 секунды.