Blog: Java, Software-Entwicklung & mehr

jbang - Shell-Skripte in Java

25.02.2021 Michael Mosmann

Shell-Scripte in Java

In den meisten Projekten gibt es Aufgaben, die zu klein sind, um dafür fertige Software einsetzen zu können, aber zu häufig durchgeführt werden, so dass dafür mindestens ein kleines Programm geschrieben wird. In den meisten Fällen ist das dann ein Bash-Script, weil dazu keine Installation nötig ist und man die meisten Probleme damit absehbar lösen kann.

Für diese Aufgaben eine andere Programmiersprache einzusetzen, war bisher einfach zu aufwändig. Dabei wäre z.B. Java ein idealer Kandidat für solche Aufgaben, denn damit entwickelte Programme laufen auf sehr vielen Systemen und der Funktionsumfang der mitgelieferten Bibliotheken ist umfangreich genug, um eigentlich jedes Problem in wenigen Zeilen Code lösen zu können. Dem stand bisher allerdings der Aufwand für Aufsetzen einer Entwicklungsumgebung und das einfache Erzeugen des ausführbaren Programms entgegen. Schnell wurde aus einer Aufgabe, die man in ein paar Zeilen Bash-Script gelöst hat, ein eigenes Projekt.

Was auch der Antrieb gewesen sein mag, man kann Dank des kleinen Projektes https://www.jbang.dev/ statt auf Bash-Scripte nun Java einsetzen, sich dabei trotzdem auf die Problemlösung konzentrieren und muss nicht auf dem Komfort einer Entwicklungsumgebung seiner Wahl verzichten. Es gibt sicher Entwickler, die ein Problem sehr schnell mit einem Bash-Script lösen können, aber für Entwickler, die meistens in Java-Projekten unterwegs sind, könnte dieser Ansatz viele Dinge erleichtern.

Lokale Installation

Um jbang benutzen zu können, muss man es vorher installieren. Damit andere Entwickler nicht erst noch selbst jbang herunterladen müssen, installieren wir alles in das lokale Projektverzeichnis.

Dabei muss man beachten, dass es eigentlich keine gute Idee ist, Scripte aus dem Internet herunterzuladen und direkt auf seinem Rechner ausführen zu lassen. Man sollte also vorher einen Blick drauf werfen, was man da eigentlich startet. Wenn man zu dem Schluss gekommen ist, dass die Quelle als vertrauenswürdig einzustufen ist, kann man jbang lokal wie folgt installieren:


curl -Ls https://sh.jbang.dev | bash -s - wrapper install

Ist bereits eine ältere Version installiert, kann diese (mit der selben Vorsicht wie bei der Installation) auf diese Weise aktualisiert werden:


curl -Ls https://sh.jbang.dev | bash -s - wrapper install --force

Erste Schritte

Auch wenn die Möglichkeiten von jbang umfangreich sind, beschränken wir uns hier nur auf das Schreiben eines einfachen Programms, dass man bisher als Bash-Script geschrieben hätte.

Um ein JBang-Script zu erzeugen, rufen wir jbang wie folgt auf:


./jbang init --template=cli fun.java

Das Programm gibt folgende Meldung aus, die bereits den Hinweis enthält, wie man das Script in einer IDE bearbeiten kann.


[jbang] File initialized. You can now run it with 'jbang fun.java' or edit it using 'jbang edit --open=[editor] fun.java' where [editor] is your editor or IDE, e.g. 'netbeans'

Es werden verschiedene Entwicklungsumgebungen unterstützt, wir benutzen in diesem Beispiel IntelliJ Idea. Durch folgenden Aufruf legt jbang entsprechende temporäre Projektdateien an und startet die gewählte Entwicklungsumgebung:


./jbang edit --open=idea fun.java 

Die Programmdatei findet man dann in dem virtuellen src-Ordner und kann mit dem Anpassen beginnen.

jbang IDE Edit

Damit das Java-Programm auch zu einem guten Ersatz für ein Bash-Script wird, hat der Parameter --template=cli bereits alles nötige eingebunden um Kommandozeilenargumente einfach verarbeiten zu können. Die erzeugte Datei sieht dabei wie folgt aus:


///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS info.picocli:picocli:4.5.0</p>

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;

import java.util.concurrent.Callable;

@Command(name = "fun", mixinStandardHelpOptions = true, version = "fun 0.1",
        description = "fun made with jbang")
class fun implements Callable<Integer> {

    @Parameters(index = "0", description = "The greeting to print", defaultValue = "World!")
    private String greeting;

    public static void main(String... args) {
        int exitCode = new CommandLine(new fun()).execute(args);
        System.exit(exitCode);
    }

    @Override
    public Integer call() throws Exception { // your business logic goes here...
        System.out.println("Hello " + greeting);
        return 0;
    }

}

Dieses Programm ist an sich bereits lauffähig. Da wir uns aber für eine lokale Installation entschieden haben, müssen wir die erste Zeile wie folgt ändern:


///usr/bin/env ./jbang "$0" "$@" ; exit $?
...

Dann kann man das Programm schon wie folgt starten:


./fun.sh

Das war schon fast zu einfach.

Wo ist die aktuellste Datei?

Jede Lösung glänzt mit einem "Hello World"-Problem. Wie schlägt sich der Ansatz mit einer echten Anforderung? Dazu schreiben wir ein kleines Programm, dass in einem Verzeichnis die aktuellste Datei finden soll:


./jbang init --template=cli newest.java

Dann öffnen wir die Datei in der IDE unserer Wahl:


./jbang edit --open=idea newest.java 

... und ändern den Inhalt wie folgt:


///usr/bin/env ./jbang "$0" "$@" ; exit $?
//DEPS info.picocli:picocli:4.5.0</p>

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;

import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.Optional;
import java.util.concurrent.Callable;

@Command(name = "newest", mixinStandardHelpOptions = true, version = "newest 0.1",
        description = "find the newest file inside of a directory")

Nachdem wir im Kopfbereich die Beschreibung angepasst haben, wollen wir sicher gehen, dass wir als Argument ein Verzeichnis übergeben bekommen. Wie das im Detail funktioniert, kann man beim `picocli`-Projekt nachschlagen:


class newest implements Callable<Integer> {

    @CommandLine.Spec
    CommandLine.Model.CommandSpec spec;

    private File directory;

    @Parameters(index = "0", description = "directory")
    public void setDirectory(File directory) {
        checkArgument(directory.exists(),directory+" does not exist");
        checkArgument(directory.isDirectory(),directory+" is not a directory");
        this.directory=directory;
    }

    private void checkArgument(boolean condition, String errorMessage) {
        if (!condition) throw new CommandLine.ParameterException(spec.commandLine(), errorMessage);
    }

    public static void main(String... args) {
        int exitCode = new CommandLine(new newest()).execute(args);
        System.exit(exitCode);
    }

    @Override
    public Integer call() throws Exception {
       ...
    }

}

Hier kommt nun das eigentliche Programm:


@Override
public Integer call() throws Exception {
    NewestFileContainer newestFileContainer=new NewestFileContainer();

    Files.walkFileTree(directory.toPath(), new SimpleFileVisitor<Path>() {
        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
            newestFileContainer.inspect(file, attrs.lastModifiedTime());
            return FileVisitResult.CONTINUE;
        }
    });

    newestFileContainer.newestPath().ifPresent(it -> System.out.println("newest file "+it));

    return 0;
}

private static class NewestFileContainer {

    private PathWithModificationDate newest = null;

    public void inspect(Path file, FileTime lastModifiedTime) {
        if (newest==null) {
            newest = new PathWithModificationDate(file, lastModifiedTime);
        } else {
            newest = PathWithModificationDate.newerInstance(newest, 
                new PathWithModificationDate(file, lastModifiedTime));
        }
    }

    public Optional<Path> newestPath() {
        return Optional.ofNullable(newest).map(it -> it.file);
    }
}

private static class PathWithModificationDate {

    final Path file;
    final FileTime lastModifiedTime;

    public PathWithModificationDate(Path file, FileTime lastModifiedTime) {
        this.file = file;
        this.lastModifiedTime = lastModifiedTime;
    }

    public static PathWithModificationDate newerInstance(PathWithModificationDate a, PathWithModificationDate b) {
        return a.lastModifiedTime.compareTo(b.lastModifiedTime) > 0 ? a : b;
    }
}

Wenn wir das Programm dann wie folgt starten


./newest.sh

wird der Code compiliert und danach ausgeführt. Da wir vergessen haben, ein Verzeichnis anzugeben, bekommen wir folgende Fehlermeldung:


Missing required parameter: '<directory>'
Usage: newest [-hV] <directory>
find the newest file inside of a directory
      <directory>   directory
  -h, --help        Show this help message and exit.
  -V, --version     Print version information and exit.

Rufen wir das Programm mit einem geeigneten Parameter auf, bekommen wir die aktuellste Datei in dem Verzeichnis:


./newest.sh .

Fazit

Die Möglichkeiten von jbang sind wirklich umfangreich. Das Schreiben von Code in einer Sprache wie Java, für die es sehr gute Entwicklungsumgebungen gibt, ist in meinen Augen viel weniger fehleranfällig als das Schreiben von einem Bash-Script. Um das Ergebnis der Änderungen in Aktion zu sehen, vergeht vernachlässigbar wenig mehr Zeit als beim Starten eines Bash-Scripts. Dass man eine IDE mit all den für Java verfügbaren Unterstützungen benutzen kann, empfinde ich als größten Vorteil dieser Lösung.

Ich kann mir vorstellen, dass man auf diese Weise nicht nur viele Lösungen die man bisher eher mühsam als Bash-Script implementiert hat, ersetzen kann. Ich kann mir auch vorstellen, dass man in großen Projekten Code, der sich bisher in vielen Bash-Scripten als Kopie wiedergefunden hat, nun als Dependency in JBang-Scripten verwendet werden könnte. Und dann wären auch wieder Unit-Tests möglich, die bei Bash-Scripten bisher eigentlich immer fehlen.