Javaアドベントカレンダー14日目になります。
相変わらず、ブログからの転載になります。
https://munchkins-diary.hatenablog.com/entry/2019/12/16/011819
若干時間オーバーです、ごめんなさい、時差的にこちらではセーフなので許してください。
うそやんけ、僕の担当14日でした。
今日15日・・・圧倒的遅刻・・・・ごめんなさい!!!!
そういえば、最近開発者コミュニティに入りました。
初学者が多めのコミュニティのようですが、それでも勉強になることが多く、参加してよかったなと感じています。
主にSlackで活動しているようです。
海外からでも参加できるため、非常にありがたいです。
僕は現地語がまだ喋れないので、現地の開発者コミュニティにはまだ参加できていないんですよね。。。。
さて、というわけで、今回は初学者向けのエラーハンドリングの記事を書いていきたいと思います。
基本的にはJavaで書かれていますが、他の言語でも参考になることはあるかと思いますので、初学者でもみ消すなに手をコマネいている方はぜひ読んでみてください。
そもそも揉み消しとは
仕事でコードを書き始めた人は、先輩やマネージャから**「Exceptionをもみ消すな」**という言葉を聞いたことがあるかと思います。
具体的に言うと、こんな感じのコード。
static void momikeshiTheException(SomeObject input) {
try {
someIoOperation(input);
} catch (IOException e) {
e.printStackTrace();
}
}
ひどい場合だとこう。
static void momikeshiTheException(SomeObject input) {
try {
someIoOperation(input);
} catch (IOException e) {}
}
初学者のうちはExceptionの扱い方がわからずこういったコードを書いてしまいがちです。
なんなら、僕も学生の頃はよく書いてました。
しかし、このコードには以下のような問題があります。
- エラーが発生した事実が呼び出し元に通知されないため、呼び出し元は成功したものとして処理を続けてしまう。
- エラーの詳細が記載されていないため、デバッグが困難になる
初学者や自習でプログラミングを学んでる人たちには、現場でどんな不都合が起きるかイメージしづらい部分があるかもしれません。
具体的なケースを挙げてみます。
コスタリカブルーの悲劇
読みにくくてかつ駄文のためQiitaでは割愛。
ブログにはそのまま載せてあるので暇な人はどうぞ。
コスタリカブルーの悲劇
簡単に説明すると、レストランの発注システムでExceptionがもみ消されたせいで、季節限定メニューのオーダーが厨房に届かずキャンペーンが失敗して、そのデバッグに受注したフリーランスの私が苦しむ話です。
ではどのように例外を取り扱えばいいか
さて、上記のような悲劇を避けるために我々はどのようにExceptionを扱えばいいのでしょうか。
この記事では、僕が経験的に例外はこう言う風に扱うといいよと考えている例をいくつか提示していこうと思います。
実はこのエラーハンドリングが、商用プログラムを書く上で最も重要になってくる箇所だったりします。
だったりついでにいうと、このエラーハンドリングをいかに上手くやるかが腕の見せ所だったりします。
鉄則と言うか絶対やらなきゃいけないこと
例外が発生した場合、開発者が絶対にやらなくてはいけないことがあります。
具体的にいうと次の二つ。
- エラーの内容を、いつ、どんな状況で、何の入力に対して例外が発生したかをlogに出力する
- エラーが発生したことを呼び出し元のクラスやユーザーに対して通知する。
この2つのうち、2番目に関してはこの後、順番に例示していきますが、1つ目に関しては非常に明快です。
すぐできます。
ほとんどの言語にはloggerを提供するライブラリが存在するため、そういったライブラリを用いてlogを出力していきます。
Loggerライブラリは様々ありますが、基本的にはプロジェクトの標準で使っているloggerライブラリを使えば問題ないかと思います。
オレオレで自分の好きなライブラリを勝手に使い出すと別の意味で怒られます。
他の人のコードなどを参考に何が使われているか調べましょう。
System.out.printlnやconsole.logが標準ならそのプロエジェクトはもうダメだ。転職を考えよう。
logに何を出力するか
さて、冗談はさておき。。
そういったライブラリは、基本的に発生時間と発生クラスを出力し、また発生した例外を引数に加えることでスタックトレースを出力してくれます。
したがって、あとはメッセージにinput内容と、何をしようとした時に起きたかを書けば最低限何かが起こっても対応出来ます。
例えばこんな感じ。
static void momikesanaiTheException(SomeObject input) {
try {
someIoOperation(input);
} catch (IOException e) {
log.error("Exception occurred while calling the someIoOperation with the input {}", input, e);
}
}
inputに関しては必要に応じてマスクしたり、プロパティを絞ったりする必要があるが、何の入力に対してエラーが発生したかを記述しておくことは、圧倒的にdebugコストを下げます。
したがって、パスワードや個人情報などには留意した上で、inputも出来るだけ詳細に出力するように心がけるべきです。
もっと言うなら、影響範囲や起こりうる障害、対応方法なども書いておくと、将来別の開発者の手にメンテが渡った時に役立ちます。
これに関してQiitaで自作のライブラリを作ったって言う超良記事があったのだけど、いつの間にかストックから消えていたので、知ってる人がいたらコメント欄に書いてもらえるととっても嬉しい、です。
どのようにログを残すかに関していえば、それだけで僕レベルでも2、3記事書けるくらい実は奥深いコンテンツなので、今回はいったんこの程度でまとめさせていただきます。
logの出力に関する注意
初学者向けの記事につき、一応注意喚起しておきます。
商用含め、自分以外に公開されるのプログラムでは、ユーザの個人情報やパスワード、クレジットカード情報など、logに出力してはいけない情報が山ほどあります。
ちなみに、Facebookですらやらかしてます。
参考: Facebookが数億人のパスワードを平文で保存していたと認める
こういった情報は必ずマスクするか、出力から外すようにしましょう。
Javaでは、Objectをloggerや標準出力に渡すと、instanceをtoStringした値を出力します。
したがって、toStringを適切に実装してやることが非常に重要です。
lombokであれば@ToString.Exclude
をプロパティにつけてやることで、toStringの対象から外すことができます。
気づけば当たり前のことなので、logを出力する際には、注意して実装してください。
また、出力していいかわからないときは、上司やクライアントに必ず確認をとるようにしてください。
あ、パスワードとかクレジットカード情報とかは、上司やクライアントがいいっていってもダメです。
呼び出し元に通知する方法
さて、ここからは、呼び出し元のクラスやユーザに対して失敗した事実を通知する方法に関して書いていきます。
僕が一番使っている期間が長いことや、Javaアドベントカレンダーの記事であることを鑑みて、今回はJavaでかかせていただいております。
他の言語の方には申し訳ありません。。(´・ω・`)
後半にいくにつれて実装難易度(って言っても大したことないけど)が上がっていくように書こうと思っています。現時点では。
また、それぞれの実装方法に僕の独断と偏見で以下のようなランクづけをしました。
-
実装難易度:
実装の難易度や工数など。高いほど言語に対する理解が必要になり、書かなくてはいけない行数も増える。 -
実用性:
実際の現場でどの程度使われるか。難しい実装でも、そこまでする必要はないとか、逆に簡単な実装でもそれでは不十分と判断されることも多いため。 -
安全性:
呼び出し元に、プログラム的な意味でどれだけ正確な情報を伝えられるか。正確な情報を呼び出し元に伝えることで呼び出し元はより柔軟に呼び出しの失敗に対応できる。
この基準が役に立つかは知らないし、状況や実装ロジックによって安全性や実用性は変わるので何とも言えません。
ただ、なんとなく実装の安全さや実用性に関してイメージを掴んでもらえたらいいなと言う意味でつけています。
1: booleanで結果を返す
実装難易度: ★
実用性: ★★
安全性: ★
説明
1つ目は、成功した場合trueを、失敗した場合falseを返すと言う実装です。
非常に簡単で、今この瞬間からもできるので、複雑な実装をしている時間がないと言う場合は、最低限このくらいはするように実装して欲しい。
static boolean momikesanaiTheException(SomeObject input) {
try {
someIoOperation(input);
return true;
} catch (IOException e) {
log.error("Exception occurred while calling the someIoOperation with the input {}", input, e);
return false;
}
}
このような実装にすることで、最低限、失敗した場合falseが帰ってくることで、呼び出し元のクラスは失敗した場合の処理を流すことができます。
この実装は呼び出し元に失敗の原因が通達されていないため、若干心もとないですが、失敗する条件が明確な時はこれでも十分かと思います。
例えば、JavaのSetのaddなどは、すでに同じ要素がCollectionに存在する時falseを返しますね。
いつ使うべきか
基本的にはあまりおすすめはしませんが、以下のようなケースでは使えるかと思います。
- 他の開発者もしくはかつての自分が実装したもみ消しを発見してしまい、急ぎ挙動を修正する必要がある場合
- シンプルかつアトミックなことが自明な場合。
2: Optionalで包んで結果を返す。
実装難易度: ★★
実用性: ★★
安全性: ★
解説
これもほぼbooleanと同じですが、booleanより使える箇所がやや限られます。
Optionalは、詳しくは他の記事を漁って欲しいのだけど、ある操作に対して結果が存在するかしないか不定の時に使われます。
したがって、例外発生時にOptionalで返していいのは、例えば指定したIDやKeyに対して要素が存在しない時に例外が投げられるケースが基本です。
例えば、FileNotFoundExceptionやNoSuchElementExceptionなどが発生するケースではoptionalで包んで返してもいいと思います。
static Optional<FileReader> momikesanaiTheException(SomeObject input) {
try {
return Optional.of(new FileReader(new File(input.getTargetPath())));
} catch (FileNotFoundException e) {
log.error("File not found to the path {}", input.getTargetPath(), e);
return Optional.empty();
}
}
実装の手間がbooleanとそう変わらないのに実装難易度を高めにつけたのは、 このOptionalを使っていいかと言う判断が割と微妙かなぁって感じたりしたからだったりします。。。
いつ使うべきか
正直、上の解説を書いててエラーハンドリングでOptional使うのは微妙かなと思い始めたのですが、以下のSOの記事で条件付きでですが、そこそこ支持されていたので、一応書いてみます。
参考: Can I use std::optional for error handling?
- IOを伴う操作で指定したリソースが存在しない、もしくは取得できない可能性がある場合
- 例外を投げたくない場合
なお、後述のEither型の方がより正確に呼び出し元に何が起きたかを伝えられるため、個人的にはEither型をオススメしています。
3: 別の例外を実装し、投げられた例外を包んで呼び出し元に投げる
実装難易度: ★★★
実用性: ★★
安全性: ★★★
解説
ロジックの中で呼び出したメソッドが非チェック例外(※1)を投げる場合、チェック例外(※2)などに包んで返すのも1つの手段です。
呼び出し元にExceptionのハンドリングを委託し、自分のロジック内でのハンドリングを諦めるパターンです。
この場合、呼び出し元のloggerにメッセージが出力されるため、鉄則と言うか絶対やらなきゃいけないことで書いたlogは省略することができる場合もあります。
static void momikesanaiTheException(SomeObject input) throws InvalidInputException{
try {
someOperation(input);
} catch (InvalidSyntaxException e) {
throw new InvalidInputException(input, "Input contains some invalid value and some Operation failed.", e);
}
}
public class InvalidException extends Exception{
private final SomeObject input;
public InvalidInputException(SomeObject input, String message, Throwable e) {
super(message, e);
this.input = input;
}
}
チェック例外などと書いたのは、非チェック例外を投げるケースも少なからずあるためです。
非チェック例外を投げる場合は、必ずJavadocのthrows欄に明記するようにしましょう。
※1:RuntimeExceptionもしくはそれを継承した、呼び出し元でtry-catchしなくてもコンパイルエラーが起きない例外。呼び出し元に瑕疵がある場合に投げられることが多い。NullPointerExceptionやIllegalArgumentExceptionなど。
※2:Exceptionもしくはそれを継承した、呼び出し元でのtry-catchを義務付ける例外。I/Oでのエラーやプログラムの実行ユーザーが権限を持っていない場合など、ロジックやinput由来ではない時に投げられることが多い。IOExceptionやExecutionExceptionなど。
いつ使うべきか
実際の開発現場ではこのように独自の例外を実装して呼び出し元に返す方法はよく使われています。
あえて使用場面をあげるなら以下のようになります。
- 発生した例外に対し、自分の実装したメソッド内での消化が難しい場合
- 呼び出し先クラスがチェック例外を投げるべきところで非チェック例外を投げている時
- 例外のハンドリングに自信がない場合(早く抜けてください。許されるのは最初だけです。)
余談
僕は過去ベトナムの開発会社にいた時、JavaDocが全く書かれていないにも関わらず、何でもかんでも例外を非チェック例外に包んで投げまくるのがデフォルトと言う、地獄のようなプロジェクトに参加したことがあります。
その時は、ありとあらゆる箇所でハンドルされない例外が投げられまくり、何かあるとすぐシステムが落ちると言うえげつない状況に陥りました。
4: 返り値のクラスで結果を表現する
実装難易度: ★★★★
実用性: ★★★★
安全性: ★★★
解説
処理の結果をクラスで表現するのもありです。
具体的に書くと、成功した場合の返り値となる値と失敗した場合の情報を1つのクラスにして返す方法です。
例えば、以下のようなResultクラスを実装し、それを呼び出し元に返すようにします。
(@Builderや@Getterと言うのはlombokアノテーションです。詳しくはlombok公式ページへ)
@Builder(access = AccessLevel.PRIVATE)
@Getter
public class MomikesanaiResult {
private final ResultType result;
private final List<SomeResult> successResults;
private final Map<FailedCauseType, List<SomeInput>> failedInputs;
private MomikesanaiResult(ResultType result, List<SomeResult> successList, Map<FailedCauseType, List<SomeInput>> failedInputs) {
this.result = result;
this.successResults = Optional.ofNullable(successList).map(Collections::unmodifiableList).orElse(Collections.emptyList());
this.failedInputs = Optional.ofNullable(failedInputs).map(Collections::unmodifiableMap).orElse(Collections.emptyMap());
}
public static class MomikesanaiResultBuilder {
public MomikesanaiResultBuilder addSuccess(SomeResult result) {
if (this.successResults == null) {
this.successResults = new ArrayList<>();
}
this.successResults.add(result);
this.result = this.failedInputs == null ? ResultType.SUCCESS : ResultType.PARTIALLY;
return this;
}
public MomikesanaiResultBuilder addFailed(FailedCauseType failedCause, SomeInput input) {
if (this.failedInputs == null) {
this.failedInputs = new HashMap<>();
}
if (!this.failedInputs.containsKey(failedCause)) {
this.failedInputs.put(failedCause, new ArrayList<>());
}
this.failedInputs.get(failedCause).add(input);
this.result = this.successResults == null ? ResultType.FAILED : ResultType.PARTIALLY;
return this;
}
}
public enum ResultType {
SUCCESS, FAILED, PARTIALLY
}
public enum FailedCauseType {
NONE,AUTH_INVALID, INVALID_INPUT, TIMEOUT, UNKNOWN
}
}
このようなクラスを実装することで、以下のように具体的にエラーと結果を受け手に返すことができます。
static void momikesanaiTheException(List<SomeInput> inputs) throws InvalidInputException{
val resultBuilder = MomikesanaiResult.builder();
for (SomeInput input : inputs) {
try {
resultBuilder.addSuccess(someOperation(input));
} catch (IllegalArgumentException e) {
log.error("Input contains some invalid value and some Operation failed. input is {}", input, e);
resultBuilder.addFailed(FailedCauseType.INVALID_INPUT, input);
} catch (AuthFailedException e) {
log.error("Authentication is failed in some Operation. input is {}", input, e);
resultBuilder.addFailed(FailedCauseType.AUTH_INVALID, input);
} catch (TimeoutException e) {
log.error("Request seems to be timeout in some operation, input is {}", input, e);
resultBuilder.addFailed(FailedCauseType.TIMEOUT, input);
} catch (Exception e) {
log.error("Unknown error occured in some operation, input is {}", input, e);
resultBuilder.addFailed(FailedCauseType.UNKNOWN, input);
}
}
return resultBuilder.build();
}
なお、ResultTypeとFailureCauseTypeをenumにする理由は、受け手側がswitchでより簡易にerrorハンドリングを実装できるようにするためです。
また、Map<FailureCause, List<SomeInput>> のようなコレクションやマップのネストは気持ち悪い場合は別の構造体を定義しても大丈夫です。
いつ使うべきか
かなり実装コストが高いですが、それなりにメリットの大きい実装になります。
使用場面をあげると、
- 他のサービスや外部の開発チームに提供されるライブラリ・SDKなど
- パースしてWebApiやXHRの返り値に使う場合(ユーザへの通知はAPIの呼び出し元が行う。webならjavascriptでトースターを表示するなど。)
- 多くの開発者に使われる基盤クラス
- 例外を投げたくない場合
などなど、個人的には、後述のEitherが使えない現場では個人的にこの実装を勧めています。
5: Either型で返す
実装難易度: ★
実用性: ★★
安全性: ★★★
解説
Either型は関数型でよく使われる型で、成功した結果もしくは失敗した例外を返すと言う型です。
関数型では、呼び出したメソッドを返り値に置換しても同じ挙動ができることを担保すべしと言う考え方があり、例外を投げること自体があまり好まれません。
したがって、メソッドの返り値として、成功した値もしくは発生しうる例外をEither型で指定することで、例外をthrowするのを回避すると言うやり方が取られることが非常に多いです。
Either型では伝統的にright(右, 正しいと言う意味もある)に成功した時の値を、left(左)に例外を入れて呼び出し元に返します。
なお、Either型はJava標準ではサポートされていないため、Functional Javaなどの依存性を追加するか、自前で実装する必要があります。(サンプルコードはFunctionalJavaのEitherを使用)
static Either<FileNotFoundexception, FileReader> momikesanaiTheException(SomeObject input) {
try {
return Either.right(new FileReader(new File(input.getTargetPath())));
} catch (FileNotFoundException e) {
log.error("File not found to the path {}", input.getTargetPath(), e);
return Either.left(e);
}
}
割と僕の周りではこの型を好む人が多いですが、周囲の開発者が関数型に慣れていない場合、呼び出し元の開発者がうまく使えない可能性が高く、日本の開発者環境においてうまく機能する現場はあまりないかもしれません。
下手に導入するとExceptionよりもみ消されるという結果になることも無きにしも非ずです。
ただし、Eitherは非常に強力な型で、シンプルな実装で安全かつ正確に何が発生したかを呼び出し元に伝えることができます。
したがって、一通り使い方を覚えるだけでかなり表現の幅が上がるため、マスターしておいて損はありません。
詳しく知りたい人は以下の記事を参考にしてみてください。
参考: Lazy Error Handling in Java, Part 3: Throwing Away Throws
いつ使うべきか
前述の通り、Eitherは非常に強力ですが、受け取り側にもそれなりの知識が必要になるので、周囲の開発者の知識量などに合わせて使うべきです。
使用場面としては、
- Exceptionを投げたくない場合
- 失敗した場合と成功した場合の返り値を一つの型で表現したい時
- 関数型を使う風土が社内もしくは最低限チーム内に共有されている時
となります。
まとめ
以上、初学者向けに例外が発生した場合のハンドリングの仕方を書いてみました。
ほんとはfrontendに絡めたり、呼び出し元がどういう風にこのエラーハンドリングに対して対応するかなども、コードとして書きたかったのですが、時間切れでした。
プログラミングを自習したり、自分専用のアプリを作ったりしてるうちは、例外の処理は甘くなりがちです。
しかし、実際にお客さんに使われるシステムやサービスでは、例外処理とロギングこそ、最も時間と経験と知識を費やす箇所になります。
上記で紹介したエラーハンドリングの方法も、どれか一つだけを使うわけではなく、状況に合わせて組み合わせて使うことも多くあります。
そこらへんのことも、そのうち記事に書きたいなとか、ぼんやり考えています。
あ、あと今回書かなかったけど、呼び出し元がエラーハンドリングしやすいように実装してやるのも大事だよっていうのを書き忘れていたので、補足でそれも意識するといいと思います。(雑ですみません。。。)
以上、Exceptionをもみ消すなってどうせえちゅうねんっていう話でした。
この記事が、上司や先輩から例外をもみ消すなと言われて途方に暮れている初学者の方々の助けになれば幸いです。