SIer だけど技術やりたいブログ

Javaのビルドの基礎知識

Java

Javaのビルド関連の知識については、Java学習初期にこんなコード書いて終わり、あとは便利なビルドツール(mavenやらgradle)に任せようって感じで知識が薄かった。

$ cat Sample.java
public class Sample {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}
$ javac Sample.java
$ java Sample
hello world

なので再勉強する。

題材

複数モジュールを作成し、mavenがやってるようなことをjdk付属ツールでやる。
module-a.jarのクラス が module-b.jarのクラス を参照する構成にする。

module-b.jarを作る

以下のようなプロジェクト構成でmodule-b.jarを作成していく。

$ tree 
|-- src
|  |-- module
|  |  |-- b
|  |     |- B.java
|-- target
   |-- classes

ソースコードは適当に用意する。

$ cat src/module/b/B.java
package module.b;

public class B {
    public void print() {
        System.out.println("B class is called");
    }
}

まずはコンパイルしてclassファイルを作る。

$ javac -d target/classes/ src/module/b/B.java
$ tree 
|-- src
|  |-- module
|  |  |-- b
|  |     |- B.java
|-- target
   |-- classes
      |-- module
         |-- b
            |-- B.class

jarを作る。jarの中身はclassファイルをまとめたzip的なやつ。

$ jar cvf target/module-b.jar -C target/classes/ .
マニフェストが追加されました
module/を追加中です(入=0)(出=0)(0%格納されました)
module/b/を追加中です(入=0)(出=0)(0%格納されました)
module/b/B.classを追加中です(入=398)(出=283)(28%収縮されました)
$ ls target/
classes  module-b.jar

module-a.jarを作成する

続いて module-a.jar を作成する。

$ tree 
|-- src
|  |-- module
|  |  |-- a
|  |     |- A.java
|-- target
   |-- classes

moduel-a のクラスでは module-b のクラスを利用する。

$ cat src/module/a/A.java
package module.a;
import module.b.B;

public class A {
    public static void main(String[] args) {
        B b = new B();
        b.print();
    }
}

先ほどと同様にコンパイルするとエラーになる。

$ javac -d target/classes/ src/module/a/A.java
src/module/a/A.java:3: エラー: パッケージmodule.bは存在しません
import module.b.B;
               ^
src/module/a/A.java:7: エラー: シンボルを見つけられません
            B b = new B();
            ^
  シンボル:   クラス B
  場所: クラス A
src/module/a/A.java:7: エラー: シンボルを見つけられません
            B b = new B();
                      ^
  シンボル:   クラス B
  場所: クラス A
エラー3

利用する(importする)クラスをクラスパスに通さないといけない。(B.class を参照しないと型チェックもできないので当然)

$ javac -cp ../module-b/target/module-b.jar -d target/classes/ src/module/a/A.java

jarを作る。

$ jar cvf target/module-a.jar -C target/classes/ .
マニフェストが追加されました
module/を追加中です(入=0)(出=0)(0%格納されました)
module/a/を追加中です(入=0)(出=0)(0%格納されました)
module/a/A.classを追加中です(入=315)(出=237)(24%収縮されました)

実行時にも module-b にクラスパスが通っていないとダメ。(module-a.jar には module-b.jar のクラスファイルが含まれていないから)

$ java -cp target/module-a.jar module.a.A
Exception in thread "main" java.lang.NoClassDefFoundError: module/b/B
        at module.a.A.main(A.java:7)
Caused by: java.lang.ClassNotFoundException: module.b.B
        at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
        ... 1 more

そのため、実行するときは、以下のようにクラスパスを指定する。

$ java -cp target/module-a.jar:../module-b/target/module-b.jar module.a.A
B class is called

module-b.jar のライブラリがバージョンアップした場合

module-b.jar がバージョンアップしたとしても、一度 module-a.jar をコンパイルしたあとは同じ module-a.jar を使うことができる。

メソッドの中の処理が変わった場合

メソッドのシグネチャは変えずに、中の処理だけ変える。

$ cat src/module/b/B.java
package module.b;

public class B {
    public void print() {
        // メソッドの中を変える
        System.out.println("B class version up");
    }
}

module-b.jarを作り直す。

$ javac -d target/classes/ src/module/b/B.java
$ jar cvf target/module-b.jar -C target/classes/ .
マニフェストが追加されました
module/を追加中です(入=0)(出=0)(0%格納されました)
module/b/を追加中です(入=0)(出=0)(0%格納されました)
module/b/B.classを追加中です(入=399)(出=285)(28%収縮されました)

module-a.jarを再コンパイルしなくても、実行できる。

$ java -cp ../module-a/target/module-a.jar:target/module-b.jar module.a.A
B class version up <- 変わってる

メソッドシグネチャが変わった場合

今度はメソッドシグネチャを変えて試してみる。

$ cat src/module/b/B.java
package module.b;

public class B {
    // メソッドの名前を変える
    public void printVerUp() {
        System.out.println("B class version up");
    }
}

module-b.jarを作り直す。

$ javac -d target/classes/ src/module/b/B.java
$ jar cvf target/module-b.jar -C target/classes/ .
マニフェストが追加されました
module/を追加中です(入=0)(出=0)(0%格納されました)
module/b/を追加中です(入=0)(出=0)(0%格納されました)
module/b/B.classを追加中です(入=404)(出=288)(28%収縮されました)

module-a.jar を再コンパイルしなくても実行できるが、 module-b.jar にはprint()メソッドがないので実行時エラーになる。

$ java -cp ../module-a/target/module-a.jar:target/module-b.jar module.a.A
Exception in thread "main" java.lang.NoSuchMethodError: module.b.B.print()V
        at module.a.A.main(A.java:8)

これが「ライブラリのバージョン上げたら動かなくなった」というやつ。

実行可能jarを作る

前述の実行方法だと、java起動時に、実行するクラスを引数で指定する必要がある。アプリケーションとして色々な人に配布するときに、これだと不便。

マニフェストファイルを作ると、起動時のクラスをあらかじめ指定できるようになる。
https://docs.oracle.com/javase/jp/1.5.0/guide/jar/jar.html

$ cat Manifest.MF
Main-Class: module.a.A
Class-Path: ./../../module-b/target/module-b.jar

マニフェストファイルをjarに含める。

$ jar cvfm  target/module-a.jar Manifest.MF -C target/classes/ .
マニフェストが追加されました
module/を追加中です(入=0)(出=0)(0%格納されました)
module/a/を追加中です(入=0)(出=0)(0%格納されました)
module/a/A.classを追加中です(入=315)(出=237)(24%収縮されました)

java -jar xxx を指定するだけで実行可能になった。

$ java -jar target/module-a.jar
B class is called

Fat jar を作る

先述の例ではマニフェストファイルの Class-Path に module-b.jar のURLを指定するため、所定のディレクトリにjarが存在しないといけない。

所定のディレクトリにjarが存在しないと、以下のようなエラーになる。

java -jar module-a.jar
Exception in thread "main" java.lang.NoClassDefFoundError: module/b/B
        at module.a.A.main(A.java:7)
Caused by: java.lang.ClassNotFoundException: module.b.B
        at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
        ... 1 more

単一のjarを配布するだけで実行したければ、全classファイルを一つのjarに入れてしまえばいい。これは、Fat jar と呼ばれる。

$ pwd
/home/kimura/maven/module-a/target
// jarを解凍してclassファイルを取り出す
$ jar -xf ../../module-b/target/module-b.jar
$ ls
META-INF  classes  module
$ rm -rf META-INF
// classファイルをまとめる
$ mv module/b classes/module/
$ ls classes/module
a/ b/
$ cd ..
// まとめたclassファイルをjarにする
$ cat Manifest.MF
Main-Class: module.a.A
$ jar cvfm  target/module-a.jar Manifest.MF -C target/classes/ .
マニフェストが追加されました
module/を追加中です(入=0)(出=0)(0%格納されました)
module/a/を追加中です(入=0)(出=0)(0%格納されました)
module/a/A.classを追加中です(入=315)(出=237)(24%収縮されました)
module/b/を追加中です(入=0)(出=0)(0%格納されました)
module/b/B.classを追加中です(入=398)(出=283)(28%収縮されました)
// まとめたclassファイルをjarにする
$ java -jar target/module-a.jar
B class is called

他の Fat jar 形式

先述の例はjarを展開して取り出したclassファイルを、Fat jarに格納した。

Spring Bootでは、依存するjarファイルを、そのままjar形式で Fat jarに格納できる。

example.jar
|
+-META-INF
| +-MANIFEST.MF
+-org
| +-springframework
| +-boot
| +-loader
| +-+-BOOT-INF
+-classes
| +-mycompany
| +-project
| +-YourClasses.class
+-lib
+-dependency1.jar
+-dependency2.jar

Appendix E. The executable jar format

ただしこの機能(JarにJarを含める)は標準的な実現方法がないため、Spring Bootは独自のクラスローダを提供している。

Java does not provide any standard way to load nested jar files (i.e. jar files that are themselves contained within a jar). This can be problematic if you are looking to distribute a self-contained application that you can just run from the command line without unpacking.

To solve this problem, many developers use “shaded” jars. A shaded jar simply packages all classes, from all jars, into a single ’uber jar’. The problem with shaded jars is that it becomes hard to see which libraries you are actually using in your application. It can also be problematic if the same filename is used (but with different content) in multiple jars. Spring Boot takes a different approach and allows you to actually nest jars directly.

MANIFEST.MFをのぞくと以下のようになっている。

cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Implementation-Title: demo
Implementation-Version: 0.0.1-SNAPSHOT
Archiver-Version: Plexus Archiver
Built-By: kimura
Implementation-Vendor-Id: com.example
Spring-Boot-Version: 1.5.6.RELEASE
Implementation-Vendor: Pivotal Software, Inc.
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.0.5
Build-Jdk: 1.8.0_141
Implementation-URL: http://projects.spring.io/spring-boot/demo/

起動クラスの org.springframework.boot.loader.JarLauncher で、各種classファイルをロードしている様子。

spring-projects/spring-bootgithub.com

まとめ

ビルドツールに任せよう