Привет, Хабр!
Не так давно я на ровном месте занялся написанием библиотеки на Go для, казалось бы, тривиальной задачи — спиннера, который гарантированно не будет ломаться при конкурентных записях в stderr и stdout.
Все началось с того, что я писал вообще другой код — консольную утилиту для запуска тестов. И захотел прикрутить туда спиннер. Но не просто спиннер, а такой, что позволил бы использовать оба потока вывода — stderr и stdout, без риска оставить артефакты на экране. Более того, во время анимации мне нужно иметь возможность показывать на экране отчеты по завершившимся тестам, не откладывая это все до конца запуска/остановки спиннера. И так уж почему‑то получилось (спойлер: потому что не слишком просто, иногда совсем невозможно и мало кому нужно), что я не смог найти библиотеку, которая бы мне подошла.
Почему мне не подходило ни одно из готовых решений:
-
Самые простые варианты готовых библиотек не поддерживают одновременные анимацию и запись (github.com/briandowns/spinner, github.com/yarlson/pin).
-
Варианты, позволяющие писать в поток спиннера не ломая его, уже гораздо тяжеловеснее и не поддерживают разделение stdout/stderr (github.com/theckman/yacspin).
-
Варианты, поддерживающие все вышеперечисленное, — только полновесные TUI фреймворки с рендер‑буфером и raw‑режимом.
Весь файл моего приложения весит меньше 9 МБ. Добавление тяжелого фреймворка лишь ради отрисовки спиннера (и как позже получилось — прогресс‑бара) — непозволительное расточительство.
Молодость и радость (наивное решение)
Поэтому я решил написать свой спиннер самостоятельно. На первый взгляд это не слишком сложная задача — нужно знать всего пару вещей:
-
ANSI‑последовательность
"\033[K"визуально очищает всю строку правее текущего положения курсора (есть еще"\033[1K"— очищает все левее, и"\033[2K"— очищает всю строку, но об этом позже) в потоках, которые поддерживают ANSI‑последовательности. -
Байт
'\r'перемещает курсор в начало текущей строки.
Комбинация "\r\033[K", таким образом, позволяет полностью очистить текущую строку и гарантированно переместить курсор в самое начало. Можно использовать и "\033[1K\r", и "\r\033[2K", но зачем печатать больше, когда можно печатать меньше?
Ну а дальше все должно быть просто. Создать свою имплементацию io.Writer и все готово:
var clearBytes []byte = []byte("\r\033[K")var spinnerBytes []byte = []byte("Тут пока что сложно")type Spinner struct { io.Writer}func (s Spinner) Write(data []byte) (int, error) { s.Writer.Write(clearBytes) res, err := s.Writer.Write(data) s.Writer.Write(spinnerBytes) return res, err}
Но, конечно же, все не так просто. Посмотрим на проблемы, которые у нас уже имеются:
-
То, что мы написали — это не спиннер. Это скорее элемент интерфейса — статический текст, закрепленный на последней строке терминала.
-
Мы все еще заворачиваем лишь один поток — любая запись в stdout может привести к появлению артефактов.
-
Самая мелочь — текущая имплементация будет втихую удалять некоторые данные с экрана. Ведь если
dataне заканчивается новой строкой, наша последовательность, отправленная в поток при следующей записи, сотрет всю строку — и наш спиннер, и все, что было до него.
Хорошо, начнем с простого. Будем отслеживать то, что нам отправили, и рисовать спиннер только тогда, когда последний байт — это новая строка.
type Spinner struct { io.Writer frame []byte isDrawn bool}func (s *Spinner) Write(data []byte) (int, error) { if s.isDrawn { s.Writer.Write(clearBytes) s.isDrawn = false } res, err := s.Writer.Write(data) // Внимательный читатель скажет "res-1 < len(data) бессмысленная проверка", // но я отвечу - io.Writer нам предоставляет юзер, так что полагаться на // джентльменские договоренности - это или ошибка новичка или шаг // ультрасеньора. Я пока что не попадаю ни в одну из категорий if err == nil && res > 0 && res-1 < len(data) && data[res-1] == '\n' { s.Writer.Write(s.frame) s.isDrawn = true } return res, err}
Заодно я перенес наши байты фрейма в сам спиннер — это понадобится в дальнейшем. Но мы решили одну из трех проблем. Осталось еще две? На самом деле осталось три проблемы. Мы используем bool для определения состояния спиннера, но у нас нет гарантий, что какой‑нибудь другой конкурентный вызов Write не вмешается в нашу логику. Этот код не является потокобезопасным. Проблема решается несложно — добавим мьютекс на весь блок Write:
type Spinner struct { ... mut *sync.Mutex}func (s *Spinner) Write(data []byte) (int, error) { s.mut.Lock() defer s.mut.Unlock() ...}
Вот теперь наконец‑то у нас всего две крупные проблемы. Одна решается просто — отрисовывать фреймы спиннера можно в фоновой горутине раз в какое‑то время:
func (s *Spinner) Start() context.CancelFunc { t := time.NewTicker(100 * time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) ch := make(chan struct{}, 1) s.mut.Lock() s.frame = []byte("- Running") if s.isDrawn { // Здесь и далее я буду использовать append(packageVar, other...) - // это просто для краткости. Не делайте так в реальном коде. s.Writer.Write(append(clearBytes, s.frame...)) } s.mut.Unlock() go func() { states := []byte{'\\', '|', '/', '-'} idx := 0 Loop: for { select { case <-t.C: s.mut.Lock() s.frame[0] = states[idx] if s.isDrawn { s.Writer.Write(append(clearBytes, s.frame...)) } s.mut.Unlock() idx = (idx + 1) % len(states) case <-ctx.Done(): break Loop } } s.mut.Lock() s.frame = []byte{} if s.isDrawn { s.Writer.Write(clearBytes) } s.mut.Unlock() close(ch) }() return func() { t.Stop() cancel() <-ch }}
Это уже рабочий спиннер. Не без изъянов, но рабочий. Как говорят англоязычные — «так далеко так хорошо». Осталось решить одну проблему — координацию двух потоков. Потому что очень часто записи в stderr и stdout отображаются в одном терминале. Ну и под шумок решим проблему того, что ошибки при записи/очистке спиннера на данный момент просто втихую проглатываются. У нас уже есть мьютекс, а значит, мы можем воспользоваться им, чтобы сериализовать все записи. Единственная проблема — придется разделить поток спиннера и поток записи, но при этом объединить все остальное, связанное со спиннером (так как у них общие isDrawn, mut, frame):
type spinnerState struct { io.Writer sync.Mutex errCh chan error frame []byte isDrawn bool}type Spinner struct { io.Writer state *spinnerState}func (s *Spinner) Write(data []byte) (int, error) { s.state.Lock() defer s.state.Unlock() if s.state.isDrawn { _, err := s.state.Write(clearBytes) s.state.isDrawn = false if err != nil { select { case s.state.errCh <- err: default: } } } res, err := s.Writer.Write(data) if err == nil && res > 0 && res-1 < len(data) && data[res-1] == '\n' { _, localErr := s.state.Write(s.state.frame) if localErr != nil { select { case s.state.errCh <- localErr: default: } } else { s.state.isDrawn = true } } return res, err}
Метод Start получает все те же замены s.Writer.Write → s.state.Write, s.mut.Lock → s.state.Lock и так далее, и все так же пишет ошибки в канал через неблокирующий select. Почему мы докладываем об ошибках именно так? Потому что у Start буквально нет другого способа (ну можно возвращать канал с ошибками еще, концептуально мало меняется), а во Write было бы странно, к примеру, на запись в stdout получить ошибку записи в stderr.
В целом, это плюс‑минус все. Для одноразового спиннера, который пишется под одну конкретную задачу. Проблема одноразовых решений под задачу в том, что когда‑нибудь такую задачу придется решать еще раз… И за неимением библиотек, я решил — а бахну‑ка я свою. Как водится с динамической версткой и прогресс‑барами.
Библиотека? Лучше — секция о библиотеке
Итак, переход к библиотечному коду. Что нам понадобится?
-
На данный момент
Startне идемпотентен, а библиотечный код не должен ломаться от таких мелочей. -
Хотелось бы иметь возможность изменять отображаемые фреймы в процессе.
Какие варианты есть у нас для решения этих проблем? Нам, конечно, придется расширять внутреннее состояние spinnerState: добавить туда флаг состояния и что‑то, что поможет нам динамически выбирать новые фреймы. Попробуем описать это:
type FrameFunc func() ([]byte, error)type spinnerState struct { ... getFrame FrameFunc isRunning bool}
Но здесь возникают новые проблемы:
-
Новое состояние, доступ к которому должен быть сериализован для избежания датарейсов.
-
При установке новой
getFrameновый фрейм должен отобразиться сразу же и не заменяться более ранним. -
Так как
FrameFuncбудет предоставляться пользователями — они могут блокировать, паниковать или возвращать результат очень медленно. А еще они могут не быть потокобезопасными.
Мы можем сериализовать все изменения внутреннего состояния при помощи того же самого мьютекса, который мы уже ввели для Write. Но внутреннее состояние (isRunning, getFrame) и использование Write — это разные вещи, которые не обязательно должны синхронизироваться друг с другом. Поэтому мы можем добавить еще один мьютекс для состояния. Этот вариант добавляет еще больше сложностей в синхронизации — некоторые секции должны забирать оба мьютекса, некоторые только один — поэтому теперь нам будет нужно следить за порядком блокировок, гарантированным освобождением и так далее. Поверх всего этого также накладываются отрисовки результатов пользовательских FrameFunc, обработка их ошибок и сериализация, а еще лучше — объединение вызовов. Поэтому для синхронизации я выбрал другой путь, который заодно дал мне возможность собрать всю логику в одном месте. Я пришел к акторной модели:
type spinnerState struct { ... ctx context.Context task chan any revision uint64 wg sync.WaitGroup inFlight bool}type start struct { notify chan error}type stop struct { notify chan error}type setGetFrame struct { getFrame FrameFunc notify chan error}type drawFrame struct { revision uint64 frame []byte err error}func (st *spinnerState) set(newFrame []byte) error { st.Lock() defer st.Unlock() st.frame = newFrame if st.isDrawn { _, err := st.Write(append(clearBytes, st.frame...)) return err } return nil}func (st *spinnerState) startBackground(t *time.Ticker) { go func() { for { select { case task := <-st.task: switch typed := task.(type) { case start: if st.isRunning { continue } // Дожидаемся вызовов getFrame, которые могут еще быть // активны st.wg.Wait() // Синхронно получаем новый фрейм, пишем его через set case stop: if !st.isRunning { continue } // Очищаем фрейм, проставляем st.isRunning = false case drawFrame: st.inFlight = false if typed.revision != st.revision || !st.isRunning { continue } if typed.err != nil { continue } err := st.set(typed.frame) if err != nil { select { case st.errCh <- err: default: } } case setGetFrame: if typed.getFrame == nil { typed.notify <- errors.New("unable to set nil FrameFunc") close(typed.notify) continue } st.getFrame = typed.getFrame st.revision += 1 st.wg.Wait() if st.isRunning { frame, err := st.getFrame() // err != nil не обозначает ошибку в обычном // смысле - я решил использовать ошибки от // FrameFunc как сигнал, что не нужно ничего // перерисовывать, поэтому она не попадает // в канал if err == nil { err = st.set(frame) if err != nil { st.isRunning = false } typed.notify <- err } } close(typed.notify) default: continue } case <-t.C: if st.inFlight || !st.isRunning { continue } st.wg.Add(1) st.inFlight = true go func(getFrame FrameFunc, revision uint64) { frame, err := getFrame() st.wg.Done() select { case st.task <- drawFrame{ revision: revision, frame: frame, err: err, }: case <-st.ctx.Done(): } }(st.getFrame, st.revision) case <-st.ctx.Done(): return } } }()}
Конечно, мы уже отошли от реалистичного кода — иначе статья была бы раза в два длиннее — поэтому я опускаю многие моменты с обработкой ошибок, защитой от глупых решений и так далее. Это скорее примерное описание того, как можно дойти до готового решения. Но даже так здесь приведен код, написанный с опытом долгих сессий дебаггинга, отлова гонок и в целом немалого количества итераций.
Изначально это все не выглядело так красиво — я даже умудрился затащить golang.org/x/sync/singleflight — ведь логично, что раз мне нужно гарантированно объединять вызовы FrameFunc, лучше всего использовать готовые и надежные решения. К сожалению, надежность решений не спасает от проблемы с прокладкой между стулом и клавиатурой. В качестве ключа объединения я выбрал ревизию, которая на тот момент имела совсем другой смысл — поколение фрейма, а не FrameFunc. И здесь, намешав значений в одну переменную (это и ключ объединения, и механизм защиты от протухших записей), я разработал не фичу, а баг. Баг, который решился отказом от singleflight и разделением значений revision. Поколение FrameFunc осталось у revision, а вот исключение в критической секции переехало в inFlight bool.
Но и помимо этого здесь уже собраны некоторые решения не самых очевидных проблем — к примеру, синхронизация start и setGetFrame с st.wg для избежания асинхронных вызовов даже разных FrameFunc. Дальше остается только подвязать это все к методам нашего Spinner, сделать конструктор для заворачивания двух io.Writer за одним спиннером и в целом все готово. Итак, мы разобрались с асинхронными, но объединенными вызовами FrameFunc, с синхронизацией взаимодействия со внутренним состоянием и даже остановкой и запуском самого спиннера. Пора переходить к самому интересному.
Жадность фраера сгубила
В целом на этом уже можно было остановиться. У меня на руках была библиотека, способная отрисовывать как простые анимированные спиннеры, так и полноценные прогресс‑бары. Правда, без подгонки размера последних под ширину экрана. И если это было позволительно небольшим по размеру анимациям, то для баров это уже сродни преступлению. «В чем сложность?» — спросите вы. В том, что это невозможно сделать нормально. Нет, буквально. Что мы имеем:
-
Если размер фрейма больше ширины терминала — фрейм перенесется на новую строку. А вы ведь помните нашу ANSI‑последовательность? Перенос курсора в начало текущей строки и удаление всего, что правее курсора.
-
Среди всего зоопарка ОС и терминалов очень тяжело со стандартизированными способами достать текущую ширину терминала.
-
Юзеры не уважают мьютексы, поэтому терминалу нельзя сказать «падажжи, не меняй размер».
-
Информация об изменении ширины терминала приходит асинхронно.
-
Ну и отдельно о Windows — там нет удобного способа получить сигнал о смене ширины вообще. Можно либо влезать в API, которое повлияет на работу приложения (ну практически перетянет терминал в raw‑режим), либо старый добрый поллинг.
Все вышеперечисленное значит, что как бы мы ни старались, ничто не защитит нас от следующей ситуации:
-
Проверяем ширину/слушаем событие изменения ширины, видим ширину 60.
-
Записываем фрейм шириной 40 — потому что использовать всю ширину терминала нам не позволяет религия. Ну или просто без причины. Неважно сколько — важно, что больше 20.
-
На новый фрейм повторяем п.1 и получаем все те же 60.
-
Пишем нашу замечательную последовательность для очистки строки. Но хитрый пользователь ждал такой неосмотрительности, поэтому в момент отправки системного вызова он тянет за край окна и сжимает терминал до 20. Наш фрейм, который мы пытаемся стереть, теперь занимает две строки из‑за переформатирования после изменения размера. Последовательность затирает одну строку.
-
Переходим на п. 1.
Здесь, конечно, нужен небольшой дисклеймер — это будет работать именно так только в терминалах с автоматическим переформатированием. К сожалению, или к счастью, большинство терминалов как раз такие. Поэтому мы опустим сценарий, в котором перенос не происходит. В итоге мы получим следующую картину:
[=================== <- Артефакт! Нижняя половина была стерта, а эта осталась[========> ] 22/33 <- Здесь мы уже спохватились и сжали фрейм
Хорошо, гарантировать 100% корректное отображение вообще без артефактов мы не можем. А что можем? Как минимум — реализовать фреймы динамического размера и best‑effort подгонку под размер при изменении ширины. Для начала представим, как бы выглядело удобное нам API для получения актуальной ширины. Лично мне очень нравятся функции:
type WidthFunc func() inttype spinnerState struct { ... width int getWidth WidthFunc}
Тогда мы можем достаточно тривиально получить best‑effort исправление артефактов:
// Не упомянул в тексте - но мы опять будем прибегать к// ANSI-трюкам. Конкретно эта последовательность передвигает// курсор на строку вышеvar jumpLineUp = []byte("\033[A")// Этот метод должен вызываться только// внутри синхронизированного мьютексом блокаfunc (st *spinnerState) clear() error { if !st.isDrawn { return nil } st.isDrawn = false oldWidth := st.width st.width = st.getWidth() if st.width != 0 && st.width < oldWidth { // Спасаем ситуацию, считаем строки - len(st.frame)/st.width lines := max((len(st.frame)+st.width-1)/st.width, 1) // Как минимум одну строку нам точно надо стирать _, err := st.Write(clearBytes) // Ну а теперь все оставшееся clearLineUp := append(jumpLineUp, clearBytes...) for range lines-1 { _, wErr := st.Write(clearLineUp) err = errors.Join(err, wErr) } return err } else { _, err := st.Write(clearBytes) return err }}
В целом этого уже достаточно для того, чтобы исправлять большинство артефактов. Для пущей нарядности и чувства собственного достоинства мы можем добавить подгонку размера видимого фрейма под новую ширину, проверку не только на очистке, но и на записи, ну и наконец‑то после всего этого сломаться на прогресс‑баре вида 🍗🍗🐔🐔🐔🐔🐔🐔🐔🐔 20/100. Ну да, а чего вы хотели? Впрочем, это мелочи — тут нужно просто найти библиотеку, которая умеет в подсчет видимой ширины строк. Я выбрал github.com/clipperhouse/displaywidth. И встраивается она предельно просто:
var graphemeOpts = displaywidth.Options{ ControlSequences: true,}func (st *spinnerState) clear() error { ... // st.visible здесь это то, что было передано в Writer на последней отрисовке lines := max((graphemeOpts.Bytes(st.visible)+st.width-1)/st.width, 1) ...}
Обратили внимание? ControlSequences: true — нам нужно быть в курсе ANSI‑последовательностей. Потому что в visible кроется еще одна мина. Цвета. Цвета в терминале обозначаются при помощи тех же самых ANSI‑последовательностей. Но эти последовательности абсолютно не отображаются сами по себе — имеют нулевую ширину. Более того, при подгонке фрейма под видимую ширину хорошая библиотека должна сохранять все ANSI‑последовательности в корректном порядке, иначе можно случайно окрасить терминал пользователя или сломать его курсор. Поэтому наш спиннер продолжает обрастать хелперами.
func (st *spinnerState) adjust() { // Быстрый путь - так как каждый байт гарантированно не может приносить // больше 1 столбца видимой ширины if st.width >= len(st.frame) { st.visible = st.frame return } iter := graphemeOpts.BytesGraphemes(st.frame) total := 0 canTakeMore := true result := bytes.Buffer{} for iter.Next() { size := iter.Width() cur := iter.Value() // Записываем все, что не отображается if size == 0 { result.Write(cur) continue } if canTakeMore && total+size <= st.width { total += size result.Write(cur) continue } canTakeMore = false } st.visible = result.Bytes()}
Отлично, с этим мы даже почти что разобрались. Осталась маленькая несостыковочка. Как заставить терминал сообщать нам о своей ширине в том виде, который удобен нам? Лично я выбрал путь наименьшего сопротивления:
func CachedGetWidth(sigwinch <-chan struct{}, getWidth WidthFunc) WidthFunc { getWidth = zeroOnPanic(getWidth) current := atomic.Int64{} current.Store(int64(getWidth())) go func() { for range sigwinch { Drain: for { select { case _, ok := <-sigwinch: if !ok { break Drain } default: break Drain } } current.Store(int64(getWidth())) } }() return func() int { return int(current.Load()) }}func WidthFromFile(file *os.File) (WidthFunc, error) { if file == nil { return func() int { return -1 }, errors.New("unable to determine width for nil file") } // Тут просто нужно вернуть лямбду, заворачивающую // вызов внешней библиотеки, и в этом тексте уже и // так слишком много кода, поэтому вот вам кот: 😺}func DefaultGetWidth(ctx context.Context) (WidthFunc, error) { if ctx == nil { ctx = context.Background() } getWidth, err := WidthFromFile(os.Stderr) if err != nil { return nil, err } return CachedGetWidth(DefaultSigwinch(ctx), getWidth), nil}
Если вам лень читать код — то я просто регистрирую обработчик сигнала SIGWINCH (на Unix‑подобных системах), который сообщает об изменении параметров окна. На каждый сигнал я пользуюсь библиотекой golang.org/x/term, чтобы получить актуальную ширину и сохраняю ее в атомике. Таким образом, эту функцию достаточно дешево вызывать даже на самом горячем пути — оверхед минимален — уж точно не сравнимый с системным вызовом.
Заключение
В целом, по моим представлениям все, описанное выше — это максимум, которого можно добиться без буферизации и переключения терминала в raw‑режим, а именно:
-
Синхронизированная запись в stderr и stdout с сохранением разделения потоков
-
Динамические размеры и анимации (от FPS до самой конструкции фреймов)
-
Контроль над жизненным циклом анимации
На самом деле здесь даже не была затронута куда более скучная, но полезная часть процесса написания библиотеки — дизайн и реализация API. Поэтому если вам интересно посмотреть на все, что я здесь описал, в чуть более красивой упаковке (и чуть менее красивом коде — задним умом код писать проще) — милости прошу в мой репозиторий. Там вы найдете это и даже немного больше (Как построить прогресс‑бар? Как запустить это все с минимальными усилиями?). Библиотека называется spinq, что расшифровывается как «Simple sPINner toolQit» и на момент написания еще не получила стабильного релиза, но хочется верить, что он не за горами.
А на этом у меня все. До скорых, надеюсь, встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1076736/