Работа с базами данных

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("Успешно подключились к БД!")
}
sql.Open не открывает соединение сразу, а создает пул соединений. Реальное подключение происходит при первом запросе.

Настройка пула соединений

// Максимум открытых соединений
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. Полный разбор - в соответствующих треках:

В этом уроке достаточно запомнить: всё, что вы видите выше (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 с пользовательскими данными. Привет, инъекции.

Мини-практика

Напиши функцию GetUserByID(ctx, db, id) с параметризованным запросом и таймаутом 2 секунды.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.