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, а у mattn — sqlite3, и большинство примеров в интернете написаны про второй:
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 из прошлого урока сработал: второй раз тот же адрес база не приняла. Проверять это в коде не нужно — достаточно прочитать ошибку.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему
sql.Openне возвращает ошибку при несуществующем пути к базе? - Что не так с
db.Exec("pragma foreign_keys = on")? - Зачем нужен
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 это имеет значение: писать в неё может один за раз.
Ответы
Показать ответы
- Потому что
sql.Openк базе не обращается. Он проверяет, что драйвер с таким именем зарегистрирован, и готовит пул соединений — а первое соединение открывается при первом настоящем запросе. Чтобы проверить сразу, вызываютdb.Ping(). - Прагма настраивает то соединение, на котором выполнилась, а
*sql.DB— это пул из нескольких соединений. Запрос попадёт на одно из них, остальные останутся ненастроенными, и внешние ключи будут проверяться через раз, без всякой ошибки. Поэтому настройку соединения пишут в строку подключения. - Потому что
rows.Next()возвращаетfalseв двух разных случаях: строки кончились или чтение сломалось.rows.Scan()об этом не расскажет — он вообще не будет вызван. Безrows.Err()вы получите неполный список и решите, что всё в порядке.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.