wissel.net

Usability - Productivity - Business - The web - Singapore & Twins

Reading Resources from JAR Files


One interesting challenge I encountered is the need or ability to make an Java application extensible by providing additional classes and configuation. Ideally extension should happen by dropping a properly crafted JAR file into a specified location and restard the server. Along the line I learned about Java's classpath. This is what is to be shared here.

Act one: onto the classpath

When you start off with Java, you would expect, that you simply can set the classpath varible either using an environment variable or the java -cp parameter. Then you learn the hard way, that java -jar and java -cp are mutually exclusive. After a short flirt with fatJAR, you end up with a directory structure like this:

Directory Structure

The secreingredient to make this work is the manifest file inside the myApp.jar. It needs to be told to put all jar files in libs onto the classpath too. In maven, it looks like this:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>${maven.jar.plugin.version}</version>
    <configuration>
        <archive>
            <manifest>
                <mainClass>com.hcl.domino.keep.Launch</mainClass>
            </manifest>
            <manifestEntries>
                <Class-Path>.</Class-Path>
                <Class-Path>libs/*</Class-Path>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

Now, that all JARS are successfully availble on the classspath, we can try to retrieve them.

Act two: find a resource

Reliably opening resources in an Java app needs to be done using an InputStream. It is the only way to happily ignore if the resource is inside a JAR or happens to live on the file system. Why is that important? Well, in a Docker deployment I usually don't package my applications as Jars, since they live inside a container already. We don't want to play Inception.

There are two method returning a resource as an InputStream:

On the surface they do the same, but on a closer look slightly different. Class.getResourceAsStream looks for resources at the path location of the class itself, so when a resource is at the root of the jar, you need a leading /, e.g. /config.json. ClassLoader.getResourceAsStream on the other hand would return null when the resource name is prefixed with /, while without it works as expected e.g. config.json.

I use Google Guava's I/O system to read a resource into a String:

  static Optional<String> stream2String(final InputStream in) {
    try {
      ByteSource byteSource = new ByteSource() {
        @Override
        public InputStream openStream() {
          return in;
        }
      };

      return Optional.of(byteSource.asCharSource(StandardCharsets.UTF_8).read());
    } catch (IOException e) {
      e.printStackTrace();
      return Optional.empty();
    }
  }

Getting one file is only half the Story, we want all of them

Act three: find all resources

The method we need for this is ClassLoader.getResources. It returns an Enumeration of URLs. You might be tempted to use URL.getFile() to get hands on the file, but you will fail when the resource is inside a jar, indicated by the URL starting with jar:file:/. The solution is the same as for single resources: use the InputStream provided by URL.openStream(). I use this helper:

String readFromURL(final URL source) {
    try (InputStream in = source.openStream()) {
      return stream2String(in).orElseThrow(() -> new Exception("Can't read " + source));
    } catch (Exception e) {
      return e.getMessage();
    }

  }

You can test this with a quick code snippet, it will provide you with 3 resources:

void allTheFileContent() throws IOException {
    Enumeration<URL> r = this.getClass().getClassLoader().getResources("settings.json");
    Collections.list(r).stream()
        .map(this::readFromURL)
        .forEach(System.out::println);
  }

Act four: merge

In my use case, I need a combined settings.json file. Luckily the Eclipse vert.x framework provides a JsonObject.mergeIn() method that does the heavy lifting. Add a Collector and you get:

JsonObject merge(final String resourceName) throws IOException {
    Enumeration<URL> r = this.getClass().getClassLoader().getResources(resourceName);
    return Collections.list(r).stream()
        .map(this::readFromURL)
        .map(JsonObject::new)
        .collect(Collector.of(() -> new JsonObject(),
            JsonObject::mergeIn,
            JsonObject::mergeIn));
  }

Mission accomplished: all settings merged. As usual YMMV


Posted by on 29 April 2021 | Comments (0) | categories: Java

Comments

  1. No comments yet, be the first to comment