LoginSignup
5
3

More than 5 years have passed since last update.

sealed classとEnumを用いてAPI通信の成否をハンドリングする

Last updated at Posted at 2019-02-05

背景

アプリからAPIに対してPOST送信でデータを登録する処理を実装していました。
APIからは、登録の成否を表す文字列resultと失敗の原因を表す数値errorCodeを含むJSONが返却されます。
具体的なJSONは下記のようになります。

成功した場合
{
  "result": "success"
}
失敗した場合
{
  "result": "failure",
  "errorCode": 1
}

この結果をハンドリングする際に、私は最初Enumでそれぞれの通信結果を定義しました。

PostResultType.kt
enum class PostResultType(
    private val resultString: String,
    private val errorCode: Int? = null
) {
    SUCCESS("success"),
    FAILURE_REACHED_LIMIT("failure", 1),
    FAILURE_SERVER_MAINTENANCE("failure", 2);

    companion object {

        fun from(resultString: String, errorCode: Int?): PostResultType? =
            PostResultType.values().find { it.resultString == resultString && it.errorCode == errorCode }
    }

しかし、このEnumでは「1つの成功と複数の失敗が同列に扱われている」ことと、「成功にはerrorCodeが含まれないにも関わらず(明示的にnullにはしているが)SUCCESSにもerrorCodeを定義しなければならない」ことで、少々分かりづらいと感じていました。
そこで今回はこの課題を解決するために、sealed classとEnumを用いて下記のような階層的な構造に修正してみました。

// 修正前
- 通信結果データ(結果文字列, エラーコード)
    - 成功(success, null)
    - 上限到達で失敗(failure, 1)
    - サーバーメンテナンスで失敗(failure, 2)

// 修正後
- 通信結果
    - 成功
    - 失敗(エラーコード)
        - 上限到達(1)
        - サーバーメンテナンス(2)

通信の成否をsealed class化

まずは通信の成否を判別するために、通信結果をsealed classとして定義します。

PostResult.kt
sealed class PostResult {
    object Success : PostResult()
    object Failure : PostResult()

    companion object {

        fun from(result: String): PostResult =
            if (result == "success") Success()
            else Failure()
    }
}

失敗の原因をEnum化

失敗の原因によってアプリが行う処理を分岐させたいので、失敗の原因をEnumとして定義します。
このEnumはエラーコードによって原因を一意に特定できるようにします。

FailureType.kt
enum class FailureType(private val errorCode: Int) {
    REACHED_LIMIT(1),
    SERVER_MAINTENACE(2);

    companion object {

        fun from(errorCode: Int?): FailureType? =
            values().find { it.errorCode == errorCode }
    }
}

Failureは失敗の原因であるFailureTypeをプロパティとして保持します。

PostResult.kt
sealed class PostResult {
    object Success : PostResult()
    data class Failure(val type: FailureType?) : PostResult()

    companion object {

        fun from(result: String, type: FailureType?): PostResult =
            if (result == "success") Success
            else Failure(type)
    }
}

これにより、最初に挙げた階層的な定義が実現できました。

修正によるメリット・デメリット

今回の修正によって、最初に挙げた課題である「1つの成功と複数の失敗が同列に扱われている」ことと、「成功にはerrorCodeが含まれないにも関わらず(明示的にnullにはしているが)SUCCESSにもerrorCodeを定義しなければならない」ことを解決することができました。

しかし、修正前と修正後で通信結果のハンドリングを行う部分のコードを見比べた時、修正後の方がネストが深くなってしまい複雑な処理を書く際は少々可読性が落ちる可能性があることに気付きました。

通信結果のハンドリング(修正前)
fun onCompleted(type: PostResultType) {
    when (type) {
        PostResultType.SUCCESS -> {}
        PostResultType.FAILURE_REACHED_LIMIT -> {}
        PostResultType.FAILURE_SERVER_MAINTENANCE -> {}
    }
}
通信結果のハンドリング(修正後)
fun onCompleted(result: PostResult) {
    when (result) {
        PostResult.Success -> {}
        is PostResult.Failure -> {
            when (result.type) {
                FailureType.REACHED_LIMIT -> {}
                FailureType.SERVER_MAINTENANCE -> {}
            }
        }
    }
}

ですが、個人的には定義として不要な要素を省くことができたことと、見た目としても明示的に成否の処理を分けられるため結果的にはプラスであると考えています。

ご意見・アドバイス等ございましたら、ぜひコメントをよろしくお願いいたします。
ご精読ありがとうございました。

fold関数による判定処理の隠蔽化

@sdkei さんがコメントで教えてくださったfold関数を用いて修正をしてみました。

fold関数の実装
sealed class PostResult {

    fun fold(
        onSuccess: () -> Unit,
        onFailure: (FailureType?) -> Unit
    ) = when (this) {
        Success -> onSuccess()
        is Failure -> onFailure(type)
    }

    // 以下省略...
}

// 呼び出し側
fun onCompleted(result: PostResult) {
    result.fold(
        onSuccess = {},
        onFailure = {
            when (it) {
                FailureType.REACHED_LIMIT -> {}
                FailureType.SERVER_MAINTENANCE -> {}
            }
        }
    )
}

JSONからのパース

Gsonを用いてAPIから返却されたJSONをPostResultにパースします。

PostResultDeserializer.kt
class PostResultDeserializer : JsonDeserializer<PostResult> {

    @Throws(JsonParseException::class)
    override fun deserialize(
        jsonElement: JsonElement,
        type: Type,
        jsonDeserializationContext: JsonDeserializationContext
    ): PostResult {
        val jsonObject = jsonElement.asJsonObject
        val resultString = jsonObject["result"].asString
        val hasCode = jsonObject.has("code")
        val errorCode: Int? = if (hasCode) jsonObject["code"].asInt else null
        return PostResult.from(resultString, FailureType.from(errorCode))
    }
}

念の為テストコードも書いておきます。

PostResultDeserializerTest.kt
class PostResultDeserializerTest {

    private val gson = GsonBuilder()
        .registerTypeAdapter(PostResult::class.java, PostResultDeserializer())
        .create()

    @Test
    fun 成功時のJSONをパスできるか() {
        val actual = gson.fromJson<PostResult>("{\"result\": \"success\"}", PostResult::class.java)
        val expected = PostResult.from("success", null)

        assertEquals(expected, actual)
    }

    @Test
    fun 登録上限エラJSONをパスできるか() {
        val actual =
            gson.fromJson<PostResult>("{\"result\":\"failure\",\"code\":1}", PostResult::class.java)
        val expected = PostResult.from("failure", FailureType.from(1))

        assertEquals(expected, actual)
    }

    @Test
    fun 予期せぬエラドが返ってきた場合にtypenullになるか() {
        val actual =
            gson.fromJson<PostResult>("{\"result\":\"failure\",\"code\":100}", PostResult::class.java)

        assertTrue(actual is PostResult.Failure)
        assertNull(actual.type)
    }
}
5
3
2

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
5
3