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

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

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

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

Команды tea.Cmd: асинхронные операции

Команды tea.Cmd — механизм асинхронных операций в bubbletea: загрузка данных, ожидание таймера, запись файла, пока терминальный интерфейс продолжает отвечать на клавиши. Разберём, почему команда — это просто func() tea.Msg, как вернуть её из Init и Update и почему собственные горутины вам почти не понадобятся.

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

Счётчик из второго урока отзывался мгновенно — и не потому, что он маленький. Update в Bubble Tea обрабатывает сообщения по одному: пришло нажатие, инкремент, перерисовка — всё за микросекунды. Теперь представьте, что по нажатию r модель должна сходить в API за списком задач. Запрос занимает полсекунды в хорошем случае и десять секунд в плохом. Если эта работа случится прямо внутри Update, весь терминальный интерфейс замрёт: клавиши не обрабатываются, q не работает, картинка стоит. Пользователь такое не прощает. Хуже того: очередь событий продолжит копить нажатия, и после разморозки они обрушатся на Update лавиной — «залипшая» клавиатура вам обеспечена. Bubble Tea решает проблему архитектурно: долгую работу объявляют командой — значением типа tea.Cmd, — фреймворк уводит её в сторону, а в Update приходит только сообщение-результат, когда работа закончена. Сегодня пройдём механизм целиком: от сигнатуры до правил и ошибок.

Команда — это func() tea.Msg#

Определение умещается в одну строку: tea.Cmd — это тип func() tea.Msg. Обычная функция без аргументов, которая возвращает сообщение — значение любого типа. Никаких интерфейсов, колбэков и промисов: асинхронная команда в bubbletea — замыкание, которое выполнит работу и вернёт результат. Что важно понимать сразу. Команду не вызывают сами — её возвращают из Init или Update, а цикл событий запускает её в собственной горутине. Пока команда работает, Update свободен и продолжает ловить клавиши. Когда функция завершается, её возвращаемое значение попадает в очередь сообщений и обрабатывается как обычное событие — тем же тип-свитчем, что и нажатия клавиш. Тип сообщения вы придумываете сами: resultMsg, tasksLoadedMsg, fileSavedMsg — соглашение простое, имя должно отвечать на вопрос «что случилось». Одно событие — одно сообщение: не пытайтесь упаковать в него три разных факта, дробите на отдельные типы. В elm архитектуре такие значения зовутся сообщениями, и это не украшение: сообщение — факт, который уже произошёл, а не просьба что-то сделать.

Свой тип сообщения и команда загрузки

GO
type tasksLoadedMsg struct {
        tasks []Task
        err   error
}

func fetchTasksCmd() tea.Cmd {
        return func() tea.Msg {
                tasks, err := store.LoadTasks()
                if err != nil {
                        return tasksLoadedMsg{err: err}
                }
                return tasksLoadedMsg{tasks: tasks}
        }
}

tasksLoadedMsg — тип-результат: успех и ошибка едут одним сообщением, просто в разных полях. fetchTasksCmd возвращает не результат, а замыкание: фреймворк вызовет его позже, в своей горутине. Внутри — обычный Go-код: поход в хранилище, разбор ошибки, упаковка в сообщение. Ошибку не печатаем и не паникуем — её увидит Update и решит, что показывать.

Возврат команды из Init и Update#

Команда живёт в двух местах, оба вы уже видели, просто там стоял nil. Init возвращает команду, которая должна выполниться при старте: загрузить настройки, проверить соединение, запустить первые часы. Update возвращает команду вслед за моделью — обычно как реакцию на конкретное событие: пользователь нажал r — верните fetchTasksCmd, пришло сообщение загрузки — верните команду сохранения. Ноль означает «асинхронной работы не заказано»: nil — штатное значение, а не ошибка. Отсюда ясная картина потока: событие → Update → (модель, команда) → горутина → сообщение → Update. Цикл может закручиваться сколько угодно: каждая команда способна породить следующую. И помните про потолок: один Update обрабатывает одно сообщение и возвращает максимум одну команду. Как упаковать несколько — тема седьмого урока про tea.Batch и tea.Sequence. И практический совет по чтению чужого кода: команды ищутся по возвращаемому типу — grep по tea.Cmd показывает все асинхронные точки программы за секунду, это удобнее, чем вылавливать голые go func().

Горутины нужны фреймворку, не вам#

Внутри Bubble Tea каждый вызов команды уходит в отдельную горутину: фреймворк запускает её, ждёт tea.Msg и проталкивает результат в очередь событий. Вам писать go func() не нужно — и в типичном приложении не нужно вообще. Это принципиальное отличие от окружающего мира: в обычном Go вы сами следите, чтобы каналы из урока про горутины закрывались, а гонки — разруливались. Здесь ответственность разделена: команда делает одну вещь и возвращает одно сообщение, остальное — забота цикла событий. Когда горутина всё-таки нужна? Для долгоживущего фона: слушатель очереди, подписка на WebSocket, чтение лога из потока — то, что не заканчивается одним результатом. Такие воркеры живут вне команд и общаются с программой через p.Send из третьего урока. Промежуточные случаи — пачки одноразовых задач — тоже решаются командами, просто упакованными вместе: об этом седьмой урок. Правило короткое: одноразовая работа — команда, бесконечный поток — горутина с Send.

Программа целиком: загрузка с состоянием

GO
type model struct {
        loading bool
        tasks   []Task
        err     error
}

func (m model) Init() tea.Cmd {
        return fetchTasksCmd()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tasksLoadedMsg:
                m.loading = false
                m.err = msg.err
                m.tasks = msg.tasks
                return m, nil
        case tea.KeyMsg:
                if msg.String() == "q" {
                        return m, tea.Quit
                }
        }
        return m, nil
}

func (m model) View() string {
        if m.err != nil {
                return "Ошибка: " + m.err.Error()
        }
        if m.loading {
                return "Загружаю задачи..."
        }
        return fmt.Sprintf("Задач: %d, q — выход\n", len(m.tasks))
}

func main() {
        m := model{loading: true}
        if _, err := tea.NewProgram(m).Run(); err != nil {
                fmt.Println("Ошибка:", err)
                os.Exit(1)
        }
}

Проследите флаг loading: Init вернул команду, фреймворк запустил её, и до прихода tasksLoadedMsg View показывает «Загружаю...». Сообщение загрузки обрабатывается тем же switch, что и клавиши, — отдельного потока, очереди или колбэка в вашей программе нет. В реальном приложении вместо статичной строки здесь будет спиннер из шестого урока.

Правила команды: что можно и что нельзя#

Внутри команды действуют три запрета. Первый: не трогайте модель. Команда получает данные при создании — через параметры фабрики — и возвращает результат сообщением; прямой доступ к состоянию ломает elm архитектуру и ведёт к гонкам данных. Второй: не печатайте в терминал. Пока программа работает, вывод принадлежит рендереру — fmt.Println из команды вклинит строку в кадр, ровно как из любой горутины. Третий: не тяните глобальное состояние. Команда, которая пишет в пакетную переменную, при повторном запуске удивит вас значением из прошлого раза — я однажды ловил именно такой баг полдня. Что можно: обычный Go — клиенты HTTP, запросы к базе, файлы, контексты с таймаутами. Аргументы передавайте фабрикой: func(id int) tea.Cmd { return func() tea.Msg { ... } } — внешняя функция принимает параметр и возвращает готовую команду с захваченным значением.

Делегирование: Update не превращается в свалку#

С появлением собственных сообщений switch в Update растёт: клавиши, тики, результаты загрузок, ответы сохранений. К пятому-шестому типу в одном switch начинаются потери. Первое правило гигиены — проверку сообщения, checkingMsg, всегда делайте type-свитчем: case tasksLoadedMsg, case tea.KeyMsg — компилятор не даст перепутать типы, и это одна из главных выгод elm архитектуры на Go. Второе — делегирование: сообщение обрабатывает тот, кому оно адресовано. В маленькой модели достаточно приватных методов: m.handleKeys(msg), m.handleResult(msg) — Update превращается в диспетчер на пять строк. Отдельный признак беды — события-строки: если вы различаете сообщения по полю внутри общей структуры, остановитесь и заведите отдельный тип. Тип — это одновременно маршрутизация и документация. В больших моделях сообщениями распоряжаются подмодели — этому посвящён восьмой урок. Третье: не плодите поля модели под каждый случай; сообщение несёт данные, Update раскладывает их по существующим полям.

Команда против горутины с p.Send#

СитуацияИнструментПочему
Один запрос к API или базеtea.Cmdодин результат — одно сообщение, горутину запустит фреймворк
Таймеры и тикиtea.Cmd через tea.Tickкоманда планирует следующее событие сама
Чтение файла при стартеtea.Cmd из Initрезультат нужен один раз, до первого кадра
Поток событий: WebSocket, tail логагорутина + p.Sendкоманда заканчивается, а поток живёт бесконечно

Ошибки из асинхронных операций#

Ошибка в команде — не повод паниковать и не повод молчать. Паника внутри горутины уронит программу целиком, а проигнорированный err превратится в интерфейс, который «просто ничего не загрузил». Рабочая схема старая, как сам Go: ошибка едет в сообщении. Либо отдельный тип errMsg с полем err, либо — как в примере выше — поле err в сообщении-результате. Update складывает её в модель, View решает, что показывать: строку об ошибке, тост, кнопку повтора. Бонус подхода в том, что ошибку можно обрабатывать как событие: показать сообщение и через пару секунд стереть, предложить повтор той же командой. В примерах Charm встречаются оба варианта — и отдельный errMsg, и поле в resultMsg; оба каноничны. Ещё антипаттерн — возвращать nil при ошибке: Update не узнает, что что-то случилось, и покажет вечную загрузку. Если ошибка возможна, она обязана доехать до модели. Выберите одну схему на проект и не смешивайте.

Контекст: как отменять затянувшуюся работу#

Команда ушла и висит: запрос к API не отвечает три минуты, пользователь смотрит на надпись «Загружаю» и жмёт Esc. Отмена в Go делается контекстом, и в командах он работает как обычно: фабрика принимает ctx context.Context, замыкание передаёт его в запрос — http.NewRequestWithContext, операции с базой, файловое чтение. Остаётся вопрос: кто вызовет cancel()? Честных схемы две. Первая: контекст с таймаутом создаётся внутри самой команды — работа гарантированно закончится и вернёт сообщение с ctx.Err(), никаких висячих запросов. Вторая: cancel хранится в модели, Update по Esc его вызывает, команда возвращается раньше срока. Второй вариант отзывчивее, но требует дисциплины — поле cancel в модели живёт ровно до следующего запуска команды, иначе отменит не то. Для старта берите первую схему: таймаут внутри команды закрывает большую часть случаев, а живую отмену добавите, когда она действительно понадобится пользователю.

Проверка: анатомия команды

Какой тип у tea.Cmd?

Проверка: nil как ответ

Пользователь нажал клавишу, асинхронная работа не нужна. Что возвращает Update?

Команда, которая планирует сама себя

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

type countMsg int

func bumpCmd() tea.Cmd {
        return func() tea.Msg { return countMsg(1) }
}

func (m model) Init() tea.Cmd {
        return bumpCmd()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case countMsg:
                m.n += int(msg)
                return m, bumpCmd()
        case tea.KeyMsg:
                if msg.String() == "q" {
                        return m, tea.Quit
                }
        }
        return m, nil
}

Заполните пропуски в команде

Восстановите сигнатуру команды, имя сообщения и вызов фабрики из Init.

func loadStats() tea. { return func() tea.Msg { return statsMsg{rows: rows} } } func (m model) Init() tea.Cmd { return () }

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

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

Строки

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

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

Карточки: словарь асинхронности

Термин

tea.Cmd

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

Тип func() tea.Msg: асинхронная работа, упакованная в значение. Возвращают из Init и Update; фреймворк запускает её в горутине.

1 / 5

Что дальше#

Теперь ваши программы умеют ждать, не замирая. Дальше — показывать ожидание: шестой урок про спиннеры подключает компонент bubbles, который тикает как раз командами этого урока. Когда понадобится запустить несколько команд разом — спиннер плюс загрузка, — открывайте седьмой урок про tea.Batch и tea.Sequence. А настоящий веб-запрос с состояниями загрузки и ошибки ждёт в уроке про HTTP-данные. Полная программа — на странице курса Bubble Tea. Домашняя практика простая: напишите команду сохранения, подложите в неё time.Sleep на три секунды и убедитесь, что q работает даже во время «долгой» записи — локальный терминал покажет правду о задержках быстрее любых слов.

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

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

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

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

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

виртуальный терминал · bubbleteaalt-screen
Проверок выполнено: 0
Статус: готов к проверке
· сервер не проверялся
enter — проверить сервер, q — выход

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

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

модель собрана: status string = "готов к проверке" · checks int = 0 · loading bool = false

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

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

  • tea.Cmd — это func() tea.Msg: команду возвращают из Init и Update, а фреймворк запускает её в горутине.
  • Результат команды — сообщение-значение; Update обрабатывает его тем же type-свитчем, что и клавиши.
  • nil в качестве команды — штатное «работы не заказано», а не ошибка.
  • Update не блокируется: долгая работа живёт в команде, а не в обработчике события.
  • Ошибка едет в сообщении — errMsg или поле err; паника в команде роняет программу целиком.
  • Одноразовая работа — команда; бесконечный поток событий — горутина с p.Send.

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

Добавьте к программе урока команду saveTasksCmd, которая «сохраняет» список и возвращает savedMsg. Пусть нажатие s запускает сохранение, а View показывает статус. Затем подложите в команду time.Sleep на три секунды и запустите локально: убедитесь, что q по-прежнему работает, пока идёт запись. Если интерфейс замер — перечитайте callout и найдите, куда просочилась работа.

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

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

Почему Update не блокируется при асинхронной операции?

Потому что операция выполняется не в Update: команда уходит в горутину фреймворка, а в Update приходит готовый результат-сообщение. Update обрабатывает сообщения по одному и всегда быстро возвращается.

Нужны ли свои горутины в Bubble Tea приложении?

Для одноразовых операций — нет: их запускает фреймворк под каждую команду. Свои горутины нужны для долгоживущих потоков событий, и общаются они с программой через p.Send, не трогая модель напрямую.

Как вернуть из Init несколько команд сразу?

Init возвращает одну команду, но её можно собрать из нескольких: tea.Batch запускает их параллельно, tea.Sequence — по очереди. Механика и ловушки — в седьмом уроке курса.

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

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

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

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

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

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

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

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

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