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

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

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

Урок 23 из 25 ~18 мин +50 XP

Структура крупного TUI-проекта

Когда организация tui кода уходит из одного файла, структура проекта bubbletea решает всё: пакеты по экранам, централизованный styles.go, общий messages.go для событий и доменный слой без единого импорта tea. Разберём каталог, который переживает рост от панели на сто строк до многоэкранного приложения, покажем, откуда тестировать каждый слой, и закончим чек-листом ревью TUI-кода.

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

Панель задач выросла из одного файла в тысячу двести строк: в Update соседствуют клавиши списка, HTTP-загрузка, валидация формы и стили, а правка строки статуса роняет валидацию пароля в другом конце файла. Это не признак плохого кода — это признак хорошей выучки предыдущих уроков, слитых в одну кучу. Куча чинится картой, и карта для Bubble Tea давно устоялась: экраны живут в отдельных пакетах, стили — в одном styles.go, общие сообщения — в messages.go, а домен — задачи, хранилище, валидация — не знает о существовании терминала. Корневая модель собирает экраны и роутит события. Разберём каждый этаж по порядку, а в конце — чек-лист ревью, который спасает на код-ревью, когда дифф большой, а терпение короткое. Всё собирается локально через go build ./... — песочница тут бессильна, структура проверяется компилятором и тестами.

Пакеты по экранам#

Ключевое решение — резать по экранам, а не по слоям. Пакет update.go со всеми обработчиками программы — антипаттерн: правка списка и правка формы едут в один файл, конфликты в git гарантированы. Экран — самодостаточная фича: у него свой model, конструктор, Update и View, и он не импортирует соседей. Соседство ломает изоляцию мгновенно: экран списка, вызвавший функцию редактора, уже не выкинуть из сборки отдельно. Имена пакетов короткие и по делу: list, editor, help — в импортах это читается как list.Update, list.Model. Корень при этом один: он знает все экраны, а экраны не знают друг о друге. Тонкий вопрос — когда резать: не с первого дня. Приложение из двух экранов честно живёт в одном пакете ui; резать стоит, когда файлы перерастают пятьсот строк или когда второй экран переиспользует первый стиль. Преждевременная нарезка даёт десяток пакетов по три файла, и навигация по проекту становится дороже навигации по одному хорошему файлу.

Дерево типичной панели#

  • taskpanel/main.go — точка входа: tea.NewProgram, опции альт-скрина, запуск
  • internal/domain/task.go — задачи, статусы, файловое хранилище без tea
  • internal/ui/root.go — корневая модель: экраны, режим, роутинг событий
  • internal/ui/screens/list/ — экран списка: model.go, update.go, view.go
  • internal/ui/screens/editor/ — экран редактирования задачи
  • internal/ui/messages/messages.go — общие типы сообщений
  • internal/ui/styles/styles.go — все стили Lip Gloss программы
  • go.mod — модуль с зависимостями bubbletea, bubbles, lipgloss

styles.go: стили в одном месте#

Каждый экран, рисующий свой lipgloss.NewStyle, через месяц даёт четыре разных красных и три разных отступа — дрейф оттенков заметен только пользователю, который смотрит на экран целиком. Лекарство — один пакет styles: все стили программы объявлены переменными в одном файле, экраны их импортируют и не создают своих. Выигрыш не только в красоте: правка палитры — один коммит в одном файле; адаптивные ширины из урока про раскладку хранятся там же, рядом с базовыми стилями; и код-ревью цветной правки сводится к просмотру одного диффа. Тёмная тема терминала, NO_COLOR и ANSI-256 обсуждались в пятнадцатом уроке — здесь важно только место: стиль объявлен один раз. Спорная деталь, на которой я обжигался: слишком умный пакет styles с функциями-конструкторами и конфигами. Пока стили — простые переменные, всё предсказуемо; как только появляется конфигурация, отладка цвета требует чтения трёх файлов вместо одного. Держите styles.go тупым.

Пакет styles целиком

GO
package styles

import "github.com/charmbracelet/lipgloss"

var (
        DocStyle = lipgloss.NewStyle().Margin(1, 2)

        Header = lipgloss.NewStyle().
                Bold(true).
                Foreground(lipgloss.Color("205"))

        StatusErr = lipgloss.NewStyle().
                Foreground(lipgloss.Color("196"))

        Selected = lipgloss.NewStyle().
                Bold(true).
                Foreground(lipgloss.Color("212"))
)

Все стили программы — один файл, ни один экран не зовёт lipgloss.NewStyle сам. Имена говорящие и не привязаны к экрану: Header используется и списком, и редактором. Правка палитры — правка этого файла и больше ничего.

messages.go: общий язык экранов#

Экраны не импортируют друг друга, но события между ними существуют: редактор сохранил задачу — список обязан перечитаться; фоновый воркер закончил индексацию — статус-бар обязан узнать. Общий язык живёт в отдельном маленьком пакете messages: типы событий, которые производит один, а потребляет корень или другой экран. Правило содержания: сообщение описывает факт — «сохранено по такому пути», «загрузка провалилась», — а не команду чужому экрану вроде «перечитай список». Факт нейтрален: сегодня список перечитывается, завтра пуш-уведомление; команда связывает отправителя и получателя жёстко. Пакет крошечный — десяток типов без единой функции, и это нормально: его задача — типобезопасный словарь событий, который не тянет ни одной зависимости ни вверх, ни вбок. Как только в messages импортируется пакет экрана, начинайте заново: словарь обязан быть независимым, иначе он станет узлом спагетти.

Типы общих событий

GO
package messages

import "time"

type tickMsg time.Time

type statusMsg string

type savedMsg struct {
        path string
        err  error
}

Три события программы: тик часов, строка статуса, факт сохранения. Ни одного импорта экранов — пакет строится за секунду и не создаёт циклов. Ошибка едет полем err по конвенции девятнадцатого урока.

Домен без tea#

Самый ценный этаж — internal/domain: задачи, их статусы, правила валидации, файловое хранилище. Ни одного импорта bubbletea — домен не знает, что его показывает терминал. Выгода тройная. Тесты: go test ./internal/domain/ гоняется за миллисекунды без рантайма интерфейса — это тесты из двадцать второго урока в лучшем виде. Переиспользование: CLI-команда add из курса Cobra может писать в то же хранилище, а в гипотетическом fyne-клиенте домен переезжает без правок — интерфейс меняется, предметная область нет. Границы: пока домен чист, IO живёт в его хранилище легально — там это и есть работа слоя; illegaly — когда файловая запись проникает в Update. Стык слоёв прост: команды-фабрики в UI-пакетах вызывают домен и упаковывают результат в messages; Update ничего не вычисляет сам, только кладёт факт в модель. Если правило «Update не ходит в файл» когда-нибудь нарушится, компилятор этого не заметит — заметит только чек-лист ревью ниже.

Проверка: где жить хранилищу

Сохранение задачи в файл падает с ошибкой пути. В каком пакете ищете баг и его тест по правилам урока?

Главный model собирает экраны#

Корень — единственный, кто знает всех. Поля: экраны, текущий режим, строка статуса. Init корня собирает команды Init'ов экранов и свои тики через tea.Batch. Update держит два этажа. Сначала перехват: события messages — savedMsg, тики, размер окна — корень разбирает сам, потому что они межэкранные. WindowSizeMsg рассылается всем экранам сразу — у каждого своя раскладка. Затем роутинг клавиш: активному экрану по mode, остальным — тишина; это та же маршрутизация по фокусу из урока про формы, только уровень выше. View — switch по режиму, каждый экран рисует свой кусок, корень доклеивает статус-бар. Заметьте, что экраны не обязаны возвращать tea.Model: у корневых методов подписи с собственными типами экранов — list.Update возвращает listModel, и корень без приведений. Программе tea нужен только корень, реализующий tea.Model, — внутренности вольны в сигнатурах.

Роутинг событий в корне

GO
func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case messages.savedMsg:
                m.mode = modeList
                m.status = "Сохранено: " + msg.path

        case tea.WindowSizeMsg:
                m.list, _ = m.list.Update(msg)
                m.editor, _ = m.editor.Update(msg)
        }

        var cmd tea.Cmd
        switch m.mode {
        case modeEditor:
                m.editor, cmd = m.editor.Update(msg)
        default:
                m.list, cmd = m.list.Update(msg)
        }
        return m, cmd
}

Два этажа: сначала межэкранные события — сохранение переключает режим, размер рассылается всем; затем клавиши и хвосты уходят активному экрану. Экранные Update возвращают собственные типы, поэтому без приведений интерфейсов. tea.Model реализует только корень.

Читаем роутинг

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

// mode = modeEditor, пользователь сохранил задачу,
// из команды вернулся messages.savedMsg{path: "tasks.json"}.

case messages.savedMsg:
        m.mode = modeList
        m.status = "Сохранено: " + msg.path

// Что произойдёт дальше с этим же сообщением
// в нижнем switch?

Откуда тестировать#

Структура задаёт и порядок тестов — снизу вверх. Домен: чистые тесты хранилища и валидации, файлы во временной папке, гоняются мгновенно. Экраны: Update каждого экрана по правилам двадцать второго урока — табличные тесты переходов, фиктивные события из messages; экран тестируется без корня и без соседей, это и есть проверка изоляции. Корень: роутинг — savedMsg переключил режим, WindowSizeMsg долетел до обоих экранов; здесь хватает нескольких тестов, потому что логики мало. Стили не тестируются вовсе, а итоговый экран получает один golden-снимок — раскладка панели является частью контракта. Границы теста совпадают с границами пакетов: go test ./internal/domain/ отрабатывает быстрее, чем стартует терминал, и это не метафора. Когда новому разработчику непонятно, с чего начать знакомство с проектом, порядок тестов — он же и порядок чтения: домен, экран, корень. А после тестов — последняя линия обороны: чек-лист ревью из семи пунктов, который я прогоняю по чужому диффу, пока чай не остыл. Он покрывает все этажи структуры и ловит почти каждую рану, встречавшуюся мне в TUI-проектах:

Чек-лист ревью TUI-кода#

  1. Update не ходит в сеть и в файл: только состояния, модели и возврат команд
  2. Команды — фабрики без побочных эффектов до вызова замыкания, побочные — внутри замыкания
  3. Ни одного lipgloss.NewStyle вне styles.go: палитра живёт в одном файле
  4. Домен собирается и тестируется без bubbletea: go test ./internal/domain/ зелёный
  5. Экраны не импортируют друг друга: межэкранные события описаны фактами в messages
  6. У каждого типа сообщения один производитель и одна ветка Update, дедлайны несут метку попытки
  7. Табличные тесты на переходы клавиш и golden-снимок на главный экран есть в диффе

Проверка: порядок чтения

Новый коллега открывает репозиторий панели впервые. В каком порядке по структуре урока разумнее всего читать код?

Соберите messages.go

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

package messages type time.Time type statusMsg type savedMsg struct { path string error }

Соберите файл сообщений

Расставьте строки messages.go: структура сохранения с полями и двумя скалярными типами событий.

Строки

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

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

Что дальше#

Структура — последний этаж перед выходом в мир: урок про сборку и дистрибуцию соберёт эту программу под три платформы и расскажет, почему деградация частоты тиков спасает SSH-сессии. Итоговый проект курса построен ровно по этому каталогу: список, редактор, статус-бар, файловое хранилище в домене. Порядок тестов отсюда — расшифровка урока про тестирование: снизу вверх, от домена к корню. Обзор всего маршрута — на странице Bubble Tea. Проверка структуры — только локальная: go build ./... и go test ./... в реальном модуле. Практика: разложите свою панель по дереву из урока, сломайте нарочно правило — импортируйте list в editor — и прочитайте ошибку компилятора: она станет вашим лучшим аргументом за messages.go на будущем ревью.

Карточки: словарь структуры

Термин

Пакет на экран

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

Экран — фича: model, конструктор, Update, View в одном пакете. Пакеты не импортируют соседей; нарезка не раньше пятисот строк или второго переиспользования.

1 / 5

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

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

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

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

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

виртуальный терминал · bubbleteaalt-screen
СТРУКТУРА ПРОЕКТА
▸ main.go
ui/styles.go
messages.go
screens/list/update.go
screens/list/view.go
роль файла: точка входа: tea.NewProgram и начальная команда
↑/↓ — по дереву, enter — открыть, q — выход

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

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

модель собрана: files []string (5) · roles []string (5) · cursor int = 0 · opened string

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

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

  • Резать по экранам, а не по слоям: пакет list со всем списком, пакет editor со всем редактором.
  • styles.go держит палитру программы: ни одного lipgloss.NewStyle в экранах, правка цвета — один файл.
  • messages.go — словарь фактов между экранами; команды чужому экрану рождают циклический импорт.
  • Домен без импорта tea тестируется мгновенно и переезжает в CLI или GUI без правок.
  • Корень перехватывает межэкранные события, рассылает размеры и роутит клавиши по режиму.
  • Тесты и чтение идут снизу вверх: домен, экраны, корень; golden — только на главный экран.

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

Разложите свою панель по дереву урока: домен с файловым хранилищем, пакет стилей, messages с тремя общими типами, два экрана и корень с роутингом. Добейтесь зелёных go build ./... и go test ./internal/domain/ локально. Затем напишите тест корня: savedMsg переключает режим на список и пишет статус с путём — два утверждения в одном тесте.

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Структура крупного TUI-проекта)

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

Сколько пакетов делать — проект ещё маленький?

Держите один пакет ui, пока файлы не перевалили за пятьсот строк или пока второй экран не начал переиспользовать первый. Нарезка раньше срока даёт пакеты по три файла и лишние импорты. Домен выносите первым — он чистый, без циклов, и его границы очевидны; экраны режьте, когда они реально обособились.

Куда класть общие виджеты — кнопку статуса, которой пользуются два экрана?

В отдельный пакет components рядом со screens: маленькие подмодели без состояния программы. Следите за направлением: components импортируют styles и messages, но не экраны. Если виджет начал знать об экранах — это уже не виджет, а часть экрана, и ему место внутри пакета экрана.

Корень разрастается — это нормально?

Нормально до предела: роутинг, перехват сообщений и статус-бар — его работа, и пара сотен строк там уместны. Лечится выносом: групповые операции — в промежуточную подмодель, глобальные клавиши — в функцию handleGlobal, возвращающую (модель, команду, обработано). Если корень начал знать детали домена — событие описали командой, а не фактом.

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

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

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

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

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

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

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

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

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