JavaMailとOSGiとUnsupportedDataTypeException


本稿はQiita Advent Calendar 2019の、Java Advent Calendar、7日目のエントリーです。

最初に

予めお断りしておきますが、この項はJava SE 8までの話が中心になります。

また、割と中途半端な理解で書いてますので間違いがありましたらツッコミ歓迎です。

OSGiとJava

OSGiはWikipediaによれば、以下のようなものだそうです。(引用元:https://ja.wikipedia.org/wiki/OSGi

遠隔から管理できるJavaベースのサービスプラットフォームを定義している。

OSGiでは、(典型的には)一つのJava VMが動作し、その上にOSGiフレームワークの実装が載ります。この上に、さまざまなプログラム(Bundleと呼ばれます)が導入され、OSGiフレームワークによってライフサイクルやリソースなどを管理されます。つまり、複数のBundleを一つのJava VM上に配備して、それぞれ独立したアプリケーションとして動作させることができるということになります。

(もっと深い部分についてはOSGiについて詳しいサイトなどを参照してください。)

トラブルの概要

さて、本題のJavaMailとOSGiの組み合わせです。一般的にJavaでメールを送信するにはJavaMailを用いるのが普通なやり方です。

ちなみにJakarta Mailになったんですが、とりあえず本稿ではJavaMailとJavaBeans Activation Framework(以下JAF)という名前のパッケージの話をさせてください。

なお、JavaMailをラップしたもっと便利なライブラリもありますが、基本的には下層でJavaMailを利用することになります。どんな感じになるのかは見たことないので分からないですが。

さて、OSGiでJavaMailを使おうとすると、ある時からUnsupportedDataTypeExceptionが出るようになりました。

回答例

「+site:StackOverflow.com JavaMail OSGi UnsupportedDataTypeException」で検索すると、40件弱程度の結果が出てきます。そして、これらの質問に対する回答は大きく分けて以下のような種類に分かれます。

  • setContextClassLoader()でJAFにJavaMailパッケージのClassLoaderをセットしてやる
  • CommandMap.getCommandMap()したものをMailcapCommandMapにキャストして、addMailcap()を用いてmultipart/*のハンドラとしてmultipart_mixedクラスを渡してやる
  • JavaMailとJAFのjarを結合して一個のjarにすれば良いのでは
  • etc

根本原因とは

勘のいい方はお分かりになると思いますが、これはJava SE 6以降JAFがJava SEの標準APIに追加されてしまったことによって生じた問題です。

古いプラットフォームでBundleがJavaMailを利用する場合、JavaMailもJAFもBundleが抱える形が一般的でした。しかし、Java SE 6以降、標準APIにJAFが追加されてしまったため、OSGi環境でも標準APIにあるJAFを使おうとします。Bundleがjarを抱えていても無視するのが仕様です。

いっぽう、OSGiは、Bundle間の独立性を確保するために、Bundle毎にBundleClassLoaderを生成します。このClassLoaderはJava SE標準のAPIのパッケージを読み込んでいるClassLoaderとは違うものになっています。Bundleが抱えているJavaMailのClassLoaderはBundle側のコンテキストにいます。

JAFはJavaMailの抱えているDataContentHandler(の派生)クラスを読み込んで利用しているのですが、(これはMIMEメールの処理をJAF側で行っているために起こります。)ここでClassLoaderが異なっていると、JavaMailパッケージが抱えているリソース(mailcapファイルなど)やDataContentHandlerが読み込めなくなってしまいます。

ちなみに、何でmultipart/mixedだけがコケるかというと、JAFはJavaMailのmailcapが読めないとかDataContentHandlerクラスが使えないとかのシチュエーションでも、multipart/*以外に関してはJAF自身が抱えているもので自力で処理できてしまうからなのでした。何じゃそりゃ。

setContextClassLoader()による解決

つまり、問題はClassLoaderです。

いちおう対策はなくはなくて、このような場合にはJAFはContextClassLoaderにセットされているClassLoaderを用いてリトライします。なので、上記の最初のsetContextClassLoader()を呼ぶのはひとまず正解です。

しかし、OSGiというのは複数のBundleが一つのJava VMの上で動作する仕組みです。なので、あるBundleが勝手に他のBundleも利用するかもしれないリソースなどをいじってしまうと、全体のシステムがダウンしてしまう可能性すらあります。このためOSGi環境では通常SecurityManagerで厳しくBundleの動作を制限しています。

なので、setContextClassLoaderはpermissionがないと使えません。許可しているかどうかはプラットフォームによります。Bundleを作っているのがすべて身内で信頼がおけるのであれば緩くなっているかも知れません。

いろんな人が作ったBundleをインストールして利用できるようにするプラットフォームの場合、セキュリティは厳しくなります。

addMailcap()による解決

それから、addMailcap()による解決は、実は根本解決ではありません。上記のBundleClassLoaderの問題を解決できていないのです。この解決法は、CommandMapクラスのデフォルトCommandMap(実体はMailcapCommandMapクラスのインスタンス)に以下を追加する、というものなのですが、

mc.addMailcap("multipart/*;;x-java-content-handler=com.sun.mail.handlers.multipart_mixed;x-java-fallback-entry=true");

ここに書かれているcom.sun.mail.handlers.multipart_mixedクラスは、JavaMailが抱えているもので、JavaMailのClassLoaderコンテキストにいるため、JAFはこのままではこれを読み出すことができません。

従って、このaddMailcap()で解決するという回答はたいてい間違っています。

そもそもJAF側がその指定に従ってJavaMailが抱えているmultipart_mixedが読めるというのであれば、JavaMailが抱えているmailcapファイルも読めるはずなので、そこにはすでに同じ設定が書いてあってこのaddMailcap()は不要のはずなのです。結局これは何も解決していない。

あと、この方法で解決するとたいていの場合、setFactoryのpermissionがなくて怒られるという事態が発生します。

setFactoryとは、何かのクラスのFactory部分に自分が指定したFactoryクラスをセットしてしまうと以降はそのFactoryが実装クラスを生成するようになるものです。このようにFactoryを挿げ替えてしまうと他のBundleの動作に影響を与えます。これもセキュリティにうるさいプラットフォームでは制限されています。

setFactoryは必要か?

しかし、驚いたことに、このsetFactoryのpermissionは本来は必要ないのでした。(まあ本来ならaddMailcap()自体が必要ないという話は脇に置いといて。)

たとえば、上でも書いたaddMailcap()するコードですが、実際には、このコメントhttps://stackoverflow.com/a/38373761/4472711 に記載されているように、

MailcapCommandMap mc = (MailcapCommandMap)CommandMap.getDefaultCommandMap();
mc.addMailcap("multipart/*;;x-java-content-handler=com.sun.mail.handlers.multipart_mixed;x-java-fallback-entry=true");
CommandMap.setDefaultCommandMap(mc);

となっています。あちこちでこれと同じようなコードが挙げられて回答とされています。

よく見れば分かりますが、これはCommandMap.getDefaultCommandMap()したインスタンスをそのまま最後にCommandMap.setDefaultCommandMap()しているだけのコードです。

…ということは、これ、setDefaultCommandMap()いらないですよね。デフォルトCommandMapの中身はいじられていますが、参照そのものを書き換えた人はいないのだから。(あ、でも実行環境によっては本当は排他しといた方がいいのかな?)

setFactoryが必要になっていたのはsetDefaultCommandMap()を呼び出していたからなので、本来不要なこれを省略すればsetFactoryは不要になります。

JavaMailとUnsupportedDataTypeExceptionに関するコードを見て回ると、こうやって最後にsetDefaultCommandMap()するコードが出回っていて、なぜかこれをみなさん無批判に使いまわしていることが分かります。

OSGi環境ではpermissionはセンシティブな問題なので、このようなことはきちんと吟味して回避することが必要です。

最後に真の解決を

setContextClassLoaderを使うのは場合によってはNGであることは上で触れました。では、本当にこれにはまってしまったらどうすればいいのでしょう。

実は、以下のようなコードを用いることでこの問題を根本的に回避することが可能です。何と実はsetContextClassLoader()とか最初からいらなかったんですよ。

まず、DataHandlerクラスの派生クラスを作成します。

public class MyDataHandler extends DataHandler {
    @Override 
    private DataContentHandler getDataContentHandler() {
        return new com.sun.mail.handler.multipart_mixed();
    }
    @Override 
    public void writeTo(OutputStream os) throws IOException {
        getDataContentHandler().writeTo(getContent(),getDataSource().getContentType(),os);
    }
}

このMyDataHandlerクラスは、Bundleの中で動作するので、ClassLoaderの問題を生じません。そして、インスタンスごとJAFに渡してしまうため、JAFの側でも問題が起こりません。今までの問題は、JAFがJavaMailの抱えているハンドラを名前で渡されたものをインスタンシエイトして使おうとするから起こっていたのです。名前渡しではなくインスタンスを直接渡してしまえば良いのです。

そしてメール本体を作成するのですが、この時、作成したMimeMessageには上記のMyDataHandlerを渡します。

    MimeMultipart multipart = new MimeMultipart("mixed");
    mimeMessage.setDataHandler(new MyDataHandler(multipart,multipart.getContentType()));
    multipart.setParent(mimeMessage);

最後のところで、multipart.setParent(mimeMessage)としているのですが、これには解説が必要でしょう。ここは素直に、mimeMessage.setContent(multipart)とやりたくなるのですが、絶対にやってはいけません。これをやってしまうとExceptionが出てしまうのです。これはJAFの設計がこうなっているから、としか言いようがありません。ソースを読む限り、MimeMessage#setContent()を叩いた場合、JavaMailは、渡されたDataHandlerがわざわざgetDataContentHandler()がカスタムしたDataContentHandlerを返すようになっているにも関わらずそれを使わないまま新たにnew DataHandler()するという動作になっています。(MimeMessage.javaの1615行め付近。)

このコードを書いたやつは誰だ!

結論

JAFの設計の「これはちょっと」な部分と、JAFをうっかりJava SEの標準APIに追加してしまうという(もしかしたら)ポカが相まってこの問題が発生しているのでした。

うちのアプリケーションプラットフォームでJDK1.4.2からJava SE 8に移行した時にもいろんなものが引っかかりました。メール送信したいですもんね。普通。

こういうのは、設計がおかしいと誰かが言わないといけないのではと思ったりするのですが、JAFはJDK11をもって標準APIから除外されてしまったので、今後も誰も直そうとしないのかなあと思っていたりします。Jakarta EEに移管されてしまったしね。

何というか、JavaMailとJAFが同じClassLoaderコンテキストにいれば何も起こらないので、軽く見られているのではという気もします。

あと、余談ですが、setFactoryのあたりは何故か誰も修正しないまま広まってしまっているのですが、正直こういうとこは何とかした方が良いと思います。まあこれもそのままで動く環境ならそれでもいいんですけど、ね。

いずれにしても標準APIの追加・削除はもうちょっと慎重にやってほしいですよね、というお話でした。それが結論かよ。