【Java】単一の実行可能JAR( Fat JAR )について

Java

Fat JAR とは

Fat JAR とは、必要な依存関係が全て一つにまとめられたJARファイルのことです。

実行可能 jar ( “uber jar” または “fat jar” と呼ばれることもあります) は、コンパイルされたクラスとコードの実行に必要なすべての jar 依存関係を含むアーカイブです。

uber jar は、すべてのアプリケーションの依存関係からのすべてのクラスを単一のアーカイブにパッケージ化します。

初めての Spring Boot アプリケーションの開発 > 実行可能 Jar の作成

そもそもJARファイルとは何かという点についてはこちらで整理しています。

Fat JAR( Uber JAR )
必要な依存関係が全て一つにまとめられたJARファイル

Fat JAR(Uber JAR)の利点

ここから、Fat JAR がどのような場面で役に立つのか、エラーが発生する例を見ながら順を追って実際に Fat JAR を作成し、確認していきます。

依存関係がある実行可能JARファイル

Fat JAR を理解するためには、まず「依存関係がある実行可能JARファイル」について確認しておく必要があります。依存関係がある実行可能JARファイルとは、ここでは「ライブラリ用に作成されたJARファイルに含まれるクラスを呼び出しており、外部ライブラリに依存している実行可能JARファイル」を指します。

まずはこちらのサンプルプログラムを用い、実行可能JARファイル app.jar を作成していきます。Main.javaPersonService.javaPerson.java を使用しますが、このうち Person.java を次のように書き換えます。

Person.java
package com.example.model;

// import文を追加(Apache Commons Lang)
import org.apache.commons.lang3.StringUtils;

public class Person {

    private final String name;

    public Person(String name) {
        this.name = name;
    }

    public String printName() {
        // StringUtilsを使用した処理に変更
        return StringUtils.defaultString(name);
    }
}

この変更では Apache Commons Lang に含まれる StringUtils を利用することにしています。これは、これから作成しようとしている Main.java をエントリポイントとする app.jar とは別に、外部ライブラリとして、ライブラリ用に作成されたJARファイル commons-lang3-x.x.x.jar が必要になるということです。

この状態で、Main.javaPersonService.javaPerson.java から、実行可能JARファイル app.jar を作成し、実行してみると、次のようなエラーが発生します。

1.javacコマンドによるコンパイルの実行

-dオプションを使用することで、ソースファイルが格納されたディレクトリ(例:src)と分けて、クラスファイルを指定したディレクトリ(例:bin)に出力することが可能です。

また、コンパイル対象のうち Person.java が外部ライブラリ commons-lang3-x.x.x.jar に依存しているため、-cpオプション等でクラスパスの設定を行う必要があります。ここではlibディレクトリの commons-lang3-3.20.0.jar をクラスパスに追加しています。

# クラスファイルの出力先にbinディレクトリを指定してコンパイル
# binディレクトリにはソースファイルと1対1でクラスファイルが同じディレクトリ構成で出力される
# ただし依存する commons-lang3 にクラスパスが通っていないためエラー
> javac -d bin src\com\example\*.java src\com\example\model\*.java src\com\example\service\*.java
src\com\example\model\Person.java:4: エラー: パッケージorg.apache.commons.lang3は存在しません
import org.apache.commons.lang3.StringUtils;
                               ^
src\com\example\model\Person.java:16: エラー: シンボルを見つけられません
        return StringUtils.defaultString(name);
               ^
  シンボル:   変数 StringUtils
  場所: クラス Person
エラー2個

# -cpオプションでクラスパスの追加も併せて行いコンパイルを実行
> javac -cp lib\commons-lang3-3.20.0.jar -d bin src\com\example\*.java src\com\example\model\*.java src\com\example\service\*.java

2.jarコマンドによる実行可能JARファイルの作成

-eオプションを使用することで、エントリポイントとなるクラスがマニフェストファイルのMain-Class属性に追加され、実行可能JARファイルとして扱えるようになります。

# -eオプションでcom.example.Mainを指定することで
# マニフェストファイルに「Main-Class: com.example.Main」が追加される
> jar -cvef com.example.Main app.jar -C bin com
マニフェストが追加されました
com/を追加中です(入=0)(出=0)(0%格納されました)
com/example/を追加中です(入=0)(出=0)(0%格納されました)
com/example/Main.classを追加中です(入=1007)(出=548)(45%収縮されました)
com/example/model/を追加中です(入=0)(出=0)(0%格納されました)
com/example/model/Person.classを追加中です(入=365)(出=258)(29%収縮されました)
com/example/service/を追加中です(入=0)(出=0)(0%格納されました)
com/example/service/PersonService.classを追加中です(入=395)(出=266)(32%収縮されました)
JARファイルの作成方法について詳細はこちらでも整理しています。
# 外部ライブラリに依存するプログラムから実行可能JARファイルを作成した場合
# 依存先のクラスが見つからずエラーが発生する
> java -jar app.jar
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
        at com.example.model.Person.printName(Person.java:16)
        at com.example.Main.main(Main.java:10)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490)
        ... 2 more

これは、作成した実行可能JARファイルには、Main.javaPersonService.javaPerson.java の三つのみが含まれており、追加で必要な StringUtils を有する commons-lang3-x.x.x.jar がクラスパスに追加されていないためです。

クラスパスについてはこちらで詳細を整理しています。

そこで、クラスパスの設定方法に従い-cpオプション環境変数CLASSPATHを利用して、依存する commons-lang3-x.x.x.jar をクラスパスに追加してみますが、この方法ではうまくいきません。

# -cpオプションでcommons-lang3-3.20.0.jarをクラスパスに追加
# → 変わらず NoClassDefFoundError が発生
> java -cp lib\commons-lang3-3.20.0.jar -jar app.jar
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
        at com.example.model.Person.printName(Person.java:16)
        at com.example.Main.main(Main.java:10)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490)
        ... 2 more

# 環境変数CLASSPATHが設定されていることを確認
> set CLASSPATH
CLASSPATH=lib\commons-lang3-3.20.0.jar

# 環境変数CLASSPATHにcommons-lang3-3.20.0.jarが追加された状態で実行
# → 変わらず NoClassDefFoundError が発生
> java -jar app.jar
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
        at com.example.model.Person.printName(Person.java:16)
        at com.example.Main.main(Main.java:10)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490)
        ... 2 more

これは、-jarオプションを使用した実行可能JARファイル起動の場合、クラスパス設定が無視されるためです。

-jarを使用すると、指定したJARファイルがすべてのユーザー・クラスのソースとなり、他のクラスパス設定は無視されます。

javaコマンド > シノプシス > -jar jarfile

実行可能JARファイルを-jarオプションで実行する際、クラスパス設定が無視されるだけであるため、app.jarcommons-lang3-3.20.0.jar と共にライブラリJARとしてクラスパスに追加すれば、引数にメインクラスを指定して正常に実行することができます。

# -cpオプションでapp.jarとcommons-lang3-3.20.0.jarを両方追加し
# 引数にはエントリポイントとなるクラスcom.example.Mainを指定する
> java -cp app.jar;lib\commons-lang3-3.20.0.jar com.example.Main
Person01 has been created!

# 環境変数CLASSPATHの利用でも同様
> set CLASSPATH
CLASSPATH=app.jar;lib\commons-lang3-3.20.0.jar

# この場合はcom.example.Mainを指定するだけで実行可能
> java com.example.Main
Person01 has been created!

ただしこの方法では、通常のJARファイルとしてクラスパスに追加しています。引数にはエントリポイントとなるmainメソッドを持ったクラスの完全修飾クラス名を正確に指定する必要があり、実行可能JARファイルのメリットを享受できていません。

従って次に示す通り、実行可能JARファイルに対して、クラスパスの追加を行う方法を考える必要があります。

依存関係がある実行可能JARファイル
ライブラリ(外部のJARファイル等)をクラスパスに追加する必要がある実行可能JAR
-jarオプションで実行する場合は外から渡したクラスパス設定が全て無視される

Class-Path属性

そこで利用されるのが、マニフェストファイルの Class-Path属性 です。これを追加することで、クラスパス設定が行われるため、依存関係がある実行可能JARファイルであっても、正常に実行できるようになります。

jarコマンドにはClass-Pass属性を追加するためのオプションは用意されていないため、ここでは手動で追記して検証します。( -mオプションに使用したいファイルを指定すれば、それをマニフェストファイルとして含めることが可能です。 )

こちらでも触れていますが、JARファイルは拡張子を.zipに書き換えれば通常のZIPファイルとして扱うことができます。あるいは、jarコマンドの-xオプションでもJARファイルを解凍できます。

いずれかの方法で マニフェストファイルMETA-INF/MANIFEST.MF)を取り出し、以下の通りClass-Pass属性を追記します。

MANIFEST.MF
Manifest-Version: 1.0
Created-By: 25.0.2 (Oracle Corporation)
Main-Class: com.example.Main
Class-Path: lib/commons-lang3-3.20.0.jar

こうすることにより、依存関係が解決され、実行時にクラスパス設定を追加することなく、実行可能JARファイルを正常に実行できるようになります。

# 必要なcommons-lang3がクラスパスに追加されたことで
# 依存関係が解決され実行可能JARファイルを正常に実行できるようになる
> java -jar app.jar
Person01 has been created!

Class-Path属性
マニフェストファイルに記述することで実行可能JAR起動時にクラスパスを追加できる
-jarオプションでクラスパス設定が無視される仕様に対応するために使用される

依存するライブラリJARの配置

ところが、依然として一つ問題が残ります。それは、Class-Pass属性に追加したファイルパスに、依存するライブラリJARを実際に配置することが必須であるという点です。

マニフェストファイルに追加したClass-Pass属性は、あくまでそこに記述されたパスをクラスパスに追加するということを定義しているに過ぎません。従って、実際にそこに実体が存在するか否かは保証されておらず、例えば先の例では、たとえClass-Pass属性が記述されていても、lib/commons-lang3-3.20.0.jar が存在しなければエラーになってしまいます。

# lib/commons-lang3-3.20.0.jarが存在する状態で実行
# → Class-Path属性に基づきこれがクラスパスに追加されるため正常に実行できる
> java -jar app.jar
Person01 has been created!

# lib/commons-lang3-3.20.0.jarが存在しない状態で実行
# → 同じapp.jarでもクラスパスに追加されるはずのJARが存在しないため依存関係が解決されずNoClassDefFoundErrorが発生する
> java -jar app.jar
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
        at com.example.model.Person.printName(Person.java:16)
        at com.example.Main.main(Main.java:10)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490)
        ... 2 more

つまり、依存関係がある実行可能JARファイル(=マニフェストファイルにMain-Class属性とClass-Pass属性が記述されているJARファイル)は、正常に実行するためにはそれ自身だけでなく必ず依存するJARファイルが全て、Class-Pass属性に記述されたディレクトリ構成そのまま、併せて必要になるということです。

これでは、複数ファイルをコンパイルしたプログラム配布時と似たようなことが問題になります。Main.classPersonService.classPerson.class を配布しやすいように app.jar にまとめ、さらにメインクラス指定も不要になるよう実行可能JARファイルとして作成したにもかかわらず、結局 app.jar だけでなく commons-lang3-3.20.0.jar も併せて渡さなくてはならなくなってしまうということです。

依存関係がある実行可能JARファイル
Class-Pass属性に記述された依存先のファイルやディレクトリが実行時に必須

例えばサーバ上に配置する際、自作アプリケーションである app.jar と、外部ライブラリである commons-lang3-3.20.0.jar を明確に分けて異なるディレクトリに配置する必要がある場合などは、このように依存関係がJARファイルごとに分かれている方が良いこともあります。

シェーディングされた jar ファイルの問題は、どのライブラリが実際にアプリケーションに含まれているかを見にくくなることです。

Spring Boot > Specification > 実行可能な Jar 形式 > ネストされた JAR

しかし実行可能JARファイルとして配布の容易性に焦点を当てると、依存関係が全て単一のJARファイルにまとまっている方が便利です。これを解決するのが、Fat JAR の考え方です。

単一の実行可能JARファイル( Fat JAR )

Fat JAR の基本的な考え方は、ここまでに見た依存関係があるJARファイルの問題(依存するライブラリJARを全てクラスパス設定に沿ったディレクトリに配置しておく必要があるということ)を解決するため、依存するJARファイルの内容も全てまとめて一つのJARファイルとして作成してしまうというものです。

しかし、単に依存するライブラリJARを含めるだけでは、これはJARファイルの仕様に沿っておらず正常に動作しません。

Java は、ネストされた jar ファイル(つまり、jar 内に含まれる jar ファイル)をロードする標準的な方法を提供しません。

Spring Boot > Specification > 実行可能な Jar 形式 > ネストされた JAR

例えば次のように、Class-Pass属性に追記したlib/commons-lang3-3.20.0.jarに合わせ、app.jarに次のようにcommons-lang3-3.20.0.jarを含めたとしても、これは認識されず NoClassDefFoundError が発生してしまいます。

app.jar
├ META-INF
│ └ MANIFEST.MF
├ lib
│ └ commons-lang3-3.20.0.jar
└ com
   └ example
      ├ Main.class
      ├ model
      │ └ Person.class
      └ service
         └ PersonService.class
# 依存する commons-lang3-3.20.0.jar を app.jar に含めても認識されず NoClassDefFoundError が発生
> java -jar app.jar
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
        at com.example.model.Person.printName(Person.java:16)
        at com.example.Main.main(Main.java:10)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490)
        ... 2 more

通常はJARファイルをネストして作成することは出来ないため、依存するJARファイルを展開し全てのクラスファイルを改めて単一のJARファイルにまとめ直すことで Fat JAR を作成しますが、Spring Boot にはJARファイルを直接ネストできる独自の仕組みが備わっています。

Spring Boot は異なるアプローチを採用しており、実際に jar を直接ネストできます。

Spring Boot > Specification > 実行可能な Jar 形式 > ネストされた JAR

これを解決するために取ることができるのが、依存するライブラリJARとしてクラスパスを通すのではなく、そこに含まれるクラスファイルを全て直接、実行可能JARファイル内に同梱してしまうという方法です。

依存ライブラリとしてのJARファイルについてはこちらでも触れています。

具体的には、まず依存するライブラリJARである commons-lang3-3.20.0.jar を解凍し、クラスパスを通すべき起点のディレクトリ下全てのクラスファイルを取り出します。( 前述 の通り、一度ZIPファイルに変換して展開するか、jarコマンドを使うかすることで、解凍が可能です。 )

commons-lang3-3.20.0.jar
├ META-INF
│ ├ ...
│ └ MANIFEST.MF
└ org
   └ apache
      └ commons
         └ lang3
            ├ ...
            └ StringUtils.class

続いて取り出したディレクトリを、そのまま実行可能JARファイル内に配置します。これにより、例えば StringUtils.class は、外部ライブラリのクラスではなく、app.jar のプログラムの一部として扱われる状態となります。

app.jar
├ META-INF
│ └ MANIFEST.MF
├ com
│ └ example
│    ├ Main.class
│    ├ model
│    │ └ Person.class
│    └ service
│       └ PersonService.class
└ org
   └ apache
      └ commons
         └ lang3
            ├ ...
            └ StringUtils.class

従って、この時点でマニフェストファイルにはClass-Pass属性も不要となります。クラスパス設定を追加せずとも、app.jar を起点とする com.example.~org.apache.commons.lang3.~ がどちらも解決できる構成になっているためです。

MANIFEST.MF
Manifest-Version: 1.0
Created-By: 25.0.2 (Oracle Corporation)
Main-Class: com.example.Main

既に app.jarcommons-lang3-3.20.0.jar に依存しておらず、内部に必要なクラスファイルを全て有しているため、単一の実行可能JARファイルとして、-jarオプションによってjavaコマンドで実行することができます。

# 単一の実行可能JARファイルとして正常に実行可能
> java -jar app.jar
Person01 has been created!

これが「Fat JAR」(あるいは「Uber JAR」)と呼ばれるJARファイルです。

Fat JAR( Uber JAR )
単一の(実行可能)JARファイルでプログラムを実行できるようにしたJARファイル
Class-Pass属性があると依存するファイルやディレクトリも併せて必要になる問題を回避
ライブラリの区別関係なくプログラムに必要な全てのクラスファイルを同梱

実行可能JARでない Fat JAR

ここまでの説明では主に実行可能JARファイルを例に挙げてきました。しかし Fat JAR は必ずしも実行可能JARファイルである必要はありません。例えば 前述 のように、クラスパスに追加することを前提としたJARファイルの場合でも、Fat JAR にまとめることで、複数のJARファイルを追加する必要がなくなり、単一のJARファイルによる利便性を得られます。

# 実行可能JARでない Fat JAR の場合
# 実行可能JARファイルではないためMainクラスの指定は必須
# クラスパスにはapp.jarのみ追加すればcommons-lang3-3.20.0.jarは不要
# もともとcommons-lang3に含まれていたクラスは全てapp.jarに同梱されているため
> java -cp app.jar com.example.Main
Person01 has been created!

# 環境変数CLASSPATHの利用でも同様
> set CLASSPATH
CLASSPATH=app.jar

# この場合はcom.example.Mainを指定するだけで実行可能
> java com.example.Main
Person01 has been created!

しかしそこまでしているのであれば、マニフェストファイルにMain-Class属性を追加し、メインクラスの指定も省略できるようにしておく方が利便性も向上するため、多くの場合、Fat JAR = 単一の実行可能JAR、となります。

Fat JAR( Uber JAR )
必須ではないものの利便性の観点から多くの場合は実行可能JARファイルとして作成される

Fat JAR( Uber JAR )の要点

  • Fat JAR とは
    • 必要な依存関係が全て一つにまとめられたJARファイルのこと
    • Uber JAR とも呼ばれる
    • 必須ではないものの実行可能JARファイルであることが多い
    • 従って単一の実行可能JARファイルと同義であることが多い
  • 依存関係がある実行可能JARファイルとは
    • マニフェストファイルにClass-Pass属性が記述されたJARファイル
    • 実行可能JARファイルの他Class-Pass属性に指定された依存関係が必須
    • Fat JAR ではない