13
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Android]Retrofitのレスポンスのハンドリングを楽にするかもしれない2つのライブラリ

Posted at

はじめに

Android 開発において Retrofit はよく使われるライブラリの一つかと思います。

今回は Retrofit のレスポンスのハンドリングを楽にしてくれるかもしれないライブラリを2つご紹介したいと思います。

2つのライブラリの特徴

この2つのライブラリの共通の特徴として、ライブラリが定義している独自のクラスでレスポンスを受け取れます。

EitherNet の方は ApiResultSandwich の方は 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つのサブクラス SuccessFailure があり、それぞれ通信の成功と失敗を表す型になっています。

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 のエラーレスポンスは messagetype というキーを含んだ 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つのサブクラス SuccessFailure があり、それぞれ通信の成功と失敗を表す型になっています。

Failure にはさらに2つのサブクラス ErrorException が定義されています。

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つのライブラリの比較

私が感じた EitherNetSandwich との違いについてです。

使いやすさ

EitherNet の方がシンプルに思いました。

Sandwich は多機能なため、機能を把握するのに少し大変かもしれません。

エラーハンドリングについて

EitherNet はエラー時のレスポンスの型も設定できるので、Moshi と併用すればエラーレスポンスの変換が簡単に行えました。

Sandwich の方は Mapper を作成してエラーレスポンスを変換する必要があるので、少しだけ手間が増える感じです。

レスポンスヘッダへのアクセス

レスポンスヘッダの中身を扱いたい場合は Sandwich 一択になります。

Sandwich では ApiResponse.SuccessApiResponse.Failure.Error のインスタンスでレスポンスヘッダを扱うことができますが、EitherNet ではできません。

メンテナンス状況

2021年6月現在、EitherNet は最近あまりメンテナンスされていないようです。

Sandwich は定期的に機能追加などのメンテナンスが行われているようです。

最後に

簡単にですが EitherNetSandwich について紹介させていただきました。

Retrofit のレスポンスのハンドリングが楽になるかもしれないので、興味のある方は使ってみてはいかがでしょうか。

https://github.com/watabee/RetrofitResponsesSample にサンプルプロジェクトを公開しています。

13
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?