2017年9月21日にいよいよ Java 9 がリリースされます。 Java 9 を利用することのメリットは一体何でしょうか?こんにちは。ヌーラボのアカウント基盤を Java で支える Nulab Apps チームの加藤です。
Nulab Apps チームが開発するアカウントの基盤はサード パーティ製の Java ライブラリだけでも 154 個の依存関係を有します。154 個の Java ライブラリは数々の破壊的変更を乗り越え、おおよそ最新の安定バージョンに更新し続けてきました。
もちろん言語のメジャーバージョンアップにも対応し、現在では Java 8 の関数型をプロダクト コードで使用可能な環境を維持しています(これが楽しい)。本ブログは、ヌーラボのアカウント基盤を先行して (ローカル開発環境のみで) Java 9 にマイグレーションして発生した問題と一時的な対処法を記録した奮戦録です。
TL;DR
- Java 9は破壊的変更があります
- Java 9の目玉はモジュール システムです
- Java 9のモジュール システムは JAR 地獄 やショット・ガン プライバシー問題を解決可能です
Java 9はメモリのフットプリント (使用量) を小さくします- Oracle JDK 9 Migration Guide の日本語要約を作りました
- Nicolai Parlog 氏のライブ コーディングが実践的で面白いです。これで勉強しました
Java 9 公式マイグレーション ガイドを日本語で要約してみた
まずは Oracle が提供する公式のJava 9マイグレーション ガイド “Oracle JDK 9 Migration Guide” を日本語に要約しました。公式のJava 9マイグレーション ガイドの印象は、流行しているモダンな言語の破壊的変更の規模と比較して、後方互換性を大切にする Java には珍しい規模の破壊的変更があると僕は思いました。
Java 9 へのマイグレーション方法
Nulab Apps のローカル開発は基本的に次の環境を採用しています。
- IntelliJ IDEA Ultimate
- Gradle
- Tomcat
- MySQL
「Java 9へのマイグレーション」の定義については「モジュールシステムを使わず、ローカル開発環境の IDE で動作すること」とします。モジュール システムの使用は最低でも Gradle の Java 9 モジュール対応の完了が望まれます。プロダクション環境は、今回検証したローカル環境のJava 9対応を参考にすると良いでしょう。
今回の目標はJava 8の資産をJava 9にそのまま移行させることですので、次の手順でマイグレーションを計画しました。
コンパイル | 実行 | 目的 |
Java 8 | Java 9 | 実行時の例外を調査する |
Java 9 | Java 9 | コンパイル時の例外を調査する |
Java 8 でコンパイルして Java 9 で実行する
既存のソースコードを Java 8 でコンパイルしてJava 9で実行することで、次のような問題を検出します。
- Java 9で削除された機能が原因で発生する起動または実行時の例外
- Java 9でデフォルトの動作変更が原因で発生する実行時の動作不良
- Java 9で非推奨にされた実行時の警告確認
Java 9の代表的な破壊的変更の例
Java 8 でコンパイルしたソースコードが Java 9でそもそも起動できない場合は、GC Option の削除 (JEP 214) を確認してください。
Java 9で NoClassDefFoundError が発生する場合は、Java EE API の一部がクラス パスのデフォルトから除外された (JEP 261) ことを確認してください。対応方法は後述します。
Java 8 と 9 とで日付や通貨の形式が実行時に異なる場合は、国際化拡張機能の初期値が CLDR に変わった (JEP 252) ことを確認してください。対応方法は後述します。
Java 9で次の警告が発生する場合は、private クラスやメソッドに強制アクセスする setAccessible(true) を宣言したディープ・リフレクションの使用を確認してください。現在のディープ・リフレクションは暗黙的な依存関係を可能にする技術で、Java 9のモジュール システムのカプセル化を壊しますので将来禁止される警告です。実行時にディープ・リフレクションが参照できる機能を明示的に公開する –add-opens オプションの使用またはリフレクションの代替となる Variable Handles (JEP 193) の導入を検討してください。
WARNING: An illegal reflective access operation has occurred
Java 9で自動テストを実行する
ヌーラボのアカウント基盤には約 750 個の自動テストがあります。自動テストをJava 9で実行すると約 30 個が失敗しました。失敗した自動テストの原因を追跡します。
Java SE の EE モジュール (java.se.ee) がデフォルトで実行できなくなった
Java 9で自動テストを実行すると、次のような実行時の例外が発生しました。
java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter
実行時に必要なクラスがJava 9に読み込まれていない例外が発生しています。これは次の Java EE (Enterprise Edition) の一部の API が実行時のクラス パスからデフォルトで除外されたこと (JEP 261) が原因です。
Java 8 パッケージ | Java 9 モジュール |
javax.activation | java.activation |
javax.activity, javax.rmi, javax.rmi.CORBA, org.omg.* | java.corba |
javax.transaction | java.transaction |
javax.xml.bind.* | java.xml.bind |
javax.jws, javax.jws.soap, javax.xml.soap, javax.xml.ws.* | java.xml.ws |
javax.annotation | java.xml.ws.annotation |
これらのモジュールはJava 9で廃止予定 (@Deprecated、JEP 277) にもなりました。つまり将来的にはこれらの代替機能を自身で解決する必要があります。これらのパッケージはJava 9ではまだモジュールとして提供されていますので、実行オプションに --add-modules
を指定すると追加のモジュールを読み込ませることが一時的に可能です。
java --add-modules java.xml.bind,java.xml.ws.annotation
複数のモジュールが必要な場合は ,
で区切ります。 この操作を繰り返して実行時に必要なモジュールをJava 9にすべて読み込ませ、自動テストがすべて動作するようになりました。
Java 9の実行に必要なモジュールを見つける方法については、通常はJava 9に付属する jdeps
コマンドを使用します。Java 8 でコンパイルした .class や .jar ファイルをJava 9の jdeps コマンドで解析します。
jdeps GoogleAnalyticsDialectTest.class com.nulab.account.googleanalytics -> javax.annotation java.xml.ws.annotation jdeps payjp-java-0.2.1.jar jp.pay.net -> javax.xml.bind java.xml.bind
Java 9 のモジュールを IntelliJ の GUI で探す方法についてもアニメーション化しました。
公式の全モジュールの依存関係の絵は JEP 200 に掲載されています。Java 9 より java.se がモジュールのルートとなりましたので、java.se.ee 配下のモジュールで java.se を通過しないモジュールがデフォルトで実行できないことがモジュール グラフから読み取れます。
公式のモジュール グラフは僕は見難いと思いました。IntelliJ IDEA Ultimate でもJava 9のモジュール グラフが表示可能です。Java 9 のモジュールの根となる java.se.ee
を中心にモジュール グラフ化したものが次の図です。
日付や通貨のフォーマットが変わった
Java 8 と 9 とで実行時の動作が変わったことにより自動テストが失敗しました。
org.junit.ComparisonFailure: Expected :お支払いが 2015/01/15 に予定されています Actual :お支払いが 2015年1月15日 に予定されています
日付のフォーマットが国際化されています。これはJava 9より国際化拡張機能の初期値が Unicode Consortium が定義した国際化の事実上の標準である CLDR (共通ロケール・データ・リポジトリ) に変更された (JEP 252) ことが原因です。
JRE | java.locale.providers |
Java 8 | COMPAT,SPI |
Java 9 | CLDR,COMPAT,SPI |
Java 9の起動パラメータ java.locale.providers
に Java 8 と同じ優先度で国際化を行う指定をします。
java -Djava.locale.providers=COMPAT,SPI
この設定で日付のフォーマットが Java 8 と同じように動作するようになり、自動テストが成功しました。
以上で 750 個のテストがすべて成功するようになりました。次はいよいよJava 9で既存のソースコードをコンパイルします。
Java 9でコンパイルして Java 9で動かす
ヌーラボのアカウント基盤には Java のコンパイル対象が約 1,360 ファイルあります。目標は 1,360 のソースコードがJava 9 ですべてコンパイルできることです。
コンパイラの設定を Java 9に切り替える
コンパイラを Java 9に切り替えます。Java 9のバージョン指定には注意が必要です。Java バージョンのスキームが変更 (JEP 223)されましたので、1.8
の次は 9
です。
遠い昔、遥か彼方の J2SE 1.4 から J2SE 5 へのプロダクト名称変更とは違い、開発バージョンの変更です。バージョン パーサ周りのライブラリには注意してください。サード パーティ製のライブラリが次のような例外を出力する場合は、バージョンのスキームが変更されたことを作者に報告しましょう。
Caused by: java.lang.StringIndexOutOfBoundsException: begin 0, end 3, length 1
Gradle を Java 9に切り替える
IntelliJ の設定から Gradle JVM をJava 9に設定して完了です。
Java Compiler を Java 9に切り替える
Gradle の build.gradle を 9 に設定して完了です。
sourceCompatibility = 9 targetCompatibility = 9
Java 9でコンパイルする
Java 9 でコンパイルを開始する前に 1 個、コンパイル時に 2 個の例外が発生しましたので、原因を追跡します。
CORBA がデフォルトで実行できなくなった
Java 9のコンパイル以前に、IntelliJ で Gradle プロジェクトを再読込すると次の例外が発生しました。
Caused by: java.lang.ClassNotFoundException: org.omg.CORBA.Object Caused by: org.gradle.api.internal.plugins.PluginApplicationException: Failed to apply plugin [id 'org.flywaydb.flyway']
「org.flywaydb.flyway プラグインの実行に必要な CORBA のクラスが見つからない」と Gradle の実行時エラーが発生しました。これは CORBA もJava 9の実行時にデフォルトのクラス パスから除外されたためです。--add-modules
を使用して Gradle の Flyaway プラグインが必要とする CORBA をJava 9に読み込ませて解決しました。IntelliJ の場合は Gradle VM Options に設定します。Gradle コマンドの場合は次のとおりです。
./gradlew -Dorg.gradle.jvmargs="--add-modules java.corba" build
Java SE の EE モジュール (java.se.ee) がデフォルトでコンパイルできなくなった
IntelliJ で Gradle プロジェクトを再ビルドすると次の例外が発生しました。
Error:(16, 13) java: package javax.annotation is not visible (package javax.annotation is declared in module java.xml.ws.annotation, which is not in the module graph)
Java 9 のコンパイラが「javax.annotation が モジュール java.xml.ws.annotation に宣言されているため参照ができない」と怒っています。この例外は自動テストで発生した実行時例外と同じことに気づきました。不足している Java SE の EE モジュール (java.se.ee) をコンパイラに指定する必要があります。javac コンパイラのオプションに --add-modules java.xml.ws.annotation
を設定して解決しました。
内部 API の sun.* や jdk.internal.* がコンパイルできなくなった
Java 9のコンパイラが次の例外を出力しました。
package com.sun.istack.internal is not visible import com.sun.istack.internal.NotNull (package com.sun.istack.internal is declared in module java.xml.bind, which does not export it to the unnamed module)
「java.xml.bind のモジュールに宣言された com.sun.istack.internal は unnamed module で export されていません」とコンパイラに怒られました。JDK の内部 API である sun.* や jdk.internal.* は、本当は非公開にしたいけどカプセル化できないので公開されていて、一般的に外部から参照しないということが Java の暗黙のルールでした。Java 9 ではモジュール システムの導入により、パッケージ レベルのカプセル化が可能になったため、JDK の内部 API を使用するとコンパイル エラーにすることが可能になりました。このエラーは代替機能を探すことが推奨されますが、一時的にこの強力なカプセル化を壊す方法がコンパイラのオプションに --add-exports
として追加されています。
javac --add-exports java.xml.bind/com.sun.istack.internal=ALL-UNNAMED
ALL-UNNAMED
はすべてのクラス パスから参照できるようにするおまじないです。
以上でモジュール システムを使わないJava 9のマイグレーションが完了です。ヌーラボのアカウント基盤は意外に少ない手数でJava 9へマイグレーションすることができました。
Java 9 マイグレーション設定のまとめ
Java 9 のマイグレーションに対応した build.gradle は次のとおりです。
tasks.withType(JavaCompile) { doFirst { sourceCompatibility = 9 targetCompatibility = 9 options.compilerArgs = [ '--add-modules', 'java.xml.ws.annotation', '--add-modules', 'java.xml.bind', '--add-exports', 'java.xml.bind/com.sun.istack.internal=ALL-UNNAMED' ] } } tasks.withType(Test) { doFirst { jvmArgs = [ '--add-modules', 'java.xml.ws.annotation', '--add-modules', 'java.xml.bind', '-Djava.locale.providers=COMPAT,SPI', ] } }
Gradle の build コマンドは次のとおりです。
./gradlew -Dorg.gradle.jvmargs="--add-modules java.corba" build
モジュールとは?
なぜJava 9ではコンパイル エラーが発生するような変更がされたのでしょうか?
Java 9の主役である「モジュール」とは「モジュール システム」、通称 Project Jigsaw (JEP 200, 201, 220, 260, 261, 282) のことです。「ジグソー」というキーワードはどこかで聞いたことがあるのではないでしょうか。
モジュール (正確には Java Platform Module System) は 2005 年 (JSR 277) から議論されていました。モジュールは Java 9 のリリースを 5 回も延期させた主役で、他のモダンな言語と比較して Java の停滞を象徴するプロジェクトとして印象深いです。日銀の物価上昇率 2% 達成の先送り回数でも 6 回なので、その深刻さが伝わると思います。あえて最近の Java をフォローすると、長大なリリースが続いた Java 8 と 9 の停滞を省みて《2018年3月以降、毎年3月と9月の年2回リリースする》ことが提案されています。一昔前は「COBOL が書ければ食いっぱぐれない」と言いましたが (以下省略)。
話を戻して、Java 9 のモジュールは 2 つの問題を解決します。
問題 1: JAR 地獄
Java が他の言語よりも有利な点のひとつは、先人が作った偉大なライブラリが充実している点です。しかしライブラリが充実しすぎて、ライブラリ同士の依存関係も複雑化しました。コンパイル時の複雑なライブラリの依存関係は Ant、Maven や Gradle などのビルド ツールが解決してきましたが、Java には依然として実行時の問題が残りました。それが「JAR 地獄」です。JAR 地獄とは地獄のことです。
地獄は次のコマンドで再現することができます。
java -classpath "commons-math3-3.0.jar;commons-math3-3.6.1.jar;"
同じライブラリのバージョン違いを 2 つ指定したとして、Java では展開されたライブラリのクラスの中で先に読み込まれたクラスが使用されます。同じ名前で違うバージョンのどちらか一方のクラスのみが使用されるため、実行時に想定外の動作をすることがあります。これが JAR 地獄です。使用するコマンドのオプションが -classpath であることから「クラスパス地獄」とも呼ばれます。
Java 9では従来の -classpath を使用したクラスパス地獄も使用可能ですが、-classpath に換わる –module-path オプション、つまりモジュール システムが追加されました。依存関係をソースコード module-info.java に記述して、コンパイル時に名前空間の重複を検出可能にしたことで、JAR 地獄が解決されました。
問題 2: ショット・ガン プライバシー問題
Perl を設計した Larry Wall 氏の有名な言葉に《Perlは、プライバシーを強制することはしません。たとえ相手がショットガンを持っていなくても、招かれていないリビング・ルームには足を踏み入れない方がよいということです》があります。これは「ショット・ガン プライバシー」問題と呼ばれ、Java のアクセス制御 (public や private) にも同じことが当てはまります。Java 8 までのアクセス制御はクラス、メソッドやフィールドまでは制御が可能でした。
Java 9のモジュール システムは、パッケージのアクセス制御が可能となります。パッケージがショット ガンの所有ライセンスを認められたので、ライブラリの作者が公開したくないパッケージを外部から明示的に守る強力なカプセル化が可能です。外部から使用されることを意図していないパッケージの動作を修正してバージョン アップした際に「破壊的変更だ!けしからん」と Twitter に投稿されて作者が負傷することがなくなります。
メモリのフットプリントが削減された?
JAR 地獄やショット・ガン プライバシー問題を解決したと言われても、特に困っていないので僕はあまり嬉しくないと思いました。ヌーラボのアカウント基盤をJava 9に移行して何が幸せだったのかは、起動直後のメモリの使用量を計測した結果にありました。
5 個の WAR から構成される長大なヌーラボのアカウント基盤では、ローカル環境のメモリの使用量が 1/3 になったことに腰を抜かしました。なぜメモリが減ったのでしょうか? モジュール化によって不要なクラスが読み込まれなくなったからでしょうか?僕にはわかりません。誰か教えてください。
(追記) 教えてもらった結果「ヒープのサイズが違う」ということで、-Xmx2g -Xms2g の指定を追加すると Java 9 の方がメモリが 22MB 多くなりました。いよいよ Java 9 にするメリットを見失った気がしました。
IntelliJ でモジュール システムを試す方法
Java 9のモジュール システム試してみたいと思ったので、モジュール システムを言語サポートする IngelliJ IDEA で試してみました。Java 9のマイグレーション作業が高レベルにサポートされて感動したので、IntelliJ を使用したモジュール システム導入手順のアニメーションを作りました。
ポイントは、モジュールを定義する module-info.java を作成後すぐに、従来のクラス パスのコンパイルから Java 9のモジュール システムのコンパイルに自動的に切り替わることです。モジュール対応で必要な requires も IntelliJ が提案してくれますし、exports 可能なパッケージ名の候補も提案してくれます。素晴らしいです。僕が最初にモジュール システムを検証した 2016 年 11 月にはこのような素晴らしい機能はなく、コンパイルのスクリプトを手書きして大変辛いと思っていました。
まとめ
Java 9は JAR 地獄を解決して Module 地獄になりました。
Nulab Apps チームでは Java 9や Java 10 のマイグレーションでも一緒に楽しんでくれるエンジニアの参加を待っています。あなたと JAVA、今すぐ。
レビュー履歴
2017/9/20 18:15 「Java EE (javax.*)」という記述が、すべての Java EE を指しているという趣旨のレビューをいただきました。「Java EE API の一部」や「Java SE の EE モジュール (java.se.ee)」に修正しました。@megascus 氏、@skrb 氏、@sugarlife 氏ありがとうございました。🎉
2017/9/21 08:25 「Java 9 でメモリが1/3」という記述に、ヒープのサイズが異なるという趣旨のレビュー1、レビュー2、レビュー3 をいただきました。ヒープを指定 -Xms2g -Xmx2g すると、Java 8 と Java 9 でほぼ同じメモリ使用量になったことを追加しました。@sugarlife 氏、@chiroito 氏、@kompiro 氏、ありがとうございました。🎉
2017/9/21 09:00 「モジュール システムが 20 年以上前」という記述に、12 年前というレビューをいただきました。「2005 年」に修正しました。@kiwanami 氏、ありがとうございました。 🎉
2017/9/22 11:30 「CLDR を Java 8 互換に設定するには COMPAT,CLDR,SPI」という記述に、Java 8 では CLDR が未設定なので「COMPAT,SPI」が正しいという趣旨のレビューをいただきました。@naotoj 氏、ありがとうございました。🎉
参考ノート
Java 9のモジュール システムを調べるにあたって、僕が最も参照したエンジニアは Nicolai Parlog (@nipafx) 氏です。彼は Java 9のライブ コーディングを YouTube にアップロードしています。タイトルは “To JAR Hell And Back – A Live Migration to the Java 9 Module System” で、これが一番実践的で面白いです。一般的なプロジェクトで発生する Java 9 のコンパイル エラーを順をステップ・バイ・ステップで解決していく点が特に良いです。彼は現在 “The Java 9 Module System” を執筆中で、Early Release 版が読むことができます。
また、O’Reilly から出版された “Java 9 Modularity” の著者 Paul Bakker (@pbakker) 氏もライブ コーディングを公開しています。彼は Visual Studio Code を使ってJava 9のマイグレーションを実演している点で最高です。Netflix のエンジニアなのであの Netflix OSS がJava 9に対応していく過程を公開してくれることも切に期待しています。著書の内容も Spring と Hibernate をモジュール化する方法や、ビルド ツールを使用したモジュール対応、クラスパスとモジュールを混在させたコンパイルなど実践的な内容で面白いと思いました。