Coming Up for Air

CLI Libraries Compared

I recently ran across a couple of pretty cool libraries for creating command-line tools: Airline from the Airlift project, and crest from Tomitribe. Having spent the last few years working on administration for GlassFish, this is an area near and dear to my heart, so I thought I’d cobble together a quick example using each to see how usable they are.

Before we look at the code, I need to lay out a few caveats. First, both of these projects seem to be pretty new. Neither has reached a 1.0 release, and crest, in fact, was "cobbled" together rather recently it seems (though if it were just cobbled together, it seems very feature rich and well-coded). Second, I’m very new to both of these projects, so my implementations may be non-optimal. Neither project has very much documentation (though the READMEs in the respective Github repos seem to have enough to get you started). Lastly, to keep things simple yet a bit more interesting than the projects' examples, we’ll interact with an existing system, a GlassFish 4 server; we’ll implement a very basic REST-based deploy command. With that said, let’s get started.

Contents

Airline

Each Airline-based command is coded as a class which implements Runnable. In the example in the README, some basic, shared functionality is implemented in a base class (which implements Runnable) and each command extends this class. Let’s see how this looks in our scenario:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class AirlineGlassFish {
    public static void main(String[] args) {
        CliBuilder<Runnable> builder = Cli.<Runnable>builder("glassfish")
            .withDescription("Sample airlift-based CLI for managing GlassFish")
            .withDefaultCommand(Help.class)
            .withCommands(Help.class, Deploy.class);

        Cli<Runnable> gitParser = builder.build();

        gitParser.parse(args).run();
    }

    public abstract static class GlassFishCommand implements Runnable {

        @Option(type = OptionType.GLOBAL, name = "-h", description = "host")
        public String host = "localhost";
        @Option(type = OptionType.GLOBAL, name = "-p", description = "port")
        public int port = 4848;

        protected final Client client;

        protected GlassFishCommand() {
            client = ClientBuilder.newClient();
        }

        protected WebTarget getBaseTarget() {
            return client.target("http://" + host + ":" + port)
                .path("/management")
                .path("domain");
        }

    }

    @Command(name = "deploy", description = "Deploy an application")
    public static class Deploy extends GlassFishCommand {

        @Arguments(description = "Archives to deploy", required = true)
        List<String> fileNames;

        @Override
        public void run() {
            WebTarget target = getBaseTarget().path("applications")
                .path("application");
            MultivaluedMap props = new MultivaluedHashMap();
            props.add("id", fileNames.get(0));
            Response r = target.request(MediaType.APPLICATION_JSON)
                    .header("X-Requested-By", "airlift")
                    .post(Entity.entity(props,
                            MediaType.APPLICATION_FORM_URLENCODED),
                        Response.class);

            try {
                if (r.getStatus() != Status.OK.getStatusCode()) {
                    final JSONObject entity =
                        new JSONObject(r.readEntity(String.class));
                    System.err.println("Deploy failed: "  +
                        entity.getString("message"));
                    throw new RuntimeException();
                } else {
                    System.out.println("The application has been deployed.");
                }
            } catch (JSONException ex) {
                Logger.getLogger(AirlineGlassFish.class.getName())
                    .log(Level.SEVERE, null, ex);
            }
        }
    }
}

Looking first at GlassFishCommand, we have a base class that exposes some options that will be shared amongst the various GlassFish-related commands, name host and port. Using the Option annotation, we can specify the type (COMMAND, GLOBAL, or GROUP), the option name, and the option description. There doesn’t appear to be a way to have long and short names (-h and --host). Default values, it seems, are handled by initializing the instance variable as I’ve done here.

The class Deploy is annotated with @Command, which specifies the name and description for the command. Using @Arguments, we are able to specify that the command, in addition to the options listed above, takes a list of arguments at the end of the command line. The actual implementation of the command lives in the run() method specified by the Runnable interface. I’ll not go through the details of that, as that’s outside the scope of this post.

Finally, if you look back at main(), you see how Airline is made aware of our new command(s). Using a builder patter, we specify the description, default command, and list of command classes. We then build the CLI parser, parse the args, and run the command. A sample invocation might look like this:

1
2
$ java -jar target/airline-demo-1.0-SNAPSHOT.jar deploy /path/to/myapp.war
The application has been deployed.

Crest

Command implmentation in crest is a bit different. Crest uses methods annotated with @Command rather than classes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class CrestGlassFish {

    public static void main(String... args) throws Exception {
        final Main main = new Main(CrestGlassFish.class);
        main.main(new SystemEnvironment(), args);
    }

    @Command(value = "deploy")
    public String hello(
            @Option("host") @Default("localhost") String host,
            @Option("port") @Default("4848") int port,
            @Option("archive") @Required URI archive) {
        Client client = ClientBuilder.newClient();
        WebTarget target = getBaseTarget(client, host, port)
            .path("applications").path("application");
        MultivaluedMap props = new MultivaluedHashMap();
        props.add("id", archive.toString());
        props.add("force", "true");

        Response r = target.request(MediaType.APPLICATION_JSON)
                .header("X-Requested-By", "airlift")
                .post(Entity.entity(props,
                        MediaType.APPLICATION_FORM_URLENCODED),
                    Response.class);

        try {
            if (r.getStatus() != Response.Status.OK.getStatusCode()) {
                final JSONObject entity =
                    new JSONObject(r.readEntity(String.class));
                System.err.println("Deploy failed: " +
                    entity.getString("message"));
                throw new RuntimeException();
            } else {
                return "The application has been deployed.";
            }
        } catch (JSONException ex) {
            Logger.getLogger(CrestGlassFish.class.getName())
                .log(Level.SEVERE, null, ex);
        }

        return "error";
    }

    protected WebTarget getBaseTarget(Client client, String host, int port) {
        return client.target("http://" + host + ":" + port)
            .path("/management").path("domain");
    }
}

Let’s look at hello() first. Note that the method name is not the same as the command name. It can be, of course, but crest (as does Airline) allows the developer to override the command name. The command options are implemented as annotated method parameters (as opposed to Airline’s instance variables). Crest’s annotations seem to be a bit more robust, as it offers @Default and @Required. This is a nice approach, clearly exposing the JAX-RS influence that creator David Blevins talks about, but I haven’t figured out how to have shared parameters (e.g., host and port).

Exposing the command to crest can be in two ways, currently. The first, I demonstrate here: I create an instance of org.tomitribe.crest.Main, passing a list of classes that contain commands, then I call Main.main(Environment env, String[] args). This isn’t currently documented anywhere (I had to read the crest source, and it’s very pretty, in my opinion, but it’s fast and works. :) The other option, which is not as fast, is to use xbean-based classpath scanning by adding org.tomitribe:tomitribe-crest-xbean:${crest.version} to your build file.

Build Notes

To make these easy to run, I borrowed the use the Maven shader plugin from the crest README to make an executable "uberjar":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<build>
    <defaultGoal>install</defaultGoal>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>2.1</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                    <configuration>
                        <transformers>
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>
                                    org.tomitribe.crest.Main
                                </mainClass>
                                <!--
                                <mainClass>
                                    com.steeplesoft.clis.crest.GlassFish
                                </mainClass>
                                -->
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

The mainClass will vary, of course, based on which library you’re using and how it’s configured. For both Airlift and the first crest configuration, the mainClass will be the one where you configure and run the libraries. If you’re using crest+xbean, mainClass will be org.tomitribe.crest.Main.

Closing

If I had to choose a library right now, it would be a touch choice, though I lean a bit toward Crest. The libraries have slightly different approaches to exposing commands (classes vs methods), so since neither is inherently better than the other, personal preference will be a large factor here. I like the method-based approach used by Crest, but, so far, there doesn’t seem to be a way to share options between commands, which the Class-based approach of Airline makes very clean and simple. This lack, if indeed it is, in Crest can be fixed of course. The library is fairly new, and David is more than happy to take pull requests, so that’s an option.

Neither library seems to offer very good support for returning error messages and codes to the command line. Currently, it seems pretty clumsy and opaque.

Airline seems to be a bit lighter in terms of dependencies (the final jars, including my Jersey deps, were 3.9M for Airline and 5.5M for crest), but crest seems to offer a bit more for it e.g., Bean Validation support for the options). Disk space is cheap, of course, so that may not be an issue for some, but it is certainly something to keep in mind, especially if you’re adding one of these libraries to an already large project.

Regardless of which library you choose, they both offer great libraries for creating command line utilities with minimum effort. Both being very young products, they also present a lot of growth potential, as well as a great opportunity to get involved in open source development for interested parties.

You can find the source for these demos in my Bitbucket repo.

tags: Java cli

Quotes

Sample quote

Quote source