Лекція 13. Взаємодія з реляційними СУБД та технології ORM
🎯 Мета лекції
- Зрозуміти роль реляційних баз даних у клієнт-серверних застосунках та принципи роботи з PostgreSQL.
- Опанувати стандартний інтерфейс
database/sqlта високопродуктивний драйверpgxдля виконання SQL-запитів. - Навчитися працювати з транзакціями та забезпечувати гарантії ACID.
- Освоїти інструменти міграцій для версіонування схеми бази даних.
- Познайомитися з ORM-підходом на прикладі бібліотеки GORM та зрозуміти компроміси між чистим SQL та об'єктно-реляційним відображенням.
🔑 Ключові терміни
- СУБД (Система Управління Базами Даних): програмний комплекс для створення, збереження та маніпулювання даними.
- PostgreSQL: вільна об'єктно-реляційна СУБД з потужними можливостями та відповідністю стандартам SQL.
- Driver (драйвер БД): бібліотека, що реалізує низькорівневу комунікацію з конкретною СУБД.
- Connection Pool (пул з'єднань): механізм багаторазового використання підключень для підвищення продуктивності.
- ORM (Object-Relational Mapping): об'єктно-реляційне відображення, технологія автоматичного зв'язування структур Go із таблицями БД.
Роль баз даних у клієнт-серверних системах
На попередніх лекціях ми навчилися створювати HTTP-сервери, обробляти запити, валідувати дані та повертати відповіді. Але всі наші дані зберігалися лише у пам'яті сервера (in-memory), що має критичні обмеження:
- Втрата даних при перезапуску: кожен перезапуск сервера призводить до втрати всієї інформації.
- Неможливість масштабування: якщо запустити кілька екземплярів сервера, кожен матиме власний, ізольований стан.
- Обмеження обсягу: розмір даних обмежений доступною оперативною пам'яттю.
- Відсутність складних запитів: у пам'яті складно виконувати агрегацію, сортування, фільтрацію та з'єднання великих наборів даних.
Реляційна база даних (Relational Database) розв'язує всі ці проблеми. Вона забезпечує:
- Персистентність даних (Persistence): інформація зберігається на диску та переживає перезапуски.
- Конкурентний доступ (Concurrent Access): тисячі клієнтів можуть одночасно читати та змінювати дані без конфліктів.
- Транзакційність (ACID Properties): гарантії атомарності, узгодженості, ізольованості та довговічності операцій.
- Мову запитів SQL (Structured Query Language): стандартизований, декларативний спосіб отримання та маніпулювання даними.
Архітектурний контекст: де живе база даних
У типовій клієнт-серверній архітектурі база даних є окремим процесом (або навіть окремою машиною), до якого сервер застосунку під'єднується через мережеве з'єднання.
Як це працює:
- Клієнт надсилає HTTP-запит до серверного застосунку.
- Сервер валідує дані, потім відкриває з'єднання з базою даних PostgreSQL.
- Сервер виконує SQL-команди (INSERT, SELECT, UPDATE, DELETE) через це з'єднання.
- База даних обробляє запити, записує/читає дані з диска та повертає результати серверу.
- Сервер перетворює результати у JSON та повертає клієнту.
5432 для PostgreSQL). Сервер не включає базу даних у себе, а під'єднується до неї через TCP-сокет, подібно до того, як клієнт під'єднується до сервера.Стандартний інтерфейс database/sql у Go
На відміну від мов з динамічною типізацією (Python, JavaScript), де існують специфічні для кожної СУБД бібліотеки (psycopg2, pg), мова Go пропонує уніфікований інтерфейс для роботи з будь-якою реляційною базою даних: пакет database/sql.
Концепція двошарової архітектури драйверів
Пакет database/sql визначає набір абстракцій (інтерфейсів):
sql.DB— представляє пул з'єднань до бази даних.sql.Conn— одне фізичне з'єднання.sql.Tx— транзакцію.sql.Rows— результат запиту SELECT (курсор).sql.Result— результат команд INSERT/UPDATE/DELETE.
Сам пакет database/sql не містить коду для роботи з конкретною СУБД. Замість цього він очікує, що ви імпортуєте драйвер (driver) — бібліотеку, яка реалізує низькорівневу комунікацію з PostgreSQL, MySQL чи іншою базою.
Переваги цього підходу:
- Портативність коду: ви можете змінити СУБД, замінивши лише драйвер та рядок підключення (connection string).
- Стандартизація API: всі операції виконуються однаково, незалежно від СУБД.
- Оптимізація драйверами: кожен драйвер може використовувати специфічні для СУБД протоколи та можливості (бінарний формат, підготовлені команди).
pgx(github.com/jackc/pgx) — нативний Go-драйвер, найшвидший, підтримує всі можливості PostgreSQL, може працювати як черезdatabase/sql, так і через власний API.sqlx(github.com/jmoiron/sqlx) — обгортка надdatabase/sql, що спрощує роботу з результатами запитів через автоматичне сканування у структури. Не є драйвером, а додатковим шаром зручності.pq(github.com/lib/pq) — старіший драйвер, режим підтримки, використовується у легаcі-проєктах.
Підключення до PostgreSQL: перші кроки
Перш ніж писати код, потрібно запустити екземпляр PostgreSQL. Найпростіший спосіб — використати Docker:
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: myapp_postgres
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secretpass
POSTGRES_DB: myapp_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Запуск:
docker compose up -d
docker run --name myapp_postgres \
-e POSTGRES_USER=appuser \
-e POSTGRES_PASSWORD=secretpass \
-e POSTGRES_DB=myapp_db \
-p 5432:5432 \
-d postgres:16-alpine
brew install postgresql@16
brew services start postgresql@16
createdb myapp_db
Після запуску СУБД вона слухатиме на порту 5432. Тепер можна під'єднатися з Go.
Встановлення драйвера pgx
go get github.com/jackc/pgx/v5
go get github.com/jackc/pgx/v5/stdlib
Чому два пакети?
github.com/jackc/pgx/v5— нативний API pgx (більш потужний, але не сумісний зdatabase/sql).github.com/jackc/pgx/v5/stdlib— адаптер для використання pgx через стандартний інтерфейсdatabase/sql.
На цій лекції ми будемо використовувати стандартний інтерфейс database/sql, щоб ви могли легко перейти на будь-яку іншу СУБД у майбутньому.
Структура підключення: рядок DSN
Для підключення до PostgreSQL використовується DSN (Data Source Name — ім'я джерела даних), також відомий як connection string (рядок підключення).
Формат DSN для PostgreSQL:
postgres://username:password@hostname:port/database?параметри
Приклад:
postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable
Розбір компонентів:
appuser— ім'я користувача бази даних.secretpass— пароль.localhost:5432— адреса та порт сервера PostgreSQL.myapp_db— назва бази даних.sslmode=disable— вимкнути шифрування SSL (лише для локальної розробки!).
sslmode=require або sslmode=verify-full). Також ніколи не зберігайте паролі в коді — використовуйте змінні оточення:dsn := os.Getenv("DATABASE_URL")
Відкриття з'єднання та пул підключень
Розглянемо базовий код підключення до PostgreSQL:
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/jackc/pgx/v5/stdlib" // Реєстрація драйвера
)
func main() {
// Рядок підключення (DSN)
dsn := "postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable"
// Відкриття пулу з'єднань
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatalf("Не вдалося відкрити базу даних: %v", err)
}
defer db.Close()
// Перевірка з'єднання
if err := db.Ping(); err != nil {
log.Fatalf("Не вдалося під'єднатися до бази даних: %v", err)
}
fmt.Println("✅ Успішне підключення до PostgreSQL")
}
Важливі деталі:
- Імпорт драйвера з
_(blank identifier):_ "github.com/jackc/pgx/v5/stdlib"
Цей імпорт не надає жодних функцій напряму. Він лише реєструє драйверpgxу глобальному реєстріdatabase/sqlпід ім'ям"pgx". sql.Open()не відкриває з'єднання:
Викликsql.Open("pgx", dsn)лише створює об'єкт*sql.DBта ініціалізує пул з'єднань, але не встановлює фізичне з'єднання з базою даних. Фактичне підключення відбувається при першому запиті.db.Ping()— перевірка доступності:
МетодPing()примушує пул створити реальне з'єднання та виконати тривіальний запит до бази. Якщо база недоступна,Ping()поверне помилку.defer db.Close():
Об'єктsql.DBзберігає пул з'єднань. При завершенні роботи програми потрібно закрити всі з'єднання, викликавшиClose().
sql.DB підтримує пул з'єднань: кілька відкритих з'єднань, які багаторазово використовуються різними горутинами.Коли горутина виконує запит, вона отримує вільне з'єднання з пулу. Після завершення запиту з'єднання повертається у пул для подальшого використання.Налаштування параметрів пулу з'єднань
За замовчуванням sql.DB створює необмежену кількість з'єднань (unlimited). Це може призвести до виснаження ресурсів бази даних. Тому важливо налаштувати розмір пулу:
// Максимальна кількість відкритих з'єднань (включно з активними та неактивними)
db.SetMaxOpenConns(25)
// Максимальна кількість неактивних (idle) з'єднань у пулі
db.SetMaxIdleConns(10)
// Максимальний час життя з'єднання (після закінчення з'єднання буде закрите)
db.SetConnMaxLifetime(5 * time.Minute)
// Максимальний час простою з'єднання (неактивні з'єднання будуть закриті)
db.SetConnMaxIdleTime(2 * time.Minute)
Рекомендації для промислового коду:
SetMaxOpenConns(25-100)— залежить від навантаження та обмежень PostgreSQL (max_connections).SetMaxIdleConns(5-10)— достатньо для утримання з'єднань між сплесками трафіку.SetConnMaxLifetime(5-30 хв)— запобігає накопиченню застарілих з'єднань.
MaxOpenConns = 25, то горутини чекатимуть на звільнення з'єднання. Це нормальна поведінка — база даних не може обробляти необмежену кількість паралельних запитів. Налаштовуйте пул відповідно до можливостей вашої СУБД.Виконання SQL-запитів: базові операції CRUD
Тепер, коли ми маємо підключення до бази, можемо виконувати SQL-запити. Розглянемо чотири базові операції CRUD (Create, Read, Update, Delete).
Створення таблиці (DDL)
Перш ніж зберігати дані, потрібно створити таблицю. Виконаємо DDL-команду (Data Definition Language):
func createTable(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
_, err := db.Exec(query)
if err != nil {
return fmt.Errorf("не вдалося створити таблицю: %w", err)
}
fmt.Println("✅ Таблиця users створена")
return nil
}
Метод Exec() використовується для виконання команд, які не повертають рядки: CREATE, INSERT, UPDATE, DELETE.
Повернене значення sql.Result:
result, err := db.Exec("INSERT INTO users ...")
if err != nil {
return err
}
lastID, _ := result.LastInsertId() // Останній згенерований ID (не для всіх СУБД)
rowsAffected, _ := result.RowsAffected() // Кількість змінених рядків
LastInsertId() не підтримується PostgreSQL напряму через database/sql. Для отримання згенерованого ID використовуйте RETURNING:INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id;
Додавання даних (INSERT) з поверненням ID
Розглянемо функцію, яка створює нового користувача та повертає згенерований ID:
package repository
import (
"context"
"database/sql"
"fmt"
)
type User struct {
ID int
Email string
PasswordHash string
FullName string
}
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, user *User) error {
query := `
INSERT INTO users (email, password_hash, full_name)
VALUES ($1, $2, $3)
RETURNING id
`
// QueryRow виконує запит та очікує рівно один рядок результату
err := r.db.QueryRowContext(ctx, query,
user.Email,
user.PasswordHash,
user.FullName,
).Scan(&user.ID)
if err != nil {
return fmt.Errorf("помилка створення користувача: %w", err)
}
return nil
}
Розбір коду:
- Плейсхолдери
$1,$2,$3:
PostgreSQL використовує позиційні параметри у форматі$N. Це захищає від SQL-ін'єкцій — значення передаються окремо від тексту запиту та автоматично екрануються драйвером. QueryRowContext(ctx, query, args...):
Виконує запит, який повертає рівно один рядок. Якщо результат порожній або містить більше одного рядка, методScan()поверне помилку.Scan(&user.ID):
Зчитує значення з рядка результату у змінну. ОператорRETURNING idповертає згенерований первинний ключ, який присвоюється полюuser.ID.Contextяк перший параметр:
Передачаcontext.Contextдозволяє контролювати таймаути та скасування операцій. Якщо HTTP-запит скасовано, контекст автоматично завершить запит до бази.
// ❌ НЕБЕЗПЕЧНО! Вразливо до SQL-ін'єкції
query := "INSERT INTO users (email) VALUES ('" + email + "')"
db.Exec(query)
email містить '; DROP TABLE users; --, виконається команда видалення таблиці.Завжди використовуйте параметризовані запити:// ✅ БЕЗПЕЧНО
db.Exec("INSERT INTO users (email) VALUES ($1)", email)
Читання даних (SELECT) — один рядок
Для отримання одного користувача за ID використовуємо QueryRowContext:
func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) {
query := `
SELECT id, email, password_hash, full_name
FROM users
WHERE id = $1
`
user := &User{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.PasswordHash,
&user.FullName,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("користувача з ID %d не знайдено", id)
}
if err != nil {
return nil, fmt.Errorf("помилка читання користувача: %w", err)
}
return user, nil
}
Важливо:
sql.ErrNoRows— спеціальна помилка, яка сигналізує, що запит не повернув жодного рядка. Це нормальна ситуація (наприклад, користувач не існує), а не критична помилка.- Порядок аргументів у
Scan()має точно відповідати порядку колонок уSELECT.
Читання даних (SELECT) — кілька рядків
Для вибірки списку користувачів використовуємо QueryContext, який повертає курсор *sql.Rows:
func (r *UserRepository) GetAll(ctx context.Context) ([]*User, error) {
query := `SELECT id, email, password_hash, full_name FROM users ORDER BY id`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("помилка виконання запиту: %w", err)
}
defer rows.Close() // Обов'язково закрити курсор!
var users []*User
for rows.Next() {
user := &User{}
if err := rows.Scan(&user.ID, &user.Email, &user.PasswordHash, &user.FullName); err != nil {
return nil, fmt.Errorf("помилка сканування рядка: %w", err)
}
users = append(users, user)
}
// Перевірка помилок, які могли виникнути під час ітерації
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("помилка ітерації рядків: %w", err)
}
return users, nil
}
Критичні моменти:
defer rows.Close():
Курсор*sql.Rowsутримує з'єднання з бази. Якщо не закрити курсор, з'єднання залишиться зайнятим, і пул вичерпається. Завжди закривайтеrowsчерезdeferвідразу після отримання.- Цикл
for rows.Next():
Курсор рухається по рядках результату. МетодNext()повертаєtrue, поки є рядки. Всередині циклу викликаєтьсяScan()для зчитування поточного рядка. rows.Err():
Після завершення циклу потрібно перевірити, чи не виникла помилка під час ітерації (наприклад, обрив з'єднання).
sql.Rows:rows, err := db.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
// Scan та обробка рядків
}
return rows.Err() // Перевірка помилок ітерації
Оновлення даних (UPDATE)
Оновлення даних виконується через ExecContext:
func (r *UserRepository) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET email = $1, full_name = $2
WHERE id = $3
`
result, err := r.db.ExecContext(ctx, query,
user.Email,
user.FullName,
user.ID,
)
if err != nil {
return fmt.Errorf("помилка оновлення користувача: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("помилка отримання кількості змінених рядків: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("користувача з ID %d не знайдено", user.ID)
}
return nil
}
Перевірка rowsAffected:
Якщо UPDATE не змінив жодного рядка, це означає, що запис з таким ID не існує. Це дозволяє відрізнити успішне оновлення від випадку відсутнього запису.
Видалення даних (DELETE)
Видалення виконується аналогічно до оновлення:
func (r *UserRepository) Delete(ctx context.Context, id int) error {
query := `DELETE FROM users WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("помилка видалення користувача: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("помилка отримання кількості змінених рядків: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("користувача з ID %d не знайдено", id)
}
return nil
}
Контекст (context.Context) — це стандартний механізм Go для керування життєвим циклом операцій. Він дозволяє:
- Встановлювати таймаути:
Якщо запит до бази виконується занадто довго, контекст автоматично скасує його. - Скасовувати операції:
Якщо HTTP-клієнт розірвав з'єднання (закрив браузер), контекст сповістить про це базу даних, і запит буде перерваний. - Передавати метадані:
Контекст може містити ідентифікатор запиту (request ID), що полегшує трейсинг у логах.
Правило: завжди передавайте контекст першим параметром у функції, що виконують введення/виведення (мережа, база даних, файлова система).
Незакритий курсор *sql.Rows утримує з'єднання з бази даних зайнятим. Якщо таких незакритих курсорів накопичиться багато, пул з'єднань вичерпається, і всі наступні запити блокуватимуться або завершуватимуться з помилкою too many connections.
Завжди використовуйте defer rows.Close() відразу після отримання курсору.
Так, об'єкт *sql.DB є потокобезпечним (thread-safe). Кілька горутин можуть одночасно виконувати запити через один і той же sql.DB — пул з'єднань автоматично розподілить їх між доступними підключеннями.
Ви створюєте один екземпляр sql.DB при старті застосунку та використовуєте його у всіх горутинах.
Спрощення роботи з SQL: бібліотека sqlx
Хоча стандартний пакет database/sql надає всі необхідні можливості, він вимагає багато шаблонного коду для сканування результатів у структури. sqlx — це розширення database/sql, яке додає зручні методи для автоматичного відображення рядків БД на Go-структури.
Встановлення sqlx
go get github.com/jmoiron/sqlx
go get github.com/jackc/pgx/v5/stdlib
Підключення через sqlx
package main
import (
"log"
"github.com/jmoiron/sqlx"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
dsn := "postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable"
// sqlx.Open повертає *sqlx.DB замість *sql.DB
db, err := sqlx.Open("pgx", dsn)
if err != nil {
log.Fatalf("Помилка підключення: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("База недоступна: %v", err)
}
log.Println("✅ Підключення через sqlx успішне")
}
Автоматичне сканування через теги db
Головна перевага sqlx — автоматичне відображення колонок на поля структури через тег db:
type User struct {
ID int `db:"id"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
FullName string `db:"full_name"`
CreatedAt time.Time `db:"created_at"`
}
Тег db вказує, яка колонка БД відповідає полю структури. Це особливо корисно, коли назви полів у Go відрізняються від назв колонок (наприклад, FullName → full_name).
Методи Get та Select
Get() — отримання одного рядка:
func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) {
user := &User{}
query := `SELECT id, email, password_hash, full_name, created_at FROM users WHERE id = $1`
// Автоматичне сканування у структуру
err := r.db.GetContext(ctx, user, query, id)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("користувача не знайдено")
}
if err != nil {
return nil, fmt.Errorf("помилка запиту: %w", err)
}
return user, nil
}
Порівняння з чистим database/sql:
// database/sql — потрібно явно вказати кожне поле
err := db.QueryRow(query, id).Scan(
&user.ID,
&user.Email,
&user.PasswordHash,
&user.FullName,
&user.CreatedAt,
)
// sqlx — автоматичне відображення за тегами db
err := db.Get(user, query, id)
Select() — отримання списку рядків:
func (r *UserRepository) GetAll(ctx context.Context) ([]*User, error) {
var users []*User
query := `SELECT id, email, password_hash, full_name, created_at FROM users ORDER BY id`
// Автоматичне заповнення слайсу
if err := r.db.SelectContext(ctx, &users, query); err != nil {
return nil, fmt.Errorf("помилка запиту: %w", err)
}
return users, nil
}
- Коли ви пишете багато SQL-запитів вручну та хочете уникнути шаблонного коду сканування.
- Коли потрібен повний контроль над SQL, але зручність відображення результатів.
- Як компроміс між чистим
database/sqlта повноцінною ORM.
Іменовані параметри
sqlx підтримує іменовані параметри через NamedExec та NamedQuery:
type UserFilter struct {
MinAge int `db:"min_age"`
City string `db:"city"`
}
filter := UserFilter{MinAge: 18, City: "Київ"}
var users []User
query := `SELECT * FROM users WHERE age >= :min_age AND city = :city`
// Іменовані параметри замість $1, $2
err := db.NamedQuery(query, filter)
Це робить запити з багатьма параметрами більш читабельними.
- pgx — це драйвер БД, низькорівнева бібліотека, що реалізує протокол комунікації з PostgreSQL. Працює як через
database/sql, так і через власний API. - sqlx — це обгортка над
database/sql, яка спрощує сканування результатів у структури. Вона не є драйвером і працює поверх будь-якого драйвера (pgx, pq, mysql тощо). - GORM — це повноцінна ORM, яка автоматично генерує SQL, відстежує зв'язки між таблицями, надає міграції та хуки життєвого циклу.
Комбінування: ви можете використовувати pgx (драйвер) + sqlx (зручність) разом для отримання продуктивності pgx та зручності sqlx.
Транзакції: гарантії ACID
Уявімо ситуацію: користувач переводить гроші з одного рахунку на інший. Операція складається з двох кроків:
- Зняти гроші з рахунку А.
- Додати гроші на рахунок Б.
Що станеться, якщо після першого кроку сервер аварійно завершиться? Гроші знімуться з рахунку А, але не додадуться до Б — вони зникнуть. Це катастрофа.
Саме для розв'язання таких проблем існують транзакції (Transactions) — механізм групування кількох операцій в одну атомарну (atomic) одиницю роботи.
Властивості ACID
Транзакції забезпечують чотири фундаментальні гарантії, відомі як ACID:
- Atomicity (Атомарність):
Всі операції у транзакції виконуються повністю або не виконуються зовсім. Не існує проміжного стану «частково виконано». - Consistency (Узгодженість):
Транзакція переводить базу даних з одного узгодженого стану в інший. Всі обмеження (constraints), тригери та правила залишаються дійсними. - Isolation (Ізольованість):
Паралельні транзакції не впливають одна на одну. Зміни, внесені транзакцією, невидимі іншим, поки вона не завершиться. - Durability (Довговічність):
Після підтвердження транзакції (commit) зміни гарантовано зберігаються на диску, навіть у разі збою.
Синтаксис транзакцій у database/sql
Транзакція створюється методом BeginTx() та завершується викликом Commit() (підтвердження) або Rollback() (відкат):
func (r *UserRepository) TransferBalance(ctx context.Context, fromID, toID int, amount float64) error {
// Початок транзакції
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("не вдалося почати транзакцію: %w", err)
}
// Відкат транзакції у разі помилки
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // Повторно викинути panic після відкату
} else if err != nil {
tx.Rollback()
}
}()
// Крок 1: Зняти гроші з рахунку fromID
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE user_id = $2",
amount, fromID,
)
if err != nil {
return fmt.Errorf("помилка зняття коштів: %w", err)
}
// Крок 2: Додати гроші на рахунок toID
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE user_id = $2",
amount, toID,
)
if err != nil {
return fmt.Errorf("помилка додавання коштів: %w", err)
}
// Підтвердження транзакції
if err = tx.Commit(); err != nil {
return fmt.Errorf("помилка підтвердження транзакції: %w", err)
}
return nil
}
Як це працює:
BeginTx(ctx, nil)створює об'єкт*sql.Tx(транзакцію).- Всі операції виконуються через
tx.ExecContext(),tx.QueryContext()замістьdb.ExecContext(). - Якщо всі операції успішні, викликається
tx.Commit()— зміни стають видимими іншим транзакціям. - Якщо будь-яка операція завершилася помилкою, викликається
tx.Rollback()— всі зміни скасовуються.
Rollback() або Commit() для кожної транзакції!Якщо ви забудете завершити транзакцію, з'єднання залишиться заблокованим, утримуючи блокування на рядках таблиці. Це призведе до дедлоків (deadlocks) та виснаження пулу з'єднань.Використовуйте defer для гарантії відкату у разі помилки або паніки.Патерн безпечної роботи з транзакціями
Типовий шаблон для роботи з транзакціями:
func (r *Repository) DoSomethingTransactional(ctx context.Context) (err error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Відкочуємо транзакцію у разі помилки
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Виконуємо операції через tx
if err = r.step1(ctx, tx); err != nil {
return err
}
if err = r.step2(ctx, tx); err != nil {
return err
}
// Підтверджуємо транзакцію
return tx.Commit()
}
Переваги цього підходу:
deferавтоматично відкотить транзакцію, якщо функція повернула помилку.- Якщо
Commit()успішний,err == nil, іRollback()не викликається (але викликRollback()післяCommit()безпечний — це no-op).
- Коли кілька операцій мають виконатися атомарно (наприклад, перевід коштів, створення замовлення з товарами).
- Коли потрібна узгодженість даних між таблицями (наприклад, додавання користувача та його профілю).
- Коли операції взаємозалежні та не можуть виконуватися окремо.
- Для одиночних операцій (
INSERT,UPDATE,DELETE) — вони атомарні за замовчуванням. - Для довготривалих операцій — транзакції блокують рядки, що знижує конкурентність.
Версіонування схеми БД: інструмент golang-migrate
У процесі розробки структура бази даних постійно змінюється: додаються нові таблиці, колонки, індекси, змінюються обмеження. Як керувати цими змінами та синхронізувати їх між розробниками та середовищами (розробка, тестування, продакшн)?
Міграції (Migrations) — це версіоновані SQL-скрипти, які описують зміни схеми бази даних. Кожна міграція має:
- UP-скрипт — застосовує зміну (наприклад, створює таблицю).
- DOWN-скрипт — відкочує зміну (видаляє таблицю).
Інструмент golang-migrate автоматизує застосування та відкат міграцій.
Встановлення golang-migrate
brew install golang-migrate
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
docker run -v $(pwd)/migrations:/migrations \
--network host \
migrate/migrate \
-path=/migrations/ \
-database postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable \
up
Створення міграції
Створімо першу міграцію для таблиці users:
migrate create -ext sql -dir migrations -seq create_users_table
Параметри:
-ext sql— розширення файлів (SQL).-dir migrations— каталог для зберігання міграцій.-seq— послідовна нумерація (альтернатива:-timestampдля міток часу).
Команда створить два файли:
migrations/
├── 000001_create_users_table.up.sql
└── 000001_create_users_table.down.sql
Файл 000001_create_users_table.up.sql (застосування):
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
Файл 000001_create_users_table.down.sql (відкат):
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;
Застосування міграцій
Для накату міграцій виконайте:
migrate -path migrations \
-database "postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable" \
up
Вивід:
000001_create_users_table.up.sql ... OK (25ms)
Інструмент створює службову таблицю schema_migrations, яка відстежує, які міграції вже застосовано:
SELECT * FROM schema_migrations;
| version | dirty |
|---|---|
| 1 | false |
Відкат міграції
Для відкату останньої міграції:
migrate -path migrations \
-database "..." \
down 1
Для скидання всіх міграцій:
migrate -path migrations -database "..." down
Міграції у коді Go
Можна вбудувати міграції у бінарний файл застосунку:
package main
import (
"database/sql"
"embed"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func runMigrations(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}
source, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return err
}
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
if err != nil {
return err
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}
log.Println("✅ Міграції застосовано")
return nil
}
Тепер міграції автоматично застосовуються при старті застосунку.
Об'єктно-реляційне відображення: технологія ORM
Досі ми писали SQL-запити вручну. Це надає повний контроль, але має недоліки:
- Багато шаблонного коду: кожна операція вимагає написання SQL, передачі параметрів та сканування результатів.
- Помилки у SQL не виявляються на етапі компіляції: друкарська помилка у
SELECT * FORM usersспричинить runtime-помилку. - Складність підтримки зв'язків: робота з foreign keys (зовнішніми ключами), вкладеними об'єктами та з'єднаннями (JOIN) вимагає значних зусиль.
ORM (Object-Relational Mapping — об'єктно-реляційне відображення) — це підхід, при якому таблиці бази даних автоматично відображаються на структури Go, а операції виконуються через методи цих структур замість написання SQL.
Концепція ORM на прикладі
Без ORM (чистий SQL):
query := "INSERT INTO users (email, full_name) VALUES ($1, $2) RETURNING id"
err := db.QueryRow(query, user.Email, user.FullName).Scan(&user.ID)
З ORM (GORM):
db.Create(&user) // Автоматично генерує INSERT та присвоює ID
ORM автоматично:
- Формує SQL-запити на основі структури.
- Відображає поля структури на колонки таблиці.
- Обробляє зв'язки між таблицями (1:1, 1:N, N:M).
- GORM (gorm.io) — найпоширеніша бібліотека, багато функцій, активна підтримка.
- ent (entgo.io) — кодогенерація на основі схем, type-safe API, розроблена Facebook.
- Bun (bun.uptrace.dev) — швидка, мінімалістична ORM.
- sqlc (sqlc.dev) — генерує type-safe код Go із чистих SQL-запитів (гібридний підхід).
Робота з GORM: основи
Встановлення GORM та драйвера PostgreSQL
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres
Підключення до PostgreSQL через GORM
package main
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func main() {
dsn := "host=localhost user=appuser password=secretpass dbname=myapp_db port=5432 sslmode=disable"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("Не вдалося підключитися до бази даних: %v", err)
}
log.Println("✅ Підключення до PostgreSQL через GORM успішне")
}
Відмінність від database/sql:
- GORM повертає об'єкт
*gorm.DB, а не*sql.DB. gorm.DBобгортає стандартний*sql.DBта надає високорівневий API.
Визначення моделей (структур)
Модель у GORM — це звичайна структура Go з тегами, що описують відображення на таблицю:
package model
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex;not null"`
PasswordHash string `gorm:"not null"`
FullName string `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // Soft Delete
}
// TableName перевизначає назву таблиці (за замовчуванням: "users")
func (User) TableName() string {
return "users"
}
Теги GORM:
primaryKey— первинний ключ.uniqueIndex— унікальний індекс.not null— поле не може бутиNULL.index— створити звичайний індекс.default:'value'— значення за замовчуванням.
Спеціальні поля:
CreatedAt,UpdatedAt— автоматично заповнюються при створенні/оновленні.DeletedAt— реалізує soft delete (запис не видаляється, а помічається видаленим).
Автоматична міграція
GORM може автоматично створювати або змінювати таблиці на основі структур:
func main() {
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
// Автоматично створити/оновити таблицю users
if err := db.AutoMigrate(&model.User{}); err != nil {
log.Fatalf("Помилка міграції: %v", err)
}
log.Println("✅ Міграція виконана")
}
AutoMigrate безпечна лише для додавання нових колонок. Вона НЕ видаляє колонки, не змінює типи даних та не видаляє таблиці.У продакшн-середовищі краще використовувати golang-migrate для контрольованих змін схеми.Базові операції CRUD у GORM
Створення запису (Create)
user := model.User{
Email: "alice@example.com",
PasswordHash: "hashed_password",
FullName: "Alice Johnson",
}
result := db.Create(&user)
if result.Error != nil {
log.Printf("Помилка створення користувача: %v", result.Error)
} else {
log.Printf("✅ Створено користувача з ID: %d", user.ID)
}
Що відбувається:
- GORM генерує запит
INSERT INTO users (...) VALUES (...) RETURNING id. - Після виконання поле
user.IDавтоматично заповнюється згенерованим первинним ключем. - Поля
CreatedAtтаUpdatedAtтакож заповнюються поточним часом.
Читання запису (Read)
Отримання одного запису за первинним ключем:
var user model.User
result := db.First(&user, 1) // WHERE id = 1
if result.Error == gorm.ErrRecordNotFound {
log.Println("Користувача не знайдено")
} else if result.Error != nil {
log.Printf("Помилка запиту: %v", result.Error)
} else {
log.Printf("Знайдено: %s (%s)", user.FullName, user.Email)
}
Отримання за умовою:
var user model.User
db.Where("email = ?", "alice@example.com").First(&user)
Отримання всіх записів:
var users []model.User
db.Find(&users)
for _, u := range users {
log.Printf("ID: %d, Email: %s", u.ID, u.Email)
}
Фільтрація та сортування:
var users []model.User
db.Where("created_at > ?", time.Now().AddDate(0, -1, 0)).
Order("created_at DESC").
Limit(10).
Find(&users)
Оновлення запису (Update)
Оновлення всіх полів:
user.FullName = "Alice Smith"
db.Save(&user) // UPDATE users SET full_name = ..., updated_at = ... WHERE id = ...
Оновлення окремих полів:
db.Model(&user).Update("full_name", "Alice Cooper")
// Або кілька полів:
db.Model(&user).Updates(map[string]interface{}{
"full_name": "Alice Cooper",
"email": "alice.cooper@example.com",
})
Updates() замість Save(), щоб оновити лише вказані поля. Save() оновлює всі поля, включаючи ті, що мають нульові значення.Видалення запису (Delete)
Фізичне видалення:
db.Delete(&user) // DELETE FROM users WHERE id = ...
М'яке видалення (Soft Delete):
Якщо модель містить поле DeletedAt, GORM автоматично використовує м'яке видалення:
db.Delete(&user) // UPDATE users SET deleted_at = NOW() WHERE id = ...
Запити за замовчуванням ігноруватимуть видалені записи:
db.Find(&users) // Не включає записи з непустим deleted_at
Для отримання видалених записів:
db.Unscoped().Find(&users) // Включає видалені
Зв'язки між таблицями (Associations)
GORM підтримує автоматичне завантаження пов'язаних даних.
Приклад: Користувач та Профіль (1:1):
type User struct {
ID uint
Email string
Profile Profile // Зв'язок 1:1
}
type Profile struct {
ID uint
UserID uint // Зовнішній ключ
Bio string
AvatarURL string
}
// Завантаження користувача з профілем:
var user User
db.Preload("Profile").First(&user, 1)
log.Println(user.Profile.Bio)
Приклад: Користувач та Пости (1:N):
type User struct {
ID uint
Email string
Posts []Post // Зв'язок 1:N
}
type Post struct {
ID uint
UserID uint
Title string
Body string
}
// Завантаження користувача з усіма постами:
var user User
db.Preload("Posts").First(&user, 1)
for _, post := range user.Posts {
log.Println(post.Title)
}
Preload() виконує окремий запит для завантаження пов'язаних даних (аналог JOIN). Це називається eager loading (жадібне завантаження) та запобігає проблемі N+1 queries (виконання окремого запиту для кожного пов'язаного об'єкта).Приклад: Зв'язок Many-to-Many (N:M) — Студенти та Курси:
type Student struct {
ID uint
Name string
Courses []Course `gorm:"many2many:student_courses;"` // Зв'язок N:M
}
type Course struct {
ID uint
Title string
Students []Student `gorm:"many2many:student_courses;"` // Зворотний зв'язок
}
// GORM автоматично створить проміжну таблицю student_courses:
// CREATE TABLE student_courses (
// student_id INTEGER,
// course_id INTEGER,
// PRIMARY KEY (student_id, course_id)
// );
// Створення студента з курсами:
student := Student{
Name: "Іван Петренко",
Courses: []Course{
{Title: "Бази даних"},
{Title: "Алгоритми"},
},
}
db.Create(&student)
// Завантаження студента з усіма курсами:
var loadedStudent Student
db.Preload("Courses").First(&loadedStudent, student.ID)
for _, course := range loadedStudent.Courses {
log.Println(course.Title)
}
// Додавання студента до існуючого курсу:
var course Course
db.First(&course, 1)
db.Model(&course).Association("Students").Append(&student)
Preload(), інакше виникне проблема N+1 queries: GORM виконає окремий запит для кожного пов'язаного об'єкта у циклі, що різко знизить продуктивність.Хуки життєвого циклу GORM
GORM підтримує хуки (hooks) — спеціальні методи, що автоматично викликаються на певних етапах життєвого циклу моделі (створення, оновлення, видалення).
Доступні хуки:
BeforeCreate,AfterCreate— до/після створення запису.BeforeUpdate,AfterUpdate— до/після оновлення.BeforeSave,AfterSave— перед будь-яким збереженням (Create або Update).BeforeDelete,AfterDelete— до/після видалення.AfterFind— після завантаження запису з БД.
Приклад: автоматичне хешування пароля перед збереженням:
type User struct {
ID uint
Email string
Password string `gorm:"-"` // Не зберігати у БД
PasswordHash string
}
// BeforeCreate викликається перед INSERT
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.Password == "" {
return fmt.Errorf("пароль не може бути порожнім")
}
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.PasswordHash = string(hash)
u.Password = "" // Очищаємо відкритий пароль
return nil
}
// BeforeUpdate викликається перед UPDATE
func (u *User) BeforeUpdate(tx *gorm.DB) error {
// Якщо пароль змінено, перехешуємо
if u.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.PasswordHash = string(hash)
u.Password = ""
}
return nil
}
// Використання:
user := User{
Email: "john@example.com",
Password: "mySecretPassword",
}
db.Create(&user) // BeforeCreate автоматично хешує пароль
Приклад: логування змін:
type Article struct {
ID uint
Title string
Content string
UpdatedBy string
}
func (a *Article) AfterUpdate(tx *gorm.DB) error {
log.Printf("📝 Стаття '%s' оновлена користувачем %s", a.Title, a.UpdatedBy)
// Можна записати у таблицю аудиту:
audit := AuditLog{
EntityType: "Article",
EntityID: a.ID,
Action: "UPDATE",
ChangedBy: a.UpdatedBy,
ChangedAt: time.Now(),
}
return tx.Create(&audit).Error
}
- Валідація даних перед збереженням (перевірка бізнес-правил).
- Автоматичне перетворення даних (хешування паролів, нормалізація email).
- Логування змін (аудит, версіонування).
- Очищення пов'язаних даних при видаленні (каскадне видалення файлів).
Розширені можливості моделей GORM
Тепер, коли ми розглянули базові операції GORM, заглибимося у потужні можливості визначення моделей, які роблять ORM гнучким та виразним інструментом.
Повна специфікація тегів структур
Теги структур (struct tags) — це основний механізм налаштування поведінки GORM. Кожен тег визначає, як поле відображається на колонку бази даних, які обмеження накладаються та як виконуються операції.
Синтаксис тегів:
type Model struct {
Field Type `gorm:"тег1:значення;тег2:значення;тег3"`
}
Теги розділяються крапкою з комою (;), деякі теги мають значення через двокрапку (:).
Основні теги відображення
type User struct {
ID uint `gorm:"column:user_id"` // Колонка називається user_id замість id
FirstName string `gorm:"column:first_name"`
}
type Product struct {
Price float64 `gorm:"type:decimal(10,2)"` // DECIMAL(10,2)
Code string `gorm:"type:varchar(100)"` // VARCHAR(100)
Tags string `gorm:"type:text"` // TEXT замість VARCHAR
Metadata string `gorm:"type:jsonb"` // JSONB для PostgreSQL
}
type Article struct {
Title string `gorm:"size:255"` // VARCHAR(255)
Slug string `gorm:"size:100"` // VARCHAR(100)
Content string `gorm:"size:10000"` // TEXT або VARCHAR(10000)
}
type Measurement struct {
Temperature float64 `gorm:"precision:6;scale:2"` // NUMERIC(6,2)
// Загалом 6 цифр, з них 2 після коми: 9999.99
}
Теги первинних та зовнішніх ключів
type User struct {
// Первинний ключ
ID uint `gorm:"primaryKey"`
// Композитний первинний ключ
// ID uint `gorm:"primaryKey"`
// Code string `gorm:"primaryKey"`
// Автоінкремент (за замовчуванням для primaryKey)
ID uint `gorm:"primaryKey;autoIncrement"`
// Відключення автоінкременту
UUID string `gorm:"primaryKey;autoIncrement:false"`
}
type Order struct {
ID uint
UserID uint `gorm:"index"` // Створити індекс
User User `gorm:"foreignKey:UserID;references:ID"`
// foreignKey вказує поле у Order, references — поле у User
}
Якщо структура має поле
ID типу uint, int або string, GORM автоматично робить його первинним ключем з автоінкрементом (для числових типів).Теги обмежень та валідації
type User struct {
Email string `gorm:"not null;unique"` // NOT NULL + UNIQUE
Username string `gorm:"not null;uniqueIndex"` // NOT NULL + унікальний індекс
Age int `gorm:"check:age >= 18"` // CHECK обмеження
Status string `gorm:"default:'active'"` // Значення за замовчуванням
Role string `gorm:"default:'user';not null"` // Комбінація тегів
// Іменований індекс
Email2 string `gorm:"uniqueIndex:idx_email"`
// Композитний індекс
FirstName string `gorm:"index:idx_name,priority:1"` // Перше поле у індексі
LastName string `gorm:"index:idx_name,priority:2"` // Друге поле у індексі
}
Теги керування читанням/записом
type User struct {
// Лише для читання (ігнорується при Create/Update)
CreatedAt time.Time `gorm:"<-:false"`
// Лише для запису (не повертається при запитах)
Password string `gorm:"->:false;<-:create"` // Тільки при створенні
// Повне ігнорування (не відображається на БД)
TempField string `gorm:"-"`
// Ігнорування при читанні, але запис дозволено
APIKey string `gorm:"->;-:migration"` // - при міграції, -> для читання
// Лише для створення
InitialPassword string `gorm:"<-:create"`
// Лише для оновлення
UpdateToken string `gorm:"<-:update"`
}
Розшифровка стрілок:
->— читання з БД (SELECT)<-— запис до БД (INSERT, UPDATE)->:false— заборонити читання<-:false— заборонити запис<-:create— дозволити лише при INSERT<-:update— дозволити лише при UPDATE
Теги для часових міток
type Model struct {
// Автоматичне заповнення при створенні
CreatedAt time.Time `gorm:"autoCreateTime"`
// Автоматичне оновлення при Create/Update
UpdatedAt time.Time `gorm:"autoUpdateTime"`
// Unix timestamp замість time.Time
CreatedUnix int64 `gorm:"autoCreateTime:nano"` // Наносекунди
UpdatedUnix int64 `gorm:"autoUpdateTime:milli"` // Мілісекунди
// Soft Delete
DeletedAt gorm.DeletedAt `gorm:"index"`
}
GORM надає
gorm.Model, яка містить ID, CreatedAt, UpdatedAt, DeletedAt. Ви можете вбудувати її у свої моделі:type User struct {
gorm.Model // Автоматично додає ID, CreatedAt, UpdatedAt, DeletedAt
Email string
Name string
}
Теги коментарів
type User struct {
Name string `gorm:"comment:Повне ім'я користувача"`
Email string `gorm:"comment:Email адреса для зв'язку"`
Age int `gorm:"comment:Вік користувача в роках"`
}
// У PostgreSQL згенерує:
// COMMENT ON COLUMN users.name IS 'Повне ім''я користувача';
Серіалізатори
GORM підтримує автоматичну серіалізацію складних типів у JSON, BSON тощо:
type User struct {
Name string
Attributes map[string]interface{} `gorm:"serializer:json"` // Зберігається як JSON
Metadata Metadata `gorm:"serializer:json"`
}
type Metadata struct {
Theme string
Language string
}
// При збереженні:
user := User{
Name: "Alice",
Attributes: map[string]interface{}{
"age": 25,
"city": "Київ",
},
Metadata: Metadata{Theme: "dark", Language: "uk"},
}
db.Create(&user)
// У БД: attributes = '{"age":25,"city":"Київ"}'
Підтримувані серіалізатори:
json— JSON (за замовчуванням для map, slice, struct)gob— Go binary encodingunixtime— Unix timestamp для time.Time
Так! GORM дозволяє реєструвати власні серіалізатори через інтерфейс serializer.SerializerInterface. Наприклад, для шифрування даних перед збереженням:
import "gorm.io/gorm/schema"
type EncryptedSerializer struct{}
func (EncryptedSerializer) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue interface{}) error {
// Розшифрувати dbValue та записати у dst
}
func (EncryptedSerializer) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue interface{}) (interface{}, error) {
// Зашифрувати fieldValue перед збереженням
}
// Реєстрація:
schema.RegisterSerializer("encrypted", EncryptedSerializer{})
Вбудовані структури (Embedded Structs)
GORM підтримує композицію структур — механізм вбудовування одних структур у інші, що дозволяє повторно використовувати спільні поля.
Базове вбудовування
type BaseModel struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
}
type User struct {
BaseModel // Вбудована структура (усі поля стають полями User)
Email string `gorm:"unique"`
Name string
}
type Article struct {
BaseModel
Title string
Content string
}
// У БД створяться таблиці:
// users: id, created_at, updated_at, email, name
// articles: id, created_at, updated_at, title, content
Як це працює:
Поля ID, CreatedAt, UpdatedAt з BaseModel «підіймаються» на верхній рівень структури. Це еквівалентно явному оголошенню цих полів у User та Article.
Вбудовування з префіксом
Коли потрібно уникнути конфліктів імен або згрупувати поля, використовуйте тег embeddedPrefix:
type Address struct {
Street string
City string
ZipCode string
}
type User struct {
ID uint
Name string
HomeAddress Address `gorm:"embedded;embeddedPrefix:home_"`
WorkAddress Address `gorm:"embedded;embeddedPrefix:work_"`
}
// У БД таблиця users міститиме:
// id, name,
// home_street, home_city, home_zip_code,
// work_street, work_city, work_zip_code
Без префіксу:
type User struct {
ID uint
Name string
Address Address `gorm:"embedded"` // Без префіксу
}
// У БД: id, name, street, city, zip_code
embeddedPrefix для таких випадків.Вбудовування без тегу gorm
Якщо вбудовуєте структуру без анотацій, GORM автоматично розгортає її поля:
type Timestamps struct {
CreatedAt time.Time
UpdatedAt time.Time
}
type User struct {
ID uint
Email string
Timestamps // Автоматично вбудовується
}
// Еквівалентно:
type User struct {
ID uint
Email string
CreatedAt time.Time
UpdatedAt time.Time
}
Вкладені вбудовані структури
type Audit struct {
CreatedBy uint
UpdatedBy uint
}
type Timestamps struct {
CreatedAt time.Time
UpdatedAt time.Time
Audit // Вкладене вбудовування
}
type Article struct {
ID uint
Title string
Timestamps // Усі поля Audit та Timestamps стають полями Article
}
// У БД: id, title, created_at, updated_at, created_by, updated_by
Перевизначення імен таблиць
За замовчуванням GORM генерує назви таблиць у множині та snake_case:
User→usersBlogPost→blog_postsOrderItem→order_items
Метод TableName()
Найчастіший спосіб перевизначення:
type User struct {
ID uint
Name string
}
func (User) TableName() string {
return "app_users" // Таблиця називатиметься app_users
}
Динамічні назви таблиць
type ShardedUser struct {
ID uint
Name string
}
func (u ShardedUser) TableName() string {
// Шардування за ID
shard := u.ID % 10
return fmt.Sprintf("users_shard_%d", shard)
}
// Використання:
user := ShardedUser{ID: 42, Name: "Alice"}
db.Table(user.TableName()).Create(&user)
// INSERT INTO users_shard_2 ...
Глобальне налаштування
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "app_", // Префікс для всіх таблиць
SingularTable: true, // Не використовувати множину
NoLowerCase: false, // Використовувати CamelCase замість snake_case
},
})
// User → app_user (замість users)
// BlogPost → app_blogpost (замість blog_posts)
TableName() vs глобальне налаштування?TableName()— для окремих моделей, legacy таблиць, шардування.- Глобальне налаштування — для узгодженості назв у всьому застосунку (наприклад, всі таблиці з префіксом
myapp_).
::
Порівняння підходів: Raw SQL vs ORM
Тепер, коли ми розглянули обидва підходи, давайте порівняємо їх переваги та недоліки.
Чистий SQL (database/sql, pgx)
Переваги:
- Повний контроль над запитами:
Ви пишете точний SQL, що виконується. Це дозволяє використовувати всі можливості PostgreSQL: оконні функції (window functions), CTE (Common Table Expressions), складні агрегації. - Максимальна продуктивність:
Відсутність проміжного шару ORM зменшує накладні витрати. Ви можете оптимізувати запити до мікросекунд. - Прозорість:
Легко зрозуміти, що саме відбувається з базою даних. Немає «магії» — кожен запит явний. - Відсутність абстракцій, що протікають:
ORM може генерувати неефективні запити (наприклад, N+1 queries). З чистим SQL ви завжди знаєте, скільки запитів виконується.
Недоліки:
- Більше коду:
Кожна операція вимагає написання SQL, передачі параметрів, обробки помилок та сканування результатів. - Ризик SQL-ін'єкцій:
Потрібна дисципліна для використання параметризованих запитів. - Складність роботи зі зв'язками:
Завантаження пов'язаних даних вимагає написання JOIN або виконання кількох запитів вручну.
ORM (GORM)
Переваги:
- Швидка розробка:
Базові операції (CRUD) виконуються однією командою. Немає потреби писати SQL для стандартних операцій. - Безпека за замовчуванням:
ORM автоматично використовує параметризовані запити, що захищає від SQL-ін'єкцій. - Автоматична робота зі зв'язками:
Preload()дозволяє легко завантажувати пов'язані дані без написання JOIN. - Міграції структур:
AutoMigrateавтоматично створює або оновлює таблиці на основі Go-структур. - Портативність:
Зміна СУБД (з PostgreSQL на MySQL) вимагає лише зміни драйвера — код залишається незмінним.
Недоліки:
- Обмежений контроль:
Складні запити (вкладені підзапити, оконні функції, рекурсивні CTE) важко виразити через ORM API. - Накладні витрати продуктивності:
ORM додає шар абстракції, що сповільнює запити на 10-30% порівняно з чистим SQL. - Неявність:
Не завжди зрозуміло, який SQL генерується під капотом. Це може призвести до несподіваних проблем продуктивності. - Проблема N+1 queries:
Якщо забути використатиPreload(), ORM виконає окремий запит для кожного пов'язаного об'єкта у циклі.
Коли використовувати що?
✅ Використовуйте, коли:
- Потрібна максимальна продуктивність
- Складні аналітичні запити
- Повний контроль над SQL
- Критичні ділянки коду (платежі, фінанси)
- Робота з великими обсягами даних
✅ Використовуйте, коли:
- Швидка розробка прототипу або MVP
- Стандартні CRUD-операції
- Багато зв'язків між таблицями
- Команда менш досвідчена у SQL
- Потрібна портативність між СУБД
✅ Найкраща практика:
- ORM для 80% стандартних операцій
- Чистий SQL для складних запитів
- GORM дозволяє виконувати чистий SQL:
db.Raw("SELECT * FROM users WHERE ...").Scan(&users)
Ви отримуєте переваги обох підходів!
- Застосовуйте GORM для базових операцій (створення, читання за ID, простий пошук).
- Для складних запитів (аналітика, звіти, агрегації) пишіть чистий SQL через
db.Raw()абоdb.Exec(). - Критичні ділянки (платежі, транзакції) реалізуйте через чистий SQL з явними транзакціями.
Патерн Repository: абстракція доступу до даних
Незалежно від того, використовуєте ви чистий SQL чи ORM, важливо ізолювати логіку роботи з базою даних у окремому шарі — Repository.
Що таке Repository?
Repository (Репозиторій) — це патерн проєктування, який забезпечує єдиний інтерфейс для доступу до даних, приховуючи деталі реалізації (SQL, ORM, NoSQL тощо).
Структура:
internal/
├── domain/
│ └── user.go # Доменна модель (без залежностей від БД)
├── repository/
│ ├── interface.go # Інтерфейс репозиторію
│ ├── postgres/
│ │ └── user_repository.go # Реалізація через pgx
│ └── gorm/
│ └── user_repository.go # Реалізація через GORM
└── usecase/
└── user_service.go # Бізнес-логіка (використовує інтерфейс)
Інтерфейс репозиторію:
package repository
import (
"context"
"myapp/internal/domain"
)
type UserRepository interface {
Create(ctx context.Context, user *domain.User) error
GetByID(ctx context.Context, id int) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
Update(ctx context.Context, user *domain.User) error
Delete(ctx context.Context, id int) error
List(ctx context.Context, limit, offset int) ([]*domain.User, error)
}
Реалізація через чистий SQL (pgx):
package postgres
import (
"context"
"database/sql"
"myapp/internal/domain"
"myapp/internal/repository"
)
type userRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) repository.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
query := `INSERT INTO users (email, password_hash, full_name)
VALUES ($1, $2, $3) RETURNING id`
return r.db.QueryRowContext(ctx, query,
user.Email, user.PasswordHash, user.FullName,
).Scan(&user.ID)
}
// ... інші методи
Реалізація через GORM:
package gormrepo
import (
"context"
"myapp/internal/domain"
"myapp/internal/repository"
"gorm.io/gorm"
)
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) repository.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
// ... інші методи
UseCase (бізнес-логіка):
package usecase
import (
"context"
"myapp/internal/domain"
"myapp/internal/repository"
)
type UserService struct {
repo repository.UserRepository
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) RegisterUser(ctx context.Context, email, password, fullName string) (*domain.User, error) {
// Перевірка, чи користувач вже існує
existing, _ := s.repo.GetByEmail(ctx, email)
if existing != nil {
return nil, errors.New("користувач з таким email вже існує")
}
// Хешування пароля
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Створення користувача
user := &domain.User{
Email: email,
PasswordHash: string(hash),
FullName: fullName,
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
Переваги патерну Repository:
- Слабка зв'язність: бізнес-логіка не залежить від конкретної реалізації доступу до даних.
- Тестованість: можна легко замінити реальну базу даних на mock-об'єкт у тестах.
- Гнучкість: можна змінити СУБД (з PostgreSQL на MongoDB) або підхід (з SQL на ORM), не торкаючись бізнес-логіки.
- Повторне використання: один інтерфейс може мати кілька реалізацій (SQL, GORM, in-memory для тестів).
Підсумок
У цій лекції ми опанували базові навички роботи з реляційними базами даних у Go:
✅ Що ми вивчили
Робота з базами даних через database/sql:
- Роль баз даних у клієнт-серверних системах та концепція персистентності даних.
- Стандартний інтерфейс
database/sqlта архітектура драйверів. - Підключення до PostgreSQL через драйвер
pgxта налаштування пулу з'єднань. - Виконання SQL-запитів:
Exec,Query,QueryRow, параметризовані запити. - Бібліотека sqlx для спрощення сканування результатів у структури через теги
db. - Транзакції: ACID властивості,
BeginTx,Commit,Rollback, SavePoints. - Версіонування схеми через міграції
golang-migrate.
Всебічне опанування GORM: 8. Моделі: повна специфікація тегів структур, embedded структури, перевизначення імен таблиць. 9. CRUD операції: розширені можливості Where, Or, Not, Select, Omit, Order, Limit, Distinct, Group, Having. 10. Scopes: переважні запити для DRY принципу та композиційних фільтрів. 11. Batch операції: CreateInBatches, FindInBatches, Upsert (OnConflict), оптимізація продуктивності. 12. Зв'язки: детальна робота з 1:1, 1:N, N:M, Association API, Preload vs Joins, поліморфні зв'язки, self-referencing. 13. Session та контекст: WithContext, Session налаштування (DryRun, SkipHooks, PrepareStmt). 14. Raw SQL: інтеграція чистого SQL, субзапити, змішування підходів. 15. Транзакції: вкладені транзакції, рівні ізоляції, pessimistic/optimistic locking. 16. Logger та Debug: налаштування логування, кастомні логери, інтеграція з slog. 17. Migrator: програмне управління схемою БД, операції з таблицями, колонками, індексами. 18. Хуки: BeforeCreate, AfterUpdate тощо для автоматичної обробки подій моделей. 19. Callbacks система: глобальні обробники для розширення функціональності GORM. 20. Plugins: створення власних плагінів, інтеграція з Prometheus, OpenTelemetry, кешування. 21. Оптимізація: N+1 queries, індекси, prepared statements, connection pool. 22. Патерн Repository: абстракція доступу до даних для слабкої зв'язності та тестованості.
📚 Ключові концепції
Основи баз даних:
- Connection Pool: механізм багаторазового використання з'єднань для підвищення продуктивності.
- Параметризовані запити: захист від SQL-ін'єкцій через плейсхолдери
$1,$2. - ACID: атомарність, узгодженість, ізольованість, довговічність транзакцій.
- Міграції: версіоновані SQL-скрипти для керування змінами схеми БД.
Підходи до роботи з БД:
- database/sql: низькорівневий інтерфейс, повний контроль, максимальна продуктивність.
- sqlx: обгортка для автоматичного сканування результатів SQL у структури через теги
db. - ORM: автоматичне відображення структур Go на таблиці БД з генерацією SQL.
GORM концепції:
- Теги структур:
column,type,size,primaryKey,foreignKey,uniqueIndex,default, та багато інших для налаштування відображення. - Embedded структури: композиція моделей через вбудовування з підтримкою префіксів.
- Scopes: переважні запити для DRY принципу та композиційної фільтрації.
- Eager Loading:
Preload()для жадібного завантаження пов'язаних даних, запобігання N+1 queries. - Association API: методи для керування зв'язками (
Append,Replace,Delete,Clear). - Session: тонке налаштування поведінки запитів (
DryRun,SkipHooks,PrepareStmt). - Callbacks: глобальні обробники для модифікації поведінки GORM.
- Plugins: система розширення функціональності (метрики, трейсинг, кешування).
- Migrator: програмний інтерфейс для управління схемою БД.
- Repository Pattern: абстракція доступу до даних для слабкої зв'язності та тестованості.
- Інтегрувати репозиторії GORM з HTTP-обробниками Gin.
- Реалізовувати автентифікацію JWT із збереженням токенів у БД.
- Організовувати складні запити з фільтрацією, пагінацією та сортуванням через Scopes.
- Працювати з потоковими даними через WebSocket та зберігати їх у PostgreSQL.
- Впроваджувати системи аудиту та логування змін через хуки GORM.
- Оптимізувати продуктивність запитів та уникати N+1 queries.
Контекст дозволяє:
- Встановлювати таймаути: якщо запит виконується занадто довго, контекст автоматично його скасує, звільнивши ресурси.
- Скасовувати операції: якщо клієнт розірвав HTTP-з'єднання, контекст сповістить базу даних, і запит буде перерваний без марної витрати ресурсів.
- Передавати метадані: контекст може містити ідентифікатори запитів для трейсингу у розподілених системах.
Завжди передавайте контекст у методи, що виконують введення/виведення.
Так, це рекомендована практика!
GORM дозволяє виконувати чистий SQL:
var users []User
db.Raw("SELECT * FROM users WHERE created_at > ? ORDER BY id DESC", time.Now().AddDate(0, -1, 0)).Scan(&users)
Використовуйте ORM для стандартних операцій (CRUD) та чистий SQL для складних аналітичних запитів, агрегацій та оптимізації критичних ділянок.
Загальна формула:
MaxOpenConns = (Кількість CPU) × 2-3 + (Очікувана кількість дискових операцій)
Приклади:
- Для веб-сервера з 4 CPU та швидким SSD:
25-50з'єднань. - Для мікросервісу з легким навантаженням:
10-20. - Для високонавантаженого API:
50-100.
Важливо: переконайтеся, що PostgreSQL може обробити таку кількість з'єднань. Параметр max_connections у postgresql.conf має бути більшим за суму MaxOpenConns усіх застосунків.
GORM:
- Повнофункціональна ORM із великою екосистемою.
- Швидка розробка, автоматичні міграції, підтримка зв'язків.
- Менше контролю, можливі проблеми продуктивності.
sqlc:
- Генерує type-safe Go-код із чистих SQL-запитів.
- Максимальна продуктивність, повний контроль над SQL.
- Вимагає написання SQL вручну, немає автоматичних міграцій.
Рекомендація: якщо вам важлива продуктивність та ви комфортно працюєте з SQL, використовуйте sqlc. Якщо потрібна швидка розробка та зручність, вибирайте GORM.
Проблема N+1 queries виникає, коли ви завантажуєте головний об'єкт одним запитом, а потім у циклі виконуєте окремі запити для кожного пов'язаного об'єкта.
Погано:
var users []User
db.Find(&users) // 1 запит
for i := range users {
db.Model(&users[i]).Association("Posts").Find(&users[i].Posts) // N запитів
}
// Всього: 1 + N запитів
Добре:
var users []User
db.Preload("Posts").Find(&users)
// SELECT * FROM users
// SELECT * FROM posts WHERE user_id IN (1,2,3,...)
// Всього: 2 запити
Ще краще для складних зв'язків:
db.Preload("Posts.Comments").
Preload("Posts.Tags").
Preload("Profile").
Find(&users)
// Виконує 4 оптимізовані запити замість тисяч
Завжди використовуйте Preload() для завантаження пов'язаних даних!
Ні, AutoMigrate має обмеження:
Що він робить:
- ✅ Створює нові таблиці
- ✅ Додає нові колонки
- ✅ Додає індекси
Що він НЕ робить:
- ❌ Не видаляє старі колонки
- ❌ Не змінює типи колонок
- ❌ Не перейменовує колонки
- ❌ Не обробляє складні міграції
Рекомендації:
- Розробка: AutoMigrate підходить для швидкого прототипування.
- Production: використовуйте
golang-migrateабо інші інструменти версіонування міграцій для контрольованих змін схеми.
// Розробка
db.AutoMigrate(&User{}, &Post{})
// Production
// Використовуйте golang-migrate з явними UP/DOWN міграціями
GORM зберігає помилку у полі Error об'єкта *gorm.DB:
// ❌ Неправильно
db.First(&user, id)
if user.ID == 0 {
// Може бути помилка БД, а не відсутність запису
}
// ✅ Правильно
if err := db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("користувача не знайдено")
}
return nil, fmt.Errorf("помилка БД: %w", err)
}
Стандартні помилки GORM:
gorm.ErrRecordNotFound— запис не знайденоgorm.ErrInvalidTransaction— транзакція неваліднаgorm.ErrNotImplemented— функція не реалізованаgorm.ErrMissingWhereClause— відсутня WHERE умова (при AllowGlobalUpdate: false)gorm.ErrUnsupportedDriver— непідтримуваний драйвер
Завжди перевіряйте Error та обробляйте різні типи помилок окремо!
Складні запити та умови
Базові операції CRUD покривають лише найпростіші сценарії. У реальних застосунках потрібні складні умови фільтрації, сортування, пагінація та агрегація. GORM надає потужний Query Builder API для побудови виразних запитів.
Where — фільтрація даних
Метод Where() додає умови до запиту. Він підтримує кілька форматів:
Рядкові умови з параметрами
// Одна умова
db.Where("email = ?", "alice@example.com").First(&user)
// SELECT * FROM users WHERE email = 'alice@example.com' LIMIT 1
// Кілька параметрів
db.Where("age > ? AND city = ?", 18, "Київ").Find(&users)
// SELECT * FROM users WHERE age > 18 AND city = 'Київ'
// IN умова
db.Where("status IN ?", []string{"active", "pending"}).Find(&users)
// SELECT * FROM users WHERE status IN ('active', 'pending')
// LIKE пошук
db.Where("name LIKE ?", "%Іван%").Find(&users)
// SELECT * FROM users WHERE name LIKE '%Іван%'
// BETWEEN
db.Where("created_at BETWEEN ? AND ?", startDate, endDate).Find(&orders)
?) замість конкатенації рядків. Це єдиний спосіб захистити застосунок від SQL-ін'єкцій:// ❌ НЕБЕЗПЕЧНО!
email := userInput
db.Where("email = '" + email + "'").Find(&user)
// ✅ БЕЗПЕЧНО
db.Where("email = ?", email).Find(&user)
Умови через структури
// Рівність (лише ненульові поля)
db.Where(&User{Name: "Alice", Age: 25}).Find(&users)
// SELECT * FROM users WHERE name = 'Alice' AND age = 25
// Нульові значення ігноруються!
db.Where(&User{Name: "Alice", Age: 0}).Find(&users)
// SELECT * FROM users WHERE name = 'Alice'
// Age = 0 не включено у запит
GORM не може відрізнити явно встановлений нуль (
Age: 0) від незаповненого поля. Для пошуку за нульовими значеннями використовуйте map або рядкові умови.Умови через map
// Map дозволяє шукати за нульовими значеннями
db.Where(map[string]interface{}{
"name": "Alice",
"age": 0, // Нуль враховується!
}).Find(&users)
// SELECT * FROM users WHERE name = 'Alice' AND age = 0
Or та Not — логічні оператори
// OR умови
db.Where("role = ?", "admin").Or("role = ?", "moderator").Find(&users)
// SELECT * FROM users WHERE role = 'admin' OR role = 'moderator'
// Комбінація AND та OR
db.Where("age > ?", 18).
Where("city = ?", "Київ").
Or("status = ?", "vip").
Find(&users)
// SELECT * FROM users WHERE (age > 18 AND city = 'Київ') OR status = 'vip'
// NOT умови
db.Not("name = ?", "Alice").Find(&users)
// SELECT * FROM users WHERE NOT name = 'Alice'
db.Not(map[string]interface{}{"name": []string{"Alice", "Bob"}}).Find(&users)
// SELECT * FROM users WHERE name NOT IN ('Alice', 'Bob')
// NOT з первинним ключем
db.Not([]int{1, 2, 3}).Find(&users)
// SELECT * FROM users WHERE id NOT IN (1, 2, 3)
Select та Omit — вибір полів
За замовчуванням GORM завантажує всі колонки (SELECT *). Це можна оптимізувати:
// Вибрати конкретні колонки
db.Select("name", "email").Find(&users)
// SELECT name, email FROM users
db.Select([]string{"name", "email", "age"}).Find(&users)
// SELECT name, email, age FROM users
// Виключити колонки
db.Omit("password_hash", "secret_token").Find(&users)
// SELECT id, name, email, created_at, ... (крім password_hash та secret_token)
// Вибір з виразами
db.Select("name, age, age * 2 as double_age").Find(&users)
// Підрахунок
type Result struct {
Name string
Total int
}
var result Result
db.Model(&User{}).Select("name, COUNT(*) as total").Group("name").Scan(&result)
- Коли потрібні лише деякі поля (економія трафіку та пам'яті).
- Для вибірки великих текстових полів (наприклад, виключити
bodyстатті у списку). - При агрегації та групуванні даних.
- Для виключення чутливих даних (паролі, токени).
- Коли легше вказати, що виключити, ніж перелічувати всі потрібні поля.
Order — сортування
// Сортування за одним полем
db.Order("age DESC").Find(&users)
// SELECT * FROM users ORDER BY age DESC
db.Order("created_at ASC").Find(&articles)
// Кілька полів
db.Order("age DESC, name ASC").Find(&users)
// ORDER BY age DESC, name ASC
// Через кілька викликів
db.Order("age DESC").Order("name").Find(&users)
// Сортування з виразами
db.Order("CASE WHEN role = 'admin' THEN 1 ELSE 2 END").Find(&users)
Limit та Offset — пагінація
// Перші 10 записів
db.Limit(10).Find(&users)
// SELECT * FROM users LIMIT 10
// Пропустити 20, взяти 10 (сторінка 3, по 10 на сторінці)
db.Limit(10).Offset(20).Find(&users)
// SELECT * FROM users LIMIT 10 OFFSET 20
// Функція пагінації
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
// Використання:
db.Scopes(Paginate(3, 20)).Find(&users)
// Сторінка 3, 20 записів на сторінці
Distinct — унікальні значення
// Унікальні міста
db.Distinct("city").Find(&users)
// SELECT DISTINCT city FROM users
// Підрахунок унікальних
var count int64
db.Model(&User{}).Distinct("city").Count(&count)
// SELECT COUNT(DISTINCT city) FROM users
Group та Having — групування
type Result struct {
City string
Count int
}
var results []Result
// Групування з підрахунком
db.Model(&User{}).
Select("city, count(*) as count").
Group("city").
Scan(&results)
// SELECT city, count(*) as count FROM users GROUP BY city
// Групування з фільтрацією (HAVING)
db.Model(&User{}).
Select("city, count(*) as count").
Group("city").
Having("count > ?", 10).
Scan(&results)
// SELECT city, count(*) FROM users GROUP BY city HAVING count > 10
// Складна агрегація
type AgeStats struct {
AvgAge float64
MinAge int
MaxAge int
}
var stats AgeStats
db.Model(&User{}).
Select("AVG(age) as avg_age, MIN(age) as min_age, MAX(age) as max_age").
Scan(&stats)
Pluck — вибірка одного стовпця у слайс
// Отримати всі email як []string
var emails []string
db.Model(&User{}).Pluck("email", &emails)
// SELECT email FROM users
// Отримати ID
var ids []uint
db.Model(&User{}).Pluck("id", &ids)
// З умовами
var activeEmails []string
db.Model(&User{}).Where("status = ?", "active").Pluck("email", &activeEmails)
First, Last, Take — отримання одного запису
// First — перший запис (ORDER BY primary key ASC)
var user User
db.First(&user)
// SELECT * FROM users ORDER BY id ASC LIMIT 1
// Last — останній запис (ORDER BY primary key DESC)
db.Last(&user)
// SELECT * FROM users ORDER BY id DESC LIMIT 1
// Take — будь-який запис (без сортування)
db.Take(&user)
// SELECT * FROM users LIMIT 1
// З умовами
db.Where("age > ?", 18).First(&user)
// За первинним ключем
db.First(&user, 42)
// SELECT * FROM users WHERE id = 42 LIMIT 1
db.First(&user, []int{1, 2, 3})
// SELECT * FROM users WHERE id IN (1,2,3) LIMIT 1
ErrRecordNotFound:Якщо запис не знайдено,
First, Last, Take повертають помилку gorm.ErrRecordNotFound. Завжди перевіряйте цю помилку:if err := db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Запис не знайдено
return nil, fmt.Errorf("користувача не існує")
}
// Інша помилка БД
return nil, err
}
Scopes — переможні запити
Scopes (області застосування) — це функції, які інкапсулюють логіку запиту та можуть багаторазово використовуватися. Вони дозволяють створювати читабельні, композиційні запити без дублювання коду.
Концепція Scopes
Scope — це функція з сигнатурою:
func ScopeName(db *gorm.DB) *gorm.DB {
return db.Where(...).Order(...)
}
Вона приймає *gorm.DB, модифікує запит та повертає модифіковану версію.
Базові приклади
// Scope для активних користувачів
func ActiveUsers(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "active")
}
// Scope для сортування за датою
func OrderByCreatedAt(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC")
}
// Використання:
var users []User
db.Scopes(ActiveUsers, OrderByCreatedAt).Find(&users)
// SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC
Переваги:
- Переважне використання: один scope можна застосовувати у багатьох місцях.
- Читабельність: запит виглядає декларативно:
db.Scopes(ActiveUsers, Premium, RecentlyActive). - Композиція: scopes можна комбінувати.
Scopes з параметрами
Для створення scopes з параметрами використовуйте замикання (closures):
// Scope для пагінації
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
// Scope для фільтрації за віковим діапазоном
func AgeRange(min, max int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("age BETWEEN ? AND ?", min, max)
}
}
// Scope для пошуку за назвою
func SearchByName(query string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("name LIKE ?", "%"+query+"%")
}
}
// Використання:
db.Scopes(
ActiveUsers,
AgeRange(18, 65),
SearchByName("Іван"),
Paginate(2, 20),
).Find(&users)
Умовні Scopes
// Scope застосовується лише якщо умова виконується
func FilterByCity(city string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if city != "" {
return db.Where("city = ?", city)
}
return db // Не модифікуємо запит
}
}
// Використання з необов'язковими фільтрами:
city := r.URL.Query().Get("city") // Може бути порожнім
status := r.URL.Query().Get("status")
db.Scopes(
FilterByCity(city), // Застосується лише якщо city != ""
FilterByStatus(status),
).Find(&users)
Scopes для складних бізнес-правил
// Користувачі з премія-підпискою
func PremiumUsers(db *gorm.DB) *gorm.DB {
return db.Where("subscription_plan IN ?", []string{"premium", "pro"}).
Where("subscription_expires_at > ?", time.Now())
}
// Користувачі, що нещодавно заходили (останні 7 днів)
func RecentlyActive(db *gorm.DB) *gorm.DB {
return db.Where("last_login_at > ?", time.Now().AddDate(0, 0, -7))
}
// Користувачі з повним профілем
func CompleteProfile(db *gorm.DB) *gorm.DB {
return db.Where("avatar_url IS NOT NULL").
Where("bio IS NOT NULL").
Where("city IS NOT NULL")
}
// Комбінований запит:
var premiumActiveUsers []User
db.Scopes(PremiumUsers, RecentlyActive, CompleteProfile).Find(&premiumActiveUsers)
Методи моделей через Scopes
Можна прикріпити scopes до моделей:
type User struct {
ID uint
Name string
Status string
Role string
}
// Scope як метод моделі
func (u *User) Active(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "active")
}
func (u *User) Admins(db *gorm.DB) *gorm.DB {
return db.Where("role = ?", "admin")
}
// Використання:
var user User
db.Scopes(user.Active, user.Admins).Find(&users)
- Іменування: використовуйте описові назви у Present Tense:
ActiveUsers,PublishedArticles,RecentOrders. - Однозначність: один scope — одна відповідальність. Не змішуйте фільтрацію та сортування в одному scope.
- Параметризація: якщо scope потребує параметрів, використовуйте замикання.
- Композиція: scopes мають бути незалежними та комбінуватися у будь-якому порядку.
- Тестування: scopes легко тестувати ізольовано.
func TestActiveUsersScope(t *testing.T) {
db := setupTestDB(t)
var users []User
result := db.Scopes(ActiveUsers).Find(&users)
assert.NoError(t, result.Error)
for _, user := range users {
assert.Equal(t, "active", user.Status)
}
}
Реальний приклад: API фільтрації
// HTTP Handler для /api/users?status=active&role=admin&page=2&page_size=20
func (h *Handler) ListUsers(c *gin.Context) {
var users []User
// Параметри з query string
status := c.Query("status")
role := c.Query("role")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// Побудова запиту через scopes
query := h.db.Model(&User{})
if status != "" {
query = query.Scopes(FilterByStatus(status))
}
if role != "" {
query = query.Scopes(FilterByRole(role))
}
query = query.Scopes(Paginate(page, pageSize))
if err := query.Find(&users).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, users)
}
// Scopes
func FilterByStatus(status string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", status)
}
}
func FilterByRole(role string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("role = ?", role)
}
}
Batch операції та продуктивність
При роботі з великими обсягами даних важливо оптимізувати операції, щоб уникнути перевантаження бази даних та пам'яті застосунку.
CreateInBatches — пакетне створення
Коли потрібно вставити тисячі записів, Create() виконає окремий INSERT для кожного. CreateInBatches() групує вставки:
var users []User
for i := 0; i < 10000; i++ {
users = append(users, User{
Name: fmt.Sprintf("User %d", i),
Email: fmt.Sprintf("user%d@example.com", i),
})
}
// ❌ Повільно: 10000 окремих INSERT запитів
db.Create(&users)
// ✅ Швидко: вставка по 1000 записів за раз
db.CreateInBatches(users, 1000)
// INSERT INTO users (...) VALUES (...), (...), ... (1000 рядків)
// INSERT INTO users (...) VALUES (...), (...), ... (1000 рядків)
// ...
Оптимальний розмір пакету:
- PostgreSQL: 500-2000 рядків
- MySQL: 100-500 рядків
- Залежить від розміру рядка та налаштувань СУБД
Деякі бази даних мають ліміт на розмір одного SQL-запиту (наприклад, PostgreSQL —
max_allowed_packet). Експериментуйте з розміром пакету для вашої СУБД.FindInBatches — обробка великих datasets
Коли потрібно обробити мільйони записів, завантаження всіх у пам'ять призведе до її виснаження. FindInBatches() обробляє дані частинами:
result := db.Where("status = ?", "pending").FindInBatches(&users, 1000, func(tx *gorm.DB, batch int) error {
// Обробка кожної партії з 1000 записів
for _, user := range users {
// Виконати щось з користувачем
processUser(user)
}
// Повернути nil для продовження, або помилку для зупинки
return nil
})
if result.Error != nil {
log.Printf("Помилка обробки: %v", result.Error)
}
log.Printf("Оброблено партій: %d, записів: %d", result.RowsAffected)
Як це працює:
- GORM виконує запит
SELECT ... LIMIT 1000 OFFSET 0. - Викликає вашу функцію з першою партією.
- Виконує
LIMIT 1000 OFFSET 1000, викликає функцію знову. - Продовжує, поки є записи.
Переваги:
- Контрольоване споживання пам'яті.
- Можливість зупинки обробки (повернути помилку).
- Прогрес обробки (параметр
batch).
Upsert — вставка або оновлення (OnConflict)
Upsert (insert or update) — операція, що вставляє запис, якщо його немає, або оновлює, якщо він існує.
// PostgreSQL: ON CONFLICT DO UPDATE
user := User{
Email: "alice@example.com",
Name: "Alice Cooper",
Age: 30,
}
result := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}}, // Ключ конфлікту
DoUpdates: clause.AssignmentColumns([]string{"name", "age"}), // Оновити ці колонки
}).Create(&user)
// INSERT INTO users (email, name, age) VALUES (?, ?, ?)
// ON CONFLICT (email)
// DO UPDATE SET name = excluded.name, age = excluded.age
Варіанти обробки конфлікту:
// 1. Оновити всі колонки крім певних
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "age"}),
}).Create(&user)
// 2. Оновити лише вказані колонки
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"name": "New Name",
"updated_at": time.Now(),
}),
}).Create(&user)
// 3. Нічого не робити при конфлікті
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoNothing: true,
}).Create(&user)
// 4. Оновити, використовуючи значення з INSERT
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"name": gorm.Expr("excluded.name"),
"age": gorm.Expr("excluded.age"),
}),
}).Create(&user)
- Імпорт даних з зовнішніх джерел (синхронізація з API).
- Кешування даних у БД (оновити, якщо існує, інакше створити).
- Ідемпотентні операції (повторний виклик не створить дублікатів).
Updates з обмеженнями
// Оновити лише перші 10 записів
db.Model(&User{}).Where("status = ?", "pending").
Limit(10).
Update("status", "processed")
// Оновити лише якщо вік менше певного значення
db.Model(&User{}).
Where("age < ?", 18).
Update("status", "minor")
Prepared Statements для продуктивності
GORM може кешувати підготовлені SQL-запити (prepared statements) для покращення продуктивності повторюваних запитів:
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
PrepareStmt: true, // Увімкнути prepared statements
})
// Тепер GORM кешує та повторно використовує підготовлені запити
for i := 0; i < 1000; i++ {
var user User
db.First(&user, i)
// Запит підготовлений лише один раз, параметри змінюються
}
Переваги:
- Підвищення продуктивності (до 30% для простих запитів).
- Захист від SQL-ін'єкцій (вбудований).
- Зменшення навантаження на парсер SQL.
Недоліки:
- Споживає з'єднання БД довше.
- Може спричинити витоки пам'яті при великій кількості унікальних запитів.
PrepareStmt у комбінації з динамічними запитами (де запити постійно змінюються) може призвести до витоків пам'яті через нескінченне накопичення підготовлених запитів. Використовуйте лише для застосунків із обмеженим набором запитів.Індекси через теги
GORM дозволяє визначати індекси безпосередньо у моделях:
type User struct {
ID uint
Email string `gorm:"uniqueIndex"` // Унікальний індекс
Name string `gorm:"index"` // Звичайний індекс
City string `gorm:"index:idx_city_status"` // Іменований індекс
Status string `gorm:"index:idx_city_status"` // Композитний індекс
}
// AutoMigrate створить індекси:
db.AutoMigrate(&User{})
// CREATE UNIQUE INDEX idx_users_email ON users(email);
// CREATE INDEX idx_users_name ON users(name);
// CREATE INDEX idx_city_status ON users(city, status);
Параметри індексів:
type User struct {
Email string `gorm:"index:,unique,where:deleted_at IS NULL"`
// Унікальний індекс лише для неvidалених записів
Name string `gorm:"index:,type:btree,length:100"`
// B-tree індекс з обмеженням довжини
Tags []string `gorm:"type:text[];index:,type:gin"`
// GIN індекс для масивів (PostgreSQL)
}
Створюйте індекси для:
- Колонок у
WHEREумовах (фільтрація). - Колонок у
JOINумовах (з'єднання таблиць). - Колонок у
ORDER BY(сортування). - Зовнішніх ключів (foreign keys).
НЕ створюйте індекси для:
- Малих таблиць (<1000 рядків) — scan швидший.
- Колонок, що рідко використовуються у запитах.
- Колонок з низькою кардинальністю (наприклад,
genderз 2-3 можливими значеннями).
Індекси прискорюють читання, але сповільнюють запис (INSERT, UPDATE, DELETE). Знаходьте баланс.
Детальна робота зі зв'язками
Зв'язки між таблицями — одна з найпотужніших можливостей GORM. Розглянемо всі типи зв'язків детально.
Налаштування зв'язків через теги
GORM автоматично визначає зв'язки, але часто потрібне тонке налаштування:
type User struct {
ID uint
Name string
Profile Profile `gorm:"foreignKey:UserID;references:ID"`
// foreignKey — поле у Profile, що вказує на User
// references — поле у User, на яке вказує foreignKey
}
type Profile struct {
ID uint
UserID uint // Зовнішній ключ
Bio string
}
Теги налаштування зв'язків
// 1. Перевизначення зовнішнього ключа
type User struct {
ID uint
Profile Profile `gorm:"foreignKey:OwnerID"` // Замість UserID
}
type Profile struct {
OwnerID uint // Зовнішній ключ
Bio string
}
// 2. Перевизначення поля посилання
type User struct {
UUID string `gorm:"primaryKey"`
Profile Profile `gorm:"foreignKey:UserUUID;references:UUID"`
}
type Profile struct {
UserUUID string
Bio string
}
// 3. Каскадне видалення
type User struct {
ID uint
Posts []Post `gorm:"constraint:OnDelete:CASCADE;"`
}
// При видаленні User автоматично видалятимуться всі Posts
// 4. Каскадне оновлення
type User struct {
ID uint
Posts []Post `gorm:"constraint:OnUpdate:CASCADE;"`
}
Параметри constraint:
OnDelete:CASCADE— видалити пов'язані записиOnDelete:SET NULL— встановити NULL у зовнішньому ключіOnDelete:RESTRICT— заборонити видалення (за замовчуванням)OnUpdate:CASCADE— оновити зовнішній ключ при зміні первинного
BeforeDelete, AfterDelete) не викликаються для пов'язаних записів. Якщо потрібна логіка при видаленні, обробляйте це вручну.Association API — керування зв'язками
GORM надає методи для роботи зі зв'язками без написання SQL:
// Отримати зв'язок
user := User{ID: 1}
db.First(&user)
var posts []Post
db.Model(&user).Association("Posts").Find(&posts)
// Завантажити всі пости користувача
// Підрахувати кількість пов'язаних записів
count := db.Model(&user).Association("Posts").Count()
log.Printf("Користувач має %d постів", count)
// Додати пов'язані записи
newPosts := []Post{
{Title: "New Post 1"},
{Title: "New Post 2"},
}
db.Model(&user).Association("Posts").Append(&newPosts)
// INSERT INTO posts (user_id, title) VALUES (1, 'New Post 1'), (1, 'New Post 2')
// Замінити всі пов'язані записи
db.Model(&user).Association("Posts").Replace(&newPosts)
// Видалить старі пости та додасть нові
// Видалити конкретні пов'язані записи
db.Model(&user).Association("Posts").Delete(&posts[0])
// DELETE FROM posts WHERE id = ? AND user_id = 1
// Очистити всі зв'язки
db.Model(&user).Association("Posts").Clear()
// DELETE FROM posts WHERE user_id = 1
Eager Loading стратегії: Preload vs Joins
Існує два способи завантаження пов'язаних даних:
1. Preload — окремі запити
var users []User
db.Preload("Posts").Find(&users)
// SELECT * FROM users;
// SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);
Переваги:
- Простота та гнучкість.
- Працює для всіх типів зв'язків (1:1, 1:N, N:M).
- Можна фільтрувати завантажені дані.
Недоліки:
- Виконує кілька запитів (N+1 за умови без умов).
- Більше мережевих round-trips.
2. Joins — одні запит з JOIN
var users []User
db.Joins("Posts").Find(&users)
// SELECT users.*, posts.* FROM users
// LEFT JOIN posts ON posts.user_id = users.id
Переваги:
- Один SQL-запит.
- Швидше для невеликих datasets.
Недоліки:
- Дублювання даних (кожен User повторюється для кожного Post).
- Не працює для N:M без додаткових налаштувань.
- Складніше фільтрувати пов'язані дані.
- Preload — коли завантажується багато пов'язаних записів (1:N, N:M).
- Joins — коли потрібна фільтрація за пов'язаними даними у WHERE.
// Знайти користувачів з опублікованими постами
db.Joins("Posts").Where("posts.status = ?", "published").Find(&users)
Вкладене Preload
// Завантажити користувачів, їхні пости та коментарі до постів
db.Preload("Posts.Comments").Find(&users)
// SELECT * FROM users;
// SELECT * FROM posts WHERE user_id IN (...);
// SELECT * FROM comments WHERE post_id IN (...);
// Вкладене з умовами
db.Preload("Posts", "status = ?", "published").
Preload("Posts.Comments", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC").Limit(5)
}).
Find(&users)
Preload з умовами
// Завантажити лише опубліковані пости
db.Preload("Posts", "status = ?", "published").Find(&users)
// Через функцію
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "published").Order("created_at DESC")
}).Find(&users)
Поліморфні зв'язки
Поліморфні зв'язки дозволяють моделі належати до кількох різних типів:
// Коментарі можуть належати до Post або Video
type Comment struct {
ID uint
Body string
CommentableID uint // ID записи
CommentableType string // Тип: "Post" або "Video"
}
type Post struct {
ID uint
Title string
Comments []Comment `gorm:"polymorphic:Commentable;"`
}
type Video struct {
ID uint
URL string
Comments []Comment `gorm:"polymorphic:Commentable;"`
}
// Використання:
post := Post{Title: "My Post"}
db.Create(&post)
comment := Comment{
Body: "Great post!",
CommentableID: post.ID,
CommentableType: "Post",
}
db.Create(&comment)
// Завантаження коментарів
db.Preload("Comments").First(&post, post.ID)
Налаштування поліморфних зв'язків:
type Comment struct {
ID uint
Body string
OwnerID uint `gorm:"index"` // Перевизначена назва
OwnerType string `gorm:"index"`
}
type Post struct {
Comments []Comment `gorm:"polymorphic:Owner;polymorphicValue:post"`
// polymorphicValue: OwnerType буде "post" замість "Post"
}
Self-Referencing — ієрархії та дерева
// Категорії з підкатегоріями
type Category struct {
ID uint
Name string
ParentID *uint // Nullable foreign key
Parent *Category `gorm:"foreignKey:ParentID"`
Children []Category `gorm:"foreignKey:ParentID"`
}
// Використання:
electronics := Category{Name: "Електроніка"}
db.Create(&electronics)
smartphones := Category{
Name: "Смартфони",
ParentID: &electronics.ID,
}
db.Create(&smartphones)
// Завантаження з підкатегоріями
var category Category
db.Preload("Children").First(&category, electronics.ID)
for _, child := range category.Children {
log.Println(child.Name) // Смартфони, Ноутбуки, ...
}
// Завантаження батьківської категорії
db.Preload("Parent").First(&category, smartphones.ID)
log.Println(category.Parent.Name) // Електроніка
GORM не підтримує рекурсивне завантаження всього дерева одним запитом. Для глибоких ієрархій потрібно завантажувати рівні вручну або використовувати спеціалізовані пакети (наприклад,
closure table або materialized path).Many-to-Many з додатковими полями
Іноді проміжна таблиця N:M потребує додаткових полів:
// Студенти записуються на курси з оцінкою та датою
type Student struct {
ID uint
Name string
Enrollments []Enrollment
}
type Course struct {
ID uint
Title string
Enrollments []Enrollment
}
// Проміжна модель з додатковими полями
type Enrollment struct {
StudentID uint `gorm:"primaryKey"`
CourseID uint `gorm:"primaryKey"`
Grade string
EnrolledAt time.Time
Student Student `gorm:"foreignKey:StudentID"`
Course Course `gorm:"foreignKey:CourseID"`
}
// Використання:
student := Student{Name: "Іван"}
course := Course{Title: "Бази даних"}
db.Create(&student)
db.Create(&course)
enrollment := Enrollment{
StudentID: student.ID,
CourseID: course.ID,
Grade: "A",
EnrolledAt: time.Now(),
}
db.Create(&enrollment)
// Завантаження з проміжною моделлю
var students []Student
db.Preload("Enrollments.Course").Find(&students)
for _, s := range students {
for _, e := range s.Enrollments {
log.Printf("%s записаний на %s з оцінкою %s",
s.Name, e.Course.Title, e.Grade)
}
}
Session та контекст виконання
GORM надає механізм Session для тонкого налаштування поведінки окремих запитів без зміни глобальних параметрів.
WithContext — передача контексту
Контекст дозволяє контролювати таймаути, скасування та трейсинг запитів:
func (r *Repository) GetUser(ctx context.Context, id uint) (*User, error) {
var user User
// Передача контексту у GORM
err := r.db.WithContext(ctx).First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
// Використання з таймаутом:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
user, err := repo.GetUser(ctx, 42)
if err == context.DeadlineExceeded {
log.Println("Запит перевищив таймаут 5 секунд")
}
Переваги WithContext:
- Автоматичне скасування запиту при закінченні таймауту.
- Інтеграція з трейсингом (OpenTelemetry, Jaeger).
- Передача метаданих між шарами застосунку.
Завжди передавайте контекст у методи доступу до даних. Це стандарт у Go-екосистемі та дозволяє коректно обробляти таймаути HTTP-запитів.
// ❌ Погано
func (r *Repo) GetUser(id uint) (*User, error) {
return db.First(&user, id)
}
// ✅ Добре
func (r *Repo) GetUser(ctx context.Context, id uint) (*User, error) {
return db.WithContext(ctx).First(&user, id)
}
Session конфігурація
Метод Session() дозволяє налаштувати поведінку для одного запиту або ланцюжка запитів:
// 1. DryRun — показати SQL без виконання
stmt := db.Session(&gorm.Session{DryRun: true}).
Where("age > ?", 18).
Find(&users).
Statement
log.Println(stmt.SQL.String()) // SELECT * FROM users WHERE age > 18
log.Println(stmt.Vars) // [18]
// 2. SkipHooks — пропустити хуки (BeforeCreate, AfterUpdate тощо)
db.Session(&gorm.Session{SkipHooks: true}).Create(&user)
// BeforeCreate та AfterCreate не викликаються
// 3. PrepareStmt — увімкнути prepared statements для сесії
db.Session(&gorm.Session{PrepareStmt: true}).First(&user, 1)
// 4. NewDB — створити нову сесію без попередніх умов
newDB := db.Session(&gorm.Session{NewDB: true})
// newDB не успадковує Where, Preload тощо від db
// 5. SkipDefaultTransaction — вимкнути автоматичні транзакції
// За замовчуванням Create/Update/Delete обгортаються у транзакцію
db.Session(&gorm.Session{SkipDefaultTransaction: true}).Create(&user)
// Працює швидше, але без атомарності
// 6. AllowGlobalUpdate — дозволити оновлення/видалення без WHERE
db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&User{})
// DELETE FROM users (без WHERE — видалить ВСІ записи)
// 7. Context — встановити контекст
ctx := context.WithValue(context.Background(), "user_id", 42)
db.Session(&gorm.Session{Context: ctx}).Find(&users)
// 8. Logger — перевизначити логер для сесії
customLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{SlowThreshold: 200 * time.Millisecond},
)
db.Session(&gorm.Session{Logger: customLogger}).Find(&users)
// 9. NowFunc — перевизначити функцію поточного часу
db.Session(&gorm.Session{
NowFunc: func() time.Time {
return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
},
}).Create(&user)
// CreatedAt буде 2020-01-01 (корисно для тестів)
QueryFields — вибір полів автоматично
За замовчуванням GORM використовує SELECT *. Для оптимізації можна вибирати лише потрібні поля:
db.Session(&gorm.Session{QueryFields: true}).Find(&users)
// SELECT id, name, email, created_at FROM users
// Замість SELECT *
CreateBatchSize — розмір пакету за замовчуванням
db.Session(&gorm.Session{CreateBatchSize: 1000}).Create(&users)
// Якщо users містить більше 1000 елементів,
// автоматично розбиває на пакети
Комбінування налаштувань
result := db.Session(&gorm.Session{
PrepareStmt: true,
SkipDefaultTransaction: true,
QueryFields: true,
Logger: customLogger,
}).
WithContext(ctx).
Where("status = ?", "active").
Find(&users)
Session налаштування застосовуються перед будь-якими методами запиту (Where, Preload, Order). Тому
Session() має викликатися першим у ланцюжку.Raw SQL та інтеграція
Хоча GORM надає багатий Query Builder API, іноді потрібен повний контроль через чистий SQL.
Raw запити для читання
// 1. Простий Raw запит
var users []User
db.Raw("SELECT * FROM users WHERE age > ?", 18).Scan(&users)
// 2. З іменованими параметрами
db.Raw("SELECT * FROM users WHERE age > @age AND city = @city",
sql.Named("age", 18),
sql.Named("city", "Київ"),
).Scan(&users)
// 3. Scan у map
var results []map[string]interface{}
db.Raw("SELECT id, name, email FROM users LIMIT 10").Scan(&results)
for _, row := range results {
log.Printf("ID: %v, Name: %v", row["id"], row["name"])
}
// 4. Scan у кастомну структуру
type UserSummary struct {
Name string
Total int
}
var summaries []UserSummary
db.Raw(`
SELECT u.name, COUNT(p.id) as total
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.name
`).Scan(&summaries)
Row та Rows — низькорівневий доступ
// Отримати один рядок
row := db.Table("users").Where("id = ?", 1).Select("name, age").Row()
var name string
var age int
row.Scan(&name, &age)
// Отримати кілька рядків
rows, err := db.Table("users").Where("age > ?", 18).Select("name, age").Rows()
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var name string
var age int
rows.Scan(&name, &age)
log.Printf("%s: %d", name, age)
}
Exec — виконання команд зміни
// 1. Простий Exec
db.Exec("UPDATE users SET status = ? WHERE age < ?", "minor", 18)
// 2. Отримання кількості змінених рядків
result := db.Exec("DELETE FROM users WHERE status = ?", "banned")
log.Printf("Видалено %d користувачів", result.RowsAffected)
// 3. DDL команди
db.Exec("CREATE INDEX idx_users_email ON users(email)")
db.Exec("DROP TABLE IF EXISTS temp_users")
// 4. Виклик збережених процедур
db.Exec("CALL update_user_stats(?)", userID)
Змішування GORM та Raw SQL
// Використання GORM для побудови WHERE, але Raw для SELECT
query := db.Model(&User{}).
Where("age > ?", 18).
Where("city = ?", "Київ")
var results []UserSummary
query.Raw(`
SELECT name, email, age * 2 as double_age
FROM users
WHERE ` + query.Statement.SQL.String(),
query.Statement.Vars...,
).Scan(&results)
? або sql.Named(), а не через конкатенацію рядків.Субзапити (Subqueries)
// 1. Субзапит у WHERE
subQuery := db.Model(&Post{}).
Select("user_id").
Where("status = ?", "published")
db.Where("id IN (?)", subQuery).Find(&users)
// SELECT * FROM users WHERE id IN (
// SELECT user_id FROM posts WHERE status = 'published'
// )
// 2. Субзапит у FROM
subQuery2 := db.Model(&User{}).
Select("id, name, age").
Where("age > ?", 18)
db.Table("(?) as u", subQuery2).
Where("u.name LIKE ?", "A%").
Find(&users)
// SELECT * FROM (
// SELECT id, name, age FROM users WHERE age > 18
// ) as u
// WHERE u.name LIKE 'A%'
Розширена робота з транзакціями
Ми вже розглянули базові транзакції, тепер заглибимося у розширені можливості.
Транзакційний блок через callback
GORM надає метод Transaction(), який автоматично обробляє commit та rollback:
err := db.Transaction(func(tx *gorm.DB) error {
// Крок 1: створити користувача
user := User{Name: "Alice", Email: "alice@example.com"}
if err := tx.Create(&user).Error; err != nil {
return err // Автоматичний rollback
}
// Крок 2: створити профіль
profile := Profile{UserID: user.ID, Bio: "Software Engineer"}
if err := tx.Create(&profile).Error; err != nil {
return err // Автоматичний rollback
}
// Крок 3: нарахувати бонуси
if err := tx.Exec("INSERT INTO bonuses (user_id, amount) VALUES (?, ?)",
user.ID, 100).Error; err != nil {
return err
}
// Якщо функція повертає nil, автоматичний commit
return nil
})
if err != nil {
log.Printf("Транзакція скасована: %v", err)
}
Переваги:
- Не потрібно вручну викликати
Commit()таRollback(). - Гарантована обробка помилок.
- Чистий, читабельний код.
Вкладені транзакції (SavePoints)
PostgreSQL підтримує вкладені транзакції через механізм SavePoint:
db.Transaction(func(tx *gorm.DB) error {
tx.Create(&user1)
// Вкладена транзакція
tx.SavePoint("sp1")
tx.Create(&user2)
if someCondition {
tx.RollbackTo("sp1") // Відкотити лише user2
}
tx.SavePoint("sp2")
tx.Create(&user3)
tx.RollbackTo("sp2") // Відкотити лише user3
return nil // Commit user1 та user2 (якщо не відкочено)
})
Автоматичні SavePoints через вкладені Transaction():
db.Transaction(func(tx *gorm.DB) error {
tx.Create(&user)
// Вкладена транзакція автоматично створює SavePoint
tx.Transaction(func(tx2 *gorm.DB) error {
tx2.Create(&profile)
if err := validate(profile); err != nil {
return err // Відкат лише profile, user залишається
}
return nil
})
tx.Create(&settings)
return nil
})
Ручні SavePoints
tx := db.Begin()
tx.Create(&user)
tx.SavePoint("before_profile")
tx.Create(&profile)
if profile.Bio == "" {
tx.RollbackTo("before_profile")
// profile не створено, але user залишається
}
tx.Commit()
Рівні ізоляції
GORM підтримує налаштування рівнів ізоляції транзакцій:
import "database/sql"
// Serializable — найвищий рівень ізоляції
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelSerializable})
tx.First(&user, 1)
tx.Commit()
// Read Committed (за замовчуванням у PostgreSQL)
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
// Repeatable Read
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead})
// Read Uncommitted (найнижчий рівень)
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelReadUncommitted})
Рівні ізоляції (від найнижчого до найвищого):
- Read Uncommitted: можна читати незакомічені дані (dirty reads).
- Read Committed: лише закомічені дані, але можливі non-repeatable reads.
- Repeatable Read: незмінність даних протягом транзакції, але можливі phantom reads.
- Serializable: повна ізоляція, як би транзакції виконувалися послідовно.
PostgreSQL не має справжнього
Read Uncommitted — він працює як Read Committed. Також Repeatable Read у PostgreSQL запобігає phantom reads, на відміну від стандарту SQL.Read-Only транзакції
tx := db.Begin(&sql.TxOptions{ReadOnly: true})
defer tx.Rollback()
// Дозволено
var users []User
tx.Find(&users)
// Заборонено (помилка)
tx.Create(&user) // Error: cannot execute INSERT in a read-only transaction
tx.Commit()
Переваги Read-Only:
- База даних може оптимізувати запити (не блокувати рядки).
- Захист від випадкових змін.
Pessimistic Locking — блокування на читання
// FOR UPDATE — ексклюзивне блокування
var user User
tx := db.Begin()
tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, 1)
// SELECT * FROM users WHERE id = 1 FOR UPDATE
// Інші транзакції чекатимуть, поки ця не завершиться
user.Balance -= 100
tx.Save(&user)
tx.Commit()
// FOR SHARE — блокування на запис (дозволяє читання іншим)
tx.Clauses(clause.Locking{Strength: "SHARE"}).First(&user, 1)
// SELECT * FROM users WHERE id = 1 FOR SHARE
// NOWAIT — не чекати, якщо заблоковано
tx.Clauses(clause.Locking{
Strength: "UPDATE",
Options: "NOWAIT",
}).First(&user, 1)
// Якщо рядок заблоковано, поверне помилку замість очікування
Оптимістичні блокування (Optimistic Locking)
type User struct {
ID uint
Name string
Balance int
Version int `gorm:"default:0"` // Лічильник версій
}
func (r *Repository) UpdateBalance(id uint, amount int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var user User
if err := tx.First(&user, id).Error; err != nil {
return err
}
originalVersion := user.Version
user.Balance += amount
user.Version++
// Оновити лише якщо версія не змінилася
result := tx.Model(&user).
Where("id = ? AND version = ?", user.ID, originalVersion).
Updates(map[string]interface{}{
"balance": user.Balance,
"version": user.Version,
})
if result.RowsAffected == 0 {
return fmt.Errorf("конфлікт оновлення: запис змінено іншою транзакцією")
}
return nil
})
}
Переваги оптимістичного блокування:
- Не блокує рядки (вища конкурентність).
- Підходить для сценаріїв із рідкісними конфліктами.
Недоліки:
- Потрібен retry механізм при конфлікті.
- Додаткове поле
versionу таблиці.
- Оптимістичне — для веб-застосунків, де конфлікти рідкісні (редагування профілю, коментарі).
- Песимістичне — для фінансових операцій, де конфлікти частіші та критичні (переказ коштів, резервування місць).
Logger та налагодження
GORM має вбудовану систему логування, яка допомагає розуміти, які SQL-запити виконуються.
Рівні логування
import (
"gorm.io/gorm/logger"
)
// 1. Silent — не логувати нічого
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
// 2. Error — лише помилки
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Error),
})
// 3. Warn — помилки та попередження (повільні запити)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
// 4. Info — всі запити, помилки та попередження
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
Налаштування логера
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // Вивід
logger.Config{
SlowThreshold: 200 * time.Millisecond, // Поріг для "повільного запиту"
LogLevel: logger.Info, // Рівень логування
IgnoreRecordNotFoundError: true, // Не логувати ErrRecordNotFound
Colorful: true, // Кольоровий вивід
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
})
Debug режим для окремих запитів
// Вивести SQL для одного запиту
db.Debug().Where("age > ?", 18).Find(&users)
// Вивід у консоль:
// [2.123ms] [rows:5] SELECT * FROM users WHERE age > 18
// Без Debug — запит не логується
db.Where("age > ?", 18).Find(&users)
Кастомний логер
type MyLogger struct {
logger.Interface
}
func (l *MyLogger) LogMode(level logger.LogLevel) logger.Interface {
return l
}
func (l *MyLogger) Info(ctx context.Context, msg string, data ...interface{}) {
log.Printf("[INFO] "+msg, data...)
}
func (l *MyLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
log.Printf("[WARN] "+msg, data...)
}
func (l *MyLogger) Error(ctx context.Context, msg string, data ...interface{}) {
log.Printf("[ERROR] "+msg, data...)
}
func (l *MyLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
elapsed := time.Since(begin)
sql, rows := fc()
log.Printf("[TRACE] [%v] [rows:%d] %s", elapsed, rows, sql)
}
// Використання:
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: &MyLogger{},
})
Інтеграція з slog (Go 1.21+)
import (
"log/slog"
"os"
)
type SlogLogger struct {
logger *slog.Logger
}
func NewSlogLogger() *SlogLogger {
return &SlogLogger{
logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
}
}
func (l *SlogLogger) LogMode(level logger.LogLevel) logger.Interface {
return l
}
func (l *SlogLogger) Info(ctx context.Context, msg string, data ...interface{}) {
l.logger.InfoContext(ctx, msg, "data", data)
}
// ... інші методи
func (l *SlogLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
elapsed := time.Since(begin)
sql, rows := fc()
l.logger.InfoContext(ctx, "SQL Query",
"duration_ms", elapsed.Milliseconds(),
"rows", rows,
"sql", sql,
"error", err,
)
}
Migrator — управління схемою БД
GORM надає інтерфейс Migrator для програмного керування схемою бази даних.
AutoMigrate — автоматична синхронізація
// Створити/оновити таблиці на основі моделей
db.AutoMigrate(&User{}, &Post{}, &Comment{})
// Додасть відсутні колонки, індекси та зв'язки
// НЕ видалить існуючі колонки або таблиці
- ✅ Створює нові таблиці
- ✅ Додає нові колонки
- ✅ Додає індекси та foreign keys
- ❌ НЕ видаляє колонки
- ❌ НЕ змінює типи колонок
- ❌ НЕ перейменовує колонки
golang-migrate або інші спеціалізовані інструменти.Ручні операції через Migrator
migrator := db.Migrator()
// Перевірка існування таблиці
if migrator.HasTable(&User{}) {
log.Println("Таблиця users існує")
}
if migrator.HasTable("users") {
log.Println("Таблиця users існує (за назвою)")
}
// Створення таблиці
migrator.CreateTable(&User{})
// Видалення таблиці
migrator.DropTable(&User{})
migrator.DropTable("users")
// Перейменування таблиці
migrator.RenameTable(&User{}, &Customer{})
migrator.RenameTable("users", "customers")
Операції з колонками
type User struct {
ID uint
Name string
Email string
Age int
}
migrator := db.Migrator()
// Перевірка існування колонки
if migrator.HasColumn(&User{}, "Email") {
log.Println("Колонка email існує")
}
// Додавання колонки
type UpdatedUser struct {
User
PhoneNumber string
}
migrator.AddColumn(&UpdatedUser{}, "PhoneNumber")
// Видалення колонки
migrator.DropColumn(&User{}, "Age")
// Зміна типу колонки
migrator.AlterColumn(&User{}, "Name")
// Перейменування колонки
migrator.RenameColumn(&User{}, "Name", "FullName")
// Отримання типу колонки
columnTypes, _ := migrator.ColumnTypes(&User{})
for _, ct := range columnTypes {
log.Printf("Колонка: %s, Тип: %s", ct.Name(), ct.DatabaseTypeName())
}
Операції з індексами
type User struct {
ID uint
Email string `gorm:"index:idx_email,unique"`
Name string `gorm:"index:idx_name"`
}
migrator := db.Migrator()
// Створення індексу
migrator.CreateIndex(&User{}, "Email")
migrator.CreateIndex(&User{}, "idx_email")
// Видалення індексу
migrator.DropIndex(&User{}, "idx_email")
// Перевірка існування індексу
if migrator.HasIndex(&User{}, "idx_email") {
log.Println("Індекс існує")
}
// Перейменування індексу
migrator.RenameIndex(&User{}, "idx_email", "idx_user_email")
Операції з обмеженнями (Constraints)
type User struct {
ID uint
Name string
}
type Post struct {
ID uint
UserID uint
User User `gorm:"constraint:OnDelete:CASCADE;"`
}
migrator := db.Migrator()
// Перевірка існування constraint
if migrator.HasConstraint(&Post{}, "fk_posts_user") {
log.Println("Constraint існує")
}
// Створення constraint
migrator.CreateConstraint(&Post{}, "User")
// Видалення constraint
migrator.DropConstraint(&Post{}, "fk_posts_user")
Отримання інформації про таблиці
// Список всіх таблиць
tables, err := migrator.GetTables()
for _, table := range tables {
log.Println(table)
}
// Отримання SQL для створення таблиці (PostgreSQL)
db.Exec("SELECT pg_get_tabledef('users')")
Практичні патерни та рекомендації
Soft Delete — детальніше
type User struct {
ID uint
Name string
DeletedAt gorm.DeletedAt `gorm:"index"`
}
user := User{ID: 1, Name: "Alice"}
db.Create(&user)
// Soft Delete
db.Delete(&user)
// UPDATE users SET deleted_at = '2024-01-01 10:00:00' WHERE id = 1
// Запити ігнорують soft-deleted
var users []User
db.Find(&users)
// SELECT * FROM users WHERE deleted_at IS NULL
// Пошук лише видалених
db.Unscoped().Where("deleted_at IS NOT NULL").Find(&users)
// Постійне видалення
db.Unscoped().Delete(&user)
// DELETE FROM users WHERE id = 1
// Відновлення видаленого запису
db.Unscoped().Model(&user).Update("deleted_at", nil)
Тенантність (Multi-Tenancy)
// Схема через окремі БД
type Tenant struct {
ID uint
DSN string
}
func GetTenantDB(tenantID uint) (*gorm.DB, error) {
var tenant Tenant
// Завантажити tenant з основної БД
mainDB.First(&tenant, tenantID)
// Підключитися до БД тенанта
return gorm.Open(postgres.Open(tenant.DSN), &gorm.Config{})
}
// Схема через tenant_id колонку
type User struct {
ID uint
TenantID uint `gorm:"index"`
Name string
}
func TenantScope(tenantID uint) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("tenant_id = ?", tenantID)
}
}
// Використання:
tenantID := 42
db.Scopes(TenantScope(tenantID)).Find(&users)
Аудит-логи
type AuditLog struct {
ID uint
UserID uint
Action string
TableName string
RecordID uint
Changes string // JSON
CreatedAt time.Time
}
type User struct {
gorm.Model
Name string
Email string
}
func (u *User) AfterCreate(tx *gorm.DB) error {
return tx.Create(&AuditLog{
UserID: getCurrentUserID(tx),
Action: "CREATE",
TableName: "users",
RecordID: u.ID,
Changes: fmt.Sprintf(`{"name": "%s", "email": "%s"}`, u.Name, u.Email),
}).Error
}
func (u *User) AfterUpdate(tx *gorm.DB) error {
changes := tx.Statement.Changed()
changesJSON, _ := json.Marshal(changes)
return tx.Create(&AuditLog{
UserID: getCurrentUserID(tx),
Action: "UPDATE",
TableName: "users",
RecordID: u.ID,
Changes: string(changesJSON),
}).Error
}
- Завжди використовуйте WithContext() для таймаутів та трейсингу.
- Індексуйте зовнішні ключі та колонки у WHERE/JOIN.
- Використовуйте Scopes для DRY принципу.
- Увімкніть PrepareStmt для high-frequency запитів.
- Логуйте повільні запити (SlowThreshold).
- Тестуйте з реальною БД, не лише з моками.
- Використовуйте міграції для production змін схеми.
- Обробляйте ErrRecordNotFound явно.
Callbacks система — розширення GORM
Хуки (hooks) — це методи моделей, але callbacks — це глобальні обробники, які можуть модифікувати поведінку GORM для всіх моделей.
Життєвий цикл запиту
Кожна операція GORM проходить через серію callbacks:
Create:
BeforeSave → BeforeCreate → Create → AfterCreate → AfterSave
Query:
BeforeQuery → Query → AfterQuery
Update:
BeforeSave → BeforeUpdate → Update → AfterUpdate → AfterSave
Delete:
BeforeDelete → Delete → AfterDelete
Row:
Row → AfterFind
Реєстрація глобальних callbacks
// Callback для логування всіх CREATE операцій
func LogCreateCallback(db *gorm.DB) {
// Доступ до контексту запиту
if db.Statement.Schema != nil {
log.Printf("[CREATE] Table: %s", db.Statement.Schema.Table)
}
}
// Реєстрація callback
db.Callback().Create().After("gorm:create").Register("log:after_create", LogCreateCallback)
// Виконається після стандартного callback "gorm:create"
// Callback для автоматичного заповнення created_by
func SetCreatedBy(db *gorm.DB) {
if db.Statement.Schema != nil {
// Отримати user_id з контексту
if userID, ok := db.Statement.Context.Value("user_id").(uint); ok {
// Якщо модель має поле CreatedBy
if field := db.Statement.Schema.LookUpField("CreatedBy"); field != nil {
field.Set(db.Statement.Context, db.Statement.ReflectValue, userID)
}
}
}
}
// Реєстрація перед створенням
db.Callback().Create().Before("gorm:create").Register("custom:set_created_by", SetCreatedBy)
Видалення callback
// Видалити стандартний callback
db.Callback().Create().Remove("gorm:create")
// Видалити власний callback
db.Callback().Create().Remove("custom:set_created_by")
Заміна callback
// Замінити стандартний callback власною логікою
db.Callback().Create().Replace("gorm:create", func(db *gorm.DB) {
// Власна логіка створення
log.Println("Custom create logic")
// Викликати оригінальний, якщо потрібно
// або реалізувати повністю свій
})
Порядок виконання callbacks
// Before — виконати перед
db.Callback().Create().Before("gorm:create").Register("custom:before", myCallback)
// After — виконати після
db.Callback().Create().After("gorm:create").Register("custom:after", myCallback)
// Match — замінити існуючий
db.Callback().Create().Match("gorm:create").Register("custom:override", myCallback)
Реальний приклад: автоматичний UUID
// Callback для автоматичної генерації UUID
func GenerateUUID(db *gorm.DB) {
if db.Statement.Schema != nil {
for _, field := range db.Statement.Schema.Fields {
// Шукаємо поле типу string з тегом "autoUUID"
if field.DataType == "string" && field.Tag.Get("autoUUID") == "true" {
// Генеруємо UUID, якщо поле порожнє
fieldValue := field.ReflectValueOf(db.Statement.Context, db.Statement.ReflectValue)
if fieldValue.String() == "" {
newUUID := uuid.New().String()
field.Set(db.Statement.Context, db.Statement.ReflectValue, newUUID)
}
}
}
}
}
// Реєстрація
db.Callback().Create().Before("gorm:create").Register("custom:generate_uuid", GenerateUUID)
// Модель з автоматичним UUID
type User struct {
UUID string `gorm:"primaryKey;autoUUID:true"`
Name string
}
Умовні callbacks
func ConditionalCallback(db *gorm.DB) {
// Виконувати лише для певних таблиць
if db.Statement.Schema != nil && db.Statement.Schema.Table == "users" {
// Логіка лише для таблиці users
log.Println("Processing users table")
}
// Виконувати лише якщо у контексті є певне значення
if skipAudit, ok := db.Statement.Context.Value("skip_audit").(bool); ok && skipAudit {
return // Пропустити callback
}
// Основна логіка
}
Хуки (Hooks):
- Методи конкретної моделі (
func (u *User) BeforeCreate(tx *gorm.DB) error) - Працюють лише для однієї моделі
- Прості у використанні
- Викликаються автоматично
Callbacks:
- Глобальні обробники для всіх моделей
- Реєструються програмно
- Можуть модифікувати поведінку GORM
- Використовуються для крос-функціональної логіки (логування, аудит, кешування)
Коли що використовувати:
- Хуки — для логіки, специфічної для моделі (валідація, хешування пароля)
- Callbacks — для загальної логіки, що застосовується до багатьох моделей (аудит, метрики, трейсинг)
Plugins — розширення функціональності
GORM підтримує систему плагінів для додавання нової функціональності.
Інтерфейс Plugin
type Plugin interface {
Name() string
Initialize(*gorm.DB) error
}
Створення власного плагіна
package plugins
import (
"log"
"time"
"gorm.io/gorm"
)
// Плагін для метрик продуктивності
type MetricsPlugin struct{}
func (p *MetricsPlugin) Name() string {
return "metrics_plugin"
}
func (p *MetricsPlugin) Initialize(db *gorm.DB) error {
// Реєстрація callbacks
// Before Query
db.Callback().Query().Before("gorm:query").Register("metrics:before_query", func(db *gorm.DB) {
db.InstanceSet("metrics:start_time", time.Now())
})
// After Query
db.Callback().Query().After("gorm:query").Register("metrics:after_query", func(db *gorm.DB) {
if startTime, ok := db.InstanceGet("metrics:start_time"); ok {
elapsed := time.Since(startTime.(time.Time))
// Відправити метрики
log.Printf("[METRICS] Query took: %v, Rows: %d",
elapsed,
db.RowsAffected,
)
// Тут можна інтегрувати Prometheus, StatsD тощо
}
})
return nil
}
// Використання:
db.Use(&MetricsPlugin{})
Популярні плагіни
1. Prometheus плагін
import "github.com/prometheus/client_golang/prometheus"
type PrometheusPlugin struct {
queryDuration *prometheus.HistogramVec
queryCounter *prometheus.CounterVec
}
func NewPrometheusPlugin() *PrometheusPlugin {
return &PrometheusPlugin{
queryDuration: prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "gorm_query_duration_seconds",
Help: "Duration of GORM queries",
},
[]string{"table", "operation"},
),
queryCounter: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "gorm_query_total",
Help: "Total number of GORM queries",
},
[]string{"table", "operation", "status"},
),
}
}
func (p *PrometheusPlugin) Name() string {
return "prometheus"
}
func (p *PrometheusPlugin) Initialize(db *gorm.DB) error {
prometheus.MustRegister(p.queryDuration, p.queryCounter)
// Реєстрація callbacks для всіх операцій
for _, operation := range []string{"create", "query", "update", "delete"} {
op := operation
db.Callback().Create().Before("gorm:"+op).Register("prometheus:before_"+op, func(db *gorm.DB) {
db.InstanceSet("prometheus:start_time", time.Now())
})
db.Callback().Create().After("gorm:"+op).Register("prometheus:after_"+op, func(db *gorm.DB) {
if startTime, ok := db.InstanceGet("prometheus:start_time"); ok {
elapsed := time.Since(startTime.(time.Time))
table := db.Statement.Table
status := "success"
if db.Error != nil {
status = "error"
}
p.queryDuration.WithLabelValues(table, op).Observe(elapsed.Seconds())
p.queryCounter.WithLabelValues(table, op, status).Inc()
}
})
}
return nil
}
2. OpenTelemetry трейсинг
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
type TracingPlugin struct {
tracer trace.Tracer
}
func NewTracingPlugin() *TracingPlugin {
return &TracingPlugin{
tracer: otel.Tracer("gorm"),
}
}
func (p *TracingPlugin) Name() string {
return "opentelemetry"
}
func (p *TracingPlugin) Initialize(db *gorm.DB) error {
db.Callback().Query().Before("gorm:query").Register("tracing:before", func(db *gorm.DB) {
ctx, span := p.tracer.Start(db.Statement.Context, "gorm.query")
db.Statement.Context = ctx
db.InstanceSet("tracing:span", span)
// Додати атрибути
span.SetAttributes(
attribute.String("db.table", db.Statement.Table),
attribute.String("db.sql", db.Statement.SQL.String()),
)
})
db.Callback().Query().After("gorm:query").Register("tracing:after", func(db *gorm.DB) {
if span, ok := db.InstanceGet("tracing:span"); ok {
s := span.(trace.Span)
if db.Error != nil {
s.RecordError(db.Error)
}
s.End()
}
})
return nil
}
3. Кешування результатів
import (
"crypto/md5"
"encoding/json"
"fmt"
"time"
)
type CachePlugin struct {
cache map[string]interface{} // Використовуйте Redis у production
ttl time.Duration
}
func NewCachePlugin(ttl time.Duration) *CachePlugin {
return &CachePlugin{
cache: make(map[string]interface{}),
ttl: ttl,
}
}
func (p *CachePlugin) Name() string {
return "cache"
}
func (p *CachePlugin) Initialize(db *gorm.DB) error {
db.Callback().Query().Before("gorm:query").Register("cache:before", func(db *gorm.DB) {
// Перевірити, чи запит має тег кешування
if cacheable, ok := db.InstanceGet("cache:enabled"); ok && cacheable.(bool) {
// Згенерувати ключ кешу
key := p.generateCacheKey(db.Statement.SQL.String(), db.Statement.Vars)
// Перевірити кеш
if cached, found := p.cache[key]; found {
// Встановити результат з кешу
db.InstanceSet("cache:hit", true)
db.InstanceSet("cache:result", cached)
}
}
})
db.Callback().Query().After("gorm:query").Register("cache:after", func(db *gorm.DB) {
// Якщо був cache hit, пропустити
if hit, ok := db.InstanceGet("cache:hit"); ok && hit.(bool) {
return
}
// Зберегти результат у кеші
if cacheable, ok := db.InstanceGet("cache:enabled"); ok && cacheable.(bool) {
key := p.generateCacheKey(db.Statement.SQL.String(), db.Statement.Vars)
p.cache[key] = db.Statement.Dest
// Встановити TTL (у production використовуйте Redis EXPIRE)
go func() {
time.Sleep(p.ttl)
delete(p.cache, key)
}()
}
})
return nil
}
func (p *CachePlugin) generateCacheKey(sql string, vars []interface{}) string {
data, _ := json.Marshal(map[string]interface{}{
"sql": sql,
"vars": vars,
})
return fmt.Sprintf("%x", md5.Sum(data))
}
// Використання:
db.Use(NewCachePlugin(5 * time.Minute))
// Увімкнути кешування для запиту
var users []User
db.Set("cache:enabled", true).Find(&users)
- Ізольованість: плагін не повинен конфліктувати з іншими плагінами.
- Продуктивність: мінімізуйте overhead callbacks.
- Налаштовність: дозволяйте конфігурувати плагін через опції.
- Документація: надайте приклади використання.
- Тестування: покрийте плагін тестами.
Оптимізація та найкращі практики
1. N+1 Query Problem — виявлення та вирішення
Проблема:
// ❌ N+1 queries
var users []User
db.Find(&users) // 1 запит
for _, user := range users {
db.Model(&user).Association("Posts").Find(&user.Posts) // N запитів
}
// Всього: 1 + N запитів
Рішення:
// ✅ 2 запити
var users []User
db.Preload("Posts").Find(&users)
// SELECT * FROM users
// SELECT * FROM posts WHERE user_id IN (1,2,3,...)
2. Вибір полів — зменшення трафіку
// ❌ Завантажує всі поля
db.Find(&users)
// ✅ Лише потрібні поля
db.Select("id", "name", "email").Find(&users)
// ✅ Виключити великі поля
db.Omit("avatar_data", "biography").Find(&users)
3. Пагінація для великих datasets
// ❌ Завантажує всі записи
var users []User
db.Find(&users) // Може бути мільйони рядків!
// ✅ Використовуйте Limit та Offset
db.Limit(20).Offset(40).Find(&users)
// ✅ Або cursor-based pagination
db.Where("id > ?", lastSeenID).Limit(20).Find(&users)
4. Batch операції
// ❌ Повільно
for _, user := range users {
db.Create(&user) // N запитів
}
// ✅ Швидко
db.CreateInBatches(users, 1000) // Пакетами по 1000
5. Індекси
// Створіть індекси для:
// - WHERE умов
// - JOIN колонок
// - ORDER BY полів
// - Foreign keys
type User struct {
Email string `gorm:"uniqueIndex"` // Часто використовується у WHERE
Status string `gorm:"index"` // Фільтрація за статусом
City string `gorm:"index:idx_location"` // Композитний індекс
State string `gorm:"index:idx_location"`
}
6. Connection Pool налаштування
sqlDB, err := db.DB()
// Максимум відкритих з'єднань
sqlDB.SetMaxOpenConns(100)
// Максимум idle з'єднань
sqlDB.SetMaxIdleConns(10)
// Максимальний час життя з'єднання
sqlDB.SetConnMaxLifetime(time.Hour)
// Максимальний час простою
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
7. Prepared Statements
// Увімкнути для повторюваних запитів
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
PrepareStmt: true,
})
// Кешує підготовлені запити для покращення продуктивності
8. Моніторинг повільних запитів
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond, // Логувати запити > 200ms
LogLevel: logger.Warn,
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
})
- N+1 queries — найпоширеніша проблема. Завжди використовуйте
Preload(). - **SELECT *** — завантаження всіх полів, навіть непотрібних.
- Відсутність індексів — запити без індексів стають повільними на великих таблицях.
- Завантаження всіх даних — використовуйте пагінацію або
FindInBatches. - Ігнорування connection pool — невірні налаштування можуть виснажити БД.
Порівняльна таблиця: database/sql vs sqlx vs GORM
| Характеристика | database/sql | sqlx | GORM |
|---|---|---|---|
| Рівень абстракції | Низький | Середній | Високий |
| Написання SQL | Вручну | Вручну | Автоматично |
| Безпека від SQL-ін'єкцій | Якщо використовувати ? | Так | Так |
| Сканування у структури | Вручну Scan() | Автоматично через теги db | Автоматично |
| Зв'язки (Relations) | Вручну через JOIN | Вручну через JOIN | Автоматично (Preload) |
| Міграції | Немає | Немає | AutoMigrate, Migrator API |
| Хуки життєвого циклу | Немає | Немає | Так (Before/After) |
| Транзакції | BeginTx() | BeginTxx() | Begin(), Transaction() |
| Продуктивність | Максимальна | Висока | Середня (overhead ORM) |
| Складність коду | Багато шаблонного коду | Помірна | Мінімальна |
| Контроль над SQL | Повний | Повний | Обмежений (але є Raw) |
| Підтримка СУБД | Через драйвери | Через драйвери | PostgreSQL, MySQL, SQLite, SQL Server |
| Крива навчання | Низька | Низька | Середня |
| Розмір бібліотеки | Стандартна (вбудована) | Мала (~500 KB) | Велика (~5 MB) |
Рекомендації щодо вибору
Використовуйте database/sql + pgx, коли:
- Потрібна максимальна продуктивність (high-frequency trading, real-time systems)
- Складні аналітичні запити, оконні функції, CTE
- Критичні системи (фінанси, платежі)
- Команда має сильні SQL-навички
Використовуйте sqlx, коли:
- Хочете зручність без overhead ORM
- Пишете багато SQL вручну
- Потрібен контроль над запитами, але без шаблонного коду
- Середні проєкти (10-100k LOC)
Використовуйте GORM, коли:
- Швидка розробка (MVP, прототипи, стартапи)
- Багато стандартних CRUD операцій
- Складні зв'язки між таблицями
- Команда менш досвідчена у SQL
- Потрібна портативність між СУБД
- Великі проєкти з багатьма моделями (100+ таблиць)
Гібридний підхід (рекомендовано):
// Використовуйте GORM для 80% операцій
db.Where("status = ?", "active").Preload("Posts").Find(&users)
// Складні запити через Raw SQL
db.Raw(`
WITH ranked_users AS (
SELECT u.*, ROW_NUMBER() OVER (PARTITION BY city ORDER BY score DESC) as rank
FROM users u
)
SELECT * FROM ranked_users WHERE rank <= 10
`).Scan(&topUsers)
// Критичні операції через чистий database/sql
sqlDB, _ := db.DB()
tx, _ := sqlDB.Begin()
// ... низькорівневі операції
tx.Commit()
Лекція 12. Валідація даних, безпека та документування у Gin
Зв
Потокова передача даних та протокол WebSocket
Технології доставки даних у реальному часі: Server-Sent Events (SSE) та протокол WebSocket (RFC 6455). Встановлення з'єднання через HTTP Upgrade, фреймінг повідомлень, патерн Hub та броадкаст у Go.