こんにちは。鎌田です。
これは TECHSCORE Advent Calendar 2017の6日目の記事です。
この記事では、僕が Gradle を使った Java の開発をしていた際に、つまづいた問題と解決までの過程についてお話します。
目次
背景
僕の配属先では、Gradle を使った Java の開発が一般的です。
そのため用意された Gradle の設定ファイルを使う事で、実行可能な jar ファイルができることが当たり前であり、途中の工程についてあまり理解できていませんでした。
Gradle と Java を使って実行可能な jar ファイルを1から作ろうと勉強を始めた事が、今回の問題につまづいたきっかけになります。
クラスパス問題
今回つまづいた問題とは jar ファイルの実行時に ClassNotFoundException の例外が発生することです。
Gradle がよしなに依存関係を解決してくれるおかげで、ビルドまでは問題なく通るのですが、jar の実行時にクラスファイルが見つからないと言われます。
今回は紹介のために HelloWorld というプロジェクトを作り、Apache Commons Lang3 の StringUtils を使った Application.java というプログラムを用意しました。
今回の記事で使用したバージョンは以下の通りです。
Java:1.8.0
Gradle:4.3.1
Apache Commons Lang3(外部ライブラリ):3.7
1 2 3 4 5 6 7 8 9 10 11 12 |
import org.apache.commons.lang3.StringUtils; public class Application { public static void main(String[] args) { String str = args.length > 0 ? args[0] : null; if (StringUtils.isNotEmpty(str)) { System.out.println("HelloWorld " + str); } else { System.out.println("Error"); } } } |
簡単に Application.java の紹介をすると、引数がある場合には HelloWorld <第1引数> を表示し、引数がない場合は Error を表示します。引数の有無のチェックに(無理矢理)StringUtils を使っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
apply plugin: 'java' repositories { jcenter() } dependencies { compile 'org.apache.commons:commons-lang3:3.7' //Apache Commons Lang3を依存関係に追加 } jar { manifest { attributes('Main-Class': 'Application') } } |
このプログラムをビルドして実行すると実行時に以下のように例外がでます。
成果物の jar ファイルは build/libs ディレクトリに生成されます。
1 2 3 4 5 6 7 8 9 10 11 |
$ ./gradlew clean build #問題なくビルドできる BUILD SUCCESSFUL in 1s 3 actionable tasks: 3 executed $ java -jar build/libs/HelloWorld.jar test #成果物の実行 Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils at Application.main(Application.java:5) #実行時に例外が発生 Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils at java.net.URLClassLoader.findClass(URLClassLoader.java:381) ... |
Gradle でビルドできているにも関わらず、なぜクラスファイルが見つけられない例外がでるのか理由がわかりませんでした。そこで java プログラムの実行までの流れやクラスパスの設定について調べました。
1.コンパイル時・実行時にクラスパスを指定
まず試したのは javac でコンパイルし java コマンドで実行するという、一番初歩的な java プログラムの実行方法です。
lib ディレクトリに commons-lang3-3.7.jar を配置し、コンパイル時と実行時に StringUtils のクラスパスを指定することで実行ができました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
$ ls Application.java lib $ ls lib/ commons-lang3-3.7.jar ## コンパイル時にクラスパスを指定しない(NG) ## $ javac Application.java # クラスパスを指定しないとStringUtils.classが見つけられないため怒られる。 Application.java:1: error: package org.apache.commons.lang3 does not exist import org.apache.commons.lang3.StringUtils; ^ Application.java:5: error: cannot find symbol if (args.length > 0 && StringUtils.isNotEmpty(args[0])){ ^ symbol: variable StringUtils location: class Application 2 errors ## コンパイル時にクラスパスを指定する(OK) ## $ javac -classpath ./lib/commons-lang3-3.7.jar Application.java $ java -classpath ./:./lib/commons-lang3-3.7.jar Application test HelloWorld test |
2. jar にクラスパスを指定して実行
1.コンパイル時・実行時にクラスパスを指定でクラスパスを指定すれば良いと分かったので、Gradle で生成した jar の実行時にクラスパスを指定すれば良いのではないかと考えました。
クラスパス問題で jar を生成したディレクトリに新しく lib ディレクトリを作り、commons-lang3-3.7.jar を配置し、Gradle で生成した HelloWorld.jar を実行しました。
1 2 3 4 5 6 7 8 9 10 11 12 |
$ ls HelloWorld.jar lib $ ls lib/ commons-lang3-3.7.jar $ java -classpath ./:./lib/commons-lang3-3.7.jar -jar HelloWorld.jar test Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils at Application.main(Application.java:5) Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils # 同じ例外が発生 at java.net.URLClassLoader.findClass(URLClassLoader.java:381) ... |
クラスパス問題と同じように例外がでました。
クラスパスを通してクラスファイルを配置したはずなのになぜ!?と思ったのですが、理由は簡単で -jar オプションを指定すると引数で渡された jar ファイルのマニフェストに記載されているクラスパス以外は、全て無効化されてしまうためだそうです。
Oracle - Java Platform, Standard Editionツール・リファレンス
-jar filename
JARファイルにカプセル化されたプログラムを実行します。filename引数は、アプリケーションの開始位置として機能するpublic static void main(String[] args)メソッドによってクラスを定義する、Main-Class:classnameの形式の行を含むマニフェストのあるJARファイルの名前です。-jarオプションを使用すると、指定したJARファイルがすべてのユーザー・クラスのソースになり、他のクラス・パスの設定は無視されます。
3.生成する jar に外部ライブラリを含める
2. jar にクラスパスを指定して実行の方法では無理だと考え、Gradle で生成した HelloWorld.jar に外部ライブラリを含めようと考えました。
やり方は簡単で、build.gradle の jar タスクに15行目を追加するだけです。
11 12 13 14 15 16 |
jar { manifest { attributes('Main-Class': 'Application') } from configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } } |
これで動くようになりました。
1 2 |
$ java -jar build/libs/HelloWorld.jar test HelloWorld test |
しかし、このやり方には問題があります。
生成された HelloWorld.jar を展開してみるとわかるのですが、外部ライブラリの内容も全てクラスパス上に展開されています。build.gradle の zipTree というメソッドが jar の中身を展開して配置するためです。
変更前
1 2 3 4 |
$ jar tf HelloWorld.jar | sort Application.class META-INF/ META-INF/MANIFEST.MF |
変更後
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$ jar tf build/libs/HelloWorld.jar |sort Application.class META-INF/ META-INF/LICENSE.txt # LICENCE.txt ファイルが増えている META-INF/MANIFEST.MF META-INF/maven/ META-INF/maven/org.apache.commons/ META-INF/maven/org.apache.commons/commons-lang3/ META-INF/maven/org.apache.commons/commons-lang3/pom.properties META-INF/maven/org.apache.commons/commons-lang3/pom.xml META-INF/NOTICE.txt org/ ... |
「変更前」の META-INFディレクトリには MANIFEST.MF ファイルしかなかったのに、「変更後」は外部ライブラリの余計なファイルまで配置されています。
今後他のライブラリを利用することを考えると、追加したライブラリによって重要なファイルやディレクトリが競合・上書きされ、意図せずプログラムが動作しなくなる可能性が高いです。exclude メソッドを使って競合しそうなファイルを除外する方法もあるのですが、ライブラリの中身を全て知る必要があり、管理が手間になります。
4.最終的にどうしたのか
最終的には成果物の HelloWorld.jar ファイルの中に外部ライブラリを jar ファイルの状態で格納し、Gradle の SpringBoot plugin を使って JarLauncher に読み込んでもらう事にしました。
build.gradle は以下のようになりました(※クラスパス問題から変更した行をハイライトしています)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
apply plugin: 'java' apply plugin: 'eclipse' apply plugin: 'spring-boot' repositories { jcenter() } buildscript { repositories { mavenCentral() } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:1.1.9.RELEASE") } } dependencies { compile 'org.apache.commons:commons-lang3:3.7' } jar { manifest { attributes('Main-Class': 'Application') } jar.into('lib') { from configurations.runtime } } |
完成した jar の中身は以下のようになりました。
1 2 3 4 5 6 7 8 9 |
$ jar tf build/libs/HelloWorld.jar | sort Application.class lib/ lib/commons-lang3-3.7.jar # jar の状態で配置されている。 META-INF/ META-INF/MANIFEST.MF org/ # SpringBoot のファイルが配置されている。 org/springframework/ ... |
なぜ SpringBoot を使うかというと、jar ファイルの状態で格納すると外部ライブラリのクラスファイルを参照できずに、今までと同じように ClassNotFoundException が発生するからです。 java のデフォルトのクラスローダが関係しているようで、今回は SpringBoot のクラスローダを使う事で回避しました。
詳しくはまた別の機会に調べたいと思います。
おわりに
Gradle での Java 開発でどのように実行できる成果物を作っているかに触れる事ができました。また普段クラスパスを意識せずに実行していたため非常に勉強になりました。
僕のように配属直後から Gradle や SpringBoot などの便利技術の恩恵を受けていると、その間で何が起きているか理解せずになんとなく開発ができてしまうということがあるのではないかと思います。
実行可能なプログラムを1から作る事で、普段当たり前に使っている技術が、どのような背景があって生まれたのか考える良い機会になりました。今後も同じような経験を積み、他の技術についても理解を深めたいと思います。