Как стать автором
Обновить

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

Время на прочтение10 мин
Количество просмотров5.7K

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

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 4: ↑3 и ↓1+3
Комментарии9

Публикации

Истории

Работа

.NET разработчик
49 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань