Shanraq.org Shanraq.org
`context`: сұранысты уақытында тоқтату
IT

Go тілі: нөлден өз блогыңызға дейін 43-сабақ (барлығы 50)

`context`: сұранысты уақытында тоқтату

Go курсының қырық үшінші сабағы. Оқырман қойындыны жапты — өңдеуші мұны өзі біледі: екі жүз миллисекундтан кейін жұмысты тастайтыны өлшенді. Дерекқорға берілген мерзім, жолдың орнына өз типіндегі кілт және кірген адамның сұраныс контекстіне уәде етілген көшуі.

Не үшін керек

Өткен сабақтардағы өңдеуші оқырман күтіп тұр деп есептейді. Оқырман — әрқашан емес.

Ол қойындыны жабады, лифтіде байланысын жоғалтады, жүктеудің ортасында «жаңарту» басады. Сервер мұны сезбейді: ол дерекқорға барып, санап, енді ешкімге керек емес жауапты құрастыра береді. Осындай он оқырманда бұл байқалмайды, он мыңда — бұл сервер жұмысының жартысы бос кетеді.

Кері бақытсыздық та бар: дерекқорға қатып қалған сұраныс. Мерзімсіз ол пулдан бір қосылымды ұстайды, сосын екіншісін, сосын бәрін — әрі блог бір баяу сұраныстың кесірінен бәріне жауап беруін қояды.

Екі бақытсыздықты да бір нәрсе шешеді — контекст.

Бірден тұтас

Жаңа қалта, go mod init sabaq40:

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"time"

	_ "modernc.org/sqlite"
)

func main() {
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	fmt.Println("== оқырман қойындыны жапты")
	cancelled()

	fmt.Println()
	fmt.Println("== жарты секунд берілген сұраныс")
	fmt.Println("жылдам:   ", count(db, 100*1000, 500*time.Millisecond))
	fmt.Println("баяу:     ", count(db, 100*1000*1000, 500*time.Millisecond))
}

// cancelled баяу өңдеушіні көтеріп, сұранысты ортасынан үзеді.
func cancelled() {
	done := make(chan string, 1)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		select {
		case <-time.After(2 * time.Second):
			done <- "аяғына дейін істеді"
		case <-r.Context().Done():
			// Сұраныс контексті оқырман кеткенде өзі жабылады.
			done <- fmt.Sprintf("%s ішінде жұмысты тастады: %v",
				time.Since(start).Round(10*time.Millisecond), r.Context().Err())
		}
	}))
	defer srv.Close()

	// Екі жүз миллисекундтан кейін бас тартатын клиент.
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
	if _, err := http.DefaultClient.Do(req); err != nil {
		fmt.Println("оқырман:   кетті —", context.Cause(ctx))
	}
	fmt.Println("өңдеуші:  ", <-done)
}

// count дерекқорда әдейі ұзақ санайды: n қадамдық рекурсивті сұраныс.
func count(db *sql.DB, n int, limit time.Duration) string {
	ctx, cancel := context.WithTimeout(context.Background(), limit)
	defer cancel()

	start := time.Now()
	var got int
	err := db.QueryRowContext(ctx, `with recursive nums(i) as (
		select 1 union all select i + 1 from nums where i < ?
	) select count(*) from nums`, n).Scan(&got)

	switch {
	case errors.Is(err, context.DeadlineExceeded):
		return fmt.Sprintf("%s ішінде үзілді: %v", time.Since(start).Round(10*time.Millisecond), err)
	case err != nil:
		return "қате: " + err.Error()
	}
	return fmt.Sprintf("%d санадық, %s ішінде", got, time.Since(start).Round(10*time.Millisecond))
}

Шығатыны:

== оқырман қойындыны жапты
оқырман:   кетті — context deadline exceeded
өңдеуші:   200ms ішінде жұмысты тастады: context canceled

== жарты секунд берілген сұраныс
жылдам:    100000 санадық, 60ms ішінде
баяу:      500ms ішінде үзілді: context deadline exceeded

Талдау

Сұраныс контексті өзі жабылады

Әр сұраныстың өз контексті бар: r.Context(). Оны ешкім қолмен жасамайды — оны http.Server жасайды, әрі оқырманмен байланыс үзілгенде ол өзі жабылады.

Өңдеушіге оны тыңдау ғана қалады:

select {
case <-time.After(2 * time.Second):
	// жұмысты бітірдік
case <-r.Context().Done():
	// оқырман кетті
}

Done() — тоқтату сәтінде жабылатын арна. Шығуда көрініп тұр: оқырман екі жүз миллисекундта бас тартты, әрі дәл екі жүз миллисекундта өңдеуші екі секундтық жұмысты тастады.

Err() себебін айтады: context canceled — тоқтатты; context deadline exceeded — мерзім бітті.

Елестетіп көріңіз. Қонақтың кеткенін айтқан даяшы. Ол ыстық тағамды бос үстелге апармайды әрі оның суығанын күтпейді — жарты жолдан кері бұрылады.

Жұмысқа мерзім: WithTimeout

Шығудың екінші жартысы басқа туралы: оқырман кеткен жоқ, жұмыс созылды.

ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()

WithTimeout берілген уақыттан кейін өзі тоқтайтын жаңа контекст жасайды. Жылдам сұраныс алпыс миллисекундта үлгерді, баяуы дәл бес жүзде үзілді.

defer cancel() міндетті, әрі әдемілік үшін емес: WithTimeout таймер ашады, ал cancel оны босатады. Ұмытсаңыз — таймер мерзімге дейін өмір сүреді, әрі жүктелген серверде мұндай ұмытылған таймерлер жиналады. Go мұны кодты тексергенде ескертеді, әрі дұрыс істейді.

Ата-анасы қайдан алынғанына назар аударыңыз: context.Background() — «бос» контекст, тізбектің түбірі. Өңдеушіде ата-ана r.Context() болуға тиіс, Background емес: сонда оқырманның тоқтатуы дерекқорға кеткен сұранысты да үзеді.

database/sql-да ...Context неге бар

Енді дерекқордағы егіз әдістердің не үшін екені түсінікті: QueryContext, QueryRowContext, ExecContext.

Кәдімгі QueryRow тоқтату туралы ештеңе білмейді: ол дерекқорды қанша қаласа, сонша күтеді. Контексті нұсқа драйверге мерзім береді, әрі ол біткенде сұраныс дерекқор жағында үзіледі — қосылым пулға қайтады, ілініп тұрмайды.

Ереже қарапайым: өңдеушіде әрқашан ...Context, әрі әрқашан r.Context()-пен. Кәдімгі нұсқалар тоқтататын ешкім жоқ скрипттер мен көші-қондарға қалады.

Контекстті бірінші аргументпен береді

func (s *Store) Get(ctx context.Context, slug string) — күте алатын кез келген функция осылай көрінеді.

Контекстті бірінші параметр етіп қояды әрі ctx деп атайды. Бұл стиль емес, бүкіл экожүйенің келісімі: стандартты кітапхана солай жазылған, барлық драйвер солай жазылған, әрі сіз де солай істегенде кодыңыз түсіндірусіз оқылады.

Не істемейді: контекстті құрылымның өрісі етіп қоймайды. Контекст бір шақыру қанша өмір сүрсе, сонша өмір сүреді; құрылым одан ұзақ жасайды, әрі оның ішіне сақталған контекст бір күні тоқтатылған немесе, керісінше, мәңгі болып шығады.

Контекстегі мән және өз типіндегі кілт

Контекст мән таси алады — әрі мұнда жаман істеу оңай:

package main

import (
	"context"
	"fmt"
)

// Кілтке өз типі — қыңырлық емес. Контекстегі мәнді кілт бойынша іздейді, ал
// кілт типімен бірге салыстырылады: userKey(0) мен сол нөлі бар басқа тип —
	// әртүрлі кілт, ал әртүрлі
// пакеттегі екі "user" жолы — бір кілт.
type userKey struct{}

type otherKey struct{}

func main() {
	ctx := context.Background()

	fmt.Println("== жолдық кілттер")
	ctx = context.WithValue(ctx, "user", "айгүл")     //lint:ignore SA1029 нарочно
	ctx = context.WithValue(ctx, "user", "кітапхана") //lint:ignore SA1029 нарочно
	fmt.Println("не қалды:", ctx.Value("user"))

	fmt.Println("== өз типтері")
	ctx = context.WithValue(ctx, userKey{}, "айгүл")
	ctx = context.WithValue(ctx, otherKey{}, "кітапхана")
	fmt.Println("біздікі:  ", ctx.Value(userKey{}))
	fmt.Println("бөтендікі:", ctx.Value(otherKey{}))
}
== жолдық кілттер
не қалды: кітапхана
== өз типтері
біздікі:   айгүл
бөтендікі: кітапхана

Алғашқы екі жол: "user" жолдық кілтімен екі WithValue — әрі біздің мән жоғалды, бөтені қалды. Өмірде де солай болады: сіздің "user" кілтіңіз бен бөтен кітапханадағы "user"бір кілт, әрі кім соңғы жазса, сол дұрыс.

Екінші жұп: өз типтеріндегі кілттер. Кілт типімен бірге салыстырылады, ал сіздің пакетте жарияланған типті сырттан қайталау мүмкін емес. Екі мән де орнында.

Сондықтан кілтті әрқашан өз типімен жариялайды, ал мәнге қатынауды екі шағын функцияның артына жасырады — withUser пен userFrom, — сыртта ешкім кілтті де, ол жерде мүлде контекст бар екенін де білмесін деп.

Кірген адам контекстке көшеді

Сессиялар мен құқықтар туралы сабақтарда мен қарыз қалдырғанмын: кірген адамды әр өңдеуші қайта іздейтін. Міне оның орны.

func withUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if u, ok := currentUser(r); ok {
			r = r.WithContext(context.WithValue(r.Context(), userKey{}, u))
		}
		next.ServeHTTP(w, r)
	})
}

Орама адамды бір рет іздеп, тапқанын сұраныс контекстіне салады. Ары қарай кез келген өңдеуші оны userFrom(r.Context()) функциясымен алады — дерекқорға бармай, кодты қайталамай.

r.WithContext жаңа контексті бар сұраныстың көшірмесін қайтарады: сұраныстың өзі өзгермейді. Осыдан r = r.WithContext(...) — меншіктеуді ұмытсаңыз, орама ештеңе істемейді, тілім туралы сабақтағы append сияқты.

Контекстке нені салмайды

Контекст — сұранысқа тұтас қатысты нәрсе үшін: тоқтату, мерзім, кірген адам, журналға арналған сұраныс нөмірі, беттің тілі.

Функцияның параметрлері үшін емес. Функцияға slug керек болса, оны контекстке жасырмай, аргументпен береді: аргумент қолтаңбада көрінеді, ал контекстегі мән көрінбейді, әрі оның жоқтығы жұмыс істеп тұрған серверде ғана байқалады.

Әрі сұранысқа емес, бағдарламаның өзіне керек нәрсе үшін де емес: қойма, баптау, логгер. Оларды анық береді — біз app құрылымы арқылы істегендей.

Сабақ картасы

Сабақ картасы: тізбек бойымен жоғарыдан төмен тоқтату

Өз сөзіңізбен айтыңыз

Қарамай, дауыстап немесе қағазға жауап беріңіз. Жауаптары — сабақтың соңында.

  1. r.Context()-ті кім әрі қашан жабады?
  2. Контекстегі кілтке неге жол алмайды?
  3. Жұмыс бітіп үлгерген жерде де defer cancel() неге міндетті?

Тапсырма

Міндетті. Блогты контекстке көшіріңіз: қойма әдістері ctx-ті бірінші аргументпен қабылдап, ...Context шақырады, өңдеушілер r.Context() береді, ал кірген адамды орама тауып, өз типіндегі кілтпен контекстке салады.

Мұның бәрі step-28 ішінде жасалған — өзіңіз жазғаннан кейін салыстырыңыз.

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

  • Дерекқорға кететін барлық сұранысқа http.TimeoutHandler немесе өз орамаңыз арқылы үш секунд мерзім беріп, баяу сұраныстың үзілетінін тексеріңіз.
  • Журналға себебін қосыңыз: context.Cause(ctx) Err()-тен дәлірек айтады.
  • defer cancel()-ды алып тастап, go vet не дейтініне қараңыз.

Блогта бұл қайда тұрады

Блог бос жұмыс істеуін қояды: оқырман кетті — біз де тоқтадық. Ал кірген адамды әр өңдеуші енді іздемейді.

Қарыздар. Дерекқорға кететін сұраныстарда мысалдан басқа жерде мерзім жоқ. Өткен сабақтағы фондық жұмыс контекст бойынша тоқтай білмейді. Әрі аяқталмаған сұраныстар өз секундтарын жасап үлгеретін серверді жайлап тоқтату да әзірге жоқ — бұл деплой туралы сабақ.

Жауаптар

Жауаптарды көрсету
  1. Оны http.Server жабады, оқырманмен байланыс үзілгенде — қойынды жабылды, байланыс кетті, сұраныс тоқтатылды. Оны қолмен ешкім жасамайды әрі жаппайды.
  2. Себебі кілт мәні мен типі бойынша салыстырылады, ал сіздің пакеттегі "user" жолы мен бөтен кітапханадағы сондай жол — бір кілт. Мәндер бірін-бірі үнсіз басып тастайды. Өз типін сырттан қайталау мүмкін емес.
  3. Себебі WithTimeout таймер ашады, ал cancel оны босатады. Онсыз таймер мерзімнің соңына дейін жасайды, ал жүктелген серверде мұндай таймерлер жиналады.

Дереккөздер

Мәтінде қате не теру қатесі кездессе, бізге айтыңыз

Тапсырманы тексеру

Алдымен VS Code-та шешіп, іске қосыңыз — редактор қатені сол жерде көрсетеді. Дайын шешімді осында қойыңыз. Тексеретін — модель: ол қатені атап көрсетеді, бірақ дайын жауапты бермейді.

Тексеру үшін кіру керек. Кіру

Пікірлер (0)

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