CLI приложения #18. Конвейеры и стандартный ввод
В шестом посте мы разбирали два потока вывода — stdout и stderr. Пора рассказать про третий, входящий. Называется он стандартным вводом, stdin, и номер у него нулевой.
Обычно stdin — это клавиатура: Console.ReadLine() читает то, что набрал человек. Но точно так же, как вывод можно перенаправить в файл, ввод можно перенаправить из файла. Или из другой программы.
Вот тут и появляется вертикальная черта:
program1 | program2
Эта конструкция называется конвейером. Читается просто: всё, что первая программа напишет в stdout, попадёт во вторую на stdin. Выход одного становится входом другого, напрямую, без промежуточных файлов.
Именно на этом принципе построен весь консольный мир. Каждая утилита делает одну вещь, а сложные задачи собираются из них, как из кубиков. И чтобы ваше приложение можно было использовать в таком конвейере, оно должно уметь читать stdin.
Читается он просто:
// построчно, пока данные не кончатся
string? line;
while ((line = Console.In.ReadLine()) != null)
{
Console.Out.WriteLine(line.ToUpper());
}
Console.In — это тот же поток, что читает Console.ReadLine(), только в виде объекта TextReader. Когда данные кончаются, метод возвращает null. Если ввод пришёл из файла — на последней строке файла. Если с клавиатуры — когда пользователь нажмёт Ctrl+D в Unix или Ctrl+Z и Enter в Windows.
Теперь как это принято оформлять. В мире CLI есть договорённость: дефис вместо имени файла означает стандартный ввод. Её поддерживают десятки утилит, и пользователи её знают.
Соберём команду, которая считает строки — хоть в файле, хоть в потоке:
using System.CommandLine;
using System.Text;
internal class Program
{
private static int Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
var pathArgument = new Argument<string>("path")
{
Description = "Файл или - для чтения из стандартного ввода",
DefaultValueFactory = _ => "-"
};
var countCommand = new Command("count", "Посчитать строки и слова");
countCommand.Arguments.Add(pathArgument);
countCommand.SetAction(parseResult =>
{
var path = parseResult.GetValue(pathArgument)!;
if (path == "-" && !Console.IsInputRedirected)
{
Console.Error.WriteLine(
"Нет данных на входе. Укажите файл или передайте данные через конвейер");
return 1;
}
if (path != "-" && !File.Exists(path))
{
Console.Error.WriteLine($"Файл не найден: {path}");
return 2;
}
using TextReader reader = path == "-"
? Console.In
: new StreamReader(path);
var lines = 0;
var words = 0;
string? line;
while ((line = reader.ReadLine()) != null)
{
lines++;
words += line.Split(
(char[]?)null,
StringSplitOptions.RemoveEmptyEntries).Length;
}
if (Console.IsOutputRedirected)
Console.Out.WriteLine($"{lines} {words}");
else
Console.Out.WriteLine($"Строк: {lines}, слов: {words}");
return 0;
});
var rootCommand = new RootCommand("Учебное CLI приложение");
rootCommand.Subcommands.Add(countCommand);
return rootCommand.Parse(args).Invoke();
}
}
Ключевая строка здесь — выбор источника. Дальше код работает с TextReader и вообще не знает, откуда пришли данные: из файла, из конвейера или с клавиатуры.
Проверяем все три способа:
# из файла (добавьте файл в текущее расположение)
dotnet run -- count data.txt
# из конвейера
Get-Content data.txt | dotnet run -- count -
# прямо из строки
"первая строка`nвторая строка" | dotnet run -- count -
Важно!
Обратите внимание на проверкуConsole.IsInputRedirected. Без неё программа, запущенная какmyapp count -без данных на входе, молча зависнет в ожидании клавиатуры. Пользователь решит, что она повисла, и будет прав.
Ещё один момент — чтение целиком. Соблазнительно написать так:
var text = Console.In.ReadToEnd();
Это нормально для небольших данных, но означает, что всё содержимое окажется в памяти. А через конвейер вам могут подать лог на несколько гигабайт. Построчное чтение из примера выше работает потоково: обработали строку — забыли про неё.
И отдельная засада для Windows. PowerShell передаёт данные внешним программам в своей кодировке, которая по умолчанию может не совпадать с UTF-8 — и русский текст приедет в виде вопросительных знаков. Лечится явной настройкой (пост)
В самом приложении симметрично задаём кодировку чтения:
Console.InputEncoding = Encoding.UTF8;
Поддержка stdin — это то, что превращает вашу утилиту из "вещи в себе" в деталь конструктора. Пользователи придумают такие связки, которые вам и в голову не приходили, и ничего дописывать для этого не придётся.