Shanraq.org Shanraq.org
SQLite и database/sql: первая строка из базы, а не из среза
IT

Go: с нуля до своего блога Урок 29 из 50

SQLite и database/sql: первая строка из базы, а не из среза

Двадцать девятый урок курса по Go. Блог наконец читает статьи из файла базы. Пакет и драйвер, почему sql.Open ничего не открывает, что такое пул соединений и почему прагму про внешние ключи пишут в строку подключения, а не отдельным запросом. Плюс ErrNoRows и проверка ошибки после цикла.

Зачем это нужно

В прошлом уроке вы научились разговаривать с базой руками. Сегодня то же самое начнёт делать программа.

Здесь есть три места, где спотыкаются почти все, и ни одно из них не про SQL. Программа скажет «подключился», не подключившись. Настройка, которую вы включили, окажется включённой не везде. И ошибка, которая случилась в середине чтения, тихо не покажется.

Сразу целиком

Новая папка, go mod init sabaq26. Драйвер ставится одной командой:

go get modernc.org/sqlite

main.go:

package main

import (
	"database/sql"
	"errors"
	"fmt"
	"log"
	"os"

	_ "modernc.org/sqlite"
)

type Article struct {
	ID    int64
	Slug  string
	Title string
	Words int
}

// open открывает базу и убеждается, что она действительно отвечает.
func open(path string) (*sql.DB, error) {
	// Прагма стоит в строке подключения, а не отдельным запросом: соединений
	// у пула несколько, и запрос настроит только одно из них.
	db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
	if err != nil {
		return nil, fmt.Errorf("открыть %s: %w", path, err)
	}
	if err := db.Ping(); err != nil {
		db.Close()
		return nil, fmt.Errorf("база не отвечает: %w", err)
	}
	return db, nil
}

func schema(db *sql.DB) error {
	_, err := db.Exec(`create table if not exists articles (
		id        integer primary key,
		slug      text    not null unique,
		title     text    not null,
		words     integer not null default 0,
		published integer not null default 0
	) strict`)
	return err
}

// add вставляет статью и возвращает номер, который присвоила база.
func add(db *sql.DB, a Article) (int64, error) {
	res, err := db.Exec(
		`insert into articles (slug, title, words, published) values (?, ?, ?, 1)`,
		a.Slug, a.Title, a.Words)
	if err != nil {
		return 0, fmt.Errorf("добавить %q: %w", a.Slug, err)
	}
	return res.LastInsertId()
}

var ErrNotFound = errors.New("статья не найдена")

// one достаёт ровно одну строку.
func one(db *sql.DB, slug string) (Article, error) {
	var a Article
	err := db.QueryRow(
		`select id, slug, title, words from articles where slug = ?`, slug).
		Scan(&a.ID, &a.Slug, &a.Title, &a.Words)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		return Article{}, fmt.Errorf("%q: %w", slug, ErrNotFound)
	case err != nil:
		return Article{}, fmt.Errorf("прочитать %q: %w", slug, err)
	}
	return a, nil
}

// all достаёт много строк — и проверяет ошибку после цикла, а не только в нём.
func all(db *sql.DB) ([]Article, error) {
	rows, err := db.Query(
		`select id, slug, title, words from articles where published = 1 order by words desc`)
	if err != nil {
		return nil, fmt.Errorf("список: %w", err)
	}
	defer rows.Close()

	var out []Article
	for rows.Next() {
		var a Article
		if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &a.Words); err != nil {
			return nil, fmt.Errorf("разобрать строку: %w", err)
		}
		out = append(out, a)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("обход строк: %w", err)
	}
	return out, nil
}

func main() {
	db, err := open("blog.db")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	defer db.Close()

	if err := schema(db); err != nil {
		log.Fatal(err)
	}

	for _, a := range []Article{
		{Slug: "dala", Title: "О степи", Words: 400},
		{Slug: "shanyraq", Title: "Что такое шанырак", Words: 1000},
		{Slug: "salem", Title: "Привет, мир", Words: 150},
	} {
		id, err := add(db, a)
		if err != nil {
			log.Println(err)
			continue
		}
		fmt.Printf("добавлена %s под номером %d\n", a.Slug, id)
	}

	a, err := one(db, "dala")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("нашли: %d %q %d слов\n", a.ID, a.Title, a.Words)

	if _, err := one(db, "kokek"); errors.Is(err, ErrNotFound) {
		fmt.Println("а такой нет:", err)
	}

	list, err := all(db)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("опубликовано:", len(list))
	for i, a := range list {
		fmt.Printf("  %d. %s — %d слов\n", i+1, a.Title, a.Words)
	}
}

go run .:

добавлена dala под номером 1
добавлена shanyraq под номером 2
добавлена salem под номером 3
нашли: 1 "О степи" 400 слов
опубликовано: 3
  1. Что такое шанырак — 1000 слов
  2. О степи — 400 слов
  3. Привет, мир — 150 слов

Разбор

Два предмета: пакет и драйвер

database/sql — это стандартный пакет, и он не умеет разговаривать ни с одной базой. Он описывает, как выглядит работа с базой вообще: открыть, спросить, прочитать строку. Кто именно будет читать файл или говорить по сети — его не касается.

Этим занимается драйвер. Мы взяли modernc.org/sqlite — это SQLite, переписанный на Go целиком, поэтому для сборки не нужен компилятор C. Есть и другой популярный драйвер, mattn/go-sqlite3; он быстрее, но тянет за собой C, а вместе с ним — отдельную возню под Windows.

Драйвер подключается странно на вид:

_ "modernc.org/sqlite"

Подчёркивание вместо имени означает «мне не нужны его функции, мне нужно, чтобы пакет просто загрузился». При загрузке драйвер сам себя регистрирует под именем, а дальше это имя вы называете в sql.Open.

Имя — частая мелкая беда. У этого драйвера оно sqlite, а у mattnsqlite3, и большинство примеров в интернете написаны про второй:

sql.Open("sqlite3", "blog.db")
sql: unknown driver "sqlite3" (forgotten import?)

Go честно подсказывает, куда смотреть: имя не зарегистрировано, вероятно, забыт импорт.

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

Вот первая ловушка. Попробуйте открыть базу по заведомо несуществующему пути:

db, err := sql.Open("sqlite", "/нет/такой/папки/blog.db")
ошибка от sql.Open:   <nil>
ошибка от db.Ping():  unable to open database file (14)

sql.Open вернул nil — то есть «всё хорошо». Он не ходил к базе вовсе: он проверил, что драйвер с таким именем есть, и подготовил пул. Первое настоящее обращение случится только тогда, когда вы что-нибудь спросите.

db.Ping() — это и есть «спросить ничего»: соединение открывается, и, если открыть не удаётся, вы узнаёте об этом сразу, а не на первом запросе читателя. Поэтому в open стоят обе строки, а не одна.

Образ. Записать номер телефона — не то же самое, что позвонить. Пока не позвонили, вы не знаете, существует ли этот номер.

database/sql — это пул, а не соединение

Вторая ловушка, и она серьёзнее.

*sql.DB — не одно соединение с базой. Это пул: набор соединений, который открывается по мере надобности и переиспользуется. Когда вы пишете db.Query(...), пул выдаёт какое-нибудь свободное соединение, а после запроса забирает обратно. Какое именно — вы не знаете.

Обычно это незаметно и приятно. Но не в тот момент, когда вы настраиваете соединение.

Прагма живёт в строке подключения

Помните pragma foreign_keys = on из прошлого урока? Прагма настраивает одно соединение, а не базу целиком. И вот что происходит, если отправить её обычным запросом.

Я открыл базу тремя способами и в каждом посмотрел на четыре одновременных соединения:

как есть                    0 0 0 0
db.Exec("pragma …")         1 0 0 0
прагма в строке подключения 1 1 1 1

Второй способ — db.Exec("pragma foreign_keys = on") — включил проверку на одном соединении из четырёх. Именно на том, которое пул выдал под этот запрос. Три остальных ничего об этом не знают, и когда запрос на удаление попадёт на одно из них, внешние ключи не сработают.

Ошибки при этом не будет. Будут осиротевшие строки.

Правильный способ — сказать это в строке подключения, чтобы настройка применялась к каждому новому соединению:

sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")

Так и написано в open. Запомните форму: всё, что настраивает соединение, — в строку подключения.

Одна строка: QueryRow, Scan и ErrNoRows

err := db.QueryRow(`select id, slug, title, words from articles where slug = ?`, slug).
	Scan(&a.ID, &a.Slug, &a.Title, &a.Words)

QueryRow берёт ровно одну строку. Scan раскладывает её колонки по переменным — по порядку, и адреса передаются через &, потому что Scan в них пишет.

Ошибку возвращает не QueryRow, а Scan — это выглядит непривычно и сделано ради цепочки в одну строку.

Отдельно стоит «строк нет». Это не поломка: читатель просто попросил статью, которой нет.

case errors.Is(err, sql.ErrNoRows):
	return Article{}, fmt.Errorf("%q: %w", slug, ErrNotFound)

sql.ErrNoRows мы переводим в свою ошибку, и вот зачем: тот, кто вызывает one, не должен знать, что внутри база. Сегодня SQLite, завтра Postgres, послезавтра файл — а ErrNotFound останется тем же. Это errors.Is из урока про ошибки и 404 из урока про журнал, соединённые вместе.

а такой нет: "kokek": статья не найдена

Много строк: Query, Next и ошибка после цикла

rows, err := db.Query(...)
if err != nil { ... }
defer rows.Close()

for rows.Next() {
	var a Article
	if err := rows.Scan(...); err != nil { ... }
	out = append(out, a)
}
if err := rows.Err(); err != nil { ... }

Четыре обязательные вещи, и третью с четвёртой забывают чаще всего.

defer rows.Close() — соединение занято, пока строки не закрыты. Тут есть тонкость, которую стоит знать точно: если цикл дочитал строки до конца, database/sql закрывает их сам, и всё обойдётся. Беда начинается там, где из цикла выходят раньше — по break, по return, по ошибке. Проверил: с SetMaxOpenConns(1) после break без Close следующий запрос не выполняется вовсе и умирает по таймауту context deadline exceeded. Поэтому defer ставят сразу, а не «когда понадобится».

rows.Err() после цикла — вот это неочевидно. rows.Next() возвращает false и когда строки кончились, и когда чтение сломалось на середине. Без rows.Err() эти два случая неразличимы: вы получите половину списка и никакой ошибки.

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

db.Exec(`insert into articles (slug, title, words, published) values (?, ?, ?, 1)`,
	a.Slug, a.Title, a.Words)

Значения передаются отдельно от текста запроса. Никогда не собирайте запрос сложением строк — даже когда «тут же точно число».

Причина в том, что склеенный запрос стирает границу между командой и данными: текст, который прислал читатель, становится частью команды. Это называется SQL-инъекцией, и ей посвящён отдельный урок; сегодня достаточно привычки ставить ?.

Заодно ? избавляет от возни с кавычками и апострофами: имя О'Хара через сложение строк сломает запрос, а через ? пройдёт как есть.

И раз уж база теперь следит за нами — запустите программу второй раз:

2026/09/06 11:28:34 добавить "dala": constraint failed: UNIQUE constraint failed: articles.slug (2067)
2026/09/06 11:28:34 добавить "shanyraq": constraint failed: UNIQUE constraint failed: articles.slug (2067)
2026/09/06 11:28:34 добавить "salem": constraint failed: UNIQUE constraint failed: articles.slug (2067)
нашли: 1 "О степи" 400 слов

unique из прошлого урока сработал: второй раз тот же адрес база не приняла. Проверять это в коде не нужно — достаточно прочитать ошибку.

Карта урока

Карта урока: database/sql даёт пул соединений, а не одно соединение

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему sql.Open не возвращает ошибку при несуществующем пути к базе?
  2. Что не так с db.Exec("pragma foreign_keys = on")?
  3. Зачем нужен rows.Err(), если ошибку уже проверили у rows.Scan()?

Задание

Обязательное. Замените в блоге срез статей на базу. Функции All и Get из пакета blog должны читать из SQLite, а форма — писать. Ошибку «такой статьи нет» верните своей ErrNotFound, а обработчик пусть по-прежнему отвечает 404.

По желанию.

  • Уберите ?_pragma=foreign_keys(1) и проверьте, останутся ли комментарии после удаления статьи.
  • Уберите defer rows.Close(), поставьте db.SetMaxOpenConns(1) и выйдите из цикла после первой строки через break. Следующий запрос сделайте через QueryRowContext с context.WithTimeout на две секунды — и прочитайте, какую ошибку он вернёт. Затем верните чтение до конца и убедитесь, что всё работает даже без Close.
  • Соберите запрос сложением строк и передайте в качестве адреса ' or 1=1 --. Посмотрите, что вернётся.

Куда это встанет в блоге

Срез уходит, база остаётся. Дальше — четыре действия над статьёй: создать, прочитать, изменить, удалить, и всё это через формы, которые уже есть.

Долги. sql.DB мы создаём и передаём руками; когда появится третья функция, потребуется тип-хранилище. Схему пока создаёт сама программа при запуске — так нельзя, когда таблиц станет десять и они начнут меняться; это урок про миграции. И SetMaxOpenConns мы не трогали, а для SQLite это имеет значение: писать в неё может один за раз.

Ответы

Показать ответы
  1. Потому что sql.Open к базе не обращается. Он проверяет, что драйвер с таким именем зарегистрирован, и готовит пул соединений — а первое соединение открывается при первом настоящем запросе. Чтобы проверить сразу, вызывают db.Ping().
  2. Прагма настраивает то соединение, на котором выполнилась, а *sql.DB — это пул из нескольких соединений. Запрос попадёт на одно из них, остальные останутся ненастроенными, и внешние ключи будут проверяться через раз, без всякой ошибки. Поэтому настройку соединения пишут в строку подключения.
  3. Потому что rows.Next() возвращает false в двух разных случаях: строки кончились или чтение сломалось. rows.Scan() об этом не расскажет — он вообще не будет вызван. Без rows.Err() вы получите неполный список и решите, что всё в порядке.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.