SwiftでJsonを構造体に変換するときに一部のStringをenumにする

2022-07-22

はじめに

iOS(モバイル)開発において Json などを WebAPI から取得することは一般的だと思います。そのときにプラン名や契約しているコース名など、あらかじめ扱うことが決まっている String も多々あるのかなと思っています(あまりたくさんのサービスを開発したことがあるわけではないので確証があるわけでもないのですが…)。業務中に、そういった String をきれいに enum に変換して、いい感じにしているコードを見て感動したので忘備録的に記事にしました。

今回実装したコードは、Xcode の Play Ground で動く形になっています。実装したコードは、GitHub にアップしているので、もしよろしければ参考にして下さい。Swift-Practice.playgroundフォルダのSources/ConvertModelStringToEnum.swiftにあります。

実際にどんな感じだったか

今回は例として返ってくる Json は単純なものにしています。ID とプラン名が返ってくるだけの Json です。プラン名は、freepremiumの 2 種類が返ってくるものとして扱います。

sample.json
    {
        "id": 1,
        "plan": "free"
    }

先に今回サンプルで作ってみた Swift のコードを示します。Combine を使っていますが、Combine がわからなくても、なんとなくのやっていることは分かるかなと思います(自分が久しぶりに Combine 書きたかったので Combine で書きました…あとメソッドチェーンでつなげたほうが楽なので…)。

ConvertModelStringToEnum.swift

import Foundation
import Combine

/// サンプルのモデル
public struct SampleModel: Decodable {
    /// 適当なID
    public let id: Int
    /// こっちのplanはあくまでJsonからの文字列を受け取るだけにするのでprivateにする
    private let plan: String

    public enum Plan: String {
        case free = "free"
        case premium = "premium"
        case notFound = "notFound"
    }

    /// ここで想定外のプラン(文字列)が返ってきたときに一括でnotFoundを返す
    public var contractPlan: Plan {
        return Plan(rawValue: plan) ?? .notFound
    }

}

/// 普通に使うパターン
public func convertModelStringToEnumSample1() {
    var cancellable: AnyCancellable?

    cancellable = """
    {
        "id": 1,
        "plan": "free"
    }
    """
        .data(using: .utf8)
        .publisher
        .decode(type: SampleModel.self, decoder: JSONDecoder())
        .print()
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("convertModelStringToEnumSample1 finished! \n")
            case .failure(let error):
                print(error)
            }
        }, receiveValue: { decodedValue in
            print(decodedValue.contractPlan)
        })

}

/// planにtestという想定外のパラメータが送られたという体でいい感じにするパターン
public func convertModelStringToEnumSample2() {
    var cancellable: AnyCancellable?

    cancellable = """
    {
        "id": 2,
        "plan": "test"
    }
    """
        .data(using: .utf8)
        .publisher
        .decode(type: SampleModel.self, decoder: JSONDecoder())
        .print()
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("convertModelStringToEnumSample2 finished! \n")
            case .failure(let error):
                print(error)
            }
        }, receiveValue: { decodedValue in
            // 未定義の「test」がplanに入っているのでnotFoundを返してくれる
            print(decodedValue.contractPlan)
        })

}

/// planをenumに変換するからfilter()とかがいい感じに使えるよということを示すパターン
public func convertModelStringToEnumSample3() {
    var cancellable: AnyCancellable?

    cancellable = """
    {
        "id": 2,
        "plan": "test"
    }
    """
        .data(using: .utf8)
        .publisher
        .decode(type: SampleModel.self, decoder: JSONDecoder())
        .print()
        // いい感じにenumを使ったフィルターが出来る
        // enumにするので、補完でプラン名が出る
        // そのため直接planのStringで比較するよりタイプミスなどが減る(はず)
        .filter({ $0.contractPlan == .free })
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("convertModelStringToEnumSample3 finished! \n")
            case .failure(let error):
                print(error)
            }
        }, receiveValue: { decodedValue in
            print(decodedValue.contractPlan)
        })

}

実行結果も載せておきます。

実行結果
receive subscription: (Decode)
request unlimited
receive value: (SampleModel(id: 1, plan: "free"))
free
receive finished
convertModelStringToEnumSample1 finished!

receive subscription: (Decode)
request unlimited
receive value: (SampleModel(id: 2, plan: "test"))
notFound
receive finished
convertModelStringToEnumSample2 finished!

receive subscription: (Decode)
request unlimited
receive value: (SampleModel(id: 2, plan: "test"))
request max: (1) (synchronous)
receive finished
convertModelStringToEnumSample3 finished!

Json→Swift の構造体に変換したいので以下のように定義しています。

SampleModel.swift
public struct SampleModel: Decodable {
    /// 適当なID
    public let id: Int
    /// こっちのplanはあくまでJsonからの文字列を受け取るだけにするのでprivateにする
    private let plan: String

    public enum Plan: String {
        case free = "free"
        case premium = "premium"
        case notFound = "notFound"
    }

    /// ここで想定外のプラン(文字列)が返ってきたときに一括でnotFoundを返す
    public var contractPlan: Plan {
        return Plan(rawValue: plan) ?? .notFound
    }

}

今回は Swift の構造体にデコードできればいいので、Decodableプロトコルに準拠します。プラン名を enum で返すために今回はcontractPlanというプロパティを定義しました。これは、String で受取ったプラン名(plan)の文字列に応じて enum で定義したPlanを返します。Planはあらかじめ WebAPI から返ってくるプラン名を定義しています。未定義のプラン名が返ってきても大丈夫なようにnotFoundを作っています。

未定義の String が返ってくる

仮に未定義のプランが WebAPI から返ってきても、contractPlanではnotFoundに変換するようにしています。ただ、そもそもとしてプラン名などの重要な情報が WebAPI から、想定外の String が返ってくる場面が起こりうるのか、というのもありますが…

convertModelStringToEnumSample2.swift
/// planにtestという想定外のパラメータが送られたという体でいい感じにするパターン
public func convertModelStringToEnumSample2() {
    var cancellable: AnyCancellable?

    cancellable = """
    {
        "id": 2,
        "plan": "test"
    }
    """
        .data(using: .utf8)
        .publisher
        .decode(type: SampleModel.self, decoder: JSONDecoder())
        .print()
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("convertModelStringToEnumSample2 finished! \n")
            case .failure(let error):
                print(error)
            }
        }, receiveValue: { decodedValue in
            // 未定義の「test」がplanに入っているのでnotFoundを返してくれる
            print(decodedValue.contractPlan)
        })

}

enum に変換することで補完が効く

自分は String を enum に変換する一番のメリットはこれかなと思いました。 プラン名を使って、Combine や RxSwift のfilter()メソッドで比較したいときも enum なので String と比べて補完が効くというメリットがあります。補完が効いていると分かるのが、以下のサンプルコードの 7 行目です。これが String だと自分で.filter({ $0.plan == "free" })のように手入力する必要があるかなと思います。freeを、freeeeと書いてしまうかもしれません。enum だと.さえ入力すれば、補完が効くのでタイポが起こりにくいというメリットがあるかなと思いました。

convertModelStringToEnumSample3.swift
public func convertModelStringToEnumSample3() {
    var cancellable: AnyCancellable?

    cancellable = """
    {
        "id": 2,
        "plan": "test"
    }
    """
        .data(using: .utf8)
        .publisher
        .decode(type: SampleModel.self, decoder: JSONDecoder())
        .print()
        // いい感じにenumを使ったフィルターが出来る
        // enumにするので、補完でプラン名が出る
        // そのため直接planのStringで比較するよりタイプミスなどが減る(はず)
        .filter({ $0.contractPlan == .free })        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("convertModelStringToEnumSample3 finished! \n")
            case .failure(let error):
                print(error)
            }
        }, receiveValue: { decodedValue in
            print(decodedValue.contractPlan)
        })

}

この enum に変換する実装を見てものすごくきれいなコードだなと感動しました。

最後に

Json で受取った String を enum に変換する方法について書いてみました。もちろん String 全部を enum にすればいいわけではなく、時と場合によると思います。ただ、プラン名などについては、このような実装をするときれいに書けていいなと思いました。

この実装のデメリットも一応考えてみました。今回の実装だと Json→ 構造体の段階で、受取ったプラン名の String を一部削ぎ落としています。convertModelStringToEnumSample2のような場合です。構造体はあくまでデータを変換するだけのもので、値の計算などをやるのはいいのかな?という気持ちも無くはないです。ここは Json の扱いをどうするか決めの問題だとは思いますが…

サンプルコードを書いてはいますが、一部分からない箇所もあって、return Plan(rawValue: plan) ?? .notFoundの部分です。これはどうして、return Plan(rawValue: plan)として書けないのだろう?となっています。optional で定義していないので nil が入りえるのでは?となっています。nil チェックを書かないとエラーが出てしまうので書いてはいますが(強制 unwrap は最後の手段だと思っているので今回は使いませんでした)…なぜなのかもう一度エラーとにらめっこしたいと思います。

エラー内容
Value of optional type 'SampleModel.Plan?'
must be unwrapped to a value of type 'SampleModel.Plan'

久しぶりに Swift と Combine を書けて楽しかったです。最近業務はほぼ Android なので!

あと、意外と日本語での記事がないのですが、Combine で検証やデバッグをするときはprint(_:to:)オペレーターを使うといい感じに出力されるので、おすすめです。RxSwift で言うdebug()みたいなものだと思ってます(多分)。ただし、多用しすぎるとコンソールがごちゃごちゃして見にくいのでそのときはmap()の中で、print()するのがいいかもです。

Tatsumi0000

Written by Tatsumi0000 モバイル開発が好きなエンジニアのブログです. GitHub

Copyright © 2023, Tatsumi0000 All Rights Reserved.