Wprowadzenie
Zapewne zastanawiasz się, czy w ogóle powinieneś lub powinnaś interesować się tworzeniem aplikacji konsolowych. Czy to jeszcze ma sens, czy ktoś jeszcze tego używa? Od jakieś czasu widzę w swoim środowisku tendencję, która polega na tym, że aplikacje konsolowe przeżywają swoisty renesans. Powstaje ich coraz więcej i więcej. Jednym z powodów takiego stanu rzeczy jest to, że nasze systemy stają się coraz bardziej rozbudowane (np. mamy wiele mikroserwisów) i coraz bardziej staramy się wszystko oskryptować. A w takiej sytuacji aplikacje konsolowe sprawdzają się najlepiej.
Czasami warto zainteresować się tym, w jaki sposób możemy coś ciekawego w aplikacji konsolowej zrobić. Dlatego postanowiłem, że w dzisiejszym wpisie pokażę Ci, w jaki sposób parsować parametry przekazywane do aplikacji konsolowej z wykorzystaniem biblioteki CommandLineParser (https://github.com/commandlineparser/commandline). Ułatwia ona cały proces, a dodatkowo dodaje kilka przydatnych rzeczy do obsługi parametrów.
Parametry aplikacji konsolowej
Uruchamiając aplikację konsolową, możemy przekazać do niej parametry, które będą sterowały jej działaniem. Parametry mogą być przekazane w różny sposób. Możemy na przykład przekazać parametry bezpośrednio po nazwie uruchamianej aplikacji:
app.exe param1 param2 param3 |
Kolejną możliwością jest skorzystanie z przełączników, w których parametrom nadajemy nazwy:
app.exe -f result.json -l log.txt |
Możemy też skorzystać z pełnych nazw parametrów:
app.exe --file result.json --log log.txt |
Jak widzisz, opcji jest sporo i parsując to ręcznie, możemy napisać bardzo dużo kodu, aby obsłużyć wszystkie możliwości.
Parsowanie parametrów
W aplikacji konsolowej parametry przekazane do niej są dostępne za pomocą parametru args metody Main:
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
// parsing parameters from args | |
} | |
} |
Bez problemu możemy je odczytać z tablicy, ale niestety ich sparsowanie nie jest już takie proste. Jak widać na poniższym zrzucie ekranu, bardzo często niektóre parametry zależą od siebie (np. dwa pierwsze parametry), przez co logika parsowania dość mocno się rozbudowuje.
CommandLineParser
CommandLineParser (https://github.com/commandlineparser/commandline) bardzo ułatwia parsowanie parametrów aplikacji konsolowej. Dodatkowo dodaje obsługę pomocy w naszej aplikacji, dzięki której użytkownik może dowiedzieć się, jak jej użyć.
Praca z biblioteką rozpoczyna się od utworzena klasy, do której zostaną sparsowane parametry. Za pomocą atrybutów możemy określić, w jaki sposób parsowane będą właściwości. Najlepiej zobaczyć to na przykładzie. Poniżej znajduje się klasa Options z dostępnymi parametrami:
public class Options | |
{ | |
[Option('f', "file", HelpText = "The result file name.", Required = true)] | |
public string ResultFile { get; set; } | |
[Option('s', "serializer", HelpText = "The result file serializer.", Required = false, Default = "json")] | |
public string Serializer { get; set; } | |
[Value(0, HelpText = "Input files names.", Required = true)] | |
public IEnumerable<string> InputFiles { get; set; } | |
} |
Klasa Options zawiera trzy parametry, które użytkownik może przekazać do aplikacji. Atrybuty Option oraz Value decydują o tym, w jaki sposób wartości będą przekazane do aplikacji. Option wykorzystujemy, gdy chcemy przekazać wartości z wykorzystaniem przełączników.
Pierwszy parametr w atrybucie określa krótką nazwę (np. -f), drugi pełną (np. –file). Required określa, czy parametr jest wymagany, czy nie. Gdy jest, a użytkownik nie przekaże jego wartości, to biblioteka wyświetli stosowny komunikat i zakończy działanie aplikacji.
Za pomocą HelpText możemy przekazać informacje o parametrze, które wyświetlą się użytkownikowi w pomocy aplikacji lub wyświetlą się w przypadku, gdy parametr jest wymagany. Default określa natomiast domyślną wartość parametru, gdy jest on opcjonalny.
Atrybut Value służy do sparsowania parametrów, które nie mają nazwy. Wymaga on podania indeksu, od którego wartości będą do niego parsowane. Możemy mieć kilka właściwości z tym atrybutem, a wtedy indeksy będą określały, jakie wartości trafią do której właściwości. Na przykład:
class Options { | |
[Value(0)] | |
public int IntValue { get; set; } | |
[Value(1, Min=1, Max=3)] | |
public IEnumerable<string> StringSeq { get; set; } | |
[Value(2)] | |
public double DoubleValue { get; set; } | |
} |
Spowoduje to, że pierwszy parametr trafi do właściwości IntValue, kolejne (od 1 do 3) do właściwości StringSeq, natomiast ostatni do właściwości DoubleValue. Osobiście unikam stosowania więcej niż jednej właściwość z atrybutem Value, bo później mogą pojawić się problemy z poprawną interpretacją parametrów, w szczególności w sytuacji, gdy korzystamy z Min i Max.
Atrybut Value ma również standardowe właściwości, takie jak HelpText czy Required.
Parsowanie parametrów
Mając już przygotowaną klasę dla parametrów, można ją sparsować przy starcie aplikacji konsolowej. Robi się to dość prosto:
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
var result = Parser.Default.ParseArguments<Options>(args); | |
result.WithParsed(r => Run(r)); | |
} | |
private static void Run(Options options) | |
{ | |
Console.WriteLine($"Parsed {options.GetType().Name}:"); | |
Console.WriteLine(options.ToString()); | |
} | |
} |
W pierwszej kolejności parsujemy parametry za pomocą biblioteki. W parametrze generycznym przekazujemy typ z parametrami, do którego mają zostać sparsowane dane. Metoda zwraca wynik parsowania. Co istotne, nie jest to nasz obiekt z parametrami. Sparsowany obiekt możemy otrzymać dopiero przez wywołanie metody WithParsed, do której przekazujemy delegat mający się wykonać w momencie, gdy sparsowane dane są poprawne (np. uzupełnione są wszystkie pola).
Możemy również obsłużyć sytuację, gdy parametry nie sparsowały się poprawnie. Wystarczy skorzystać z metody WithNotParsed, do której biblioteka przekaże listę błędów. Sama biblioteka wyświetla również listę błędów, więc z metody WithNotParsed na ogół nie musimy korzystać.
Uruchomienie aplikacji
Aplikacje możemy uruchamiać na różne sposoby. Może być to wiersz polecenia dla skompilowanej aplikacji, może to być również Visual Studio, szczególnie gdy zależy nam na debugowaniu aplikacji. Domyślnie Visual Studio nie przekazuje żadnych parametrów, gdy uruchamiamy aplikację w trybie debugowania. Możemy to zmienić i określić parametry, których potrzebujemy lub których wymaga aplikacja. Robi się to we właściwościach projektu:
W liście zakładek z lewej strony wybieramy Debug, a następnie w polu Command line arguments możemy przekazać listę parametrów, które Visual Studio przekaże do aplikacji podczas uruchamiania jej z poziomu Visual Studio. Wynik działania aplikacji z tymi parametrami wygląda tak:
W przypadku gdy parametry przekazane do aplikacji nie zostaną poprawnie sparsowane, biblioteka domyślnie wyświetli stosowny komunikat. Poniżej wynik działania aplikacji, gdy nie przekażemy do niej żadnego parametru:
Aplikacja wyświetla informacje o wymaganym parametrze 'f, file’. Dodatkowo pokazuje również informacje o innych dostępnych opcjach. CommandLineParser automatycznie dodaje dwie opcje do aplikacji. „–version” wyświetla wersję aplikacji – jest to tak naprawdę wersja assembly, która jest ustawiona w pliku AssemblyInfo.cs.
Drugą, ciekawszą opcją jest parametr „–help”. Poniżej wynik działania tego parametru:
Aplikacja wyświetla informacje o wszystkich dostępnych opcjach wraz z ich opisami.
Komendy
W bardziej rozbudowanych aplikacjach możemy chcieć dodać obsługę komend. W takiej sytuacji pierwszy parametr przekazany do aplikacji określa nazwę operacji, którą chcemy wykonać. CommandLineParser wpiera parsowanie komend, przez co ich obsługa w aplikacji również jest dość prosta.
Główna różnica w stosunku do parsowania samych parametrów polega na tym, że dla każdej komendy tworzymy klasę parametrów, którą dekorujemy atrybutem Verb. Klasy parametrów komend mogą dziedziczyć z innych klas, dzięki czemu nie musimy w każdej z klas definiować od nowa tych samych parametrów. W przykładzie przygotowałem dwie komendy:
[Verb("pack", HelpText = "Pack input files.")] | |
public class PackOptions : Options | |
{ | |
} |
[Verb("test", HelpText = "Test packing input files.")] | |
public class TestOptions : Options | |
{ | |
} |
Jak widać, nie ma tutaj nic skomplikowanego. Dodajemy klasy (w przykładzie klasy dziedziczą z klasy Options, więc będą miały te same parametry) i dekorujemy je atrybutem Verb. Pierwszy parametr określa nazwę komendy, którą będziemy musieli przekazać jako pierwszy parametr.
Parsowanie komend jest dość podobne do parsowania parametrów. Różnica polega na tym, że w parsowaniu przekazujemy, w formie parametrów generycznych, wszystkie klasy komend. Podobnie później dla każdej klasy komendy wywołujemy metodę WithParsed:
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
var result = Parser.Default.ParseArguments<PackOptions, TestOptions>(args); | |
result | |
.WithParsed<PackOptions>(r => Run(r)) | |
.WithParsed<TestOptions>(r => Run(r)); | |
} | |
private static void Run(Options options) | |
{ | |
Console.WriteLine($"Parsed {options.GetType().Name}:"); | |
Console.WriteLine(options.ToString()); | |
} | |
} |
Wynik wykonania komendy pack („pack -f result.json file1.txt file2.txt file3.txt”) wygląda tak:
W przypadku gdy nie przekażemy żadnej komendy do aplikacji, wtedy CommandLineParser wyświetli nam błąd oraz listę wszystkich dostępnych komend:
Następnie możemy skorzystać z komendy help, która wyświetli nam informacje o określonej komendzie (np. „help pack”):
Dynamiczne komendy
Taki sposób pracy z komendami, z racji złamania reguły Open Closed Principle, na dłuższą metę jest dość problematyczny, gdyż do aplikacji dodajemy sporo nowych komend. Na szczęście można to zorganizować nieco inaczej, aby nie trzeba było zmieniać klasy Program po dodaniu nowej komendy do aplikacji.
W tym celu do aplikacji dodaję nowy interfejs (IVerb), który służy mi do definiowania nowych komend aplikacji:
public interface IVerb | |
{ | |
void Run(); | |
} |
Jak widać, interfejs jest bardzo prosty, zawiera tylko jedną metodę Run. Następnie do klas komend dodaję implementacje tego interfejsu:
[Verb("pack", HelpText = "Pack input files.")] | |
public class PackOptions : Options, IVerb | |
{ | |
public void Run() | |
{ | |
Console.WriteLine($"Parsed {GetType().Name}:"); | |
Console.WriteLine(ToString()); | |
} | |
} |
[Verb("test", HelpText = "Test packing input files.")] | |
public class TestOptions : Options, IVerb | |
{ | |
public void Run() | |
{ | |
Console.WriteLine($"Parsed {GetType().Name}:"); | |
Console.WriteLine(ToString()); | |
} | |
} |
Na końcu wystarczy zmienić nieco parsowanie parametrów w CommandLineParser:
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
var iverb = typeof(IVerb); | |
var verbs = typeof(Program).Assembly.GetTypes().Where(t => iverb.IsAssignableFrom(t)); | |
var result = Parser.Default.ParseArguments(args, verbs.ToArray()); | |
result | |
.WithParsed<IVerb>(r => r.Run()); | |
} | |
private static void Run(Options options) | |
{ | |
Console.WriteLine($"Parsed {options.GetType().Name}:"); | |
Console.WriteLine(options.ToString()); | |
} | |
} |
Tym razem w pierwszej kolejności szukamy za pomocą refleksji wszystkich typów, które implementują interfejs IVerb. Następnie przekazujemy do aplikacji te typy w postaci tablicy typów.
Na końcu metodę WithParsed wywołujemy dla interfejsu, a nie konkretnej komendy, gdzie w momencie sparsowania danych zostanie wywołana metoda Run, która wykona logikę z właściwej komendy.
Poniżej wynik wykonania komendy test („test -f result.json file1.txt file2.txt file3.txt”):
Jak widać, aplikacja zachowuje się w ten sam sposób, tylko że tym razem jest dużo przyjemniejsza w rozwoju. Dodanie nowej komendy sprowadza się do dodania nowej klasy, która implementuje interfejs IVerb, oraz udekorowania jej atrybutem Verb. Idealny przykład dla reguły Open Close Principle.
Przykład
Na githubie (https://github.com/danielplawgo/CommandLineParserExample) znajduje się przykład do tego wpisu. Dla każdego z trzech przypadków przygotowałem odpowiednie metody, które należy wywoływać w metodzie Main. Przed każdą z testowych metod podane są przykłady parametrów, z jakimi należy uruchomić aplikację, aby ją przetestować.
Podsumowanie
Myślę, że w najbliższym czasie coraz częściej będziemy tworzyć małe aplikacje konsolowe, które następnie będą wykonywane w ramach jakichś większych całości. Sam ostatnio stworzyłem kilka takich drobnych aplikacji, które później zostały wykorzystane w większych skryptach czy wywołane przez inne aplikacje. Rozmawiaj z kolegami i koleżankami – u nich też widzę powoli ten trend.
Na podstawie powyższego uważam, że warto zainteresować się bibliotekami takimi jak CommandLineParser, które ułatwiają tworzenie takich aplikacji konsolowych.
1 thought on “Parsowanie parametrów w aplikacji konsolowej za pomocą CommandLineParser”