背景
アプリからAPIに対してPOST送信でデータを登録する処理を実装していました。
APIからは、登録の成否を表す文字列result
と失敗の原因を表す数値errorCode
を含むJSONが返却されます。
具体的なJSONは下記のようになります。
{
"result": "success"
}
{
"result": "failure",
"errorCode": 1
}
この結果をハンドリングする際に、私は最初Enumでそれぞれの通信結果を定義しました。
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として定義します。
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はエラーコードによって原因を一意に特定できるようにします。
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
をプロパティとして保持します。
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関数を用いて修正をしてみました。
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にパースします。
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))
}
}
念の為テストコードも書いておきます。
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 予期せぬエラーコードが返ってきた場合にtypeがnullになるか() {
val actual =
gson.fromJson<PostResult>("{\"result\":\"failure\",\"code\":100}", PostResult::class.java)
assertTrue(actual is PostResult.Failure)
assertNull(actual.type)
}
}