Веха 3: переносим задачи в PostgreSQL

У вехи 2 один недостаток, зато решающий: при перезапуске всё исчезает. Теперь, после урока про работу с базами данных, заменим map на PostgreSQL.

Главное открытие этого урока в том, насколько мало придётся менять. HTTP-обработчики останутся ровно теми же. Изменится один слой - и это не совпадение, а следствие того, как мы разложили код в вехе 2.

Что меняется, а что нет

СлойВеха 2Веха 3
обработчики HTTPhandler.goтот же
коды ответов, JSONwriteJSON, errors.Isте же
хранилищеmap + мьютекстаблица в PostgreSQL
время жизни данныхдо перезапусканавсегда
одновременный доступsync.RWMutexтранзакции базы

Мьютекс, кстати, исчезает. Не потому, что гонок больше нет, а потому, что синхронизацией теперь занимается база: параллельные запросы к одной строке разруливает она, а не наш код. Это не «стало проще» - это ответственность переехала туда, где её умеют нести правильно.

Первые две вехи жили в `examples/go-milestones`. Третья опирается на `examples/todo-api` - тот самый проект, который ты доведёшь до конца в [финальном уроке](./27-project.md).

Это сделано намеренно. Написать четвёртую копию задачника значило бы завести ещё один источник того же знания, который однажды разойдётся с остальными. Вместо этого веха 3 - первый шаг внутрь финального проекта: ты видишь его слой работы с базой раньше, чем всё остальное.

Схема живёт в SQL, а не в Go

CREATE TABLE IF NOT EXISTS todos (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- А этот индекс нужен: PostgreSQL не создаёт индекс под внешний ключ сам.
-- Без него выборка задач пользователя идёт последовательным чтением таблицы,
-- и удаление пользователя проверяет каждую строку todos.
CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);

Разберём три решения в этих двадцати строках.

REFERENCES users(id) ON DELETE CASCADE. Удалили пользователя - его задачи уходят вместе с ним. Без CASCADE база откажется удалять пользователя, у которого есть задачи, и придётся чистить руками в правильном порядке. Хуже - если внешнего ключа нет вовсе: тогда задачи остаются висеть с user_id, которого больше не существует, и обнаруживается это через полгода.

CREATE INDEX idx_todos_user_id. Под внешний ключ PostgreSQL индекс не создаёт автоматически - вопреки распространённому ожиданию. А запрос WHERE user_id = $1 мы делаем на каждый список задач. Без индекса это полный проход по таблице.

Никакого CREATE INDEX по users(email). Там стоит UNIQUE, а UNIQUE в PostgreSQL уже создаёт уникальный индекс. Второй индекс поверх дублировал бы его и удваивал работу на каждой вставке, ничего не ускоряя.

Как миграции применяются - через go:embed и чтение каталога:

// Package migrations хранит схему базы и отдаёт её как embed.FS.
//
// Зачем пакет ради двух SQL-файлов: чтобы у схемы был ровно один источник.
// Раньше те же CREATE TABLE лежали и здесь, и строками внутри Migrate() в Go.
// Две копии одного знания расходятся молча - меняешь одну, вторая остаётся
// правильной на вид и неправильной по факту. Теперь Go-код читает эти файлы,
// а не повторяет их.
//
// `go:embed` не умеет подниматься выше своего каталога, поэтому переменная
// объявлена рядом с самими .sql, а не в пакете database.
package migrations

import "embed"

// FS содержит все SQL-файлы каталога. Порядок применения задаётся именами:
// 001_, 002_ и так далее, поэтому сортировка по имени и есть порядок миграций.
//
//go:embed *.sql
var FS embed.FS

Каталог встраивается в бинарник на этапе сборки. Благодаря этому готовая программа не зависит от того, лежат ли рядом с ней .sql-файлы: миграции уезжают на сервер внутри неё.

Репозиторий вместо map

package repository

import (
	"context"
	"database/sql"
	"errors"
	"fmt"

	"todo-api/internal/models"
)

var ErrTodoNotFound = errors.New("todo not found")

type TodoRepository struct {
	db *sql.DB
}

func NewTodoRepository(db *sql.DB) *TodoRepository {
	return &TodoRepository{db: db}
}

func (r *TodoRepository) Create(ctx context.Context, todo *models.Todo) error {
	query := `
        INSERT INTO todos (user_id, title, description)
        VALUES ($1, $2, $3)
        RETURNING id, created_at, updated_at
    `

	err := r.db.QueryRowContext(
		ctx,
		query,
		todo.UserID,
		todo.Title,
		todo.Description,
	).Scan(&todo.ID, &todo.CreatedAt, &todo.UpdatedAt)

	if err != nil {
		return fmt.Errorf("create todo: %w", err)
	}

	return nil
}

// FindByID возвращает одну задачу конкретного пользователя.
//
// user_id в условии - не украшение: без него любой авторизованный смог бы
// читать чужие задачи, подставив чужой id. Проверка владельца делается
// в самом запросе, а не после выборки, потому что «забыть сравнить»
// в коде проще, чем забыть условие в SQL.
func (r *TodoRepository) FindByID(ctx context.Context, id, userID int) (*models.Todo, error) {
	todo := &models.Todo{}

	query := `
        SELECT id, user_id, title, description, completed, created_at, updated_at
        FROM todos
        WHERE id = $1 AND user_id = $2
    `

	err := r.db.QueryRowContext(ctx, query, id, userID).Scan(
		&todo.ID,
		&todo.UserID,
		&todo.Title,
		&todo.Description,
		&todo.Completed,
		&todo.CreatedAt,
		&todo.UpdatedAt,
	)

	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrTodoNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find todo by id: %w", err)
	}

	return todo, nil
}

func (r *TodoRepository) FindByUserID(ctx context.Context, userID int) ([]*models.Todo, error) {
	query := `
        SELECT id, user_id, title, description, completed, created_at, updated_at
        FROM todos
        WHERE user_id = $1
        ORDER BY created_at DESC
    `

	rows, err := r.db.QueryContext(ctx, query, userID)
	if err != nil {
		return nil, fmt.Errorf("find todos: %w", err)
	}
	defer rows.Close()

	// Инициализируем пустым срезом, а не nil: nil-срез сериализуется в `null`,
	// и клиенту приходится обрабатывать два вида «пусто». Пустой даёт `[]`.
	todos := []*models.Todo{}
	for rows.Next() {
		todo := &models.Todo{}
		err := rows.Scan(
			&todo.ID,
			&todo.UserID,
			&todo.Title,
			&todo.Description,
			&todo.Completed,
			&todo.CreatedAt,
			&todo.UpdatedAt,
		)
		if err != nil {
			return nil, fmt.Errorf("scan todo: %w", err)
		}
		todos = append(todos, todo)
	}

	// rows.Err() обязателен: цикл заканчивается и при ошибке чтения,
	// и без этой проверки половина строк молча превратится в успешный ответ.
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate todos: %w", err)
	}

	return todos, nil
}

func (r *TodoRepository) Update(ctx context.Context, todo *models.Todo) error {
	query := `
        UPDATE todos
        SET title = $1, description = $2, completed = $3, updated_at = CURRENT_TIMESTAMP
        WHERE id = $4 AND user_id = $5
    `

	result, err := r.db.ExecContext(
		ctx,
		query,
		todo.Title,
		todo.Description,
		todo.Completed,
		todo.ID,
		todo.UserID,
	)
	if err != nil {
		return fmt.Errorf("update todo: %w", err)
	}

	rows, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("rows affected: %w", err)
	}

	if rows == 0 {
		return ErrTodoNotFound
	}

	return nil
}

func (r *TodoRepository) Delete(ctx context.Context, id, userID int) error {
	query := `DELETE FROM todos WHERE id = $1 AND user_id = $2`

	result, err := r.db.ExecContext(ctx, query, id, userID)
	if err != nil {
		return fmt.Errorf("delete todo: %w", err)
	}

	rows, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("rows affected: %w", err)
	}

	if rows == 0 {
		return ErrTodoNotFound
	}

	return nil
}

Сравни это с Store из вехи 2. Методы называются иначе, но роль та же: Create, FindByID, список, обновление, удаление. Именно поэтому обработчики менять почти не пришлось.

Три вещи, которых в вехе 2 быть не могло.

RETURNING вместо второго запроса

INSERT INTO todos (user_id, title, description)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at

База сама проставляет id и временные метки. Наивный путь - вставить, потом отдельным SELECT прочитать, что получилось: два обращения вместо одного и гонка между ними. RETURNING отдаёт значения той же командой.

$1, а не склейка строк

r.db.QueryRowContext(ctx, query, todo.UserID, todo.Title, todo.Description)

Значения передаются отдельно от текста запроса. База получает шаблон и данные по раздельности, поэтому строка '; DROP TABLE todos; -- в названии задачи остаётся просто строкой. Это единственная надёжная защита от SQL-инъекции - не экранирование, а разделение кода и данных.

user_id в условии, а не после выборки

SELECT ... FROM todos WHERE id = $1 AND user_id = $2

Проверка владельца стоит в самом запросе. Можно было бы выбрать задачу по id и сравнить todo.UserID в коде - но забыть такую проверку в одной из десяти функций легко, а забыть условие в SQL сложнее: без него запрос просто не напишешь осмысленно.

Ошибка «любой авторизованный читает чужие данные, подставив чужой id» - одна из самых частых в самописных API и одна из самых дорогих.

Ошибки переводятся на язык предметной области

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrTodoNotFound
}

sql.ErrNoRows - деталь работы с базой. Наружу отдаётся ErrTodoNotFound, и обработчик, который мы написали в вехе 2, продолжает работать без правок: он и раньше сравнивал ошибку через errors.Is со своей доменной ошибкой.

Слой базы не протекает в HTTP-слой - в этом и был смысл разделения.

Подключение и пул

package database

import (
	"context"
	"database/sql"
	"fmt"
	"io/fs"
	"sort"
	"time"

	_ "github.com/lib/pq"

	"todo-api/migrations"
)

type DB struct {
	*sql.DB
}

// New подключается к базе по строке из конфигурации.
//
// Принимаем готовый DSN, а не пять отдельных параметров: тогда невозможно
// случайно подключиться не туда, потому что подключаться просто некуда,
// кроме как по тому, что пришло из окружения. Логин и пароль в сигнатуре
// функции - это приглашение подставить их прямо в вызове и забыть.
//
// В строке подключения для локальной разработки будет `sslmode=disable`.
// Для прода это надо менять: см. чеклист в конце урока.
func New(ctx context.Context, dsn string) (*DB, error) {
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		return nil, fmt.Errorf("open database: %w", err)
	}

	// sql.Open не подключается - он лишь готовит пул, поэтому первая реальная
	// проверка связи это Ping. И если он не прошёл, пул надо закрыть: за ним
	// стоят горутина обслуживания соединений и захваченные ресурсы, а вернув
	// одну ошибку, мы теряем на них последнюю ссылку.
	//
	// PingContext, а не Ping: без контекста вызов ждёт столько, сколько решит
	// драйвер, и запуск сервиса зависает на недоступной базе вместо того,
	// чтобы честно упасть.
	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	if err := db.PingContext(pingCtx); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping database: %w", err)
	}

	// Настройка пула соединений
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(5 * time.Minute)

	return &DB{db}, nil
}

// Migrate применяет SQL-файлы из каталога migrations по порядку имён.
//
// Раньше схема была продублирована здесь строками Go, и файлы рядом лежали
// для вида. Копия документации всегда отстаёт от кода - с копией схемы то же
// самое, только чинить дороже: расхождение вылезает на проде при первом
// развёртывании с нуля.
//
// Настоящей системы миграций тут нет намеренно: нет таблицы с историей,
// нет отката. Для учебного проекта достаточно `IF NOT EXISTS` в самих файлах,
// а на реальном проекте берут goose, migrate или Atlas.
func (db *DB) Migrate() error {
	files, err := fs.Glob(migrations.FS, "*.sql")
	if err != nil {
		return fmt.Errorf("list migrations: %w", err)
	}
	if len(files) == 0 {
		return fmt.Errorf("no migrations found: каталог migrations пуст")
	}

	// Имена начинаются с номера, поэтому сортировка по имени и есть порядок.
	sort.Strings(files)

	for _, name := range files {
		query, err := fs.ReadFile(migrations.FS, name)
		if err != nil {
			return fmt.Errorf("read %s: %w", name, err)
		}
		if _, err := db.Exec(string(query)); err != nil {
			return fmt.Errorf("apply %s: %w", name, err)
		}
	}

	return nil
}

sql.Open ничего не открывает

Вопреки названию, sql.Open только готовит пул и не проверяет подключение. Ошибка в пароле или адресе выяснится при первом запросе - то есть у пользователя, а не при старте. Поэтому сразу после Open идёт PingContext: сервис должен падать на старте, если базы нет.

Ограничения пула

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)

По умолчанию число соединений не ограничено. Под нагрузкой Go откроет столько, сколько потребуется, и упрётся в max_connections PostgreSQL - после чего база начнёт отказывать всем клиентам, включая другие сервисы.

ConnMaxLifetime нужен по другой причине: соединения, живущие вечно, не переживают переключение базы на реплику и перезапуск - они остаются в пуле мёртвыми.

Проверяем

Поднимаем базу:

docker run --rm -d --name todo-pg \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=todo \
  -p 5432:5432 postgres:16-alpine

Запускаем сервис и проверяем, что данные переживают перезапуск:

curl -s -X POST localhost:8080/api/todos \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"Купить хлеб"}'

# перезапускаем процесс и снова читаем список
curl -s localhost:8080/api/todos -H "Authorization: Bearer $TOKEN"

Задача на месте - в вехе 2 её бы уже не было.

Загляни в базу глазами, а не доверяй логу:
docker exec -it todo-pg psql -U postgres -d todo -c '\d todos'

Тут должны быть и таблица, и индекс idx_todos_user_id. Расхождение между «миграция выполнилась» в логе и тем, что реально в схеме, - классический источник загадочных ошибок на проде.

Что дальше

Три вехи собрали задачник: сначала в терминале, потом по HTTP, теперь с настоящим хранением. Дальше - тестирование, context и финальный проект, где к этому добавятся регистрация, JWT-авторизация, middleware и Docker.

Основа при этом уже написана: репозиторий из этого урока - часть финального проекта, а не черновик, который выбросят.

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

Добавь в репозиторий метод CountByUser(ctx, userID) (int, error) с запросом SELECT count(*) FROM todos WHERE user_id = $1. Затем ответь себе на вопрос: почему для счётчика достаточно QueryRowContext, а для списка нужен QueryContext с обязательным defer rows.Close()? Что произойдёт с пулом соединений, если Close забыть?

Мини-задание

Практика выше про код. Это - про базу и окружение.

  • Подними чистую базу (новый контейнер или новая схема) и прогони миграции с нуля. Всё, что работает только на твоей базе, здесь и вскроется
  • Останови базу и постучись в своё API. Проверь, что оно отвечает 500 с понятным текстом, а не падает
  • Посмотри в логах базы, какие запросы реально уходят. Найди хотя бы один, который можно было не делать
  • Замерь время ответа на списке из 1000 записей. Если тормозит, загляни в EXPLAIN ANALYZE
  • Прогони go vet ./... и тесты перед закрытием вехи

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