Привет, Хабр! Многие пользовались консольными приложениями (тот же git). Недавно решил создать свое консольное приложение для управления роутером. По всем правилам, сначала разработал ядро, содержащее бизнес логику, написал тесты и затем приступил к соединению бизнес логики с представлением. Все шло хорошо, до того момента как мне понадобилось парсить аргументы командной строки. В этом посте расскажу как я решил эту задачу.
Введение
В общих чертах задача состояла следующая:
Спарсить аргументы поданые на вход.
Понять, что хочет пользователь.
Выполнить необходимую команду.
В процессе приходил к нескольким вариантам. Буду рассказывать хронологически.
Нативный способ
Первый способ - в лоб. В .NET нам передается уже готовый массив аргументов static void Main(string[] args).
Поэтому просто итерируемся по этому массиву и находим команду. Звучит просто. Сложности возникают когда:
Появляются аргументы ключ-значение или флаги
--verbosegrep -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]... . Сначала подается команда (слова разделенные пробелом), а затем идут опции (пары ключ-значение, причем ключ имеет префикс -- ). Результатом парсинга является объект представляющий саму команду.
// 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-х на практике:
Нам нужно выполнить команду - паттерн Command.
Команды могут быть вложенными. А какая структура это позволяет? Правильно - дерево. Это паттерн Composite.
Также нам нужно создать команду, здесь может понадобиться фабрика - паттерн Abstract Factory.
Реализация
Начнем проектирование сверху-вниз, а именно с Компоновщика.
Компоновщик
Наши команды имеют структуру дерева (иерархическую):

Выделим базовый класс, представляющий абстрактный узел и наследуем от него 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); } }
Абстрактная фабрика
Наши листья - конечные точки (почти такие же как и в интернете). Каждый лист - порождает команду. В моей реализации я возвращал команды напрямую из Компоновщика, т.е. объединил Фабрику и Компоновщика. Это нарушет принцип единственной ответственности, так как если:
Мы захотим ввести псевдонимы (aliases).
Нам нужно будет в рантайме заменить поведение команд.
То придется нехило попотеть занимаясь рефакторингом. Но я осознаю, что пренебрег 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(); } }
Итоги
В результате можно сделать следующие выводы:
Если приложение простое и аргументы однородны - можно просто итерироваться по входному массиву. Не нужно увеличивать сложность.
Не изобретайте велосипед без крайней необходимости. Уже существуют готовые решения - используйте их.
Мое решение еще раз доказало, насколько важно разделение логики и представления.
Будьте готовы изобретать велосипед, решившись делать свою реализацию парсинга: текст помощи, кастование аргументов к нужному типу и т.д.
А как вы работаете с аргументами командной строки? Напишите в комментариях.
З.Ы. исходный код по ссылке
З.З.Ы Хабр, добавь поддержку синтаксиса F#!!! (Спасибо, добавили)
