Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~20 мин +50 XP

Данные из сети: HTTP-запросы в командах

Списку задач нужен сервер: bubbletea http-запросы выполняет теми же командами tea.Cmd, что и таймеры, — сеть уходит в замыкание, интерфейс продолжает отвечать на клавиши, а в модель возвращается сообщение с данными или ошибкой. Соберём полный цикл: http.Get внутри команды, конечный автомат loading — loaded — error, декодирование JSON, таймауты через context и оптимистичные обновления, без которых терминальный клиент кажется тормозным.

Содержание урока · 10 разделов

Панель задач без данных — рамка и пустой viewport. Данные лежат на сервере, и тропа к ним начинается с вопроса: где именно выполнять запрос. Механику команд дал пятый урок — функция типа tea.Cmd, которую фреймворк запускает в собственной горутине и чьё возвращаемое значение приходит в Update сообщением. Сеть встраивается в этот механизм без единой новой сущности, но добавляет три условия, которых раньше не было. Медленность: ответ приходит за сто миллисекунд или за десять секунд, и всё это время интерфейс обязан жить. Падучесть: сервер недоступен, отдал 500, вернул кривой JSON — каждое из этих состояний должно быть видно пользователю, а не только в логе. Формат: данные едут JSON, и разбор — часть работы команды, а не Update. Этот урок собирает три условия в каркас: команда с http.Get, три состояния, декодер и таймаут. Проверять его можно только с живой сетью — локальный запуск против настоящего API, потому что песочницы сети не эмулируют. И напоминание из пятого урока, которое в сети бьёт больнее всего: запрос прямо в Update заморозит интерфейс на всё время ожидания — клавиши не работают, q молчит, картинка стоит. Поэтому первым делом — команда.

HTTP-запрос внутри tea.Cmd#

Правило одно и жёсткое: сеть живёт только в команде. Фабрика — обычная функция, принимающая нужные аргументы и возвращающая готовое замыкание func() tea.Msg: fetchItemsCmd(url) захватывает строку и запускается позже, в горутине фреймворка. Внутри замыкания — обычный Go: http.Get, чтение тела, разбор. Три запрета из пятого урока действуют и здесь: команда не печатает в терминал, не паникует и не трогает модель — она не знает о её существовании. Ошибка любого этапа упаковывается в поле err сообщения-результата: команда вернулась — значит, событие произошло, а каким оно было, решает Update. Хорошая привычка — не отдавать наружу сырые байты: превратите тело в []Item внутри замыкания, тогда Update и View работают с доменными данными и не знают, откуда они приехали. Поменять источник с HTTP на файл или кэш — значит переписать одну фабрику, а не весь Update. Именование фабрик в живых проектах держится одного шаблона: fetchXCmd, loadXCmd, saveXCmd — по префиксу grep находит все сетевые точки программы за секунду, и это отдельный аргумент против инлайн-замыканий посреди Update: их потом не найти.

Команда загрузки: три точки возврата

GO
type itemsLoadedMsg struct {
        items []Item
        err   error
}

func fetchItemsCmd(url string) tea.Cmd {
        return func() tea.Msg {
                resp, err := http.Get(url)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                defer resp.Body.Close()

                body, err := io.ReadAll(resp.Body)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }

                items, err := parseItems(body)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                return itemsLoadedMsg{items: items}
        }
}

Каждый этап может провалиться — запрос, чтение тела, разбор, — и все три провала едут одним сообщением в поле err. fetchItemsCmd возвращает не результат, а замыкание: фреймворк вызовет его в своей горутине. parseItems — ваша функция конвертации байт в доменный срез, Update о HTTP не знает.

Состояния loading, loaded, error#

Данные из сети асинхронны, поэтому модель обязана знать, в какой фазе находится загрузка. Соблазн — три булевых поля loading, loaded, failed — работает плохо: компилятор не мешает комбинации loading и failed одновременно, и рано или поздно она появится. Честнее — один тип-перечисление: stateLoading, stateLoaded, stateError через iota, и модель хранит единственное поле state. Переходы очевидны: нажали r или старт — stateLoading, пришло сообщение с данными — stateLoaded, с ошибкой — stateError. View превращается в switch по состоянию: спиннер или строка «Загружаю...», список, ошибка с подсказкой. Отдельно про экран ошибки: «Ошибка: dial tcp: connection refused» без подсказки — тупик для пользователя, поэтому рядом всегда должна стоять дорога назад — «r — повторить». Ошибка из сети — не крах программы, а штатное состояние, которое она обязана уметь показывать и перезапускать. Полезный расширяемый случай — четвёртое состояние stateRefreshing: данные на экране есть, но поверх идёт обновление, и интерфейс показывает и то, и другое. Не заводите его заранее — на один список хватает трёх состояний, четвёртое появляется вместе с требованием продукта, не раньше.

Конечный автомат и View по состоянию

GO
type state int

const (
        stateLoading state = iota
        stateLoaded
        stateError
)

func (m listModel) View() string {
        switch m.state {
        case stateLoading:
                return "Загружаю задачи..."
        case stateError:
                return "Ошибка: " + m.err.Error() +
                        "\nr — повторить, q — выход"
        default:
                return fmt.Sprintf("Задач: %d\n", len(m.items))
        }
}

Одно поле state вместо трёх булевых: невозможно оказаться в двух состояниях сразу, а switch в View покрывает все ветки. В реальном приложении строку загрузки заменит спиннер из шестого урока — флаг состояния остаётся тем же.

Проверка: что делать с ошибкой

Команда вернулась с itemsLoadedMsg{err: err}. Чего в ней точно быть не должно?

Что на экране до ответа

Прочитайте код и выберите, что он выведет:

// state = stateLoaded, items: пусто.
// Пользователь нажал r:

case "r":
        m.state = stateLoading
        return m, fetchItemsCmd(m.url)

// Что покажет View, пока ответ не пришёл?

JSON decode внутри команды#

Первый сюрприз новичка: http.Get не считает 404 и 500 ошибками. Значение err возвращается только когда запрос не состоялся физически — нет сети, не разрешилось имя, отвалится TLS. Ответ «404 Not Found» — полноценный *http.Response, и если статус не проверить, команда попытается распарсить страницу ошибки как JSON и упадёт с загадочным сообщением. Проверяйте resp.StatusCode руками и упаковывайте resp.Status в err. Второй сюрприз — тело надо закрывать: defer resp.Body.Close() в каждой ветке, где тело вообще открывалось, иначе пул соединений клиента исчерпается. Сам разбор: json.NewDecoder(resp.Body).Decode(&v) читает поток напрямую и не грузит весь ответ в память — для типичных API этого достаточно, а json.Unmarshal уместен, когда байты уже прочитаны другим кодом. Структуру под JSON описывают тегами, и это лучший кандидат на отдельный тип api-слоя, о котором Update не знает. Декодер понимает UTF-8 из коробки, а для недоверенных источников размер ответа стоит ограничить io.LimitReader: читать гигабайт мусора из чужого API не стоит.

Статус, декодер и конвертация в домен

GO
type apiItem struct {
        ID    int    `json:"id"`
        Title string `json:"title"`
}

func fetchJSONCmd(url string) tea.Cmd {
        return func() tea.Msg {
                resp, err := http.Get(url)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                defer resp.Body.Close()

                if resp.StatusCode != http.StatusOK {
                        return itemsLoadedMsg{err: fmt.Errorf(
                                "сервер вернул %s", resp.Status)}
                }

                var api []apiItem
                if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
                        return itemsLoadedMsg{err: err}
                }

                items := make([]Item, 0, len(api))
                for _, a := range api {
                        items = append(items, Item{ID: a.ID, Title: a.Title})
                }
                return itemsLoadedMsg{items: items}
        }
}

Теги json мапят поля ответа на структуру, декодер читает тело напрямую из потока. Проверка StatusCode отсекает 404 и 500 раньше разбора. Последний цикл конвертирует API-тип в доменный Item — модель не узнает, что данные вообще были JSON.

Проверка: чего не хватает

http.Get вернул ответ без ошибки, но Decode упал с «invalid character '<'». Что забыли?

Таймауты и context#

Дефолтный http.Get использует глобальный клиент без таймаута: зависший сервер означает зависшую команду — навсегда. Лекарство двухуровневое. Сначала клиент с Timeout: http.Client{Timeout: 5 * time.Second} ограничивает всю жизнь запроса — от набора соединения до чтения тела. Затем контекст: http.NewRequestWithContext принимает context.Context, и контекст с WithTimeout даёт лимит конкретной операции, который жёстче клиентского. Зачем оба? Клиент — страховка от забывчивости, контекст — осознанное решение об операционном лимите, и оба числа озвучиваются в коде явно. В терминальном приложении контекст делает ещё одну вещь: отмену по уходу пользователя — если экран закрыт, а запрос ещё едет, cancel обрывает его и освобождает соединение. Для одного запроса это тонкость, для панели с десятью живыми запросами — гигиена. Про устройство контекстов, отмену и распространение по дереву вызовов подробнее рассказывает урок про context курса go-advanced. И практичная деталь: числа таймаутов не универсальны — пять секунд для обычного API нормально, для тяжёлого отчёта издевательство. Лимит передавайте фабрике команды параметром рядом с url, пусть решает вызывающий.

Клиент с таймаутом и контекст операции

GO
var apiClient = &http.Client{Timeout: 5 * time.Second}

func fetchWithTimeout(url string) tea.Cmd {
        return func() tea.Msg {
                ctx, cancel := context.WithTimeout(
                        context.Background(), 3*time.Second)
                defer cancel()

                req, err := http.NewRequestWithContext(
                        ctx, http.MethodGet, url, nil)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }

                resp, err := apiClient.Do(req)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                defer resp.Body.Close()

                var api []apiItem
                if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
                        return itemsLoadedMsg{err: err}
                }
                return itemsLoadedMsg{items: toItems(api)}
        }
}

Два лимита: клиент живёт на уровне программы и страхует любой запрос, контекст задаёт три секунды конкретной операции — если сеть молчит дольше, Do вернёт ошибку про deadline. Провал обычным путём уезжает в itemsLoadedMsg, интерфейс остаётся живым.

Оптимистичный UI и защита от повторов#

Оптимистичное обновление — приём, когда изменение применяется до ответа сервера: пользователь пометил задачу выполненной — модель перевернула галочку сразу, а подтверждение приедет позже. Ощущение скорости резкое: интерфейс отвечает со скоростью нажатия, а не сети. Цена — откат: пришла ошибка, и галочку придётся вернуть с честным сообщением. Где оптимизм уместен — на дешёвых обратимых операциях: пометки, лайки, сортировка. Где нет — на создании и удалении: запись, которой нет на сервере, откатывать мучительно для пользователя, поэтому такие действия честнее держать в состоянии loading до ответа. Мое правило — оптимистично только то, что откатывается одним присваиванием. Вторая половина темы — защита от повторов: пользователь жмёт r пять раз подряд, и наивная модель отправит пять параллельных запросов, ответы придут в случайном порядке и затрут друг друга. Флаг уже есть: если state равен stateLoading, ветка r просто игнорирует нажатие. И про порядок ответов: пять параллельных запросов приходят в случайном порядке, и последним может оказаться самый старый — ещё одна причина не плодить запросы, а перезапускать один текущий.

Запрос при старте: Init#

Данные нужны сразу — верните команду из Init, и загрузка стартует вместе с программой: Init() tea.Cmd { return fetchItemsCmd(m.url) }. До прихода ответа модель живёт в stateLoading, и экран не выглядит пустым. Спиннеру нужна собственная команда Tick из шестого урока, а на старте возвращается несколько команд сразу — их объединяет tea.Batch из седьмого: Batch(spinner.Tick, fetchItemsCmd(m.url)) запускает и анимацию, и запрос параллельно. Когда запрос при старте — плохая идея? В утилитах, которые открывают панель ради локальных действий: без сети приложение будет стоять в загрузке без нужды, и данные лучше тянуть по требованию. Отдельный вопрос — кэш: держать последние данные в модели и при перезагрузке показывать их сразу, помечая refresh, — пользователи прокрутки оценят, что позиция не прыгает к началу. Всё это не усложняет механизм: та же команда, то же сообщение, другая точка возврата. Заметьте только, что Init выполняется один раз за жизнь программы, поэтому периодическое обновление списка — не его работа: тикающий refresh делает команда-таймер, о которой дальше по курсу.

Соберите команду загрузки

Восстановите каркас: запрос, упаковка ошибки, закрытие тела и сборка сообщения.

func fetchItemsCmd(url string) tea.Cmd { return func() tea.Msg { resp, err := http.(url) if err != nil { return itemsLoadedMsg{: err} } defer resp.Body.() items, err := parseItems(resp) if err != nil { return itemsLoadedMsg{: err} } return itemsLoadedMsg{: items} } }

Соберите ветку провала загрузки

Расставьте строки: сообщение, проверка ошибки, сохранение, состояние, возврат, закрытие условия.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Проверка: где разбирать JSON

Почему разбор JSON оставляют команде, а не делают в Update?

Карточки: словарь сетевой загрузки

Термин

Фабрика команды

нажмите, чтобы перевернуть
Определение

Внешняя функция принимает аргументы — url, id — и возвращает замыкание func() tea.Msg. Сеть живёт только внутри замыкания; модель команды не знает.

1 / 5

Что дальше#

Получать данные научились — следующий шаг отдавать: форма, которая собирает логин и пароль и отправляет их на сервер через ту же команду с http.Post, — это следующий урок про фокус и формы. Пока запрос едет, экран должен жить: спиннер из шестого урока вешается на stateLoading за десять минут, а параллельный старт анимации и загрузки — Batch из седьмого. Проверяйте каркас на реальном API — публичные тестовые серверы вроде jsonplaceholder подходят идеально, но только локальный запуск покажет таймауты в действии. Когда загрузок станет много, а фабрики команд размножатся по файлам, им понадобится дисциплина структуры — до неё руки дойдут в уроке про тестирование TUI, где фиктивные ответы проверяют Update без сети. Обзор всего маршрута — на странице курса Bubble Tea.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный терминал

модель Bubble Tea · Model → Update → View

Отредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.

виртуальный терминал · bubbleteaalt-screen
ДАННЫЕ ИЗ СЕТИ
статус: нажмите enter — уйдёт GET /api/tasks
enter — запрос, тики — ответ или таймаут, r — повтор, q — выход

Клавиатура — обработано в Update

Журнал программы

модель собрана: state string = "нажмите enter — уйдёт GET /api/tasks" · retries int = 0 · items []string (0)

программа работает — нажмите клавишу ниже

Коротко: что запомнить

  • Сеть живёт только в tea.Cmd: Update отвечает за состояние, команда — за работу.
  • Сообщение-результат несёт данные и err; три состояния loading, loaded, error рисуются switch в View.
  • http.Get не ошибается на 404 и 500 — проверяйте resp.StatusCode руками до декодирования.
  • json.NewDecoder(resp.Body) разбирает поток; поля мапятся структурой с тегами json.
  • Клиенту нужен Timeout, операции — context.WithTimeout; resp.Body закрывается defer всегда.
  • Оптимистичный UI — только для обратимых пометок; флаг stateLoading защищает от повторных запросов.

Домашнее задание

Заведите команду загрузки для публичного тестового API вроде jsonplaceholder.typicode.com/todos?_limit=10. Выведите список через три состояния, добавьте ветку ошибки с подсказкой r, таймаут клиента в пять секунд и игнорирование r во время загрузки. Проверьте оба сценария: с сетью и с выключенным Wi-Fi — во втором случае экран ошибки и повторная загрузка обязаны сработать.

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Данные из сети: HTTP-запросы в командах)

Вопросы и ответы по уроку

Команда зависла навсегда — что происходит?

Скорее всего http.Get без таймаута: клиент по умолчанию ждёт ответа бесконечно, Update жив, но данные не придут никогда. Дайте клиенту Timeout и накройте запрос context.WithTimeout — тогда провал вернётся сообщением, и интерфейс покажет ошибку вместо вечной загрузки.

Куда девать статус ответа, например 404?

В сообщение. Нестандартный ответ — тоже результат: сравните resp.StatusCode с http.StatusOK и упакуйте resp.Status в err поля сообщения. View покажет «сервер вернул 404 Not Found», и пользователь поймёт, что ссылка битая, а не сеть легла.

Нужно ли отменять запрос, когда пользователь ушёл с экрана?

Для одного запроса — нет: сообщение просто обновит скрытую модель. Отмена нужна, когда запросов много и они тяжёлые: храните CancelFunc рядом с моделью и вызывайте при уходе. Устройство отмены — контексты из курса go-advanced, там это разобрано от и до.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Zap25 уроков~9 чпрактика25 уроков уже доступны

Zap: структурное логирование на Go

Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.

  • Писать структурные логи с полями вместо строк
  • Выбирать между Logger и SugaredLogger осознанно
  • Настраивать энкодеры, выводы и ротацию файлов
  • Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler