JavaRush /Курсы /Go SELF /Прокидывание контекста: HTTP → app → storage; CLI → impor...

Прокидывание контекста: HTTP → app → storage; CLI → import → storage

Go SELF
70 уровень , 3 лекция
Открыта

1. Зачем прокидывать ctx по слоям

Если вы уже писали HTTP-обработчики или команды CLI, то наверняка ловили ощущение «ну я же вернул ответ/напечатал результат — какая разница». Разница появляется в момент, когда пользователь ушёл, соединение оборвалось, таймаут истёк или операция стала слишком долгой. Без прокидывания контекста нижние слои продолжают работать «в пустоту»: читают файл, считают, ходят в хранилище, держат блокировки. Это как продолжать готовить пиццу после того, как клиент отменил заказ — вкусно, но бизнес-модель страдает.

В Go контекст задуман как стандартный способ протащить через границы API три вещи: отмену, дедлайн и метаданные операции. Это особенно важно в серверном коде, где один входящий запрос часто запускает ещё несколько подопераций.

Ментальная модель: одна операция → один ctx, который идёт вниз

Когда мы говорим «прокидывать контекст», это не магия и не ритуал. Это прямой договор: у нас есть верхняя граница операции (HTTP-запрос или команда CLI), и именно там мы получаем/создаём корневой ctx. Дальше этот же ctx (или его дочерний контекст) передаётся вниз по стеку вызовов: handler → app/service → storage. И ни один слой по дороге не «сбрасывает» его на context.Background().

Если представить систему как эстафету, то ctx — это палочка. Уронили палочку — команда дальше бежит, но результат уже не считается, потому что отмена и дедлайн потерялись. А ещё потеряются метаданные вроде request_id, и логи превратятся в квест «угадай, какой запрос это был».

2. Пример: Tasker и контракты слоёв

Чтобы примеры не были набором абстрактных кусочков, будем считать, что у нас есть учебное приложение Tasker: оно хранит задачи и умеет их импортировать (например, из CSV). Мы не уходим в сложную архитектуру, но придерживаемся понятного деления:

  • HTTP слой: принимает запрос, ставит таймаут, вызывает app-логику.
  • App слой: бизнес-операции (создать задачу, импортировать задачи).
  • Storage слой: хранение (для примера — in-memory).

Схема (упрощённо):

flowchart TD
  HTTP[HTTP handler<br/>r.Context + WithTimeout] --> APP[app.Service]
  APP --> ST[storage.TaskStorage]
  CLI[CLI command<br/>Background + WithTimeout] --> APP
  APP --> ST

Главная идея: и HTTP, и CLI ведут в один и тот же app-слой, а контекст идёт первым параметром везде.

Контракты слоёв: ctx — первый параметр

Сейчас будет важный момент, который выглядит как «придирка к стилю», но потом экономит часы. Мы делаем так, чтобы любая операция, которая может быть долгой или отменяемой, принимала ctx первым параметром. Это визуальный маркер: «эта функция уважает контекст».

Storage интерфейс

Начнём снизу: storage умеет создать задачу и вернуть список.

package storage

import "context"

type Task struct {
	ID    int
	Title string
	Done  bool
}

type TaskStorage interface {
	Create(ctx context.Context, t Task) (Task, error)
	List(ctx context.Context) ([]Task, error)
}

Обратите внимание: мы не храним ctx внутри TaskStorage. Контекст относится к вызову Create/List, а не к «хранилищу как объекту». Это ровно тот принцип, который рекомендуют придерживать в Go API: контекст передаётся аргументом, а не сохраняется в поле.

App/service слой

App слой «говорит человеческими словами»: AddTask, ImportCSV.

package app

import (
	"context"
	"tasker/internal/storage"
)

type Service struct {
	st storage.TaskStorage
}

func NewService(st storage.TaskStorage) *Service {
	return &Service{st: st}
}

И метод (коротко, без деталей валидации):

package app

import (
	"context"
	"tasker/internal/storage"
)

func (s *Service) AddTask(ctx context.Context, title string) (storage.Task, error) {
	t := storage.Task{Title: title}
	return s.st.Create(ctx, t)
}

Важная мысль: app слой не придумывает новый контекст, он уважает входной и передаёт его дальше.

3. HTTP → app → storage: как не потерять ctx

HTTP — это идеальный пример «границы операции»: запрос начался, запрос закончился. У Go HTTP сервера уже есть готовый контекст запроса: r.Context(). Он отменяется, когда клиент уходит или когда handler завершился (в зависимости от ситуации). Поэтому стартовая точка у нас есть из коробки.

Handler: берём r.Context() и добавляем таймаут

Частая практика — ограничить время обработки запроса. Не внутри storage, не «где-то глубоко», а прямо в handler: это место, где вы понимаете ожидания клиента.

package httpapi

import (
	"context"
	"net/http"
	"time"
)

func withRequestTimeout(r *http.Request, d time.Duration) (context.Context, func()) {
	ctx, cancel := context.WithTimeout(r.Context(), d)
	return ctx, cancel
}

Теперь применим в handler:

package httpapi

import (
	"net/http"
	"time"
)

func (h *Handler) handleAddTask(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := withRequestTimeout(r, 2*time.Second)
	defer cancel()

	_, _ = h.svc.AddTask(ctx, "buy milk")
	w.WriteHeader(http.StatusCreated)
}

Да, пример нарочно упрощён (мы не читаем JSON/форму), потому что сейчас нас интересует маршрут контекста. Таймаут задан на границе, cancel() вызван дисциплинированно.

Почему defer cancel() обязателен даже при таймауте? Потому что WithTimeout создаёт внутренний таймер/ресурсы, и ранняя отмена освобождает их быстрее. Это часть нормальной гигиены.

App слой ничего не «сбрасывает»

Правильный app слой выглядит скучно. И это комплимент.

package app

import (
	"context"
	"tasker/internal/storage"
)

func (s *Service) ListTasks(ctx context.Context) ([]storage.Task, error) {
	return s.st.List(ctx)
}

Самая опасная «оптимизация» новичка — написать так:

// ПЛОХО: мы потеряли отмену/таймаут/метаданные.
return s.st.List(context.Background())

Это ломает всю идею: handler мог быть отменён, но storage этого уже не узнает.

Storage: хотя бы минимально проверяем ctx.Done()

В in-memory хранилище операция обычно быстрая, но привычку формируем здесь же. Пусть List проверяет отмену перед работой (минимум) — так код ведёт себя предсказуемо, даже если позже вы замените storage на файловый или сетевой.

package memstore

import (
	"context"
	"sync"
	"tasker/internal/storage"
)

type Store struct {
	mu    sync.Mutex
	tasks []storage.Task
	next  int
}

func (s *Store) List(ctx context.Context) ([]storage.Task, error) {
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	out := append([]storage.Task(nil), s.tasks...)
	return out, nil
}

Здесь мы сделали два важных шага: проверили отмену и скопировали слайс (чтобы не отдавать наружу «живую» внутреннюю память). В контексте этой лекции ключевое — ctx.Err() как корректная причина завершения.

4. CLI → import → storage: Background() на границе

CLI отличается от HTTP одной важной деталью: у вас нет готового r.Context(). Зато у вас есть «граница операции» в виде команды. Это значит: корневой контекст вы создаёте сами, обычно через context.Background().

В реальном приложении часто добавляют отмену по сигналам ОС, но сегодня нам достаточно понять маршрут: CLI создаёт ctx и прокидывает его в app и storage.

Точка входа CLI: создаём ctx и ставим таймаут команды

Представим команду tasker import --file tasks.csv. Мы хотим, чтобы импорт не висел бесконечно.

package main

import (
	"context"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	_ = ctx
	// дальше: открыть файл и вызвать svc.ImportCSV(ctx, f)
}

Здесь опять таймаут задан на границе операции. Это тот же принцип, что и в HTTP.

App метод импорта принимает ctx и io.Reader

Импорт — отличный пример «слоёв»: CLI работает с файлом, но app слой не обязан знать про os.File. Ему достаточно io.Reader. И — контекст.

package app

import (
	"context"
	"encoding/csv"
	"fmt"
	"io"
)

func (s *Service) ImportCSV(ctx context.Context, r io.Reader) (int, error) {
	cr := csv.NewReader(r)

	n := 0
	for {
		select {
		case <-ctx.Done():
			return n, ctx.Err()
		default:
		}

		rec, err := cr.Read()
		if err == io.EOF {
			return n, nil
		}
		if err != nil {
			return n, fmt.Errorf("read csv: %w", err)
		}

		if len(rec) == 0 {
			continue
		}
		if _, err := s.AddTask(ctx, rec[0]); err != nil {
			return n, err
		}
		n++
	}
}

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

CLI слой: открыл файл, передал ctx вниз

Теперь свяжем это с os.Open. CLI слой занимается файловыми деталями, app слой — импортом как бизнес-операцией.

package main

import (
	"context"
	"os"
	"time"
)

func runImport(path string, svc *Service) error {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()

	_, err = svc.ImportCSV(ctx, f)
	return err
}

Обратите внимание на «культурный слой» кода: ctx появился на верхнем уровне функции, и дальше он идёт вниз, как чемодан без ручки, который всё равно несём, потому что это наш чемодан.

5. Таймауты и жизненный цикл контекста

Где задавать таймауты: на границе или внутри

Вопрос «где ставить таймаут» кажется философским, пока вы не поймали баг: запрос умер, а ваш storage продолжает работать. Общая практика такая: таймаут задаётся там, где понятен смысл операции для пользователя.

Ниже небольшая таблица «как думать», без претензии на единственную истину:

Слой Имеет право задавать таймаут? Почему
HTTP handler Да Он ближе всего к клиенту и понимает SLA запроса
CLI команда Да Она описывает «операцию пользователя» (импорт, экспорт, list)
App слой Иногда Может задавать подтаймаут для подоперации, но только от входного ctx
Storage слой Обычно нет Storage должен уважать ctx, а не навязывать свои правила всем

Если app слою нужен более короткий лимит на подоперацию, это делается только так: context.WithTimeout(parentCtx, d). Контексты образуют дерево, и дочерний унаследует отмену родителя, а не заменит её.

Почему нельзя «для удобства» хранить ctx в struct

Это искушение выглядит очень невинно: «ну я же всё равно всегда вызываю методы сервиса, почему бы не положить ctx внутрь Service?». Потому что тогда вы смешиваете времена жизни. Контекст относится к одной операции, а Service живёт долго. В результате следующий вызов сервиса может случайно работать с уже отменённым контекстом или с дедлайном «вчера». И вы получите поведение уровня «иногда работает, иногда нет» — любимый жанр багов, который отнимает сон.

Официальная рекомендация Go для большинства случаев проста: контексты не хранят в структурах, их передают аргументом в методы, чтобы у каждого вызова был свой жизненный цикл и свои дедлайны/метаданные.

6. Схемы последовательностей: HTTP и CLI

Иногда полезно увидеть не код, а «мультик» про то, кто кого вызывает и куда течёт ctx.

HTTP запрос

sequenceDiagram
  participant C as Client
  participant H as HTTP handler
  participant S as app.Service
  participant ST as storage

  C->>H: Request
  Note over H: ctx = r.Context()<br/>ctx = WithTimeout(ctx, 2s)
  H->>S: AddTask(ctx, title)
  S->>ST: Create(ctx, Task)
  ST-->>S: (Task, nil) или ctx.Err()
  S-->>H: result
  H-->>C: 201 Created

CLI import

sequenceDiagram
  participant U as User
  participant CLI as CLI main
  participant S as app.Service
  participant ST as storage

  U->>CLI: tasker import tasks.csv
  Note over CLI: ctx = Background()<br/>ctx = WithTimeout(ctx, 10s)
  CLI->>S: ImportCSV(ctx, file)
  loop each row
    S->>ST: Create(ctx, Task)
  end
  ST-->>S: ok / ctx.Err()
  S-->>CLI: count / error
  CLI-->>U: exit code + message

И в обеих ветках одна и та же ключевая дисциплина: ctx не «обнуляется» и не заменяется на Background() в середине.

7. Типичные ошибки

Ошибка №1: «Сбросить» контекст на context.Background() где-то в app/storage, потому что так проще.
Это ломает отмену и дедлайны, и делает верхний слой (HTTP/CLI) бессильным. В результате клиент ушёл, а вы продолжаете работу, тратите ресурсы и иногда держите блокировки дольше, чем нужно. Лечится просто: ctx всегда приходит сверху и уходит вниз, без самодеятельности.

Ошибка №2: Забыть defer cancel() после WithTimeout/WithDeadline.
Даже если кажется, что «таймаут сам всё отменит», cancel() — это дисциплина освобождения внутренних ресурсов (таймеров, ссылок на дерево контекстов). Правильная привычка: создали (ctx, cancel) — сразу следующей строкой defer cancel().

Ошибка №3: Дать ctx не первым параметром, а где-то в середине сигнатуры.
Функции начинают выглядеть непоследовательно, а код-ревью превращается в игру «найди контекст». Go-экосистема привыкла к правилу: ctx context.Context идёт первым. Это не закон физики, но это очень удобный «стандарт чтения».

Ошибка №4: Не проверять ctx.Done() в долгих циклах (например, импорт/обход/обработка больших данных).
Если операция состоит из тысячи шагов, но вы не смотрите на ctx.Done(), то отмена «не работает» на практике: пользователь отменил, а вы дочитываете файл до конца. Решение — ставить точки проверки внутри цикла и корректно возвращать ctx.Err() как причину остановки.

Ошибка №5: Хранить ctx в поле структуры сервиса или хранилища «на всякий случай».
Так вы смешиваете жизненные циклы разных операций: один вызов может случайно отменить другой, а дедлайны становятся непредсказуемыми. Вместо этого контекст передают аргументом в каждый метод, который относится к конкретной операции.

1
Задача
Go SELF, 70 уровень, 3 лекция
Недоступна
Три слоя
Три слоя
1
Задача
Go SELF, 70 уровень, 3 лекция
Недоступна
Таймаут на входе
Таймаут на входе
1
Задача
Go SELF, 70 уровень, 3 лекция
Недоступна
Тестовый handler
Тестовый handler
1
Задача
Go SELF, 70 уровень, 3 лекция
Недоступна
Импорт CSV
Импорт CSV
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ