CLI приложения #6. stdout и stderr

В прошлом посте ошибки выводились через Console.Error.WriteLine, а не через привычный Console.WriteLine. Разберёмся, зачем так.
Из любой запущенной программы выходит не одна, пусть будет, «труба» с текстом, а две.
Первая называется стандартным выводом, сокращённо stdout. В неё идёт то, ради чего программу вообще запускали: результат работы.
Вторая называется потоком ошибок, stderr. В неё идёт всё служебное: сообщения о ходе работы, предупреждения, ошибки.

stdout в data.json, stderr на экран: схема потоков

схема потоков вывода

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

myapp export > data.json

Значок > — это перенаправление. Он говорит оболочке: не показывай текст на экране, а запиши его в файл. Но забирает он только первую трубу, stdout. Вторая по-прежнему идёт на экран.
И вот тут выясняется, кто как писал вывод. Если приложение печатало через Console.WriteLine вообще всё подряд, то в data.json окажется примерно такое:
text

Начинаю экспорт...
Подключение установлено
{"items":[...]}
Готово! Обработано 42 записи

Файл испорчен. Это уже не JSON, его не откроет ни одна программа, точнее открыть то откроет, но распознать json не сможет. А если бы служебные строки шли в stderr, в файл попала бы ровно одна строка с данными, а всё остальное осталось бы на экране — там, где оно и нужно человеку.

Отсюда правило: вstdoutидёт только результат, всё остальное — вstderr.

// результат работы — его сохранят в файл или передадут дальше
Console.Out.WriteLine(json);

// служебное — человеку на экран
Console.Error.WriteLine("Обработано записей: 42");

Console.WriteLine — это то же самое, что Console.Out.WriteLine (привет Java с её system.out.printLn - похоже, не так ли)), просто короче. Когда в приложении используются оба потока, лучше писать полную форму: сразу видно, что автор выбирал осознанно, а не написал первое, что подсказал IntelliSense.
Если аналогия с трубами кажется абстрактной — вспомните обычный логгер. Там ведь тоже не один канал: LogInformation и LogError живут отдельно, и вы можете настроить, чтобы информационные сообщения шли в файл, а ошибки — ещё и в почту. Разделение по назначению.
Кстати, стандартный консольный логгер в .NET по умолчанию отправляет в stderr вообще ничего — все уровни, включая ошибки, летят в stdout. Чинится одной настройкой:

builder.Logging.AddConsole(options =>
{
    options.LogToStandardErrorThreshold = LogLevel.Warning;
});

Теперь всё, что уровнем от Warning и выше, пойдёт во вторую трубу.

Важно!
Попадание в stderr не означает «случилась ошибка». Оно означает «это сообщение не является результатом работы». Прогресс-бар, до которого мы доберёмся через несколько постов, тоже пишется в stderr — иначе он изуродует любой сохранённый файл.

Полезно знать и обратную сторону: программа может выяснить, смотрит на неё человек или её вывод перенаправили.

if (Console.IsOutputRedirected)
{
    // пишем в файл: голые данные, без украшений
    Console.Out.WriteLine(json);
}
else
{
    // человек за терминалом: можно с отступами и подсветкой
    Console.Out.WriteLine(prettyJson);
}

Именно так устроен, например, git log: в терминале он красит текст и открывает его постранично, а при перенаправлении в файл отдаёт простой текст. Никакой магии, всего одна проверка.

Заведите привычку сразу: если не результат — значит, stderr. Не придётся переделывать потом по всему проекту.

И теперь самое главное и, пожалуй, неочевидное: коды возврата, о которых мы говорили в прошлом посте, не попадают ни в один из потоков вывода!

Поэтому, правильный путь для организации и визуальной информации и отдачи корректной информации об успешности выполнении задачи - вернуть число из Main (или из SetAction), а в stderr вывести текстовое описание для пользователя:

Console.Error.WriteLine($"Файл не найден: {path}");
return ExitCode.FileNotFound;   // число уходит в ОС, а не на экран