Tapestry Training -- From The Source

Let me help you get your team up to speed in Tapestry ... fast. Visit howardlewisship.com for details on training, mentoring and support!

Thursday, August 23, 2007

Quick and dirty Java application launcher

I've been busy working at a new company, for a new client, helping out on an existing product. The product is a Swing application and so I ran into my old nemesis: the ugly, buggy, hand maintained script to launch the application. You know the beast: start.bat and start.sh that you dread having to maintain.

We have a nifty, Maven based build, and a continuously evolving set of dependencies. Nobody wants to have to maintain that in two or three additional locations.

I've been seeing these kinds of scripts for years and hating them all along. Mostly, these scripts exist to find a bunch of JARs, put together a classpath (often by creating a monster CLASSPATH environment variable), then run Java to launch a particular class.

Previously, I've pushed to use Ant to do this. Ant is great a searching directories, building up classpaths, and invoking Java programs. It's pretty much inherently cross-platform. Who cares if it says "BUILD SUCCESSFUL" at the end?

Sun's tried to tackle this before, with the typical half-assed, lame results. Executable JAR files, Java Web Start, etc. Always stuff that looked good on some engineer's workstation, but never made sense in terms of a real development and deployment scenario. Ten years of Java later and it's still too much work to write a Java application to do a simple job. Meanwhile, Ruby and Groovy are so easy to run, it's painful (or laughable, or both).

JDK 1.6 has a new option to search a directory and pick up the JARs it finds. But we're targetting JDK 1.5 for both the client and the server. Thus my little launcher utility:

package org.example.launcher;

import java.io.File;
import java.io.FilenameFilter;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;

/**
 * A wrapper used to assemble the classpath before launching the actual
 * application. The big trick is assembling the classpath before the launch.
 * Yep, this is functionality that should be built right into Java.
 * 
 * <p>
 * This is packaged into a JAR with no dependencies.
 * 
 */
public final class LauncherMain {

  private static final List<URL> _classpath = new ArrayList<URL>();

  /**
   * Usage:
   * 
   * <pre>
   * java -jar mblauncher.jar some.class.to.launch [--addclasspath dir] [--addjardir dir] [options]
   * </pre>
   * 
   * <p>
   * The --addclasspath parameter is used to add a directory to add to the
   * classpath. This is most commonly used with a directory containing
   * configuration files that override corresponding files stored inside other
   * JARs on the classpath.
   * 
   * <p>
   * The --addjardir parameter is used to define directories. JAR files
   * directly within such directories will be added to the search path. 
     * 
     * <p>Any
   * remaining options will be collected and passed to the main() method of
   * the launch class.
   */
  public static void main(String[] args) {

    List<String> launchOptions = new ArrayList<String>();

    if (args.length == 0)
      fail("No class to launch was specified.  This should be the first parameter.");

    String launchClass = args[0];

    int cursor = 1;

    while (cursor < args.length) {

      String arg = args[cursor];

      if (arg.equals("--addclasspath")) {
        if (cursor + 1 == args.length)
          fail("--addclasspath argument was not followed by the name of the directory to add to the classpath.");

        String dir = args[cursor + 1];

        add(dir);

        cursor += 2;
        continue;        
      }

      if (arg.equals("--addjardir")) {

        if (cursor + 1 == args.length) {
          fail("--addjardir argument was not followed by the name of a directory to search for JARs.");
        }

        String dir = args[cursor + 1];

        search(dir);

        cursor += 2;
        continue;
      }

      launchOptions.add(arg);

      cursor++;
    }

    String[] newArgs = launchOptions.toArray(new String[launchOptions
        .size()]);

    launch(launchClass, newArgs);
  }

  private static void launch(String launchClassName, String[] args) {

    URL[] classpathURLs = _classpath.toArray(new URL[_classpath.size()]);

    try {
      URLClassLoader newLoader = new URLClassLoader(classpathURLs, Thread
          .currentThread().getContextClassLoader());

      Thread.currentThread().setContextClassLoader(newLoader);

      Class launchClass = newLoader.loadClass(launchClassName);

      Method main = launchClass.getMethod("main",
          new Class[] { String[].class });

      main.invoke(null, new Object[] { args });
    } catch (ClassNotFoundException ex) {
      fail(String.format("Class '%s' not found.", launchClassName));
    } catch (NoSuchMethodException ex) {
      fail(String.format("Class '%s' does not contain a main() method.",
          launchClassName));
    } catch (Exception ex) {
      fail(String.format("Error invoking method main() of %s: %s",
          launchClassName, ex.toString()));
    }
  }

  private static void add(String directoryName) {
    File dir = toDir(directoryName);

    if (dir == null)
      return;

    addToClasspath(dir);
  }

  private static File toDir(String directoryName) {
    File dir = new File(directoryName);

    if (!dir.exists()) {
      System.err.printf("Warning: directory '%s' does not exist.\n",
          directoryName);
      return null;
    }

    if (!dir.isDirectory()) {
      System.err.printf("Warning: '%s' is a file, not a directory.\n",
          directoryName);
      return null;
    }

    return dir;
  }

  private static void search(String directoryName) {

    File dir = toDir(directoryName);

    if (dir == null)
      return;

    File[] jars = dir.listFiles(new FilenameFilter() {

      public boolean accept(File dir, String name) {
        return name.endsWith(".jar");
      }

    });

    for (File jar : jars) {
      addToClasspath(jar);
    }

  }

  private static void addToClasspath(File jar) {
    URL url = toURL(jar);

    if (url != null)
      _classpath.add(url);
  }

  private static URL toURL(File file) {
    try {
      return file.toURL();
    } catch (MalformedURLException ex) {
      System.err.printf("Error converting %s to a URL: %s\n", file, ex
          .getMessage());

      return null;
    }
  }

  private static void fail(String message) {
    System.err.println("Launcher failure: " + message);

    System.exit(-1);
  }

}
This gets packaged up as an executable JAR (i.e., with a MainClass manifest entry pointing to this class). Launching ends up looking like.
java -jar launcher.jar --addjardir lib org.example.MyMain --my-main-opt
Basically, the -jar launcher.jar and its options (--addjardir) replaces any other classpath manipulations. Once the classpath is setup, launcher emulates Java in terms of locating MyMain and executing its main() method, passing any remaining options.

13 comments:

Jesse Kuhnert said...

Sweet, you make it look so obvious and shameful that it's missing when you put it like that. ;)

Sure beats the scripts and definitely beats web start.

I'm still fond of wrapper http://wrapper.tanukisoftware.org - for reasons I don't remember anymore and have no desire to re-visit.

Mandr said...

we use web-start to launch our swing app, so that we don't need to maintain any start up scripts, and we can update the client remotely. You may want to consider this solution.

btw, I think tapestry-ioc is a pretty good app server. any thoughts on how to integrate it with a swing client?

Matt said...

Look interesting.

In our product PaperCut NG, we use the "Java Service Wrapper" for the services. It works very well, and handles JVM crashes gracefully bu restarting the application.

For client applications, we use simple shell script for Unix that add all JAR files in the lib dir to the path.

On Mac we create a native ".app" package.

On windows we use a great little launcher called Janel (http://janel.sourceforge.net/). You just use a copy of their launcher "exe", rename it to "myapp.exe", and create a "myapp.lap" text file in the same directory. This file defines the classes to load JVM options, etc. It works perfectly ... and gives your applications a native feel.... which is essential for commercial applications.

PS: PaperCut NG's user interface is also completely developed in Tapestry. It's Tapestry 3.0 and we'd love to move to a newer version ... but we don't have the time right now. It would be a big job!

Thanks again Howard.

Technically speaking said...

I commonly use this convention when I need to include many JARs in the classpath:

java -Djava.ext.dirs="path" "Classname" "Parameters..."

Maybe this would also help.

-Nearchos

speedskater said...

Your approach looks really nice.

Have you thought about using one-jar.
With it you get a single jar that you can start with java -jar yourapplication.jar. All your depending jars
are put into this jarfiles.
I tried it yesterday and it realy makes a good impression. We used the new preview but had to batch it because of a small bug with resourcefiles having a dot within their path.
We are still having a problem with classloading by javassist and tapestry 4.1 and try to figure out how we can get around it.
But beside this it really offers an easy and elegant way to deliever standalone applications. For getting started with it there is also an eclipse plugin for generating this kind of jar fjep

Sixty4Bit said...

I found a small bug, the code after the fail will never be run.

if (arg.equals("--addclasspath")) {
if (cursor + 1 == args.length) {
fail("--addclasspath argument was not followed by the name of the directory to add to the classpath.");

String dir = args[cursor + 1];

add(dir);

cursor += 2;
continue;
}
}

Howard said...

speedskater: good catch! Why didn't QA complain I wonder?

Thanks to all for some of the pointers; many of the more obvious solutions (single JAR, JWS) don't fit our profile for cultural and technical reasons I can't really get into. Futher, this code was designed more for the QA team (the real client code uses an installer).

Mike said...

Another alternative is to use the Class-Path attribute in your main JAR's MANIFEST.MF file. There are a couple of ANT tasks that make it quite easy.

<manifestclasspath property="jar.classpath" jarfile="${leyton.jar}">
<classpath refid="manifest.classpath" />
</manifestclasspath>
<manifest file="${manifest.file}">
<attribute name="Class-Path" value="${jar.classpath}" />
<attribute name="Main-Class" value="uk.co.pekim.leyton.Main" />
<attribute name="SplashScreen-Image" value="uk/co/pekim/leyton/resources/leyton-large.png" />
</manifest>

Howard said...

Yep, I'm aware of the manifest tricks, I've used them before. They aren't appropriate to what we're trying to do for a number of reasons, including our own custom/secure artifact download system as part of the application launcher.

Philip said...

In a proof of concept for a rich client, I used Tapestry IOC (v5) and it worked quite nicely... particularly distributed contribution (a la Eclipse extension points). The project has just received the go-ahead, so I'm hoping that we'll build a Tapestry-inspired framework for Swing (business forms-focused) development. (Hope to publish/OS the results soon.)

Howard said...

Sounds like fun. That's what I'm doing, except that I'm retrofitting Tapestry 5 IoC into an existing framework. It often feels like an uphill battle.

Joachim said...

Or you could use the (windows only) java launcher at http://blog.progs.be/?p=15 which also gets rid of the windows console which hangs around and has some other neat tricks (like copying the jars to allow updates, downloading jars, restarting when the program is killed).

Maurice said...

Cool...

Under what license do you place this class? BSD? GPLv2? Apache?

Thanks,

Maurice