はじめに
Android 開発において Retrofit はよく使われるライブラリの一つかと思います。
今回は Retrofit のレスポンスのハンドリングを楽にしてくれるかもしれないライブラリを2つご紹介したいと思います。
2つのライブラリの特徴
この2つのライブラリの共通の特徴として、ライブラリが定義している独自のクラスでレスポンスを受け取れます。
EitherNet
の方は ApiResult
、Sandwich
の方は ApiResponse
という独自のクラスが定義されており、これらのクラスは sealed class になっています。
これらの sealed class のサブクラスが通信の成功や失敗を表す型になっているため、クラスの型を判別すれば通信処理の結果が判定できます。
Retrofit のエラーハンドリングは手間がかかる?
例えば Qiita の API で記事一覧を取得することを考えます。
まずは以下のように Retrofit で使うためのインターフェースを定義します。
interface QiitaApi {
@GET("/api/v2/items")
suspend fun fetchItems(@Query("page") page: Int, @Query("per_page") perPage: Int): List<Item>
}
この場合、記事一覧の取得は以下のように呼び出します。
suspend fun fetchItems() {
val items = qiitaApi.findItems(1, 30)
// items を使った処理...
}
ただ、この場合通信処理を行うので、もしかしたらネットワーク状態が悪くて通信が失敗してしまうこともあり得ます。
その場合、以下のようにエラーハンドリングを行う必要があります。
suspend fun fetchItems() {
try {
val items = qiitaApi.findItems(1, 30)
// items を使った処理...
} catch (e: Throwable) {
// エラー処理...
}
}
また API からレスポンスは返ってきたけど、HTTP ステータスコードが400以上の場合にはレスポンスの内容をパースして別に処理を行いたい、などの場合も考えられます。
その場合は Retrofit の HttpException
の例外を捕捉して処理する必要があります。
suspend fun fetchItems() {
try {
val items = qiitaApi.fetchItems(1, 30)
// items を使った処理...
} catch (e: HttpException) {
if (e.code() == 400) {
val errorBody = e.response()?.errorBody()
// HTTP ステータスコードが400の場合のエラー処理...
}
} catch (e: Throwable) {
// エラー処理...
}
}
このように Retrofit で API 呼び出し時に自前でエラーハンドリングをするのは結構手間がかかってしまうかもしれません。
そこで先ほど挙げた2つのライブラリを使って、レスポンス時の処理を変更してみようと思います。
slackhq/EitherNet
EitherNet
の方では ApiResult<out T, out E>
でレスポンスの結果を受け取ることができ、v0.2.0 時点での定義は以下のようになっています。
public sealed class ApiResult<out T, out E> {
public data class Success<T : Any>(public val response: T) : ApiResult<T, Nothing>()
public sealed class Failure<out E> : ApiResult<Nothing, E>() {
// 通信エラー.
public data class NetworkFailure internal constructor(public val error: IOException) : Failure<Nothing>()
// 予期しないエラー(レスポンスの JSON のパースエラーなど).
public data class UnknownFailure internal constructor(public val error: Throwable) : Failure<Nothing>()
// HTTP ステータスコードが 4xx or 5xx の時のエラー.
public data class HttpFailure<out E> internal constructor(public val code: Int, public val error: E?) : Failure<E>()
// レスポンスボディを自前で変換する時に ApiException を throw した際のエラー.
public data class ApiFailure<out E> internal constructor(public val error: E?) : Failure<E>()
}
}
T
は成功時の型、E
はエラー時の型になります。
ApiResult
には2つのサブクラス Success
と Failure
があり、それぞれ通信の成功と失敗を表す型になっています。
Failure
にはさらに4つのサブクラスが定義されていて、エラーの発生要因によってそれぞれ対応するクラスのインスタンスがレスポンスとして返るようになります。
使い方
例として、先ほどの Qiita の API に対して対応してみます。
まずは通信成功、失敗時のレスポンスのデータクラスを作ります。
レスポンスの JSON 文字列をパースするために Moshi を使います。
// Qiita の記事の投稿データ.
// https://qiita.com/api/v2/docs#投稿
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "id") val id: String,
@Json(name = "title") val title: String
)
// Qiita の API のエラーレスポンス
// https://qiita.com/api/v2/docs#エラーレスポンス
@JsonClass(generateAdapter = true)
data class QiitaApiError(
@Json(name = "message") val message: String,
@Json(name = "type") val type: String
)
Qiita API のエラーレスポンスは message
と type
というキーを含んだ JSON オブジェクトの文字列が返ってくるので、上記のように定義することができます。
interface QiitaApi {
@DecodeErrorBody // HttpFailure 時にレスポンスボディを QiitaApiError に変換するために必要
@GET("/api/v2/items")
suspend fun fetchItems(@Query("page") page: Int, @Query("per_page") perPage: Int): ApiResult<List<Item>, QiitaApiError>
}
val qiitaApi = Retrofit.Builder()
.addConverterFactory(ApiResultConverterFactory)
.addCallAdapterFactory(ApiResultCallAdapterFactory)
.addConverterFactory(MoshiConverterFactory.create()) // addConverterFactory(ApiResultConverterFactory) より後に設定する必要がある
.build()
.create<QiitaApi>()
Retrofit のインターフェースの定義と作成は上記のように行います。
ApiResult<List<Item>, QiitaApiError>
は通信成功時には List<Item>
、通信失敗(HttpFailure
or ApiFailure
)時には QiitaApiError
でレスポンスを受け取ることができます。
@DecodeErrorBody
アノテーションを付けないとエラー時のレスポンスボディを QiitaApiError
に変換してくれないので注意が必要です。
もう一つ注意点としては、Retrofit のインスタンスを作成する際の addConverterFactory
の呼び出しの順序です。
addConverterFactory(ApiResultConverterFactory)
より後に addConverterFactory(MoshiConverterFactory.create())
を呼び出さないと正しくレスポンスがパースされないので注意してください。
そして以下のように呼び出して、結果をハンドリングすることができます。
when (val result = qiitaApi.fetchItems(1, 3)) {
is ApiResult.Success -> Log.d(TAG, "Success: ${result.response}")
is ApiResult.Failure -> when (result) {
// result.error は QiitaApiError 型のインスタンス
is ApiResult.Failure.HttpFailure -> Log.e(TAG, "HttpFailure: ${result.code}, ${result.error}")
is ApiResult.Failure.ApiFailure -> Log.e(TAG, "ApiFailure: ${result.error}")
is ApiResult.Failure.NetworkFailure -> Log.e(TAG, "NetworkFailure: ${result.error}")
is ApiResult.Failure.UnknownFailure -> Log.e(TAG, "UnknownFailure: ${result.error}")
}
}
skydoves/Sandwich
Sandwich
の方では ApiResponse<out T>
で結果を受け取ることができ、v1.1.0 時点での定義は以下のようになっています。
sealed class ApiResponse<out T> {
data class Success<T>(val response: Response<T>) : ApiResponse<T>() {
val statusCode: StatusCode = getStatusCodeFromResponse(response)
val headers: Headers = response.headers()
val raw: okhttp3.Response = response.raw()
val data: T? = response.body()
override fun toString() = "[ApiResponse.Success](data=$data)"
}
sealed class Failure<T> {
// HTTP ステータスコードが200番台以外のエラー(ただし設定で変更は可能).
data class Error<T>(val response: Response<T>) : ApiResponse<T>() {
val statusCode: StatusCode = getStatusCodeFromResponse(response)
val headers: Headers = response.headers()
val raw: okhttp3.Response = response.raw()
val errorBody: ResponseBody? = response.errorBody()
override fun toString(): String = "[ApiResponse.Failure.Error-$statusCode](errorResponse=$response)"
}
// Failure.Error 以外のエラー.
data class Exception<T>(val exception: Throwable) : ApiResponse<T>() {
val message: String? = exception.localizedMessage
override fun toString(): String = "[ApiResponse.Failure.Exception](message=$message)"
}
}
}
T
は成功時の型になります。
EitherNet
と同じように ApiResponse
にも2つのサブクラス Success
と Failure
があり、それぞれ通信の成功と失敗を表す型になっています。
Failure
にはさらに2つのサブクラス Error
と Exception
が定義されています。
Failure.Error
は HTTP ステータスコードが200番台以外の場合のエラー(ただし設定で変更することが可能)で、それ以外のエラーの場合は Failure.Exception
になります。
使い方
Sandwich
の README を見るとわかりますが、このライブラリは多くの機能が備わっています。
今回は EitherNet
と同じように suspend 関数で API 呼び出しをする際の使い方について、簡単に紹介します。
interface QiitaApi {
@GET("/api/v2/items")
suspend fun fetchItems(@Query("page") page: Int, @Query("per_page") perPage: Int): ApiResponse<List<Item>>
}
val qiitaApi = Retrofit.Builder()
.addCallAdapterFactory(CoroutinesResponseCallAdapterFactory())
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create<QiitaApi>()
Retrofit のインターフェースの定義と作成は上記のように行います。
ApiResponse
型の戻り値の suspend 関数を呼び出すには addCallAdapterFactory(CoroutinesResponseCallAdapterFactory())
を設定する必要があります。
そして以下のように呼び出して、結果をハンドリングすることができます。
when (val result = qiitaApiForSandwich.fetchItems(1, 3)) {
is ApiResponse.Success -> Log.e(TAG, "Success: ${result.response.body()}")
is ApiResponse.Failure.Error -> Log.e(TAG, "Error: ${result.statusCode}, ${result.errorBody?.string()}")
is ApiResponse.Failure.Exception -> Log.e(TAG, "Exception: ${result.exception}")
}
エラー時のレスポンスボディをパースして QiitaApiResult
に変換したい場合などは、Mapper などの機能を使って変換する必要があります。
Failure.Error
時に Mapper
を適用して QiitaApiResult?
に変換する例は以下になります。
class QiitaApiErrorMapper(private val moshi: Moshi) : ApiErrorModelMapper<QiitaApiError?> {
override fun map(apiErrorResponse: ApiResponse.Failure.Error<*>): QiitaApiError? {
return apiErrorResponse.errorBody?.source()?.let(moshi.adapter(QiitaApiError::class.java)::fromJson)
}
}
when (val result = qiitaApiForSandwich.fetchItems(1, 3)) {
is ApiResponse.Failure.Error -> result.onError(QiitaApiError.Mapper(moshi)) { Log.e(TAG, "Error: ${this?.message}") }
...
}
2つのライブラリの比較
私が感じた EitherNet
と Sandwich
との違いについてです。
使いやすさ
EitherNet
の方がシンプルに思いました。
Sandwich
は多機能なため、機能を把握するのに少し大変かもしれません。
エラーハンドリングについて
EitherNet
はエラー時のレスポンスの型も設定できるので、Moshi と併用すればエラーレスポンスの変換が簡単に行えました。
Sandwich
の方は Mapper
を作成してエラーレスポンスを変換する必要があるので、少しだけ手間が増える感じです。
レスポンスヘッダへのアクセス
レスポンスヘッダの中身を扱いたい場合は Sandwich
一択になります。
Sandwich
では ApiResponse.Success
と ApiResponse.Failure.Error
のインスタンスでレスポンスヘッダを扱うことができますが、EitherNet
ではできません。
メンテナンス状況
2021年6月現在、EitherNet
は最近あまりメンテナンスされていないようです。
Sandwich
は定期的に機能追加などのメンテナンスが行われているようです。
最後に
簡単にですが EitherNet
と Sandwich
について紹介させていただきました。
Retrofit のレスポンスのハンドリングが楽になるかもしれないので、興味のある方は使ってみてはいかがでしょうか。
https://github.com/watabee/RetrofitResponsesSample にサンプルプロジェクトを公開しています。