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 алдық — бұл толығымен Go тілінде жүзеге асырылған SQLite, сондықтан жинауға 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 сұранысы пәрмен мен дерек арасындағы шекараны өшіреді: оқырман жіберген мәтін пәрменнің бөлігіне айналады. Мұны 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.Scan()-да тексерілген болса, rows.Err() не үшін керек?

Тапсырма

Міндетті. Блогтағы мақалалар тілімін дерекқорға ауыстырыңыз. blog пакетіндегі All мен Get SQLite-тан оқуы керек, ал форма — жазуы. «Ондай мақала жоқ» қатесін өз ErrNotFound-ыңызбен қайтарыңыз, ал өңдеуші бұрынғысынша 404 жауап берсін.

Қалауыңызша.

  • ?_pragma=foreign_keys(1) дегенді алып тастап, мақаланы өшіргеннен кейін пікірлер қала ма, тексеріңіз.
  • defer rows.Close() дегенді алып тастаңыз, db.SetMaxOpenConns(1) қойыңыз да, циклден бірінші жолдан кейін break арқылы шығыңыз. Келесі сұранысты екі секундтық context.WithTimeout-пен QueryRowContext арқылы жасап, қандай қате қайтатынын оқыңыз. Содан кейін соңына дейін оқуды қайтарып, 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)

Әзірге пікір жоқ. Бірінші болыңыз.