想定読者
- FlutterでのAPIレスポンスのハンドリングに
Result<T>
を使いたい - swiftの
Union
や kotlinのsealed class
のようなものをFlutterで扱いたい
はじめに
普段Androidを書いているときに retrofit
から受け取ったAPIレスポンスを sealed class
を利用し、以下のように変換しています。
sealed class Response<T> {
data class Success<T>(val body: T?) : Response<T>()
data class Error<T>(val type ErrorType) : Response<T>()
companion object {
fun create<T>(retrofitResponse: retrofit2.Response<T>): Response<T> {
return when {
response.isSuccessful -> Success(response.body())
else -> Error(ErrorType.create(response))
}
}
}
}
Response
に変換することでViewModel層などでは、retrofit
に依存せず、when文
を利用し、レスポンスをさばくことが可能になります。
fun request(keyword: String) {
viewModelScope.launch {
when (val res = repository.fetchSomething(keyword)) {
is Success -> res.body?.let { _elements.value = it }
is Error -> _errorType.value = res.type
}
}
}
最近Flutterを書いていて、上記と同じようなことをしたいと考え、以下のように実装してみました。
至らない点もあると思うので、ぜひコメント等でご指摘いただけたら嬉しいです。
dio
HTTP Clientライブラリです。
Flutterの公式では http
というライブラリを推していますが
個人的にはAndroidで OkHttp
を利用していたこともあり、dio
の書き方がそれに似ている感触を得たので、dio
を利用しています。
また、Githubのstar数が異常に多く、日本語の記事は少ないものの、英語での記事は大量にあったため安心して選定しました。
公式のリファレンスにもありますが、Dio
クラスはabstract class
なので、以下のようにアプリ独自のクラスを作ります。
class MyDio with DioMixin implements Dio{
// ...
}
(公式 Extends より引用)
簡単なリクエストは以下のようにかけます
Response response;
Dio dio = new Dio();
response = await dio.get("/test?id=12&name=wendu");
print(response.data.toString());
// Optionally the request above could also be done as
response = await dio.get("/test", queryParameters: {"id": 12, "name": "wendu"});
print(response.data.toString());
(公式 Examples より引用)
freezed
Dartではsealed class
のようなものがありません。
ただ、同様のものを生成してくれるライブラリとして freezed
があります。
詳細は割愛します。(公式を読んでいただけたらわかると思うので。)
@freezed
abstract class Union<T> with _$Union<T> {
const factory Union(T value) = Data<T>;
const factory Union.loading() = Loading<T>;
const factory Union.error([String message]) = ErrorDetails<T>;
}
(公式 The syntax より引用)
実装
Result
クラスは以下のように設定しております。
(簡略化のため、importなどは省略しております。)
result.dart
@freezed
abstract class Result<T> with _$Result<T> {
const factory Result.success(T value) = Success<T>;
const factory Result.failure(Error error) = Failure<T>;
}
以下の記事を参考に作成しました。
Handling Network Calls and Exceptions in Flutter
余談ですが、Error
クラスは以下のようにつくっています。
@freezed
abstract class Error with _$Error {
const factory Error.requestCancelled() = _RequestCancelled;
const factory Error.unauthorisedRequest() = _UnauthorisedRequest;
const factory Error.requestError({ ApiError apiError }) = _RequestError;
const factory Error.serviceUnavailable() = _ServiceUnavailable;
const factory Error.sendTimeout() = _SendTimeout;
const factory Error.noInternetConnection() = _NoInternetConnection;
const factory Error.unexpectedError() = _UnexpectedError;
const Error._();
static Error getApiError(error) {
if (error is Exception) {
try {
Error _error;
if (error is DioError) {
switch (error.type) {
case DioErrorType.CONNECT_TIMEOUT:
case DioErrorType.SEND_TIMEOUT:
case DioErrorType.RECEIVE_TIMEOUT:
_error = Error.sendTimeout();
break;
case DioErrorType.CANCEL:
_error = Error.requestCancelled();
break;
case DioErrorType.RESPONSE:
final statusCode = error.response.statusCode;
if (400 <= statusCode && statusCode < 500) {
_error = Error.requestError(apiError: ApiError.fromJson(error.response.data));
} else if (500 <= statusCode) {
_error = Error.serviceUnavailable();
}
break;
default:
_error = Error.unexpectedError();
}
} else if (error is SocketException) {
_error = Error.noInternetConnection();
} else {
_error = Error.unexpectedError();
}
return _error;
} catch (_) {
return Error.unexpectedError();
}
} else {
return Error.unexpectedError();
}
}
String get errorMessage => this.when(
requestCancelled: () => "キャンセルされました",
unauthorisedRequest: () => "認証エラーです",
requestError: (apiError error) => error.message,
serviceUnavailable: () => "しばらく時間をおいてから再度お試しください",
sendTimeout: () => "通信環境の良いところで再度お試しください",
noInternetConnection: () => "通信環境の良いところで再度お試しください",
unexpectedError: () => "不明なエラーが発生しました"
);
}
次に、AppDio
というクラスを作成し、ここでDio
の設定を色々と行っています。
app_dio.dart
class AppDio with DioMixin implements Dio {
factory AppDio() {
if (_instance == null) {
final dio = AppDio._();
dio.httpClientAdapter = DefaultHttpClientAdapter();
dio.options = BaseOptions(...);
dio.interceptors
..add(LogInterceptor(responseBody: true));
_instance = dio;
}
return _instance;
}
static AppDio _instance;
AppDio._();
}
APiClient
, Dio
などを利用し、DataSourceImpl
などから以下のようにリクエストを送ります。
class SomethingDataSourceImpl with SomethingDataSourceMixin {
SomethingDataSourceImpl({ @required Dio dio }) : _dio = dio;
final Dio _dio;
@override
Future<Result<Something>> fetchSomething() async {
try {
return await _dio.get<Map<String, dynamic>>("something").then((response) =>
Result.success(Something.fromJson(json)));
} catch(error) {
return Result.failure(error);
}
}
}
上記のようにクラスやメソッドを用意することにより、SomethingDataSourceImpl
からデータを受け取ったクラスでは、
レスポンスを以下のように処理することができます。
final Result<Something> result = await _somethingRepository.fetchSomething();
result.when(
success: (Something something) {
...
},
failure: (Error error) {
...
}
);
result.
とうったら when()
が予測に出てくると思います。
これによって、kotlin
の sealed class
のように満たしたかった HTTP Client のレスポンスnの型に依存せず、when
文を利用し、success
, failure
を処理することができました。
おまけ
dioのレスポンスをハンドルするクラスとして ApiClient
というクラスを作成しました。
処理の内容はAppDio
に書いても問題ないかもしれません。
api_client.dart
class ApiClient {
factory ApiClient() {
if (_instance == null) {
_instance = ApiClient._();
}
return _instance;
}
static ApiClient _instance;
ApiClient._();
Future<Result<T>> sendRequest<T>({
@required Future<Response<Map<String, dynamic>>> request,
@required T Function(Map<String, dynamic>) jsonDecodeCallback
}) async {
try {
return await request.then((value) => Result.success(jsonDecodeCallback(value.data)));
} catch (error) {
return Result.failure(Error.getApiError(error));
}
}
}
ここで鍵になるのは、引数でrequest
とjsonDecodeCallback
を受け取っていることです。
request
は、dioを利用したリクエストそのもので、dioにはget(), post(), put() 等、様々なリクエストがありますが、それらのレスポンスの型が Future<Response<T>>
となっていることを利用し、ここで集約します。Future<Response<T>>
における <T>
は then で流れてくる Response
クラスのインスタンスが保持している data
プロパティの型です。
これを Map<String>
十することで、Response
クラスのインスタンスから json
を受け取っています。
そして、受け取った json
を本来ほしい型へ変換するために jsonDecodeCallback
を用意しています。
T Function(Map<String, dynamic>) jsonDecodeCallback
とすることで、Map<String, dynamic>
を引数として T
を返す Function
を引数とすることができます。
これをやろうと思った背景は、上記の SomethingDataSourceImpl
のように、DataSource
層に毎回 try-catch
、エラーハンドリングを書くことが煩わしかったため、作成しました。
try {
return await _dio.get().then((response) => Result.success(Something.fromJson(response)));
} catch(error) {
return Result.failure(Error.getApiError(error));
}
SomethingDataSourceImpl
は以下のように書き換わります。
class SomethingDataSourceImpl with SomethingDataSourceMixin {
SomethingDataSourceImpl({ @required Dio dio }) : _dio = dio;
final Dio _dio;
@override
Future<Result<Something>> fetchSomething() async => ApiClient().sendRequest(
request: _dio.get<Map<String, dynamic>>("something"),
jsonDecodeCallback: (json) => Something.fromJson(json)
);
}
スッキリしたように感じないでしょうか?
感じなかったら別にやる必要はないと思います。
終わりに
拙い文章でしたので、もし何かdio
やfreezed
の使い方、Result<T>
型のつくり方、ミスの指摘やフィードバック、質問等がありましたら、ぜひ @muttsu_623 までご連絡ください。