SwiftyJSONからDecodableへ移行する際に気をつけてよかったこと - Mirrativ Tech Blog

Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

SwiftyJSONからDecodableへ移行する際に気をつけてよかったこと

こんにちは、iOSエンジニアのいっちー(icchi (@0IcchI) / X)です。

MirrativのiOSアプリでは4年ほど前にレスポンスパラメータとレスポンスクラスのマッピングにDecodableを採用したAPIクライアントを作成し運用してきました。

新規の実装ではDecodable対応のAPIクライアントを採用していましたが、旧式APIクライアントを用いた膨大な量の実装はそのままにしていたので、今年から気持ちを高めてAPIクライアントの移行作業を開始しました。

気持ちを高めたものの、レスポンスクラスのデコード処理をSwiftyJSONからDecodableへ移行する際に、「レスポンスクラスの継承関係/依存関係の複雑さ」や「マッピングの際の型変換」によって悩まされることが多々ありました。

そんな悩みを乗り越えて、レスポンスクラスをDecodableへ移行する際に気をつけてよかったことを紹介します。

SwiftyJSONからDecodableへ移行するメリット

前提として、何を期待してDecodableへ移行したのかについて紹介します。

Decodableへ移行するメリットとしては、膨大なレスポンスパラメータ全てについてデコード処理を省略できることがあります。膨大なデコード処理の記述はコードの可読性を悪くしますし、デフォルトでデコード処理が書けるとビジネスロジック側で担保すべきような処理を書く隙が生まれてしまいます。

そのためデコード処理を全てDecodable/JSONDecoder側で吸収して貰えるのは大きいメリットになります。

また脱SwiftyJSONのメリットとして、レスポンスに必須なパラメータを非オプショナルで定義できるようになったことが挙げられます。それにより、レスポンスの必須パラメータが明示化されると共に、レスポンスクラス利用側での不要なアンラップ操作を削除することができました。

このようなメリットが各エンドポイントで発揮されることでメンテナンス性の大幅な向上が見込まれます。

また、移行の必要がある実装はMirrativを古くから支えているコアなエンドポイントに集中して存在しているので、そこのメンテナンス性が上がるというのもモチベーションになっています。

本記事と関連して、現行APIクライアントを作成したモチベーションについて紹介している記事が存在するので興味がある方はぜひ読んでみてください。

tech.mirrativ.stream

レスポンスクラスのDecodable移行例

レスポンスクラスをSwiftyJSONからDecodableへ移行するイメージを伝えるために、簡単な移行例について紹介します。

Decodableへの移行作業はとてもシンプルで、SwiftyJSONのJSONDeserializable準拠とデコード処理部分を削除してDecodableへ準拠させるだけです。

下記例ではResponseクラスを置き換えたことで、デコード処理が削除され可読性が改善されていることを確認できます。

また、レスポンスモデルをビジネスロジック側で直接書き換えるのは非推奨であるため、型をclassから値型でイミュータブルなstructに置き換えています。 レスポンスプロパティのxxxは必須パラメータのため非オプショナルで定義しており、ビジネスロジック側でのアンラップを削除することができます。

// [置き換え前] 旧APIクライアントのレスポンスクラス
final class Response: JSONDeserializable {
    final class XXX: JSONDeserializable {
        var yyy: String

        required init(json: JSON) {
            yyy = json["yyy"].string
        } 
    }

    var xxx: XXX?

    required init(json: JSON) {
        xxx = Response.XXX(json: json["xxx"])
    }
}

// [置き換え後] 現行APIクライアントのレスポンスクラス
struct Response: Decodable {
    struct XXX: Decodable { 
        let yyy: String
    }

    let xxx: XXX
}

特に変わった実装がない場合は、上記手順通りに機械的に作業することが可能です。

チームで大きな課題に取り組むのには事前準備と認識合わせが大切です。 上記のような作業工程の共有のほかに、ゴールと進め方の認識を合わせたり段階的な進め方の方針と見通しを立てる必要があります。

それについては以下の記事が詳しいので興味がある方はぜひ読んでみてください。

tech.mirrativ.stream

移行作業で気をつけてよかったこと

旧APIレスポンスクラスからSwiftyJSONを剥がしDecodableに準拠させる対応を進めるにあたって、気をつけてよかったことについて紹介します。

デコード処理の記述量をなるべく減らす

デコード処理の記述を省略できるのは、Decodableへ移行する大きなメリットです。

しかし、デコード処理内で型変換が必要なケースは省略できません。

ミラティブでは、デコード処理内でレスポンスパラメータを型変換してからマッピングしている実装が多数存在していたため、Decodable移行をしているにも関わらずデコード処理の記述が必要になる場合が多く負担になっていました。

具体的には以下のようになります。

// SwiftyJSON: デコード処理内でレスポンスパラメータをint型に変換している
final class Response: JSONDeserializable {
    var xxx: Int?

    required init(json: JSON) {
        if let int = json?.int {
            xxx = int
        }
        if let string = json?.string {
            xxx = Int(string)
        }
    }
}

// Decodabe:デコード処理を記述する必要がある
struct Response: Decodable {
    var xxx: Int?

    required init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        if let value = try? container.decode(T.self) {
            xxx = value
        } else if let string = try? container.decode(String.self), let int = Int(string) {
            xxx = int
        }
    }
}

このようにマッピングの際に型変換を噛ますと、デコード処理の記述が必要になり、それに引っ張られて他パラメータのデコードも記述する必要があり余計に負担になります。

この課題は、あらかじめデコード時の振る舞いを定義したプロパティラッパーを利用することで解決しました。

struct ForcibleNumber<T: AnyNumberForcible>: ForcibleValue {
   var wrappedValue: T

   var description: String {
        wrappedValue.description
    }

   init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
    }

   init(from decoder: any Decoder) throws { 
       // 型変換処理
   }
}

このプロパティラッパーをレスポンスクラスの型変換を行いたいプロパティに付与することで、レスポンスクラスでのデコード処理記述を省略できます。

struct Response: HTTPResponse {
    @ForcibleNumber.Option let xxx: Int?
}

この機能はiOSエンジニアの竹澤さん(@to4ki)によりライブラリ(ForcibleValue)が作成されているので、内部実装が気になる方はコチラからご確認ください。

マッピング時の型変換を行いながらも、自前でデコード処理を書く必要がなくなりました。これにより、Decodableの嬉しさを残したままで現行のAPIクライアントへ置き換えることができました。

影響範囲を絞って置き換えを進める

プルリクエストのレビューやQAは影響範囲が絞られていた方が進めやすいので、エンジニア側で置き換えの影響範囲を調整して移行作業を進める必要があります。

しかし、Decodable移行を行ったレスポンスクラスが他のレスポンスクラスで所有されていた場合、所有している側のレスポンスクラスもDecodable移行しないといけません。

さらにそれを所有したり継承するレスポンスクラスが存在したら...数珠繋ぎ的に置き換えの影響範囲が広がってしまいます。

そのため、どこかのレスポンスクラスでSwiftyJSONとDecodableのレスポンスを共存させて置き換えの連鎖を止める必要があります。

以下はSwiftyJSONのJSONDeserializable準拠のレスポンスクラスのデコード処理の中で、Decodable準拠のレスポンスクラスをデコードしている例です。

// SwiftyJSONからDecodableへの移行するときに使う
extension SwiftyJSON.JSON {
    ...
    public func decode<T: Decodable>() -> T? {
        guard let data = self.rawString(.utf8)?.data(using: .utf8) else {
            return nil
        }
        return try? Self.decoder.decode(T.self, from: data)
    }
}

final class Response: JSONDeserializable {
    var xxx: XXX? // Decodable準拠のレスポンスクラス

    required init(json: JSON) {
        xxx = json["XXX"].decode()
    }
}

SwiftyJSON.JSONから生やしているdecode()メソッドを用いて簡潔に共存させています。 このようにすることで、影響範囲を絞って移行作業を進めることが可能になりました。

ベースクラスを持つレスポンスクラスの移行は依存解消も視野に入れる

ベースクラスを持つレスポンスの移行はコストがかかります。そのため、継承を解除させると言うのも有効な選択肢の一つになります。

以下で具体例を示して説明します。

例えば、移行対象のレスポンスクラスがSwiftyJSONに依存したベースクラスを継承している場合、初めにベースクラスをDecodableへ移行すると思います。

しかし、もしベースクラスが他の複数のレスポンスクラスからも継承されていたら、その複数のレスポンスクラスまで移行が必要になってしまいます。

そのようなケースではベースクラスをSwiftyJSONとDecodableの両方に依存させることで、移行対象を絞ります。

以下はResponseクラスのみをDecodableへ移行するために、BaseResponseでSwiftyJSONとDecodableを共存させている例です。

// SwiftyJSONとDecodable両方に依存
final class BaseResponse: JSONDeserializable, Decodable {
    var xxx: Int?

    required init(json: JSON) {
        if json.type == .null { return }
        xxx = json["xxx"].int
    }
}

// BaseResponseを継承
// Decoderの方でデコードする
final class Response: BaseResponse {
    var yyy: Int

    enum CodingKeys: String, CodingKey {
        case yyy
    }

    // デコード処理の記述を追加する必要あり
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        yyy = try container.decodeIfPresent(Int.self, forKey: .yyy) ?? 0
        try super.init(from: decoder)
    }

    // SwiftyJSONは使わないようにする
    required init(json: JSON) {
        fatalError("init(json:) has not been implemented")
    }
}

上記コードを読んでいただくと、SwiftyJSONとDecodable両方のデコード処理の記述が必要になってしまっていることが分かります。

上記コードには書いていませんが、ベースクラスを継承している他のレスポンスクラスにもデコード処理の記述が必要になります。

そして、ベースクラスを継承しているすべてのレスポンスクラスのDecodable移行が終わると、ベースクラスを純粋なDecodable準拠クラスにするという仕上げ作業が待っています。

このように、SwiftyJSONとDecodableを共存させて複数回に移行を分けるという戦略もありますがコストは高めです。

ベースクラスを削除しても設計的に問題がない場合やベースクラス自体がそこまで大きくない場合は、移行作業のコストを下げるため、レスポンスクラスからベースクラスの依存を無くしていって、最終的にベースクラスを削除するのを視野に入れるといいと思います。

以下はベースクラスへの依存を無くした例です。すっきりした記述になっていることが確認できると思います。

// BaseResponseを継承しない
struct Response: Decodable {
    var xxx: Int?
    var yyy: Int
}

実際にミラティブでは移行作業中に新規実装では使われていないベースクラスにぶつかった場合、積極的に依存を解消し削除をすることで、移行作業も楽になり、レスポンスクラス自体の見通しも良くなりました。

旧APIレスポンスクラスからSwiftyJSONを剥がしDecodableに準拠させる上で気をつけてよかったことについて紹介しました。上記で示したテクニックを用いることでレスポンスクラスをミニマムに定義することができました。

移行作業の今後の展望

置き換え作業自体は今後も継続して進めていきます。 今回のような大きい改修は個人で進めていくのはなかなか難しいと思うので、チーム全体で取り組むのが良いかと思います。

そのためには、方針に関する認識合わせや、小さな知見でもこまめに共有する動きが必要不可欠になります。

チームで協力する分、難易度は上がってしまいますが、この機会にチーム内のコミュニケーションやコラボレーションを促進できたら嬉しいですね!

We are hiring!

Mirrativ では一緒に開発してくれるエンジニアを募集しています!

speakerdeck.com

mirrativ.notion.site

www.mirrativ.co.jp