Привет, Хабр! Многие пользовались консольными приложениями (тот же 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]...
. Сначала подается команда (слова разделенные пробелом), а затем идут опции (пары ключ-значение, причем ключ имеет префикс --
). Результатом парсинга является объект представляющий саму команду.
// 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#!!! (Спасибо, добавили)