JArgs - (bardzo) prosty parser argumentów dla Javy

1

TL;DR: link do repozytorium na GitHub z kodem źródłowym i testami.
Poniżej podaję całkiem spory opis działania parsera. Jeśli komuś się nudzi, proszę o przejrzenie kodu na repo i podpowiedź, co mógłbym zrobić lepiej.
Mam nadzieję, że poniższy opis będzie łatwy do zrozumienia, bo nie umiem tłumaczyć, jak coś działa. Umieściłem trochę przykładów kodu, które powinny trochę pomóc.


Istnieje już trochę parserów argumentów linii komend, w tym używany przeze mnie dotychczas JSAP, ale miały sporo elementów kompletnie mi niepotrzebnych. Napisałem więc własny parser, chcąc możliwie jak najbardziej go zminimalizować. Jednocześnie chciałem zachować najważniejsze dla mnie elementy, czyli wartości i flagi.

Wartości
Klasa CopyFile przyjmuje dwa argumenty - ścieżki do plików - i kopiuje plik z pierwszej ścieżki do drugiej ścieżki.

import st.jargs.*;

public class CopyFile {
	public static void main(String[] args) {
		Parser p = new Parser();
		Variable sourcePath = new Variable();
		Variable destPath = new Variable();
		p.insertElements(filePath, destPath); //można umieszczać w środku dowolną ilość elementów
		p.parse(args);
		
		if (sourcePath.isUsed() && destPath.isUsed()) {
			System.out.println("Source: " + sourcePath.getValue());
			System.out.println("Destination: " + sourcePath.getValue());
			/* operacja kopiowania tutaj */
		}
	}
}

Po uruchomieniu:

java CopyFile "katalogA/plik.txt" "katalogB/plik.bak"

W konsoli wyświetli się:

Source: katalogA/plik.txt
Destination: katalogB/plik.bak

Z czasem, wraz z chęcią maksymalnego uproszczenia inicjalizacji parsera i wszystkich elementów, zaczynałem umożliwiać coraz krótsze jego tworzenie. Parsowanie argumentów z efektem identycznym do powyższego można zrealizować też tak:

public static void main(String[] args) {
	Variable sourcePath = new Variable();
	Variable destPath = new Variable();
	Parser p = Parser.createParser(sourcePath, destPath); //można umieszczać w środku dowolną ilość elementów
	p.parse(args);
	/*
	Jeżeli obiekt nie będzie używany wielokrotnie, można to skrócić jeszcze bardziej:
	Parser.createParser(sourcePath, destPath).parse(args);
	*/
}

Lub w inny sposób, wprowadzając od razu tworzone obiekty klasy Variable do parsera:

public static void main(String[] args) {
	Parser p = new Parser();
	Variable sourcePath = p.newVariable();
	Variable destPath = p.newVariable();
	p.parse(args);
}

**Flagi bez wartości** Rozszerzmy możliwości klasy `CopyFile` o funkcję usunięcia pliku źródłowego. Skorzystamy do tego z flagi `-d` / `--delete`. ```java import st.jargs.*;

public class CopyFile {
public static void main(String[] args) {
Parser p = new Parser();
Variable sourcePath = new Variable();
Variable destPath = new Variable();
Flag removeSource = new Flag('d', "delete", false); //"false" oznacza niżej brak wartości przypisanej do flagi.
p.insertElements(filePath, destPath, removeSource);
p.parse(args);

	if (sourcePath.isUsed() && destPath.isUsed()) {
		System.out.println("Source: " + sourcePath.getValue());
		System.out.println("Destination: " + sourcePath.getValue());
		System.out.println("Remove: " + removeSource.isUsed());
		
		/* operacja kopiowania i usuwania tutaj */
	}
}

}

Po uruchomieniu:

java CopyFile "katalogA/plik.txt" "katalogB/plik.bak"

W konsoli wyświetli się:

Source: katalogA/plik.txt
Destination: katalogB/plik.bak
Remove: false

Natomiast po użyciu przełącznika `-d`/`--delete` w dowolnym miejscu:

java CopyFile -d "katalogA/plik.txt" "katalogB/plik.bak"
java CopyFile "katalogA/plik.txt" -d "katalogB/plik.bak"
java CopyFile "katalogA/plik.txt" --delete "katalogB/plik.bak"
java CopyFile "katalogA/plik.txt" "katalogB/plik.bak" -d

Otrzymamy:

Source: katalogA/plik.txt
Destination: katalogB/plik.bak
Remove: true


Flagi możemy tworzyć łatwiej klasą `FlagBuilder`:
```java
Flag removeSource = FlagBuilder.createFlag()
	.setShortFlag('d')
	.setLongFlag("delete").build();
Parser.createParser(removeSource).parse(args);

Używając metody newFlag() clasy Parser możemy skorzystać z FlagBuilder i jednocześnie wstawić flagę do parsera:

Parser p = new Parser();
p.newFlag().setShortFlag('d').setLongFlag("delete").build();
p.parse(args);

Pominięcie metod setShortFlag i setLongFlag powoduje, że odpowiednio krótka i długa flaga nie jest możliwa do użycia.

Używając kilku flag, możemy w Linuksowym stylu łączyć krótkie flagi ze sobą:

Flag delete = FlagBuilder.createFlag().setShortFlag('d').setLongFlag("delete").build();
Flag create = FlagBuilder.createFlag().setShortFlag('c').setLongFlag("create").build();

Aby użyć w linii komend obydwóch flag, możemy zapisywać je oddzielnie, np.:

-d -c
--delete --create
-c --delete

Lub połączyć krótkie flagi w jedną:
-cd


Flagi z wartościami
Flagi z wartościami można stworzyć, używając w konstruktorze true jako ostatniego argumentu:

Flag repeatCount = new Flag('r', "repeat-count", true);

Lub w FlagBuilder, dodając dodatkową metodę setVariableRequired(true):

Flag repeatCount = FlagBuilder.createFlag()
	.setShortFlag('r')
	.setLongFlag("repeat-count")
	.setVariableRequired(true).build();

Wartość true dla metody setVariableRequired jest domyślna i może zostać pominięta. Identycznie działa to przy Parser.newFlag().

Flagi z wartościami różnią się od poprzedników tym, że bezpośrednio po fladze użytkownik powinien w linii komend wpisać przypisaną fladze wartość. Przykładowo:

public static void main(String[] args) {
	Parser p = new Parser();
	Variable name = p.newVariable();
	Flag repeatCount = p.newFlag()
		.setShortFlag('r')
		.setLongFlag("repeat-count")
		.setVariableRequired().build();
	p.parse(args);
	System.out.println("Name: " + name.getValue());
	System.out.println("Repeat count: " + repeatCount.getValue());
}

Wówczas dla argumentów:

"jakaś nazwa" -r 5

lub

"jakaś nazwa" --repeat-count 5

Otrzymamy:

Name: jakaś nazwa
Repeat count: 5

Flagi z wartościami i bez wartości możemy bez problemu ze sobą łączyć.

public static void main(String[] args) {
	Parser p = new Parser();
	Flag repeatCount = p.newFlag()
		.setShortFlag('r')
		.setLongFlag("repeat-count")
		.setVariableRequired().build();
	p.parse(args);
	Flag saveToFile = p.newFlag()
		.setShortFlag('s')
		.setLongFlag("save")
		.setVariableRequired().build();
	Flag logger = p.newFlag()
		.setShortFlag('l')
		.setLongFlag("logger").build();
	p.parse(args);
	System.out.println("Repeat count: " + repeatCount.getValue());
	System.out.println("Save to file: " + saveToFile.getValue());
	System.out.println("Use logger: " + logger.isUsed());
}

Wówczas dla wszystkich poniższych przykładów:

 --repeat-count 5 --save "plik.txt" --logger
 -lr 5 -s "plik.txt"
 -s "plik.txt" -r 5 --logger
 -lsr "plik.txt" 5

Otrzymamy identyczny efekt:

Repeat count: 5
Save to file: plik.txt
Use logger: true
0

Fajnie że pojawił się tutaj tak nienormalny temat.

Nie podoba mi się jednak trochę sposób przekazywania zmiennych. Najpierw tworzysz obiekt, a on jest przekazywany do innego obiektu i dopiero wtedy staje się użyteczny. A przecież Variable ma super potencjał do bycia immutable.

0

Hmm... Chodzi ci o coś takiego?
(w wersji "na szybko"):

public static void main(String[] args) {
	Parser p = new Parser();
	Flag a = p.newFlag().setShortFlag('a').setVariableRequired().build();
	p.parse(args);
	Variable[] vars = p.getRemainingVariables();
	
	System.out.println("A = " + a.getValue());
	for (int i = 0; i < vars.length; i++) {
		System.out.println("vars[" + i + "] = " + vars[i].getValue());
	}
}

Wówczas dla:
-a wartość
Wyświetli się:
A = wartość

Zaś dla np.
jakaś -a inna wartość argumentu
Otrzymamy:

A = inna
vars[0] = jakaś
vars[1] = wartość
vars[2] = argumentu

W sumie, nawet jeśli nie o to ci chodziło, to jest to chyba dobry pomysł :P

0

Po chwili główkowania, zrobione.

Muszę teraz jeszcze przemyśleć kwestię wyjątków. Są one obecnie rzucane w miejscach, na które programista nie ma wpływu - na błędne użycie flag. Jeżeli przykładowo utworzymy flagę --default, a użytkownik zrobi literówkę --defautl, parser rzuci wyjątkiem Correct flag for --defautl not found. Nie jestem pewien, czy to nie jest przesada i czy nie lepiej byłoby po prostu zignorować błędną flagę.

0

Różne konsolowe mikroprogramy właśnie w ten sposób reagują na błędne argumenty - wyświtlają listę dozwolonych komend i ich kombinacje; Nic nie jest ignorowane, bo może to całkowicie zmienić zachowanie programu.

0

W sumie racja. Pojawia się mi jednak drobne pytanie.
Zauważam czasem pewną wrogość do "checked exceptions" jako do czegoś kompletnie nieprzydatnego i rozszerzają RuntimeException zamiast zwykłego Exception tylko po to, by nie zmuszać do pisania throws w każdej metodzie przekazującej wyjątek wyżej. Z jednej strony oczyszcza to mocno kod, ale z drugiej powoduje, że można zapomnieć o przechwyceniu takiego wyjątku.

Obecnie ParserException właśnie rozszerza RuntimeException, ale zastanawiam się, czy zmienić to na Exception, żeby głośno krzyczało, że coś może pójść nie tak.

0

Przejrzałem kod, i brakuje tutaj spójnej koncepcji. Zobacz na bibliotekę SuperCSV. Używając jej najpierw definiujesz procesor lini, i za jego pomocą ładnie sczytujesz rekordy. Istnieje tam bardzo fajny podział pracy, na przygotowanie się i używanie narzędzia. Tutaj sprawa mogła by wyglądać podobnie, a nawet prościej bo linia jest tylko jedna. Czyli najpierw deklarujesz jak powinna wyglądać lista argumentów, robisz parse i dostajesz niezmienny obiekt z którego możesz pobrać flagi i argumenty. Jako że jest niezmienny to może być współdzielony przez wszystkie wątki aplikacji.
Dodatkowo wieszasz byty. W aplikacjach używa się albo flag albo argumentów. Łączenie ich to rzadki przypadek i zwykle ważna jest kolejność, tj argumenty są wymagane i mają pozycje na początku. Załóżmy że chciałbym uzyskać mnożnik do jakiegoś parametru właśnie z argumentu. Wydaje mi się że gdy wpiszę -1 to wczyta mi to jako flagę '1' (nie jestem pewny, nie miałem czasu dokładnie się wgryźć).
Zobacz na samo API:

public Parser flag(Optional<Character> shortFlag, Optional<String> longFlag, boolean withArgument, boolean isRequired){...}
...

Parser p = new Parser();
p.mode(Modes.FLAGS);
p.flag(of('a'), of('all'), false, false);
p.flag(of('b'), empty(), true, true);
/*----*/
Arguments parsedArguments = p.parse(args);
/*----*/
boolean isADeclared = parsedArguments.isFlagDeclared('a'); // aka   parsedArguments.isFlagDeclared("all");
Optional<Integer> b = parsedArguments.flagAsInt('b');
Integer b2 = parsedArguments.requiredFlagAsInt('b');

Odpalajać
java xxx -a -b 12
Dostaję argumenty w łatwy i przyjemny sposób. Do tego jedyny możliwy, co jest ważne w małych bibliotekach, bo nikomu nie chcę się uczyć API czegoś tak prostego.

Odnośnie CheckedExceptions. To nie jest tak że jest on od razu znienawidzony. Po prostu Java została stworzona jako język dla debili i miał być na nich odporny. Ale praktyka pokazała że to często więcej niż potrzebujemy. Prawda jest taka że checkedException ma sens tylko wtedy, kiedy jesteśmy w stanie sensownie zareagować konkretnie na niego, a właściwie musimy to zrobić.

I teraz zobacz praktyczny przykład użycia Twojej biblioteki.
Tworzysz program techniczny, dla ludzi umiejętnych i na samym początku musi sczytać argumenty w których podasz dane do bazy danych i pobierzesz z niej jakieś dane. Jak już to zrobisz, to możesz zacząć pracę.
Co może się zrypać:

  • Parsowanie argumentów
  • Nieprawidłowe pasy do bazy
  • Nieprawidłowa struktura bazy
  • Brak odpowiednich danych w bazie

Odpalając program zakładasz że to wszystko jest poprawne, więc możesz zrobić funkcję inicjującą która zrobi to wszystko. Czy istotny jest dla Ciebie typ wyjątku? Nie. W tym przypadku wystarczy pobrać z niego wiadomość i wyświetlić. Użytkownik na niego zareaguje i poprawi argument/dane do bazy itd.

Dlatego tutaj RuntimeException zda egzamin.

Jeszcze drugi przykład. Gdy w ww. przypadku programista zrobi:

parsedArguments.flagAsInt('a');

To poleci wyjątek bo zadeklarowaliśmy że a nie posiada argumentu. Jak powinniśmy na to zareagować? Poprawić nasz program, bo to my daliśmy ciała i łamiemy własne zasady. Ale to wyjdzie w testach lub nawet przy pierwszej próbie uruchomienia, więc czy jest sens tworzyć tutaj checked exception skoro wiemy że na produkcji to nigdy nie wyjdzie? Potem powstają kwiaty w stylu:

try{
...
}
catch(SomeException e){
 //To się nigdy nie stanie więc mam to w dupie :)
}

I jednak się okazuje że raz na 1000000 się staje i zamiast wysypać cały program to się człowiek zastanawia gdzie wśród miliona linii jest błąd.

PS. Pola w klasach deklarujemy na początku :)

0

Łoah, dzięki :3
Dobra, moja odpowiedź na pojedyncze fragmenty:

"brakuje tutaj spójnej koncepcji" - bardzo możliwe, bo nie planowałem przed napisaniem, jak to ma dokładnie działać. Miałem jedynie ogólny zarys tego, co ma to robić, bez szczegółów technicznych. To może być problem.

"W aplikacjach używa się albo flag albo argumentów. Łączenie ich to rzadki przypadek" - możliwe, ale chciałem użyć tego w ramach innego, nieco większego projektu, w którym w ogóle używam konsoli do pisania komend i często argumenty podają konkretny cel, a flagi jedynie lekko je modyfikują.

"argumenty są wymagane i mają pozycje na początku" - racja. Nie zauważyłem tego wcześniej, a to przecież proste i znacznie ułatwiłoby mi pracę.

kwestia wyjątków:
Oddzieliłem od siebie dwa wyjątki. ParserException dziedziczy po RuntimeException i zawiera właśnie te byki, które programista może pominąć, jak np. próba wyciągnięcia wartości z nieużytej flagi. Błędne użycie flagi lub zbyt duża ilość "wolnych argumentów" rzuca WrongArgumentException, które musi zostać przechwycone przez try...catch, ale jest on rzucany tylko w jednym miejscu - w metodzie parse() - i nie powinno tworzyć nadmiarowych ilości tych konstrukcji.

"Pola w klasach deklarujemy na początku" - o, tu się nie zgodzę :P Znaczy, tu jest już sprawa stosowania konwencji, ja takiej nauczyłem się z "Core Java" Horstmanna i Cornella. Jasne jest, że w większości projektów pola deklaruje się na początku i gdybym musiał w takim projekcie pisać, bez problemu bym się przystosował. Autor "Czystego kodu" wspomniał o tym, że w C++ zwykle deklaruje się na końcu, a w Javie zwykle na początku, ale zauważył też, że:

Nie widzę powodów, dla których mielibyśmy stosować się do którejkolwiek z tych konwencji. Najważniejsze dla zmiennych instancyjnych jest to, aby były zadeklarowane w jedynym znanym miejscu. Każdy powinien wiedzieć, gdzie zajrzeć, aby zobaczyć deklaracje.

Póki nie rzucam zmiennymi w losowych miejscach, mam spokojne sumienie :P

1 użytkowników online, w tym zalogowanych: 0, gości: 1