Dependency management
We saw previously how libraries can be packaged into JAR files for easier distribution. After packaging our message library as a JAR, our chief architect is so impressed they immediately mandate its use across all projects at our company Picosoft. It proves so popular we’re inundated with feature requests from other teams. One such request is to support reading messages from a JSON file containing a string array e.g.
["Hello", "world", "!"]
We’re confident our existing MessageSource
interface can support this use case, but don’t want to write our own JSON parser. As conscientious
professionals, we embark on a detailed evaluation of the available options, and settle on the first Google search result - JSON Java.
We fetch the JSON library JAR locally and begin work:
> curl -L -o json-java.jar https://search.maven.org/remotecontent?filepath=org/json/json/20230618/json-20230618.jar
after a frenetic caffeine-fueled 10-hour coding session, we finally have our new message source implementation:
JSONMessageSource.java
package enterprisey;
import java.io.FileInputStream;
import java.io.Reader;
import java.io.StringReader;
import java.io.IOException;
import java.util.Iterator;
import java.util.NoSuchElementException;
import org.json.JSONArray;
import org.json.JSONTokener;
public class JSONMessageSource implements MessageSource {
private final JSONArray arr;
public JSONMessageSource(JSONArray arr) {
this.arr = arr;
}
public static JSONMessageSource fromFile(String fileName) throws IOException {
try (FileInputStream is = new FileInputStream(fileName)) {
JSONTokener tokeniser = new JSONTokener(is);
JSONArray arr = new JSONArray(tokeniser);
return new JSONMessageSource(arr);
}
}
public Iterable<Reader> messages() throws IOException {
return new Iterable<Reader>() {
public Iterator<Reader> iterator() {
return new Iter();
}
};
}
private class Iter implements Iterator<Reader> {
private int index;
public Iter() {
this.index = 0;
}
public boolean hasNext() {
return this.index < arr.length();
}
public Reader next() {
if (this.index < arr.length()) {
String s = arr.getString(this.index);
this.index++;
return new StringReader(s);
} else {
throw new NoSuchElementException();
}
}
}
}
as before, we compile the library, updating the build classpath to include the dependency JAR:
> javac -cp json-java.jar -d classes/libhello src/libhello/*.java
> jar --create --file libhello.jar -C classes/libhello .
we write a small test application and data file for the new source
EchoJSON.java
package test;
import enterprisey.*;
import java.io.Reader;
import java.io.IOException;
public class EchoJSON {
public static void main(String[] args) {
try {
MessageSource source = JSONMessageSource.fromFile(args[0]);
MessageSink sink = new PrintStreamMessageSink(System.out);
for(Reader message : source.messages()) {
sink.writeMessage(message);
}
} catch (IOException ex) {
System.err.println("Error processing messages");
}
}
}
messages.json
["Hello", "world", "!"]
and run it to check the input messages are displayed as expected:
> javac -cp json-java.jar:libhello.jar -d app EchoJSON.java
> java -cp json-java.jar:libhello.jar:app test.EchoJSON messages.json
satisfied with another job well done, we email the updated JAR to the chief scrum master of the originating team for the request.
Shortly after, we receive a response complaining that when running their application, they receive the following error:
Exception in thread "main" java.lang.NoClassDefFoundError: org/json/JSONTokener
at enterprisey.JSONMessageSource.fromFile(JSONMessageSource.java:21)
This occurs because when running their application, the other team has not placed json-java.jar
on the classpath, and this defines
the org.json.JSONTokener
class. This is not surprising, since given the libhello.jar
file, there is no way to detect that it depends
on json-java.jar
at runtime. JAR files are just an archive of class files and do not define a mechanism for declaring which other JARs
define dependency classes.
In addition, the other team have a few other requirements for consuming our library:
- They would prefer not to commit binary files to their source control repository
- They would like to be able to identify the library version from the JAR file name
- They need to be able to identify all dependency JARs so they can fetch them and construct the correct classpath to run their application
Maven
Maven is a tool for managing Java projects. It defines a standard project layout and built-in tools for common project operations such as running tests and building JAR files. It also defines the format for binary repositories where JARs can be published and retrieved, along with their dependencies.
POM files
Maven projects are defined by a Project Object Model (POM) file in XML format called pom.xml
. This defines all aspects of the project, such as
where to find source and test files, build and packaging information, and dependency definitions. All project pom.xml
files implicitly inherit
from a ‘super POM’ file defined by the Maven distribution. This defines
the standard layout of a Maven project.
Dependencies
Most projects make use of external libraries, which often in turn declare dependencies of their own. As we’ve seen, manually managing these dependencies and putting them on the classpath rapidly becomes impractical, even for small projects. Maven allows projects to declare which libraries they depend on, and manages fetching these locally and constructing the required classpath. Dependencies are identified by a set of co-ordinates which can be used to locate them within a repository they’re published to. These consist of three main components:
- Group - The entity or organisation responsible for maintaining and publishing the artifact. These are usually identified by a reversed domain name controlled by the publisher, such as
org.apache
orcom.google
. - Artifact - The name of the artifact, such as
clojure
orguava
- Version - The version of the artifact to use
Taken together, the Group, Artifact and Version (often abbreviated GAV) are usually enough to uniquely identify the dependency artifact. Non-standard dependencies can specify other components such as a classifier or different type - see Maven Coordinates in the POM reference for the full details.
Repositories
In order to make artifacts available to others, the responsible organisation publishes them to a repository. Maven repositories have a defined layout which can be used to locate an artifact based on its co-ordinates. For most artifacts, the location is given by:
/$groupId[0]/../${groupId[n]/$artifactId/$version/$artifactId-$version.$extension
where $groupId[0]..groupId[n]
are the dot-separated components of the publisher’s groupId. For example artifacts for the org.clojure
group will be published under org/clojure
. The $extension
component is
determined by the artifact packaging type - this is usually jar
for Java artifacts. For example, the data.json
project is published by the org.clojure
group, so the JAR for version 0.2.6
would be located at:
org/clojure/data.json/0.2.6/data.json-0.2.6.jar
within a repository.
Maven central
Maven defines a single repository central
in the super POM. This is located at http://repo.maven.apache.org/maven2 and it means that Maven
projects can reference dependencies published to this repository without any further configuration.
Local repositories
During dependency resolution, Maven fetches any required dependencies and copies them to a local repository. By default, this repository is located at ~/.m2/repository
and follows the same layout at described above.
Therefore, version 0.2.6
of the data.json
JAR would be fetched to ~/.m2/repository/org/clojure/data.json/0.2.6/data.json-0.2.6.jar
.
Maven library development
Now we understand the basics of Maven we can create a project for our library. First we create a directory for the project and set it up in the
standard Maven layout. As defined in the super POM Java source files go under
${project.basedir}/src/main/java
.
libhello/
pom.xml
src/
main/
java/
enterprisey/
CommandLineMessageSource.java
JSONMessageSource.java
...
Our pom.xml
file defines the co-ordinates for the library itself along with its dependencies:
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.enterprisey</groupId>
<artifactId>libhello</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20230618</version>
</dependency>
</dependencies>
</project>
Now we can compile and package the library as a JAR:
mvn package
The super POM sets the default build directory to ${project.basedir}/target
and if we look there we can see a libhello-1.0.0.jar
file has been
created.
This can be installed into our local repository with
mvn install
As we would expect from the library co-ordinates and the default local repository location, this is installed into ~/.m2/repository/com/enterprisey/libhello/1.0.0/
.
Both the JAR and POM files are published so the dependency information is still accessible.
Maven application development
With the library published to our local repository, we decide to convert our test application into a Maven project as well. Again we setup the directory layout and POM files:
json-test/
src/
main/
java/
test/
EchoJSON.java
pom.xml
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.enterprisey</groupId>
<artifactId>json-test</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>com.enterprisey</groupId>
<artifactId>libhello</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>
The application POM declares a dependency on the version of the library we want to use. As before the JAR can be build with
mvn package
which creates the JAR in the json-test-1.0.0.jar
. When we try to run the application with
java -cp target/json-test-1.0.0.jar test.EchoJSON messages.json
we run into the same java.lang.NoClassDefFoundError
error as before (this time for one of our library classes). This is because the
output JAR still only contains the application classes and none of the classes defined by its dependencies. One option is to get the runtime
classpath from Maven and supply that to run the application:
mvn dependency:build-classpath -Dmdep.outputFile=classpath.txt
java -cp $(cat classpath.txt):target/json-test-1.0.0.jar test.EchoJSON messages.json
If we look in the classpath.txt
file written by the mvn
command we can see it references all transitive dependencies of the application
which Maven downloaded into our local repository:
classpath.txt
~/.m2/repository/com/enterprisey/libhello/1.0.0/libhello-1.0.0.jar:~/.m2/repository/org/json/json/20230618/json-20230618.jar
Rather than manually construct the runtime classpath, we can use the exec plugin during development:
mvn exec:java -Dexec.mainClass=test.EchoJSON -Dexec.args="messages.json"
Another option is to build uber JARs using the Maven shade plugin.
Publishing
Now the library and application are working locally, it’s time to share the library with our colleagues. Our chief architect is convinced it constitutes a key competitive advantage for the company and is unwilling to unleash it on an unsuspecting public. She arranges for a private Maven repository to be setup, and our CI process to publish there instead of the Maven central repository. We advise any teams wishing to use it to configure the private repository in their application POM files with the following fragment:
<repositories>
<name>Picosoft internal</name>
<id>picosoft-internal</id>
<url>https://maven.picosoft.com/</url>
<layout>default</layout>
</repositories>
Repository settings
Public repositories can be accessed anonymously, but our private repository requires authentication to access. Since authentication is connected
to a user instead of a project, Maven allows user settings to be configured outside of a project. By default these settings are defined within
a ~/.m2/settings.xml
file. The full format of the file is specified in the documentation, but for our private
repository, we just need to configure the credentials to use:
~/.m2/settings.xml
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>picosoft-internal</id>
<username>fluffy</username>
<password>${env.MAVEN_INTERNAL_PASSWORD}</password>
</server>
</servers>
</settings>