レベル: 上級 夷藤 勇人, ソフトウェア事業,
IBM
2005年 06月 22日 J2EE開発プロジェクトにおいて必要不可欠なJ2EEパッケージング戦略とはなにか?すべてのJ2EE開発者が正しいJ2EEパッケージング戦略をとれるように、詳細にその理論・方法を解説していきます。
全5回シリーズ「クラスローダーとJ2EEパッケージング戦略を理解する」、
第1回、
第2回では、
Java・クラスローダーの動作の基本について解説しました。地味な話題ではありましたが、Javaの根底を支える仕組みです。第3回となる今回は、J2EEの世界でエンタープライズ・アプリケーション・EARをどのようにパッケージングすればよいか、J2EEパッケージング戦略について解説していきます。第1回・第2回で学んだ知識を今こそ生かすときです。
純血戦略
J2EEパッケージング戦略の基本方針は、J2EE仕様に準拠した「どんなアプリケーション・サーバーでも通用するポータブルなEAR」を作成することです。これをJ2EE純血パッケージング戦略 - 略して純血戦略 - と呼ぶことにしましょう。この王道を身につけることで、アプリケーション・サーバー独自に用意されている「ちょっと楽をできる」仕組みに頼る必要がなくなります。純血戦略を貫くには、以下の3原則を遵守することが大事です。
- ルール1. EARは独立・自己完結[1]したアプリケーションのパッケージング単位です。EARひとつを渡せば全て済むというのが、正しいEARの姿です。EAR以外のライブラリーが別途必要だったり、アプリケーション・サーバー特有のクラスパスの修正を要求したりするようなEARは、「不完全なEAR」とみなして毛嫌いしましょう。
- ルール2. クラスローダー・デリゲーションモードの設定は、アプリケーション・クラスローダー、各WARクラスローダー、共に「PARENT LAST(親が最後)」に設定します。
- ルール3. クラスローダー・ポリシー(アイソレーション・モード[2])の設定は、デフォルト設定をそのまま使用します。アプリケーションごとにアプリケーション・クラスローダーが1つ、さらに下位クラスローダーとして、WARごとに専用のWARクラスローダーが1つずつ割り当てられる設定です。決して「パッケージングについてあまり考えたくない」という理由だけで、後先のことを考えずに「単数(シングル)」や「アプリケーション」等に変更しないでください。「ダークサイド」に落ちてはいけません。
[1]自己完結というのは、「システム全体」をEAR1つにしなければいけないという意味ではありません。システム全体としては、複数のEARが各サーバーに分散されて成り立つケースの方がむしろ多いでしょう。ここでいう自己完結とは、アプリケーション起動にあたって、EAR以外に何もライブラリーを必要としない、EAR単体で「動作」するという意味です。「他のシステムのリモートメソッドを呼び出す」、といった実行時依存関係は、当然残ります。EAR分割戦略については、次回以降で解説します。
[2]アイソレーション・モードの詳細については、「あえて」省略します。アイソレーション・モードを変更することは、正しいパッケージングを行っていない誤ったEARへの一時的な救済策です。
もし、あなたがかかわっているJ2EEプロジェクトが、単にWebアプリケーション1つだけというのなら、なんて幸せでしょうか。J2EEパッケージングについては悩む必要はありません。Webアプリケーションが使用するライブラリー・各種Jarファイルは、WAR内の「WEB-INF/lib」以下に配置するだけです。J2EEパッケージング戦略が重要になってくるのは、WARが複数、EJBモジュールを使用、さらに複数のライブラリー間の依存関係が絡んでくるといった、より複雑なJ2EEアプリケーションに対処するときです。
J2EEパッケージング戦略-モデル1
図1は、あるJ2EEアプリケーションの例です。WebアプリケーションとしてWARが3つ、EJBモジュールとしてejb-jarが2つ、さらに複数のライブラリーを使用しています。各Webアプリケーションは、Strutsを使用しています。
(注)正確にはStrutsは複数のライブラリーに依存していますが、ここでは、それらをcommons-xxx.jarとして代表させています。
図1 モジュール・ライブラリー依存関係
J2EEパッケージングをスタートしましょう。純血戦略では、自己完結したEARを完成させるのが目標です。使用するライブラリのうち、J2SE・J2EEに含まれていないものは、全てEAR内にパッケージングします。リスト1に、パッケージング例 - モデル1 - を示します。WARひとつの場合と同様に、各WAR内部に使用するライブラリーを全て入れておきます。さらに、EARの直下にも使用ライブラリーを配置しています。
リスト1 J2EEパッケージング戦略 - モデル1 - 重複戦略
luv-app.ear
- META-INF/application.xml
- ejb1.jar
- ejb2.jar
- utility.jar
- commons-xxx.jar
- luv1.war
- WEB-INF/lib
- utility.jar
- commons-xxx.jar
- struts.jar
- luv2.war
- WEB-INF/lib
- utility.jar
- commons-xxx.jar
- struts.jar
- luv3.war
- WEB-INF/lib
- commons-xxx.jar
- struts.jar
- web-util.jar
|
EAR内に配置した各モジュールやライブラリーはどのようにJ2EEコンテナに認識されるのでしょうか?WARやejb-jarといったJ2EEモジュールについては、心配ありません。EARのDD(Deployment Descriptor:配置記述子)である「META-INF/application.xml」内に明記されるからです。各WAR内・「WEB-INF/lib」以下に配置したライブラリーも問題ありません。「WEB-INF/lib」以下のJarファイルはコンテナに認識されると、WAR仕様で定められているからです。問題は、EAR直下に配置したJ2EEモジュールではないライブラリーです。これらはただ置くだけでは認識されません。そのようなライブラリー発見の仕組みは、J2EE仕様にはありません。
リスト1に示すパッケージングを行ったEARを、アプリケーション・サーバーにデプロイするとどのようになるでしょうか?図2は、クラスローダーの観点から見た場合、各モジュール・ライブラリーがどのように配置されるかを示したものです。utility.jar等に依存しているEJBモジュールは、このままでは正常に動作しないことがわかるでしょう。EJBモジュールはutility.jarを発見できません。下位クラスローダーである各WARクラスローダー内は、クラス検索の対象にならないからです。
図2 クラスローダーの観点から見た場合 - Missing library?
Bundled Optional Package
そこで、パッケージングを行う際は、使用する依存ライブラリーをJ2EEコンテナに教えてあげる必要があります。今回の例では、EJBモジュール・ejb1.jarは、utility.jar等に依存しています。そこで、このEJBモジュール内部の、マニフェスト・ファイル「META-INF/MANIFEST.MF」に使用するライブラリーを宣言しておきます(リスト2)。
リスト2 ejb1.jar内のMETA-INF/MANIFEST.MFの内容
Manifest-Version: 1.0
Class-Path: utility.jar
commons-xxx.jar
commons-yyy.jar
commons-zzz.jar
|
マニフェスト・ファイルの「Class-Path」エントリーがライブラリー発見メカニズムです。J2EEコンテナは、J2EEモジュール、すなわちWAR、ejb-jar内のマニフェスト・ファイルにClass-Pathエントリーがあった場合、そこに宣言されているライブラリーを、アプリケーション・クラスローダー配下に配置します。Class-Pathエントリーに記述するパスは、EARのトップからの相対位置で指定します。今回のように、EARのトップ直下に依存ライブラリーをおいた場合は、Class-Pathで指定するのは、そのJarファイルの名前だけです。依存ライブラリーの発見の仕組みは、連鎖します。依存先ライブラリー内のマニフェスト・ファイルにさらにClass-Pathエントリーがあった場合は、「依存先ライブラリーの依存先ライブラリー」も、アプリケーション・クラスローダー配下に同様に配置されます。
このように、EAR内に同梱され、J2EEモジュールから使用されるライブラリーのことを、J2EE仕様では「Bundled Optional Package」と呼んでいます。ですが、実際にそう呼んでいる人は少ないです。単に「ユーティリティーJar」と呼ばれることが多いです。マニフェスト・ファイルに記述することによって、図3のように、「Bundled Optional Package」も、アプリケーション・クラスローダー配下に配置されることになります。
(注)正確には、全てのJ2EEモジュールのマニフェスト・ファイルに該当エントリーを記述する必要はありません。たとえ、そのモジュールがそのライブラリーを必要としていても、です。ひとつのJ2EEアプリケーション内では、アプリケーション・クラスローダーはひとつです。全J2EEモジュールで共通に使用されます。そのため、あるひとつのJ2EEモジュールのマニフェスト・ファイルに使用ライブラリーが宣言されていれば、そのライブラリーは全J2EEモジュールから見えることになります。
図3 クラスローダーの観点から見た場合 - Bundled Optional Packageの可視化
マニフェスト・ファイルのClass-Pathエントリーそのものは、J2EEのためだけに用意されている仕組みではありません。もっと根本的なところ、実はJARファイルの仕様として定められています。
JARファイルの仕様 (US)
WAR is Not ライブラリー
WARに含まれているクラスを、別のWARやJARの中から使用しようとしないでください。試しにluv1.warのマニフェスト・ファイルを、リスト3のように書き換えたとしましょう。
リスト3 luv1.war内のマニフェスト・ファイル - luv2.warへの誤った参照
Manifest-Version: 1.0
Class-Path: luv2.war
|
このように、luv2.warへの参照を明記しても、luv2.war内のクラスが見えるようにはなりません。WARはあくまで、Web「アプリケーション」であって、「ライブラリー」ではないのです。他から使用するようものではありません。
そもそも、WARはライブラリー「Jar」ではないのです。確かにWARの物理的なファイル・フォーマットは「Jar」ですが、論理的なパッケージング構造は「Jar」ではありません。「Jar」がライブラリーとして正しく機能するためには、Javaのパッケージ階層構造がそのまま「Jar」内部のディレクトリー構造と一致する必要があります。そういう意味では、EJBモジュールは、「Jar」です。Javaのパッケージ階層構造がそのままJarとしてパッケージングされているからです。
「あるWARに含まれているクラスを、別のWARでも使用したくなった」[注]というのはよいメッセージです。そのクラスが「ある1つのWebアプリケーション特有」ではないということを教えてくれています。そのクラスは、別途ユーティリティーJarとしてライブラリー化した方がよいでしょう。
[注]この目的だけのために、クラスローダー設定・アイソレーション・モードを変更するのは、「ワースト・プラクティス」です。
J2EEパッケージング戦略 - モデル2
図3を見てみると、「ひとつのEARの中に、struts.jarやcommons-xxx.jarが重複しているけど、なんか無駄だよね」と感じることでしょう。より重複を省く戦略 - モデル2 - をリスト4に示します。
リスト4 J2EEパッケージング戦略モデル2-ユニーク戦略
luv-app.ear
- META-INF/application.xml
- ejb1.jar
- ejb2.jar
- utility.jar
- commons-xxx.jar
- struts.jar
- luv1.war
- luv2.war
- luv3.war
- WEB-INF/lib
- web-util.jar
|
クラスローダーの観点から見た場合は、図4のようになります。マニフェスト・ファイルの修正を忘れないでください。
図4 クラスローダーの観点から見た場合 - モデル2
時間と空間 - トレードオフ
モデル1とモデル2、どちらかよいのでしょうか?一概にはいえません。これらの間には典型的なトレードオフ関係が成立します。一般に「キャッシュ」が「時間」と「空間」のトレードオフ関係をなすのと同様です。キャッシュのサイズをいくつに設定するのがよいか一概にはいえないのと同様に、J2EEパッケージング戦略において、ライブラリーをどこに置くべきか・重複して持つべきかどうかは、さまざまな要因によって決定されます。
モデル1では、各WARの独立性が高まります。あるWAR内でのライブラリーの変更は、他のWARへ影響を与えません。一方、モデル2では、ライブラリーは一箇所に置かれるため、ライブラリーの管理は容易になります。また、ランタイム時、若干ではありますがリソースの節約にもつながります。その反面、EAR内の全てのWARにそのライブラリーは公開されるというリスクを背負うことにもなります。
モデル1、モデル2のどちらがよいかは、むしろ、ライブラリーごとに決定するべき問題といえましょう。たとえば、前回、第2回で解説した「シングルトンがシングルトンでなくなる日」を覚えているでしょうか?各WARで、シングルトンクラスのインスタンスを別々に持ちたいケースもありますし(モデル1)、逆に全WARで共通に一個だけのシングルトンを持ちたいというケース(モデル2)もあります。
恐怖のドッペルゲンガー
純血戦略のメリットとして、1)自己完結EARのためサーバー環境からの影響を受けにくい、2)ライブラリーの可視範囲を最小限におさえることができるためクラス汚染を防げる、などがあげられます。しかし、純血はただでは得られません。単にクラスが見つかるか見つからないかだけではなく、ひとつひとつのライブラリーの依存関係を意識したパッケージングが必要です。そうでないと、世にも恐ろしい「ドッペルゲンガー」に出くわしてしまいます。
以下のシナリオを考えて見ましょう。
現在、パッケージング戦略として、図4に示す、パッケージング戦略 - モデル2 - をとっていたとします。話を具体的にするため、共通に使用しているライブラリーの代表例として、「Jakarta Commons - BeanUtils」を具体的に取り上げてみましょう。
現在は、Commons BeanUtilsのライブラリーとして、最新ではないバージョン「commons-beanutils-old.jar」を共通に使用しています。ある理由のため、luv1.warの開発チームが、最新のバージョン「commons-beanutils-new.jar」をluv1.war内部で使用する必要が出てきたとしましょう。ただし、luv2.war、luv3.warを担当している開発チームはそれに同意してくれません。特に新しいバージョンの必要性を感じていないからです。luv1チームが「新しいバージョンでも何の問題もありません。互換性はあります。」と、いくら説得しても、luv2チーム、luv3チームは耳も貸してくれません。
そこで、luv1開発チームは、図5に示すような戦略をとりました。理由はこうです。
「luv2、luv3に何の影響を与えなければ、問題はないだろう。共通に使用されているcommons-beanutils-oldを置き換えるのではなく、自分たちの担当しているWebアプリケーション、luv1.war内にだけ新しいバージョンであるcommons-beanutils-new.jarを入れておこう。幸い、我々は純血戦略を貫いている。クラスローダーはそれぞれ別だから、他のチームには影響はないはずだ。」
図5 J2EEパッケージング戦略 - luv1チームのとった戦略
確かに、luv2, luv3には何の影響も与えません。しかし、この結果、luv1には悲劇が待っています。リスト5をご覧ください。これはluv1チームが担当しているStruts・Actionクラスから、一部を抜粋したものです。
リスト5 ドッペルゲンガーが出会う空間 - HelloAction
// ....
public class HelloAction extends Action {
public ActionForward execute(ActionMapping mapping,
ActionForm form,
HttpServletRequest req,
HttpServletResponse res)
throws Exception {
System.out.println(">>> Class: " + form.getClass()); // (1)
DynaBean bean = (DynaBean) form; // (2)
// ...
}
}
|
今までは、何の問題もなく動いていました。ところが、図5に示すパッケージング戦略をとったとたん、エラーが発生するようになりました(リスト6)。
リスト6 HelloActionの実行結果 - 謎のClassCastException
>>> Class: class org.apache.struts.action.DynaActionForm
...(略)...
---- Begin backtrace for Nested Throwables
java.lang.ClassCastException: org.apache.struts.action.DynaActionForm
at com.example.HelloAction.execute(HelloAction.java:10)
|
リスト5 -(1)の実行結果は、リスト6の一行目です。executeメソッドに渡されるパラメーターformは、実際にはタイプがDynaActionFormのインスタンスであることがわかります。DynaActionFormの定義は、
public class DynaActionForum extends ActionForm implements DynaBean {
|
となっています。DynaBeanインターフェースを確かにインプリメントしています(図6)。
図6 DynaBean - Type Hierarchy
ところが、リスト5 -(2)、DynaActionFormのインスタンスを、DynaBeanにキャストしようとすると、リスト6の2行目以降に示すようなClassCastExeptionが発生してしまいました。「DynaBeanインターフェース」をインプリメントしているオブジェクトを「DynaBeanインターフェース」にキャストできないのです。どうしてでしょうか?
残念ながら、上の議論は、大事な視点が抜けています。シリーズ、第2回で述べたことが覚えているでしょうか?
.....ひとつのJVM内で、クラスを識別するキーとなるのは「パッケージ名+クラス名」ではないのです。「パッケージ名+クラス名+ロードしたクラスローダー」が識別のためのキーとなるのです。
先ほどの状況を正しく言い換えると、次のようになります。
(A)(アプリケーション・クラスローダーがロードした)DynaBeanインターフェース
をインプリメントしているオブジェクトを
(B)(WARクラスローダーがロードした)DynaBeanインターフェース
にキャストすることはできません。
図5をもう一度よく見てみましょう。HelloActionのexecuteメソッドに渡されるパラメーター・DynaActionFormのインスタンスが誕生するのはそもそもどこでしょうか?それはアプリケーション・クラスローダー配下にあるStruts.jar内で行われます。その際、使用されるライブラリーは、アプリケーション・クラスローダーの世界のもの -commons-beanutils-old- です。作成されるDynaActionFormのインスタンスは、(A)をインプリメントしています。アプリケーション・クラスローダーの世界の住人なのです。
一方、WARクラスローダー配下にあるHelloActionが、DynaBeanインターフェースを探すときは、commons-beanutils-new.jar内に含まれる、(B)が先に見つかります。純血戦略では「PARENT LAST(親が最後)」設定を採用しているためです。
(A)と(B)は別世界の住人です。それがClassCastExceptionの原因です。
「(A)と(B)は、そもそも違うバージョンだから起きるのでは」、とお思いになるかもしれません。ですが、それは問題の本質ではありません。違うバージョンではなく、まったく同じcommons-beanutils.jarを両方に配置して同じテストを行っても、やはりClassCastExceptionが発生します。HelloActionは、見かけ(名前)は同じですが、それぞれ別世界出身の2つのDynaBean、世にも恐ろしいドッペルゲンガーに不幸にも出会ってしまったのです。
ドッペルゲンガーに対抗する
それでは、luv1開発チームが他の開発チームに影響を与えることなく、新しいバージョン「commons-beanutils-new」を使用することは不可能なのでしょうか?第1回・第2回で学んだクラスローダーに関する知識を使用するときがようやくやってきました。ドッペルゲンガーに対抗しましょう(図7)。
図7 ドッペルゲンガーを消滅させる
「全てをWARクラスローダーの世界で完結させる」という戦略をとります。図5との違いは、luv1内に、Struts.jar、commons-xxxなどのライブラリーを入れたことです。これでドッペルゲンガーを消滅できます。今度は、全てWAR内で、出来事が起こるようになります。DynaBeanはどちらもWARクラスローダーの世界出身、同一人物です。
このように「上位クラスローダーに存在するライブラリーと、違うバージョンのライブラリーを自クラスローダー配下に置くときは、それに依存するライブラリーも同じクラスローダー配下に置く」というのが、ドッペルゲンガーへの対抗策です。
今回は、ドッペルゲンガーの消滅に成功しましたが、いつもこのようにうまくいくとは限りません。たとえば、Webアプリケーション・luv1がEJBモジュール・ejb2.jarを利用することになったとしましょう。EJBモジュールで提供されているあるセッションビーンのメソッドの戻り値が、DynaBeanだったらどうなるでしょうか?luv1内では、その戻り値をDynaBeanとして扱うことはできません。またもやドッペルゲンガーに出くわすことになります。先ほどのような解決策は残念ながら不可能です。EJBモジュールというアプリケーション・クラスローダーの世界を、WARクラスローダー内だけに閉じ込めることはできません。
リフレクションとDynamic Proxyを使用して、強引に、戻り値を、「DynaBean」として扱うことは技術的には可能です(このシリーズの範囲を超えることになるので、詳細は省略します)。しかし、ここまでいたずらに問題を複雑にするよりは、素直に「EAR全体でcommons-beanutils-newを使用する」ように、luv2開発チームとluv3開発チームを説得した方がよいのかもしれません。時として「政治的な問題」の方が「技術的な問題」よりはるかに解決がたやすいものです。
あなたの隣にもドッペルゲンガー
今回のドッペルゲンガーの例は、無理やりつくったかのようで、実際にはこのようなことが起きるのは稀であるという印象を受けたかもしれません。しかし、ちょっとしたパッケージングのミスで、ドッペルゲンガー現象が起きうる状況をつくりだしてしまうことは少なくありません。
またもや、Strutsを実例にしてみましょう。Strutsのディストリビューションには、雛形Webアプリケーションであるstruts-blank.warが同梱されています。過去、この中には、jdbc-ext.jarが入っていました。これには、javax.sql.Datasourceが含まれています。古いJREには、javax.sql.Datasourceが含まれていなかったためです。親切心なのでしょう。これらは、最近のJ2SE/J2EEまたはアプリケーション・サーバー付属のJarでも提供されています。そのため、以下のようなデータソースをルックアップする部分で、ドッペルゲンガーに出会ってしまいます。アプリケーション・サーバーが提供するデータソースは、一般にサーバーレベルのクラスローダーの世界で生成されるためです。
リスト7 データソースがドッペルゲンガーのケース
InitialContext context = ....
Datasource datasource = (Datasource) context.lookup("...");
|
このケースでのドッペルゲンガーへの対抗策は、WAR内に含まれるjdbc-ext.jarを削除することです。こちらが「にせもの」のデータソースです。
これ以外にも、新しいXMLパーサーのライブラリーを何気なく、「WEB-INF/lib」以下に入れて、意味不明のエラーが出た経験がありますでしょうか?全てはドッペルゲンガーのなせるわざだったのです。これらは、悪いことに「ClassCaseException」のようなわかりやすいエラーが表示されるとは限りません。形をかえて一見意味不明なエラーが起きます。そのため、クラスローダーに対する知識がないとなかなか原因に気づくことができません。
まとめ
「クラスローダーとJ2EEパッケージング戦略を理解する」、第3回となる今回は、J2EEパッケージングの基本方針となるJ2EE純血パッケージング戦略、さらにドッペルゲンガー現象について解説しました。パッケージングをどのようにおこなうかは、全J2EEプロジェクトにとって最重要問題のひとつです。
次回、「クラスローダーとJ2EEパッケージング戦略を理解する - 第4回 Caretaker」では、今回、学んだJ2EE純血パッケージング戦略を、現実の問題解決に向けて適用していきます。有名なcommons-logging問題、さらにプロパティー・ファイル配置戦略等について取り上げます。
参考文献
著者について  | |  | 著者である夷藤氏は、現在IBMにおいて、WebSphere Application Serverの技術支援を担当しており、多くのJ2EEプロジェクトにおいてシステムデザインやアプリケーション開発の助言を行っています。また、業界標準パフォーマンス評価団体、
SPECにおけるJ2EEアプリケーション・サーバー評価システム、
SpecJAppServer2002の開発を行っていました。
SPEC (US)
SpecJAppServer2002 (US)
専門はJava/J2EEですが、彼の興味はサーバーサイドのみならずクライアントサイド・テクノロジー、Python、Eclipseなどへと多岐に渡っており、雑誌「Eclipseパーフェクトマニュアル」でのテストファースト・プログラミングに関する記事執筆や、Eclipse
- RCPやJava/J2EEに関する講演活動などでもおなじみです。
Eclipse - RCP
Java/J2EE |
記事の評価
|