Как я работаю с командной строкой
Привет, Хабр! Многие пользовались консольными приложениями (тот же git). Недавно решил создать свое консольное приложение для управления роутером. По всем правилам, сначала разработал ядро, содержащее бизнес логику, написал тесты и затем приступил к соединению бизнес логики с представлением. Все шло хорошо, до того момента как мне понадобилось парсить аргументы командной строки. В этом посте расскажу как я решил эту задачу.
Введение
В общих чертах задача состояла следующая:
Спарсить аргументы поданые на вход.
Понять, что хочет пользователь.
Выполнить необходимую команду.
В процессе приходил к нескольким вариантам. Буду рассказывать хронологически.
Нативный способ
Первый способ — в лоб. В .NET нам передается уже готовый массив аргументов static void Main(string[] args) .
Поэтому просто итерируемся по этому массиву и находим команду. Звучит просто. Сложности возникают когда:
Появляются аргументы ключ-значение или флаги —verbose grep -i ‘hello, world!’ .
Команды вложенные ( нужно учитывать вложенность git remote add ).
Нужно собственно выполнить код: скорее всего бизнес логика будет выделена в отдельные функции расположенные в классе, в котором Main и содержится.
Но это не значит что решение не достойно упоминания. Оно допустимо если:
Аргументы однородны. Например, легкий клон mv — все аргументы, кроме последнего файлы для перемещения, а последний — место куда перемещаем.
Делаем MVP. Нам просто нужно захардкодить 1-2 команды для нашего proof of concept*.
* Этот подход я использовал в самом начале разработки пет-проекта и когда понял, что приложение рабочее — моя мотивация повысилась (это тоже плюс).
Но с ростом функциональности и выше перечисленных трудностей я начал искать другие пути парсинга и пришел к следующему варианту.
Готовая библиотека
Я решил не изобретать велосипед и найти готовые решения. В экосистеме .NET довольно популярна библиотека CommandLineParser. Ведет историю, начиная с 2006 года. Решил использовать ее. Ее достоинствами считаю:
Декларативный способ описания аргументов (Атрибуты Verb, Option).
Поддержка отображения помощи (Help Text, Usage).
Поддержка коротких (-v) и длинных (—verbose) опций.
Автоматическое конверитрование типов опций (IEnumerable, bool, string . ).
Поддержка не только в C#, но и F#, VB.
Это малая часть ее возможностей. Больше описано в ее README.md.
Все шло хорошо вплоть до определенного момента. Выше я упомянул команду git remote add . К сожалению, CommandLineParser не имеет поддержки вложенных команд. (Можно использовать костыли по типу git remote-add или git «remote add» , но выглядит не очень, на мой взгляд).
Продолжил искать дальше, но не нашел подходящей библиотеки (возможно искал плохо). В результате пришел к выводу, что нужно делать самому.
К/Ф «Пятый элемент»
Свой парсер
Я приступил к созданию своего велосипеда парсера командной строки. Для начала определим, в каком виде поступают аргументы на вход.
В моем случае это выглядело так: COMMAND [OPTION]. . Сначала подается команда (слова разделенные пробелом), а затем идут опции (пары ключ-значение, причем ключ имеет префикс — ). Результатом парсинга является объект представляющий саму команду.
Для парсинга я использовал F# и код получился довольно лаконичным:
Ну вот мы спарсили нашу командную строку. Но что дальше? Как определить ЧТО нам делать?
Мат. часть
Помните как я упомянул слово команда и вложенный? Так вот. Это те самые паттерны команды 4-х на практике:
Нам нужно выполнить команду — паттерн Command.
Команды могут быть вложенными. А какая структура это позволяет? Правильно — дерево. Это паттерн Composite.
Также нам нужно создать команду, здесь может понадобиться фабрика — паттерн Abstract Factory.
Реализация
Начнем проектирование сверху-вниз, а именно с Компоновщика.
Компоновщик
Наши команды имеют структуру дерева (иерархическую):
‘git’ — не передается в аргументы, т.к. это сама программа
Выделим базовый класс, представляющий абстрактный узел и наследуем от него 2 других — лист и внутренний узел:
Абстрактная фабрика
Наши листья — конечные точки (почти такие же как и в интернете). Каждый лист — порождает команду. В моей реализации я возвращал команды напрямую из Компоновщика, т.е. объединил Фабрику и Компоновщика. Это нарушет принцип единственной ответственности, так как если:
Мы захотим ввести псевдонимы (aliases).
Нам нужно будет в рантайме заменить поведение команд.
То придется нехило попотеть занимаясь рефакторингом. Но я осознаю, что пренебрег Single Responsibility, и принимаю все будущие трудности.
Вот пример реализации листа:
Команда
Мы подошли к завершающему этапу — бизнес-логика. За ее исполнение отвечает интерфейс IRouterCommand , который, как вы могли догадаться, и является паттерном Команда.
Вы уже увидели, что фабрики реализуют один и тот же интерфейс IRouterCommandFactory . А вот собственно и он.
Команды возвращают только листы, узлы — перенаправляют.
И сама реализация команды:
Теперь складываем все вместе:
Итоги
В результате можно сделать следующие выводы:
Если приложение простое и аргументы однородны — можно просто итерироваться по входному массиву. Не нужно увеличивать сложность.
Не изобретайте велосипед без крайней необходимости. Уже существуют готовые решения — используйте их.
Мое решение еще раз доказало, насколько важно разделение логики и представления.
Будьте готовы изобретать велосипед, решившись делать свою реализацию парсинга: текст помощи, кастование аргументов к нужному типу и т.д.
А как вы работаете с аргументами командной строки? Напишите в комментариях.
Возможности консольных приложений для Windows
В настоящее время, когда у разработчиков программного обеспечения появляется всё больше возможностей для сознания приложений со сложным графическим интерфейсом, консольные приложения по-прежнему прочно удерживают свои позиции, даже в такой, казалось бы им среде, как современная операционная система Windows. Это объясняется в первую очередь простотой их исполнения и некоторыми специфическими особенностями консольных приложений, которые делают их в ряде случаев более подходящими для решения задачи, чем приложения с графическим интерфейсом. Один минус: выглядят такие приложения очень уныло и однообразно, как безликая серая масса букв и цифр на чёрном фоне окна консоли. Но не всё так безнадёжно, как может показаться на первый взгляд. В этой статье я попытаюсь дать несколько полезных рецептов расширения функциональности консольных приложений и придания их внешнему виду большей выразительности.
И так: начнём с заголовка окна консоли. При запуске программы в заголовке окна отображается полное имя файла, с которого было запущено приложение. Вместо имени файла, в заголовке окна можно указать любой другой текст: название приложения, например. Это делается с помощью функции SetConsoleTitle.
Далее рассмотрим проблему с выводом на консоль текста кириллицы. Практически каждому программисту когда-нибудь приходилось с этим сталкиваться. По сложившейся традиции, для вывода текста на консоль в большинстве примеров на C++ используются функции стандартной библиотеки, такие как printf или puts, которые работают с текстом в кодировке OEM, что соответствует кодовой странице 866 для русского языка. То же самое происходит при использовании потока вывода cout. Но проблема в том, что большинство текстовых редакторов для Windows работают с текстом в кодировке ANSI, что для русского языка соответствует кодовой странице 1251.
Как решить эту проблему? Проще всего использовать функции, которые записывают текст непосредственно в буфер консоли, а не в стандартный поток. Для этого нужно в программный код включить заголовочный файл CONIO.H и, вместо функций printf и puts, вызывать аналогичные функции _cprintf и _cputs.
Так они выглядят в коде на Visual C++:
Несложно написать и собственную процедуру вывода текста на консоль с использованием системной функции WriteConsole. Вот пример такой процедуры:
А вот так она используется:
Как вы уже наверно заметили, для перевода строки необходимо указывать последовательность из двух символов: «\r\n», в отличие от стандартного потока, где указывается лишь один символ ‘\n’. Но бывает и так, что нужно вывести текст именно через стандартный поток, чтобы его можно было перенаправить в текстовый файл или в другое консольное приложение. Для этого текст нужно перевести в другую кодировку с помощью функции CharToOem. Или использовать функцию WideCharToMultiByte с параметром CP_OEMCP, если ваш текст в кодировке UNICODE.
Размер буфера консоли по умолчанию равен 80х300, т.е. 300 строк по 80 символов каждая. Задать буферу консоли другой размер можно при помощи функции SetConsoleScreenBufferSize.
Атрибутами текста консоли являются: цвет символов и цвет заднего фона. Код атрибута соответствует числовому значению в диапазоне от 0x00 до 0x7F. Так выглядит таблица с числовыми значениями атрибутов текста в шестнадцатеричном коде:
00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 0A | 0B | 0C | 0D | 0E | 0F |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 1A | 1B | 1C | 1D | 1E | 1F |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 2A | 2B | 2C | 2D | 2E | 2F |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 3A | 3B | 3C | 3D | 3E | 3F |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 4A | 4B | 4C | 4D | 4E | 4F |
50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 5A | 5B | 5C | 5D | 5E | 5F |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 6A | 6B | 6C | 6D | 6E | 6F |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 7A | 7B | 7C | 7D | 7E | 7F |
Функция SetConsoleTextAttribute задаёт выводимому тексту указанный атрибут. Для задания всему окну консоли указанных атрибутов применяется функция FillConsoleOutputAttribute.
Позиционирование текста (т.е. установка курсора на позицию с заданными координатами) производится с помощью функции SetConsoleCursorPosition.
Полный код моего примера в Visual C++ 2005 выглядит так:
Приведённый пример был создан в среде Visual C++ 2005 как проект Win32 Console Application.
В заключение хочу отметить, что возможности консольного интерфейса Windows отнюдь не исчерпываются теми функциями, которые были упомянуты в этой статье. Не было рассказано про получение и обработку сообщений от мыши, об использовании в консольных приложениях возможностей графического интерфейса Windows. Но об этом речь пойдёт в дальнейших публикациях.
Консольное приложение
Это руководство раскроет для вас некоторые возможности .NET и языка C#. Вы узнаете:
- общие сведения о .NET CLI;
- структура консольного приложения C#;
- консольный ввод-вывод;
- основные сведения об интерфейсах API файлового ввода-вывода в .NET;
- основные сведениях об асинхронном программировании задач в .NET.
Вам предстоит создать приложение, которое считывает текстовый файл и выводит его содержимое в консоль. Вывод в консоль осуществляется с такой скоростью, которая позволяет читать текст вслух. Вы можете ускорить или замедлить темп, нажав клавиши ‘<‘ (меньше) или ‘>’ (больше). Это приложение можно запустить в ОС Windows, Linux, macOS или в контейнере Docker.
В этом руководстве описано множество функций. Попробуем собрать их по одному.
Предварительные требования
- .
- Редактор кода.
Создание приложения
Первым шагом является создание нового приложения. Откройте командную строку и создайте новый каталог для приложения. Перейдите в этот каталог. В командной строке введите команду dotnet new console . Эта команда создает начальный набор файлов для базового приложения Hello World.
Прежде чем вносить изменения, давайте запустим простое приложение Hello World. Когда вы создадите приложение, наберите в командной строке команду dotnet run . Эта команда запускает процесс восстановления пакета NuGet, создает исполняемый файл приложения и запускает этот файл.
Весь код простого приложения Hello World размещается в файле Program.cs. Откройте этот файл в любом текстовом редакторе. Замените код в Program.cs на следующий код:
В верхней части файла вы видите инструкцию namespace . Как и другие объектно ориентированные языки, с которыми вы могли работать ранее, C# использует пространства имен для организации типов. В нашей программе Hello World все точно так же. Как вы видите, программа находится в пространстве имен TeleprompterConsole .
Чтение и вывод файла
Первая функция, которую мы добавим, будет считывать данные из текстового файла и выводить полученный текст в консоль. Сначала нам нужно добавить текстовый файл. Скопируйте в каталог проекта файл sampleQuotes.txt из репозитория GitHub для этого примера. Он будет источником текста для вашего приложения. Чтобы скачать пример приложения для этого раздела, воспользуйтесь инструкциями в разделе Примеры и руководства.
Теперь добавьте в класс Program (он расположен сразу за методом Main ) следующий метод:
Этот метод является специальным типом метода перечислителя C#. Метод перечислителя возвращает последовательности, для которых применяется отложенное вычисление. Это означает, что каждый элемент в последовательности создается только в тот момент, когда к нему выполняется обращение в коде обработки последовательности. Методы перечислителя содержат одну или несколько инструкций yield return . Возвращаемый методом ReadFrom объект содержит код для создания каждого элемента последовательности. В нашем примере он читает следующую строку текста из исходного файла и возвращает эту строку. Каждый раз, когда вызывающий код запрашивает следующий элемент из последовательности, код считывает из файла и возвращает следующую строку текста. Когда файл закончится, последовательность сообщает, что в ней больше нет элементов.
Здесь используются два элемента синтаксиса C#, которые могут быть для вас новыми. Инструкция using в этом методе управляет освобождением ресурсов. Переменная, которая инициализируется в инструкции using (в нашем примере это reader ) должна реализовывать интерфейс IDisposable. Этот интерфейс определяет единственный метод ( Dispose ), который вызывается для освобождения ресурса. Компилятор создает такой вызов, когда выполнение кода достигает закрывающей скобки инструкции using . Созданный компилятором код гарантирует освобождение ресурса даже в том случае, если в блоке кода, определенном инструкцией using, будет создано исключение.
Переменная reader определена с ключевым словом var . Ключевое слово var определяет неявно типизированную локальную переменную. Это означает, что тип переменной определяется во время компиляции по типу объекта, присвоенного этой переменной. Здесь это возвращаемое значение метода OpenText(String), то есть объект StreamReader.
Теперь давайте создадим в методе Main код для чтения файла:
Запустите программу командой dotnet run и убедитесь в том, что все текстовые строки выводятся в консоль.
Добавление задержек и форматирование выходных данных
Сейчас данные отображаются слишком быстро для чтения. Поэтому нам нужно добавить задержку в процесс вывода. Для этого вы создадите несложный код, выполняющий асинхронную обработку. Но первые наши действия будут нарушать стандартные рекомендации. Эти нарушения мы укажем в комментариях при создании кода, а затем заменим этот код в последующих шагах.
В этом разделе описаны два действия. Во-первых, обновите метод итератора, чтобы он возвращал не всю строку целиком, а каждое слово отдельно. Для этого внесите такие изменения. Замените инструкцию yield return line; следующим кодом:
Теперь следует изменить код обработки строк файла, добавив задержку после вывода каждого слова. Замените инструкцию Console.WriteLine(line) в методе Main на такой блок кода:
Запустите пример и проверьте выходные данные. Теперь слова появляются по одному и с задержками по 200 мс. Но пока с выводом сохраняются некоторые проблемы, поскольку в исходном текстовом файле есть несколько строк длиной более 80 символов, и они выводятся без перевода строки. Это не очень удобно читать с прокруткой. Но эту проблему легко исправить. Вам нужно лишь отслеживать длину каждой строки и создавать новую строку каждый раз, когда эта длина достигает определенного порога. После объявления words в методе ReadFrom объявите локальную переменную для хранения длины строки:
Теперь добавьте следующий код после инструкции yield return word + " "; (перед закрывающей фигурной скобкой):
Запустите пример, и теперь вы сможете читать текст вслух в заданном темпе.
Асинхронные задачи
И на последнем этапе мы добавим код, который позволяет выполнять две асинхронные задачи, одна из которых — вывод текста, а вторая — ожидание ввода от пользователя для ускорения, замедления или прекращения вывода текста. Этот этап разделяется на несколько шагов, по завершении которых вы получите все необходимые обновления. Первым шагом является создание асинхронной задачи (Task), которая возвращает метод с тем кодом, который вы создали ранее для чтения и отображения файла.
Добавьте следующий метод в класс Program . Этот текст основан на тексте метода Main :
Вы можете заметить два изменения. Во-первых, в тексте нет вызова Wait(), который в синхронном режиме ожидает завершения задачи. Вместо него в этой версии используется ключевое слово await . Чтобы это работало, в сигнатуру метода нужно добавить модификатор async . Этот метод возвращает Task . Обратите внимание, что здесь нет инструкции для возвращения объекта Task . Вместо этого объект Task создается в коде, который компилятор предоставляет в точке использования оператора await . Представьте, что метод завершает выполнение при достижении await . Он возвращает Task в знак того, что работа еще не завершена. Метод возобновит свою работу, когда завершится ожидаемая задача. Когда работа метода завершится, это будет отражено в возвращаемом объекте Task . Вызывающий код может отслеживать состояние полученного Task , чтобы определить момент завершения метода.
Теперь наш новый метод можно вызвать из метода Main :
Здесь, в методе Main , код синхронно ожидает завершения. Всегда, когда это возможно, следует использовать оператор await вместо синхронного ожидания. Но в методе Main консольного приложения запрещено использовать оператор await . В противном случае приложение завершит работу раньше, чем выполнит все свои задачи.
При использовании C# 7.1 или более поздней версии консольные приложения можно создавать с помощью метода async Main .
Затем необходимо написать второй асинхронный метод для чтения из консоли и просмотра ключей «<» (меньше), «>» (больше) и «X» или «x». Для выполнения этой задачи добавьте приведенный ниже метод.
При этом создается лямбда-выражение, представляющее Action делегат, который считывает ключ из консоли и изменяет локальную переменную, представляющую задержку, когда пользователь нажимает клавиши «<» (меньше) или «>» (больше). Метод делегата завершается, когда пользователь нажимает клавиши «X» или «x», что позволяет пользователю останавливать отображение текста в любое время. Этот метод использует метод ReadKey(), чтобы блокировать выполнение и ожидать нажатия клавиши.
Чтобы завершить создание этой функции, нам нужна новая инструкция async Task , которая вернет метод, запускающий обе задачи ( GetInput и ShowTeleprompter ) и управляющий обменом данными между этими задачами.
Пришло время создать класс, который может обрабатывать совместное использование данных двумя задачами. Этот класс содержит два открытых свойства: delay (задержка) и флаг Done , который означает, что файл прочитан полностью:
Поместите этот класс в отдельный новый файл и включите его в пространство имен TeleprompterConsole , как показано выше. Также следует добавить оператор using static в верхнюю часть файлами, чтобы можно было ссылаться на методы Min и Max без указания имени внешнего класса или пространства имен. Инструкция using static импортирует все методы из одного класса. Это отличается от оператора using без static , который импортирует все классы из пространства имен.
Теперь вам нужно обновить методы ShowTeleprompter и GetInput для использования нового объекта config . И еще одна инструкция Task , которая возвращает метод async , запускающий обе задачи и завершающий работу после окончания первой задачи:
Новым методом здесь является WhenAny(Task[]). Этот метод создает задачу ( Task ), которая завершается сразу, как только завершится любая из задач в списке аргументов.
Теперь вам нужно обновить методы ShowTeleprompter и GetInput , чтобы они использовали объект config для задержки:
Новая версия метода ShowTeleprompter вызывает новый метод из класса TeleprompterConfig . Сейчас нужно изменить метод Main , чтобы вместо ShowTeleprompter он вызывал RunTeleprompter :
Заключение
В этом учебнике мы продемонстрировали вам ряд функций языка C# и библиотек .NET Core, связанных с работой в консольных приложениях. На основе полученных знаний вы сможете развивать свои представления о языке и представленных здесь классах. Вы увидели базовые примеры использования файлового и консольного ввода-вывода, асинхронного программирования на основе задач с блокировкой и без блокировки. Вы узнали о языке C# и структуре программ на C#, а также о .NET CLI.
Дополнительные сведения о файловом вводе-выводе см. в статье Файловый и потоковый ввод-вывод. Дополнительные сведения о модели асинхронного программирования, используемой в учебнике, см. в статьях Асинхронное программирование на основе задач и Асинхронное программирование.