Как я работаю с командной строкой

—

от автора

Привет, Хабр! Многие пользовались консольными приложениями (тот же git). Недавно решил создать свое консольное приложение для управления роутером. По всем правилам, сначала разработал ядро, содержащее бизнес логику, написал тесты и затем приступил к соединению бизнес логики с представлением. Все шло хорошо, до того момента как мне понадобилось парсить аргументы командной строки. В этом посте расскажу как я решил эту задачу.

Введение

В общих чертах задача состояла следующая:

  1. Спарсить аргументы поданые на вход.

  2. Понять, что хочет пользователь.

  3. Выполнить необходимую команду.

В процессе приходил к нескольким вариантам. Буду рассказывать хронологически.

Нативный способ

Первый способ — в лоб. В .NET нам передается уже готовый массив аргументов static void Main(string[] args).

Поэтому просто итерируемся по этому массиву и находим команду. Звучит просто. Сложности возникают когда:

  1. Появляются аргументы ключ-значение или флаги --verbose grep -i 'hello, world!' .

  2. Команды вложенные ( нужно учитывать вложенность git remote add ).

  3. Нужно собственно выполнить код: скорее всего бизнес логика будет выделена в отдельные функции расположенные в классе, в котором Main и содержится.

Но это не значит что решение не достойно упоминания. Оно допустимо если:

  1. Аргументы однородны. Например, легкий клон mv — все аргументы, кроме последнего файлы для перемещения, а последний — место куда перемещаем.

  2. Делаем MVP. Нам просто нужно захардкодить 1-2 команды для нашего proof of concept*.

* Этот подход я использовал в самом начале разработки пет-проекта и когда понял, что приложение рабочее — моя мотивация повысилась (это тоже плюс).

Но с ростом функциональности и выше перечисленных трудностей я начал искать другие пути парсинга и пришел к следующему варианту.

Готовая библиотека

Я решил не изобретать велосипед и найти готовые решения. В экосистеме .NET довольно популярна библиотека CommandLineParser. Ведет историю, начиная с 2006 года. Решил использовать ее. Ее достоинствами считаю:

  1. Декларативный способ описания аргументов (Атрибуты Verb, Option).

  2. Поддержка отображения помощи (Help Text, Usage).

  3. Поддержка коротких (-v) и длинных (--verbose) опций.

  4. Автоматическое конверитрование типов опций (IEnumerable, bool, string …).

  5. Поддержка не только в C#, но и F#, VB.

Это малая часть ее возможностей. Больше описано в ее README.md.

Все шло хорошо вплоть до определенного момента. Выше я упомянул команду git remote add . К сожалению, CommandLineParser не имеет поддержки вложенных команд. (Можно использовать костыли по типу git remote-add или git "remote add", но выглядит не очень, на мой взгляд).

Продолжил искать дальше, но не нашел подходящей библиотеки (возможно искал плохо). В результате пришел к выводу, что нужно делать самому.

К/Ф "Пятый элемент"
К/Ф «Пятый элемент»

Свой парсер

Я приступил к созданию своего велосипеда парсера командной строки. Для начала определим, в каком виде поступают аргументы на вход.

В моем случае это выглядело так: COMMAND [OPTION]... . Сначала подается команда (слова разделенные пробелом), а затем идут опции (пары ключ-значение, причем ключ имеет префикс -- ). Результатом парсинга является объект представляющий саму команду.

// CommandLineContext.cs using Router.Domain;  namespace Router.Commands;  public record CommandLineContext(string[] Command, RouterParameters RouterParameters, IDictionary<string, string> Arguments, OutputStyle OutputStyle) {     private int _currentCommandIndex = 0;     public string? CurrentCommand =>         _currentCommandIndex < Command.Length             ? Command[_currentCommandIndex]             : null;      public string? NextCommand => _currentCommandIndex + 1 < Command.Length                                       ? Command[_currentCommandIndex + 1]                                       : null;     public bool HasNextCommand => _currentCommandIndex < Command.Length;          public bool MoveNext()     {         if (_currentCommandIndex + 1 >= Command.Length)         {             return false;         }          _currentCommandIndex++;         return true;     } }

Для парсинга я использовал F# и код получился довольно лаконичным:

// CommandLineContextParser.fs module Router.Commands.Utils.CommandLineContextParser.CommandLineContextParser  open System.Net open Microsoft.FSharp.Collections open Router.Commands open Router.Domain  type ParsingError =     | ArgumentExpectedError of Argument: string     | IncorrectArgumentValueError of Argument: string * Actual: string     | DuplicatedArgumentError of Argument: string  type Arguments = Map<string, string> type Command = string list  type CommandLineContextUnparsed =     { Command: Command       RouterParameters: RouterParameters       Arguments: Arguments       Output: OutputStyle       Rest: string list }  type ParsingPipe = CommandLineContextUnparsed -> Result<CommandLineContextUnparsed, ParsingError>  let (>=>) func1 func2 x =     match (func1 x) with     | Ok s -> func2 s     | Error err -> Error err  type ParseCommand = ParsingPipe type ParseArguments = ParsingPipe  type ParseCommandLineContext = string list -> Result<CommandLineContext, ParsingError>  let parseCommandFromCommandLineInput: ParseCommand =     (fun context ->         let rec parseCommandFromCommandLineInputRec             (result: CommandLineContextUnparsed)             : Result<CommandLineContextUnparsed, ParsingError> =             match result.Rest with             | [] -> Ok result             | first :: rest when not (first.StartsWith '-') ->                 parseCommandFromCommandLineInputRec                     { result with                         Rest = rest                         Command = first :: result.Command }             | _ -> Ok result          parseCommandFromCommandLineInputRec context)  let normalizeArgumentName (arg: string) = arg[2..]  let parseArgumentsFromCommandsParsed: ParseArguments =     (fun context ->         let rec parseInner (ctx: CommandLineContextUnparsed) =             match ctx.Rest with             | [] -> Ok ctx             | [ arg ] -> Error(ParsingError.ArgumentExpectedError arg)             | argument :: value :: rest ->                 let normalized = normalizeArgumentName argument                 match Map.containsKey normalized ctx.Arguments with                 | true ->                     Error(ParsingError.DuplicatedArgumentError normalized)                 | false ->                     parseInner { ctx with Arguments = (Map.add normalized value ctx.Arguments)                                           Rest = rest }          parseInner context)  let (??>) =     fun option fallback ->         match option with         | None -> fallback         | Some x -> x  let fallback value defaultValue map =     (Map.tryFind value map) ??> defaultValue  let extractRouterParameters: ParsingPipe =     (fun ctx ->         let args = ctx.Arguments          let address = fallback "address" "192.168.0.1" args          let username = fallback "username" "admin" args          let password = fallback "password" "admin" args          match IPAddress.TryParse address with         | (true, ip) -> Ok { ctx with RouterParameters = RouterParameters(ip, username, password) }         | _ -> Error(ParsingError.IncorrectArgumentValueError("address", address)))   let toUnparsedFromList (list: string list) : Result<CommandLineContextUnparsed, ParsingError> =     Ok  { Rest = list           Command = List.empty           Arguments = Map.empty           RouterParameters = RouterParameters.Default           Output = OutputStyle.KeyValue}  let toCommandLineContext (unparsed: CommandLineContextUnparsed) : Result<CommandLineContext, ParsingError> =     Ok(CommandLineContext(unparsed.Command                           |> List.rev                           |> List.toArray,                           unparsed.RouterParameters,                           unparsed.Arguments,                           unparsed.Output))  let outputArgumentName = "output"  let (|Json|Xml|KeyValue|Table|Invalid|) str =     match str with     | "json" ->  Json     | "xml" ->   Xml     | "plain" ->  KeyValue     | "table" -> Table     | _ -> Invalid  let toOutputStyle (outputString: string): Result<OutputStyle, ParsingError> =     match outputString with     | Json -> Ok OutputStyle.Json     | Xml -> Ok OutputStyle.Xml     | KeyValue -> Ok OutputStyle.KeyValue     | Table -> Ok OutputStyle.Table     | _ -> Error (ParsingError.IncorrectArgumentValueError(outputArgumentName, outputString))  let extractOutputStyle: ParsingPipe =     (fun context ->         match Map.tryFind outputArgumentName context.Arguments with         | Some outputString -> match toOutputStyle outputString with                                 | Ok output -> Ok {context with Output = output}                                 | Error parsingError -> Error parsingError         | None -> Ok context                                  )  let parsingPipeline =     parseCommandFromCommandLineInput     >=> parseArgumentsFromCommandsParsed     >=> extractRouterParameters     >=> extractOutputStyle   let parseCommandLineContext: ParseCommandLineContext =     toUnparsedFromList     >=> parsingPipeline     >=> toCommandLineContext 
// FSharpCommandLineParser.fs namespace Router.Commands.Utils  open System open Router.Commands open Router.Commands.Exceptions open Router.Commands.Utils.CommandLineContextParser.CommandLineContextParser  [<CLSCompliant(true)>] type FSharpCommandLineParser() =     member this.ParseCommandLineContext(args: string []) : CommandLineContext =         match parseCommandLineContext (Array.toList args) with         | Ok context -> context         | Error err ->             match err with             | ArgumentExpectedError expected -> raise (ArgumentValueExpectedException(expected, args))             | IncorrectArgumentValueError (argument, actual) ->                 raise (IncorrectArgumentValueException(argument, actual, args))             | DuplicatedArgumentError argument -> raise (DuplicatedArgumentsException(argument, args))      interface ICommandLineContextParser with         member this.ParseCommandLineContext(args) = this.ParseCommandLineContext args 

Ну вот мы спарсили нашу командную строку. Но что дальше? Как определить ЧТО нам делать?

Мат. часть

Помните как я упомянул слово команда и вложенный? Так вот. Это те самые паттерны команды 4-х на практике:

  1. Нам нужно выполнить команду — паттерн Command.

  2. Команды могут быть вложенными. А какая структура это позволяет? Правильно — дерево. Это паттерн Composite.

  3. Также нам нужно создать команду, здесь может понадобиться фабрика — паттерн Abstract Factory.

Реализация

Начнем проектирование сверху-вниз, а именно с Компоновщика.

Компоновщик

Наши команды имеют структуру дерева (иерархическую):

'git' - не передается в аргументы, т.к. это сама программа
‘git’ — не передается в аргументы, т.к. это сама программа

Выделим базовый класс, представляющий абстрактный узел и наследуем от него 2 других — лист и внутренний узел:

// InternalTpLinkCommandFactory.cs internal abstract class InternalTpLinkCommandFactory : IRouterCommandFactory {     public string Name { get; }      public InternalTpLinkCommandFactory(string name)     {         ArgumentNullException.ThrowIfNull(name);         Name = name;     }     public abstract IRouterCommand CreateRouterCommand(RouterCommandContext context); }
// SingleTpLinkCommandFactory.cs internal abstract class SingleTpLinkCommandFactory : InternalTpLinkCommandFactory {     protected SingleTpLinkCommandFactory(string name) : base(name) { } }
using Router.Commands; using Router.Commands.Exceptions;  namespace Router.TpLink.CommandFactories;  internal abstract class CompositeTpLinkCommandFactory : InternalTpLinkCommandFactory {     protected Dictionary<string, InternalTpLinkCommandFactory> Commands { get; }      protected CompositeTpLinkCommandFactory(IEnumerable<InternalTpLinkCommandFactory> commands, string rootName)         : base(rootName)     {         ArgumentNullException.ThrowIfNull(commands);         Commands = commands.ToDictionary(c => c.Name);     }      public override IRouterCommand CreateRouterCommand(RouterCommandContext context)     {         var currentCommand = context.CurrentCommand;         if (currentCommand is null || !Commands.TryGetValue(currentCommand, out var factory))             throw new UnknownCommandException(currentCommand, context.Command.ToArray());         context.MoveNext();         return factory.CreateRouterCommand(context);      } }

Абстрактная фабрика

Наши листья — конечные точки (почти такие же как и в интернете). Каждый лист — порождает команду. В моей реализации я возвращал команды напрямую из Компоновщика, т.е. объединил Фабрику и Компоновщика. Это нарушет принцип единственной ответственности, так как если:

  1. Мы захотим ввести псевдонимы (aliases).

  2. Нам нужно будет в рантайме заменить поведение команд.

То придется нехило попотеть занимаясь рефакторингом. Но я осознаю, что пренебрег Single Responsibility, и принимаю все будущие трудности.

Вот пример реализации листа:

// GetWlanStatusCommandFactory.cs // Полная команда на вход: "wlan status" using Router.Commands; using Router.TpLink.Commands;  namespace Router.TpLink.CommandFactories.Wlan;  internal class GetWlanStatusCommandFactory : SingleTpLinkCommandFactory {     public GetWlanStatusCommandFactory()          : base("status")      { }     public override IRouterCommand CreateRouterCommand(RouterCommandContext context)     {         return new TpLinkGetWlanStatusCommand(Console.Out, context.Router, context.OutputFormatter);     } }

Команда

Мы подошли к завершающему этапу — бизнес-логика. За ее исполнение отвечает интерфейс IRouterCommand , который, как вы могли догадаться, и является паттерном Команда.

namespace Router.Commands;  public interface IRouterCommand {     public Task ExecuteAsync(); }

Вы уже увидели, что фабрики реализуют один и тот же интерфейс IRouterCommandFactory . А вот собственно и он.

// IRouterCommandFactory.cs using Router.Commands;  namespace Router.TpLink;  internal interface IRouterCommandFactory {     IRouterCommand CreateRouterCommand(RouterCommandContext context); }

Команды возвращают только листы, узлы — перенаправляют.

Пример листа.

// GetWlanStatusCommandFactory.cs using Router.Commands; using Router.TpLink.Commands;  namespace Router.TpLink.CommandFactories.Wlan;  internal class GetWlanStatusCommandFactory : SingleTpLinkCommandFactory {     public GetWlanStatusCommandFactory()          : base("status")      { }     public override IRouterCommand CreateRouterCommand(RouterCommandContext context)     {         return new TpLinkGetWlanStatusCommand(Console.Out, context.Router, context.OutputFormatter);     } }

И сама реализация команды:

// TpLinkGetWlanStatusCommand.cs using Router.Commands; using Router.TpLink.Commands.DTO;  namespace Router.TpLink.Commands;  public class TpLinkGetWlanStatusCommand : TpLinkBaseCommand {     private readonly IOutputFormatter _formatter;     private readonly TextWriter _output;          public TpLinkGetWlanStatusCommand(TextWriter output, TpLinkRouter router, IOutputFormatter formatter)          : base(router)     {         _formatter = formatter;         _output = output;     }          public override async Task ExecuteAsync()     {         var wlan = await Router.Wlan.GetStatusAsync();         var display = new WlanDisplayStatus(wlan.Password, wlan.SSID, wlan.IsActive);         var result = _formatter.Format(display);         await _output.WriteLineAsync(result);     } }

Теперь складываем все вместе:

// RouterApplication.cs using Router.Commands;  namespace TpLinkConsole.Infrastructure;  public class RouterApplication : IApplication {     private readonly ICommandLineContextParser _parser;     private readonly IRouterCommandFactory _factory;      public RouterApplication(ICommandLineContextParser parser, IRouterCommandFactory factory)     {         _parser = parser;         _factory = factory;     }      public async Task RunAsync(string[] args)     {         var context = _parser.ParseCommandLineContext(args);         var command = _factory.CreateRouterCommand(context);         await command.ExecuteAsync();     } }

Итоги

В результате можно сделать следующие выводы:

  1. Если приложение простое и аргументы однородны — можно просто итерироваться по входному массиву. Не нужно увеличивать сложность.

  2. Не изобретайте велосипед без крайней необходимости. Уже существуют готовые решения — используйте их.

  3. Мое решение еще раз доказало, насколько важно разделение логики и представления.

  4. Будьте готовы изобретать велосипед, решившись делать свою реализацию парсинга: текст помощи, кастование аргументов к нужному типу и т.д.

А как вы работаете с аргументами командной строки? Напишите в комментариях.

З.Ы. исходный код по ссылке

З.З.Ы Хабр, добавь поддержку синтаксиса F#!!! (Спасибо, добавили)


ссылка на оригинал статьи https://habr.com/ru/articles/667438/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *