1. Зачем CLI нужен прогресс как поток событий
Когда вы пишете CLI‑приложение, сначала кажется, что прогресс — это роскошь. Ну правда: команда запустилась, что-то поделала, что-то вывела, завершилась. Но как только операция занимает больше пары секунд (сетевые запросы, загрузка пачки данных, пересборка индекса, миграция файла), пользователь начинает подозревать худшее: «оно зависло», «оно умерло», «оно обиделось на меня за неправильный аргумент». И в этот момент прогресс — это не декорация, а UX‑страховка.
Проблема в том, что прогресс бывает очень активный и может обновляться много раз. Если мы попытаемся «просто печатать изнутри функции, где идёт работа», код очень быстро превращается в кашу: бизнес‑логика перемешивается с выводом, тестировать становится больно, а изменить формат вывода — ещё больнее.
Хороший подход, который идеально ложится на AsyncStream, такой: долгая операция публикует события, а CLI‑слой подписывается и превращает события в строки, а затем печатает финальный отчёт. И это ровно то, что мы сейчас сделаем.
2. Архитектура: producer делает события, consumer делает строки
Если объяснять совсем по‑человечески, то мы разделяем две роли.
Producer — это часть приложения, которая выполняет реальную работу (например, fetch-many скачивает данные и сохраняет их). Consumer — это CLI‑слой, который отвечает за разговор с человеком: печатает «старт», «прогресс», «ошибки», «итог». Между ними мы кладём «конвейер» в виде AsyncStream.
Важно: AsyncStream — это AsyncSequence, то есть мы читаем его обычным for await. И так же важно: поток должен завершиться через finish(), чтобы цикл for await вышел сам. Это прям часть контракта: без finish() читатель может ждать следующий элемент бесконечно.
Схематично это выглядит так:
flowchart LR
A[Producer: долгая операция] -->|yield ProgressEvent| B[AsyncStream<ProgressEvent>]
B -->|for await event| C[Consumer: CLI-вывод]
C -->|print| D[Терминал]
C -->|собирает итоги| E[Итоговый отчёт]
Идея простая, но очень дисциплинирующая: producer не печатает, consumer не лезет внутрь бизнес‑логики. Если вам хочется нарушить это правило — значит, вы устали и вам нужен чай.
3. События и рендеринг прогресса
ProgressEvent как «словарь» между слоями
Когда мы говорим «события», нужно договориться: какие именно? Если события будут «просто строками», мы снова потеряем структуру: невозможно нормально посчитать проценты, невозможно сделать «тихий режим», невозможно красиво сформировать финальный отчёт. Поэтому события лучше делать типизированными — чаще всего через enum.
Ниже — минимальный, но полезный набор событий. Он не претендует на «единственно правильный», но для CLI‑прогресса работает прекрасно.
import Foundation
enum ProgressEvent: Sendable {
case started(total: Int)
case advanced(done: Int, total: Int)
case info(String)
case completed
}
Мы сделали Sendable, потому что события потенциально пересекают границы задач (producer работает в Task, consumer читает в другом месте). В Swift 6 компилятор к этому относится строго, и это скорее плюс: он заставляет нас заранее думать, какие данные мы «перетаскиваем» между конкурентными кусками кода.
Теперь полезно зафиксировать, что каждое событие — это данные, а не готовый текст. Текст появится позже, на уровне рендера.
Рендер: превращаем событие в строку
Когда у нас есть ProgressEvent, следующий шаг — функция, которая превращает его в человеческую строку. Здесь важно две вещи.
Во‑первых, рендер должен быть чистым: на вход событие, на выход строка, без побочных эффектов. Во‑вторых, формат вывода — это часть UX, и его хочется менять, не переписывая бизнес‑логику.
Сделаем простой рендерер. Он будет печатать прогресс в стиле Progress: 3/10 (30%). Проценты — штука необязательная, но в терминале они работают удивительно успокаивающе.
import Foundation
func render(_ event: ProgressEvent) -> String {
switch event {
case .started(let total):
return "Start: 0/\(total)"
case .advanced(let done, let total):
let percent = total == 0 ? 100 : (done * 100 / total)
return "Progress: \(done)/\(total) (\(percent)%)"
case .info(let text):
return "Info: \(text)"
case .completed:
return "Done"
}
}
Обратите внимание на защиту от total == 0. В реальном мире «ноль элементов» встречается чаще, чем кажется, особенно когда пользователь передал пустой список ID. И очень обидно падать с делением на ноль только потому, что никто не предупредил нас о пустоте.
4. Где разместить AsyncStream в проекте LibraryCLI
Теперь встраиваем это в наше учебное приложение. Мы уже строили LibraryCLI как набор команд, где CLI‑слой парсит команду и вызывает application‑service. Здесь логично сделать так: сервис даёт метод, который возвращает AsyncStream<ProgressEvent>, а CLI‑слой просто «смотрит» этот поток и печатает строки.
Идеальная «жертва» для прогресса — команда наподобие fetch-many, которая делает N сетевых операций. Сейчас мы не будем усложнять, а сфокусируемся на прогрессе.
Пусть сервис выглядит так (упрощённо): на вход список BookID, внутри он по одному «получает данные», а наружу выдаёт поток событий.
import Foundation
struct FetchService {
func fetchMany(ids: [String]) -> AsyncStream<ProgressEvent> {
AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
Task {
continuation.yield(.started(total: ids.count))
for (i, id) in ids.enumerated() {
continuation.yield(.info("Fetching \(id)"))
continuation.yield(.advanced(done: i + 1, total: ids.count))
}
continuation.yield(.completed)
continuation.finish()
}
}
}
}
Здесь мы выбрали .bufferingNewest(1). Почему это разумно для прогресса? Потому что прогресс — это «состояние», а не «журнал событий». Обычно человеку важнее увидеть последнее значение прогресса, чем получить тысячу промежуточных шагов.
Отдельный момент: yield(_:) возвращает YieldResult, и теоретически мы могли бы анализировать, были ли значения отброшены. Но в CLI‑прогрессе это чаще всего не критично: если прогресс обновился 200 раз, а пользователь увидел 50 — он всё равно счастливее, чем без прогресса.
И ещё раз: мы обязаны вызвать finish(). Именно finish() переводит поток в терминальное состояние, после чего consumer выйдет из for await.
5. Интеграция в CLI и итоговый отчёт
Теперь самое вкусное: как это выглядит на стороне CLI. Мы хотим, чтобы обработчик команды:
- запустил stream,
- печатал каждое событие,
- после завершения вывел финальную строку «итоговый отчёт».
В самом простом варианте итоговый отчёт может быть отдельным print после цикла. Даже это уже приятно: пользователь видит, что всё закончилось и CLI не «пропал в тумане».
import Foundation
func runFetchMany(ids: [String]) async {
let service = FetchService()
let stream = service.fetchMany(ids: ids)
for await event in stream {
print(render(event))
}
print("Report: fetched \(ids.count) items")
}
Это простейшая интеграция, но уже важная: мы показали однонаправленный поток данных. Producer публикует ProgressEvent, consumer читает и печатает строки.
Теперь добавим чуть больше реализма: итоговый отчёт обычно хочется собирать не из входных данных (ids.count), а из реального результата. Например, часть ID могла завершиться «ошибкой». Чтобы не уходить в AsyncThrowingStream (здесь мы сознательно держимся не‑throwing потока), добавим в события информацию об успехах/ошибках и соберём простой отчёт.
Сделаем расширенную модель событий: «успех по ID», «ошибка по ID», и финальное событие completed без данных, потому что данные мы соберём сами в consumer.
import Foundation
enum FetchEvent: Sendable {
case started(total: Int)
case succeeded(id: String)
case failed(id: String, message: String)
case completed
}
И consumer, который собирает числа:
import Foundation
func consumeFetch(_ stream: AsyncStream<FetchEvent>) async {
var ok = 0
var failed = 0
for await event in stream {
switch event {
case .succeeded(let id):
ok += 1; print("OK:", id)
case .failed(let id, let msg):
failed += 1; print("FAIL:", id, "-", msg)
default:
break
}
}
print("Report: ok=\(ok), failed=\(failed)")
}
Да, это чуть многословнее. Зато теперь consumer реально формирует отчёт из фактов, а не из ожиданий.
6. Вывод в одну строку и корректное завершение
«Обновлять одну строку» в терминале
Иногда хочется прогресс «как в установщике»: одна строка, которая обновляется. Это делается через \r (carriage return) и terminator: "". Но тут важно не переиграть: разные терминалы ведут себя по‑разному, а лог‑файлы вообще не любят «перерисовку».
Поэтому разумная стратегия: сделать отдельную маленькую функцию, которая печатает прогресс в одной строке, но финальные сообщения печатает обычным print.
import Foundation
func printInlineProgress(_ text: String) {
print("\r" + text, terminator: "")
fflush(stdout)
}
И использовать её только для .advanced, а остальные события печатать обычными строками:
import Foundation
func showProgress(_ stream: AsyncStream<ProgressEvent>) async {
for await event in stream {
switch event {
case .advanced:
printInlineProgress(render(event))
default:
print("\n" + render(event))
}
}
print("\nReport: completed")
}
Это не идеальный «терминальный UI», но для учебного CLI уже выглядит убедительно. И главное: мы не трогаем producer. Он по‑прежнему выдаёт события, а consumer сам решает, как их показывать.
Почему finish() — часть контракта, а не формальность
Сейчас у нас уже есть ощущение «всё работает». Но AsyncStream довольно строгий по контракту: если producer не вызвал finish(), consumer не имеет права «догадаться», что всё закончилось. Он будет ждать следующий элемент. А в CLI это выглядит так, будто команда повисла, хотя вся работа давно закончена.
Это поведение не «особенность реализации», а часть модели: finish() переводит поток в терминальное состояние, после чего итератор возвращает nil (и цикл for await завершается).
Дополнительно есть ещё один важный момент: если consumer завершил чтение раньше (например, вышел по break), producer может продолжать работать «в пустоту». Для таких сценариев существует onTermination, чтобы producer узнал, что потребление закончено, и остановил работу. Это встроенная часть AsyncStream.Continuation.
Даже если сегодня мы не строим сложный «вечный стрим», полезно держать в голове: finish() — для нормального конца, onTermination — для раннего конца.
Мини-таблица: события и типичный вывод
Иногда полезно зафиксировать договор в виде маленькой таблицы. Это особенно помогает, когда вы через неделю возвращаетесь к коду и уже не помните, почему .info печатался с префиксом, а .advanced — без.
| Событие | Смысл | Типичный вывод в CLI |
|---|---|---|
|
Начали операцию, знаем размер работы | |
|
Прогресс изменился | |
|
Небольшое сообщение «что сейчас делаем» | |
|
Завершили работу | |
Эта таблица — не закон, но она задаёт стиль: события структурированные, строки — человекочитаемые.
7. Типичные ошибки
Ошибка №1: producer печатает print() прямо внутри бизнес‑логики.
Сначала это кажется удобным: «я же просто хочу увидеть прогресс». Но затем внезапно оказывается, что вы хотите изменить формат, хотите отключить прогресс флагом, хотите протестировать сервис без реального stdout — и всё ломается. Привычка «сервис не печатает, сервис сообщает события» экономит вам часы будущей боли.
Ошибка №2: забыли вызвать finish() и consumer завис в for await.
Это одна из самых частых проблем с AsyncStream: работа закончилась, но поток не завершён, и цикл ждёт «следующее значение». В CLI пользователь воспринимает это как зависание. Нужно прямо дисциплинированно ставить continuation.finish() в конце producer‑задачи, и помнить, что это часть контракта AsyncStream.
Ошибка №3: выбрали .unbounded буфер для частых событий и «накормили память».
Если producer может быстро генерировать много событий (например, 1000 обновлений прогресса в секунду), а consumer печатает медленно, неограниченный буфер начнёт расти. Для прогресса почти всегда логичнее .bufferingNewest(1) или небольшой лимит, потому что важнее последнее состояние.
Ошибка №4: сделали события строками и потеряли структуру.
Когда события — строки, вы не можете нормально посчитать проценты, не можете отделить «информационное сообщение» от «прогресса», не можете сделать альтернативный рендер (например, JSON‑вывод для машин). enum сначала кажется «лишним», но потом оказывается, что это самая дешёвая инвестиция в ясность.
Ошибка №5: пытаются получить итоговый отчёт «из воздуха», не собирая факты.
Очень соблазнительно вывести Report: fetched N просто по входному списку. Но если внутри были ошибки, часть данных не сохранилась, часть была пропущена — отчёт будет врать. Либо включайте факты в события, либо собирайте счётчики в consumer, либо передавайте финальный отчёт отдельным событием. Главное — не заставлять пользователя угадывать, что реально произошло.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ