Привет, Хабр! Многие пользовались консольными приложениями (тот же 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#!!!

Комментарии (9)


  1. Tzimie
    24.05.2022 07:15
    +8

    А вот когда то мы на голом C парзили все что угодно без фабрик и абстракций за один проход буфера. И не жужжали)


    1. AshBlade Автор
      24.05.2022 07:22

      Я работал в ООП парадигме, а C - язык процедурный)


      1. Tzimie
        24.05.2022 07:41
        +3

        На самом деле в вашем случае для командной строки это и не важно, но вообще умение сделать парзинг в один проход конечным автоматом без копирования строк очень полезно


    1. domix32
      24.05.2022 15:41
      +1

      Ну, к слову сказать, я не так много сишных программ видел, где парсинг аргументов имел хоть сколько-нибудь адекватный и структурированный вид, а не каскад ifов, whileов и прочих CF конструкций, которые можно было сходу понимать. Так что может зря не жужжали.


  1. CrazyAlex25
    24.05.2022 09:10
    +4

    В репе dotnet существует System.CommandLine и поддерживает иерархию команд


    1. Saladin
      24.05.2022 13:09

      System.CommandLine не плох, только он всё никак из бэты не выйдет :(
      Из-за этого мэйнтенеры любят поломать существующее API.


  1. DenSyo
    24.05.2022 10:02

    А как вы работаете с аргументами командной строки?

    обычно, со стандартным массивом Main(string[] args), если речь про C#. в других языках так же. очень важный момент в консольном приложении - грамотно создать структуру параметров избавляющую от необходимости проверять все действия пользователя. тогда вся программа выполняется за один цикл.


  1. shai_hulud
    24.05.2022 15:10

    Есть еще моя либа, которая следует синтаксису getOpt и поддерживает "под-команды".
    Но там задел под маленькие утилиты т.к. команды реализуется через методы, а не через классы команд.


  1. nronnie
    25.05.2022 21:12

    Сам очень люблю CLI. У меня сейчас профиль PowerShell вместе со всеми самодельными модулями уже больше 1000 строк. И, по-моему если сейчас писать какие-то консольные утилиты под .NET, то больше имеет смысл ориентироваться на PowerShell, а не делать standalone приложение. Все есть мысли с этим разобраться, но до сих пор какого-то конкретного повода нет, пока что возможностей самого PowerShell всегда хватало.