Flutterのネットワーク周りのエラーハンドリングについて
Flutterでhttpパッケージを使ってAPIリクエスト周りの処理を書いている時に考えたこと。
具体的には、httpリクエストやレスポンスのエラーケースをどうハンドリングするのがいいのか、という話。
ざっくり考えると、2通りの対応方法がある。
- Exceptionを利用する
- Errorを表現するクラスを作成する
Exceptionを利用するケースについて。
400や500系のレスポンスをハンドリングするクラスは、次のような形を想定する。
class ErrorResponseException implements Exception {
const ErrorResponseException({
required String message,
required this.code,
required this.body,
});
final int code;
final Map<String, dynamic> body;
String toString() {
return 'ErrorResponseException: code=$code, body=$body';
}
}
また、ネットワークエラーなどは次のsealed classが良さそうに思える。
class ConnectionException with _$ConnectionException implements Exception {
const factory ConnectionException.noNetwork() = NoNetworkException;
const factory ConnectionException.timeout() = TimeoutException;
String toString() {
return 'ConnectionException';
}
}
Dartの場合、 ConnectionException
を NoNetworkException
が継承する関係になるので、 NoNetworkException
は ConnectionException
としても Exception
としても扱えるようになる。
処理の全体像を書くと、次のようなコードになる。
try {
final response = repository.post(request: resuest);
} on ErrorResponseException catch (e) {
debugPrint(e.toString());
} on NoNetworkException catch (e) {
debugPrint(e.toString());
} on TimeoutException catch (e) {
debugPrint(e.toString());
} catch (e) {
debugPrint(e.toString());
}
もしもネットワーク周りのエラーケースで分岐をしないのであれば、次のように省略することもできる。
try {
final response = repository.post(request: resuest);
return response.token;
} on ErrorResponseException catch (e) {
debugPrint(e.toString());
} on ConnectionException catch (e) {
debugPrint(e.toString());
} catch (e) {
debugPrint(e.toString());
}
Errorを表現するクラスを作る場合は、次のようなクラスを想定する。
class RequestResult with _$RequestResult {
const factory RequestResult.success({
required int code,
required Map<String, dynamic>? body,
}) = RequestResultSuccess;
const factory RequestResult.failure({
required int code,
required Map<String, dynamic>? body,
}) = RequestResultFailure;
}
ネットワーク周りの問題をカバーする場合は、クラスに要素を足していく。
class RequestResult with _$RequestResult {
const factory RequestResult.success({
required int code,
required Map<String, dynamic>? body,
}) = RequestResultSuccess;
const factory RequestResult.failure({
required int code,
required Map<String, dynamic>? body,
}) = RequestResultFailure;
const factory RequestResult.noNetwork() = RequestResultNoNetwork;
const factory RequestResult.timeout() = RequestResultTimeout;
}
処理を記述すると、次のようになる。
final response = repository.post(request: resuest);
response.when(
success: (code, body) {},
failure: (code, body) {},
noNetwork: () {},
timeout: () {},
);
改めて書いてみると、Exceptionを利用したほうがシンプルになる印象がある。
このため、下記の2点が気になる場合にはErrorを表現するクラスを利用した方が良さそう、というのが現在の見解。
- DartにはExceptionをハンドリングさせることを、強制する仕組みがない
- freezedのsealedクラスを利用することで、メソッドの返り値として統一的な処理ができる
Dartには「Javaにおける検査例外がない」というのが辛さがある。SwiftにはJavaよりも便利な検査例外的な仕組みがあるので、この課題感はAndroidエンジニアでもiOSエンジニアでも感じるものなのでは、という気がする。(なお、RuntimeExceptionによる検査例外の形骸化については、取り扱わない。)
このため、あるメソッドが例外的な処理をハンドリングさせることを、型情報で伝える場合にはErrorを表現するクラスが便利そうに思える。
また、世間的にはExceptionを避けるという風潮もある。例えば、Kotlinでは次のようなdiscussionがあったりする。
golangやRustにはExceptionがなく、複数の値を返したりResult
型のような仕組みで、エラーを伝播している。
話の結論としては、「チームメンバーや開発するアプリケーションによって選びましょう」といういつものものになってしまうのだけれど、現時点での見解は下記の通り。
- 設計した時に想定しきれないExceptionがあり得るので、Exceptionを利用する方が網羅的
-
async/await
をKotlinやSwiftっぽく書くのであれば、Exceptionの方が近い書き味になる -
freezed
によるExceptionのsealed classを作れば、Exceptionの処理をまとめたい時に実装者が工夫しやすい