2019-09-19  Java プログラミング

JavaFXの例外処理ベストプラクティス

「例外をむやみにキャッチして握り潰してはいけない」 これはよく知られている鉄則です。自ら対処できないのであれば、 例外をキャッチせずに上位にそのまま伝搬させるのが良い設計であるとされています。

例外を握り潰してしまっている例
public void myBusinessLogic() { try { doSomething(); } catch(IOException e) { e.printStackTrace(); } }

この鉄則は広く浸透しており、 上記のようなコードを書いている人はもういません。多くの開発者は、 処理できない例外をそのまま伝搬させるコードを書きます。

例外をキャッチせずにそのまま上位に伝搬させる例
public void myBusinessLogic() throws IOException { doSomething(); }

この鉄則は 「呼び出される側のコード」 について言及したものです。呼び出される側のライブラリならそれで十分でしょう。しかし、 伝搬していった例外はいつか誰かがキャッチしなければなりません。その役回りは、 ほとんどの場合、 アプリケーションが務めることになります。

ライブラリでは投げっぱなしにできた例外も、 アプリケーションではそうはいきません。投げっぱなしにされた例外をアプリケーションはどのようにハンドリングすれば良いのでしょうか?

JavaFX を例にアプリケーションでの例外処理を解説します。

アプリケーションは自ら対処できない例外も必ずキャッチすべし

コンソールアプリケーションであれば例外をキャッチしないという選択肢もあります。main メソッドの外まで伝搬した例外は Java ランタイムによってスタックトレースが出力されるので、 ユーザーはターミナルに表示されたスタックトレースを見てエラーが発生したことを察することができます。長い呪文のように意味不明のスタックトレースであってもそれくらいのことは感じ取れるものです。

GUI アプリケーションの場合はそうはいきません。キャッチされなかった例外はターミナルに出力されることもなく、 瞬く間にアプリケーションウィンドウは消え去ってしまいます。ユーザーは何が起こったのか理解できないでしょう。(操作を誤って自分でウィンドウを閉じてしまったと思うかもしれません。)

GUI アプリケーションにおいては、 たとえ自ら対処できない回復不能な例外であっても、 必ずそれをキャッチし、 最期の力を振り絞ってエラーが発生したことをユーザーに伝えてからアプリケーションを終了させることが重要になります。

この目的は、 エラー状態を回復させて処理を継続することではなく、 ユーザーにエラーを提示したり、 スタックトレースをログファイルに記録することにあります。ですから、 個々の例外発生箇所に応じた対処 (try~catch) ではなく、 アプリケーション全体で広域的に共通の例外処理ができると都合が良いです。

JavaFXで例外を広域的にキャッチする

Java には、 キャッチされなかった例外 (未処理例外) によってスレッドが終了しようとしたときに呼び出される UncaughtExceptionHandler という仕組みがあります。このハンドラーを仕掛けておけば、 ソースコードの各所で個別に例外をキャッチしなくても広域的に例外を処理することができます。

このハンドラーは特定のスレッドに対して設定することも、 すべてのスレッドに対して設定することもできます。特定のスレッドに対してハンドラーを設定する場合は、 対象となる Thread インスタンスの setUncaughtExceptionHandler インスタンス ・ メソッドを使用します。すべてのスレッドを対象とする場合は、 Thread クラスの setDefaultUncaughtExceptionHandler クラス ・ メソッドを使用します。

この未処理例外ハンドラーの仕組みが JavaFX でも使えることは Application クラスの javadoc に記載されています。

イベントのディスパッチ中、 アニメーション ・ タイムラインの実行中、 またはその他のコード中に JavaFX アプリケーション ・ スレッドで発生したすべての未処理例外は、 スレッドの uncaught exception handler に転送されます。

JavaFX アプリケーション ・ スレッドに未処理例外ハンドラーを設定するには以下のようにします。

public class Sample extends Application {

	@Override
	public void start(Stage primaryStage) throws Exception {
		//FXアプリケーションスレッドでキャッチされなかった例外を処理します。
		Thread.currentThread().setUncaughtExceptionHandler((t, e) -> {
			showException(e);
		});
	}

	protected void showException(Throwable e) {
		e.printStackTrace();

		Alert dialog = new Alert(Alert.AlertType.WARNING, e.getMessage());
		dialog.setTitle(e.getClass().getSimpleName());
		dialog.setHeaderText(null);
		dialog.showAndWait();
	}
}

start メソッドは JavaFX アプリケーション ・ スレッドで呼び出されますから、 ここで現在のスレッド Thread.currentThread() に対して UncaughtExceptionHandler を設定すれば、 JavaFX アプリケーション ・ スレッドでスローされた未処理例外をまとめて受け取れるようになります。上記の例では、 自作のメソッド showException を定義し発生した例外をダイアログで表示するようにしています。

サンプルコードにもう少し肉付けをして実際に動くコードを完成させましょう。

Sample.java
import javafx.application.Application; import javafx.scene.Scene; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.stage.Stage; public class Sample extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) throws Exception { //FXアプリケーションスレッドでキャッチされなかった例外を処理します。 Thread.currentThread().setUncaughtExceptionHandler((t, e) -> { showException(e); }); Button button = new Button("Click Me!"); button.setPrefSize(240, 160); button.setOnAction(event -> { myBusinessLogic(); }); Scene scene = new Scene(button); primaryStage.setScene(scene); primaryStage.show(); } public void myBusinessLogic() { throw new RuntimeException("oops!"); } protected void showException(Throwable e) { e.printStackTrace(); Alert dialog = new Alert(Alert.AlertType.WARNING, e.getMessage()); dialog.setTitle(e.getClass().getSimpleName()); dialog.setHeaderText(null); dialog.showAndWait(); } }

1 つのボタンを配置しました。ボタンをクリックすると myBusinessLogic メソッドが実行され、 myBusinessLogic メソッドは RuntimeException をスローします。

スローされた RuntimeException は未処理例外ハンドラーに転送されます。未処理例外ハンドラーでは showException メソッドを呼び出してダイアログを表示します。

未処理例外ハンドラーを使わない場合、 以下のように個別に例外をキャッチしてダイアログを表示する必要があります。

button.setOnAction(event -> {
	try {
		myBusinessLogic();
	} catch(Exception e) {
		showException(e);
	}
});

これが try~catch なしで書けるのはスマートですよね。コードが簡潔になると全体の見通しも良くなります。

button.setOnAction(event -> {
	myBusinessLogic();
});

ここまでは上手い具合にいっています。

未処理例外ハンドラーと検査例外は仲が悪い

UncaughtExceptionHandler は未処理例外を処理するための仕組みでした。対象となるのは文字通り 「キャッチされなかった例外」 です。この 「キャッチされなかった例外」 と検査例外は相性が良くありません。なぜなら、 未処理例外ハンドラーに転送するためには例外を投げっぱなしにする必要があるのですが、 検査例外は投げっぱなしにされることを許さないからです。

下記のような書き方ができるのは、 myBusinessLogic メソッドがスローするのが RuntimeException (実行時例外) だからです。

button.setOnAction(event -> {
	myBusinessLogic();
});

myBusinessLogic が実行時例外ではなく検査例外 (たとえば IOException) をスローするように変更してみます。

public void myBusinessLogic() throws IOException {
	throw new IOException("oops!");
}

すると、 検査例外である IOException をキャッチして、 実行時例外に変換してから再スローしなければならなくなります。

button.setOnAction(event -> {
	try {
		myBusinessLogic();
	} catch(Exception e) {
		throw new RuntimeException(e);
	}
});

このような書き方をしなければいけない理由は、 setOnAction メソッドに指定する EventHandler 関数型インターフェースのシグネチャにあります。このラムダ式を使った記述を匿名クラスに展開すると以下のようになります。

button.setOnAction(new EventHandler<ActionEvent>() {
	@Override
	public void handle(ActionEvent event) {
		try {
			myBusinessLogic();
		} catch(Exception e) {
			throw new RuntimeException(e);
		}
	}
});

EventHandler 関数型インターフェースの handle メソッドに throws Exception が付いていないため、 JavaFX のイベントハンドラー内で検査例外をキャッチせずにそのままスローして伝搬させることができないのです。Callable 関数型インターフェースのように throws Exception が付いていてくれたら良かったのですが…。残念です。

検査例外を実行時例外に変換するラッパーを作る

JavaFX のイベントハンドラー内では実行時例外 (非検査例外) しかスローすることはできません。EventHandler 関数型インターフェースに throws 句が付いていないのですから、 どうしようもないのです。

「実行時例外しかスローできない」 という JavaFX イベントハンドラーの制限を回避することはできませんが、 try~catch で検査例外を実行時例外に変換して再スローするという冗長なコード記述を削減する手立てはまだ残されています。

検査例外を実行時例外に変換する関数型インターフェース ・ ラッパーを作るのです。

検査例外を投げっぱなしにできない原因は EventHandler 関数型インターフェースに throws 句がないことでした。それなら、 throws 句の付いた関数型インターフェースを自分で定義してしまえば良いのです。

JavaFX の EventHandler は以下のようになっています。

EventHandler.java
@FunctionalInterface public interface EventHandler<T extends Event> extends EventListener { void handle(T event); }

これを真似て throws Exception を持つ関数型インターフェースを自分で作ります。

@FunctionalInterface
public interface Silent<T extends Event> {
	void handle(T event) throws Exception;
}

自作の関数型インターフェースの名前は Silent としました。Silent は EventHandler も EventListener も継承しません。EventHandler を継承すると throws Exception を付けられなくなってしまいます。

この Silent インターフェースは JavaFX とは何の関係もないので JavaFX のイベントハンドラーとして直接使うことはできません。

JavaFXのイベントハンドラーとして自作のインターフェースを指定することはできない
// このコードはエラーになりコンパイルできません。 button.setOnAction(new Silent<ActionEvent>() { @Override public void handle(ActionEvent event) throws Exception { } });

JavaFX のイベントハンドラーとして指定するには、 自作の関数型インターフェースではなく EventHandler (もしくはそのサブインターフェース) が必要です。

そこで、 自作の関数型インターフェース Silent を JavaFX の EventHandler 関数型インターフェースでラップする wrap メソッド作ります。wrap メソッドは Silent インスタンスを引数として取り、 EventHandler インターフェースを戻り値として返します。EventHandler インターフェースの handle が呼び出されると、 それはそのまま Silent インターフェースの handle 呼び出しに委譲されます。Silent インターフェースの handle メソッドは検査例外をそのままスローする可能性があるので、 それを try~catch で吸収して実行時例外 (RuntimeException) に変換して再スローしています。これなら、 JavaFX の EventHandler インターフェースの実行時例外しかスローできないという制限にも適合します。

static <T extends Event> EventHandler<T> wrap(Silent<T> handler) {
	return event -> {
		try {
			handler.handle(event);
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	};
}

wrap メソッドを使うと JavaFX のイベントハンドラーを以下のように書くことができます。

button.setOnAction(wrap(event -> {
	myBusinessLogic(); //検査例外をスローするコードをそのまま書けます。
}));

どうですか? 当初のコードに近いだいぶ簡潔なコードになりましたよね。wrap(...) で包むだけで検査例外をスローするコードを try~catch なしでそのまま書けるようになりました。もちろん、 例外は握り潰されることなく未処理例外ハンドラーに転送されます。

完成したコード

これが完成したコードです。

Sample.java
import javafx.application.Application; import javafx.event.Event; import javafx.event.EventHandler; import javafx.scene.Scene; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.stage.Stage; import java.io.IOException; public class Sample extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) throws Exception { //FXアプリケーションスレッドでキャッチされなかった例外を処理します。 Thread.currentThread().setUncaughtExceptionHandler((t, e) -> { while(e instanceof Silent.WrappedException) { e = e.getCause(); } showException(e); }); Button button = new Button("Click Me!"); button.setPrefSize(240, 160); button.setOnAction(wrap(event -> { myBusinessLogic(); })); Scene scene = new Scene(button); primaryStage.setScene(scene); primaryStage.show(); } public void myBusinessLogic() throws IOException { throw new IOException("oops!"); } protected void showException(Throwable e) { e.printStackTrace(); Alert dialog = new Alert(Alert.AlertType.WARNING, e.getMessage()); dialog.setTitle(e.getClass().getSimpleName()); dialog.setHeaderText(null); dialog.showAndWait(); } protected <T extends Event> EventHandler<T> wrap(Silent<T> handler) { return Silent.wrap(handler); } @FunctionalInterface public interface Silent<T extends Event> { void handle(T event) throws Exception; static <T extends Event> EventHandler<T> wrap(Silent<T> handler) { return event -> { try { handler.handle(event); } catch (Exception e) { throw new Silent.WrappedException(e); } }; } class WrappedException extends RuntimeException { public WrappedException(Throwable cause) { super(cause); } } } }

サンプルコードを含む Gradle プロジェクト一式を下記のリンクからダウンロードできます。

本文での解説から少し手直しをしています。

RuntimeException ではなく、 自作の Silent.WrappedException で検査例外を包むようにしました。Silent.WrappedException は RuntimeException を継承しただけの例外クラスで特別なことは何もしません。汎用的に使われる RuntimeException と区別するために別の例外クラスとしました。

こうすることで、 未処理例外ハンドラーで元の例外を取り出しやすくなります。未処理例外ハンドラーでは例外クラスが Silent.WrappedException の場合、 内包されている原因例外を取り出すようにしています。これなら、 元の例外がきちんとダイアログに表示されます。

//FXアプリケーションスレッドでキャッチされなかった例外を処理します。
Thread.currentThread().setUncaughtExceptionHandler((t, e) -> {
	while(e instanceof Silent.WrappedException) {
		e = e.getCause();
	}
	showException(e);
});

wrap メソッドと WrappedException 例外クラスはどこに定義しても構わないのですが、 とりあえず Silent インターフェースに内包させました。Silent インターフェースの部分だけをコピペで使い回せます。

JavaFX アプリケーションのイベントハンドラー実装が try~catch だらけになってしまって悩んでいる方、 ぜひ試してみてくださいね。

おまけ

JavaFX アプリケーションスレッドで発生したすべての未処理例外は UncaughtExceptionHandler に転送されると javadoc に記載されているのですが、 これには実はバグがあってドラッグ関連のイベントハンドラーで発生した例外は静かに飲み込まれてどこかへ消えてしまうのです。

これは Windows 版 JavaFX 固有のバグであり Linux や macOS では発生していないとのこと。上記のリンク先は JavaFX 8 に関するものですが、 JavaFX 11 でもこのバグは残っており未だ解決していません。(2019 年 9 月現在)

このバグに対処するには、 JavaFX のイベント処理機構に例外が伝搬する前に自分で例外を捕捉する必要があります。つまり、 ドラッグ関連のイベントハンドラー内に try~catch を書くということです。

このバグに対するワークアラウンドを本記事で紹介した wrap メソッドに組み込むことができます。

static <T extends Event> EventHandler<T> wrap(Silent<T> handler) {
	return event -> {
		try {
			handler.handle(event);
		} catch (Exception e) {
			throw new Silent.WrappedException(e);
		}
	};
}

この wrap メソッドを以下のように変更します。

ドラッグ関連イベントハンドラーで例外が飲み込まれてしまうバグへの対策
static <T extends Event> EventHandler<T> wrap(Silent<T> handler) { return event -> { try { handler.handle(event); } catch (Exception e) { // ここから Thread.UncaughtExceptionHandler ueh = Thread.currentThread().getUncaughtExceptionHandler(); if(ueh != null) { ueh.uncaughtException(Thread.currentThread(), e); return; } // ここまで throw new Silent.WrappedException(e); } }; }

現在のスレッド (つまり JavaFX アプリケーションスレッド) に未処理例外ハンドラーが設定されているなら、 実行時例外で包んで再スローするのではなく、 直接、 未処理例外ハンドラーに例外を転送してしまう (uncaughtException メソッドを直接呼び出す) のです。

例外を再スローすると (JavaFX のバグによって) 例外がどこかへ消えてしまいますが、 自分で未処理例外ハンドラーに転送すれば大丈夫というわけです。

Windows 環境で JavaFX のドラッグ関連イベントを使う場合は、 例外処理にご注意ください。

最終更新日 2024-12-13
この記事を共有しませんか?
ブックマーク ポスト