OpenAPI SpecificationsからiOSプロジェクトのネットワーク層を自動生成する

こんにちは。iOSエンジニアの河邉です。 今回は海外出向研修プログラムで出向した海外版Cookpadを開発するブリストルオフィスで取り組んだ、OpenAPI SpecificationsからiOSプロジェクトのネットワーク層を自動生成する話をしたいと思います。

海外出向研修プログラムについての体験談はこちらをご覧ください。

techlife.cookpad.com

本題に入る前にまず海外版Cookpadについて簡単に紹介します。 海外版Cookpadは、日本のクックパッドレシピサービスとはコードベースも開発チームも会社も分かれていて、イギリスのブリストルという都市にあるオフィスをベースとするチームによって開発されています。 現在は世界74の国や地域で、32言語をサポートし、各地域のコミュニティチームと連携しながら世界中の毎日の料理を楽しみにするためにレシピサービスを展開しています。

海外版CookpadとOpenAPI

海外版Cookpadでは2019年頃からOpenAPIを用いたスキーマ駆動開発の導入を開始しました。 Androidプロジェクトではすでに2021年の段階でOpenAPIのスキーマからDTO(Data Transfer Object)と呼んでいるネットワーク層に用いるオブジェクトや、APIエンドポイントの定義の自動生成をしていました。

英語の記事ではあるのですが、iOSに先行して実施したAndroidプロジェクトへの導入について、以下の記事で紹介しています。 Let OpenAPI generate your Android network layer by leveraging Retrofit, Moshi, and Coroutines

しかし、iOSプロジェクトは2022年になってもOpenAPI SpecificationsからDTOやエンドポイントの定義を生成することができていませんでした。

Androidと比較してiOSのプロジェクトが遅れてしまっていたひとつの要因は、もちろんマンパワー的な問題もありますが、Androidプロジェクトには導入以前からすでに手書きのDTOが存在したためOpenAPIから生成するものと置き換えることができましたが、iOSプロジェクトにはDTOが存在しなかったため単に置き換えることができず、新しくDTO層を追加する必要があったからです。

DTO層が存在しなかったので、1つのオブジェクトをAPIレスポンスのデコードからUIロジックまで使っていて責務の切り分けができない・肥大化する・変更しづらいといった問題と、自動生成していないことによって単調なコードをAPIの変更のたびに手書きする必要がある・記述ミスのリスクが排除できないなどの問題を認識していました。

OpenAPIからiOSプロジェクトのネットワーク層を自動生成する

DTOの無かったiOSプロジェクトにOpenAPIから生成したDTOを導入した実際の手順について紹介します。

DTOを生成する

まず初めに取り組んだのはDTOの自動生成でした。自動生成に用いたライブラリはCreateAPI/CreateAPIという非常に新しいライブラリです。

// Generated by CreateAPI
public final class UserDTO: Codable, DTO {
    public let type: `Type`
    public let id: Int
    public let name: String
    public let avatarURL: URL
}

海外版Cookpadアプリはバイナリサイズにシビアな地域でも多くのユーザーに使われているので、アプリのバイナリサイズにはより細心の注意を払いました。 海外版Cookpadは既に400以上のComponentsが定義されていて、決して小さいアプリとは言えず、全てのComponentsをDTOとして一気に生成してしまうとバイナリサイズを不必要に大きくしてしまう懸念がありました。

今回のCreateAPIを使ったDTOの自動生成は当初は実験的に開始したため、CreateAPIの include というパラメータを用い、アプリのバイナリサイズを肥大化させないために少しずつDTOの生成を進めていきました。

また、CreateAPIにあるisGeneratingStructsというパラメータを用いて、DTOをStructではなくClassとして生成しました。我々のケースではStructで生成するのと比較して1.7MB縮小することができました。

Mapperを記述する

enum UserMapper {
    static func toEntity(from dto: UserDTO) -> User {
        User(
            id: dto.id,
            name: dto.name,
            avatarURL: dto.avatarURL
        )
    }
}

CreateAPIによって生成したDTOを既存のオブジェクトにマッピングする関数を記述しました。 これによって既存のオブジェクトには変更を加えずに、DTOとMapperを用いたネットワーク層を新しく追加することができます。

DTOとMapperのテスト

DTOとMapperが既存のオブジェクトと同じようにAPIのレスポンスをシリアライズしているかを確認するために、実際のレスポンスのJSONと同じ形式のダミーデータを用意し、DTOとMapperの組み合わせと既存のシリアライズ方法で全く同じ結果を得られるかを確かめるテストを用意しました。

final class UserMapperTest: XCTestCase {
    func test_user() throws {
        // Given User response json
        let response: Data = try Bundle.module.data(forResource: "User") // JSONファイルを読み込む関数を用意
    
        // when parse the response to User with legacy parser
        let legacyParser = DefaultModelParser<User>()
        let old = legacyParser.parse(response).value // 従来の方法でシリアライズ

        // and when parse and map the response to User with DTO parser
        let parser = DTOResponseParser<UserDTO>()
        let new = UserMapper.toEntity(from: parser.parser(response).get().result) // 新しい方法でシリアライズ

        // then
        XCTAssertEqual(new, old)
    }
}

この過程で既存の実装にAPIの定義とは異なる記述が見つかりましたが、ここではそれらのリファクタリングは後回しにして、とにかく現状の実装と一致する結果が得られるようにMapperやテストを記述しました。 タスクのスコープをネットワーク層の導入以外に広げないようにしたことが、導入をやり切る上でとても重要だったと思います。

これらのテストは移行期間中のみの一時的なもので移行が完了したら一緒に削除していきます。

DTOとエンドポイントはモジュールに分割して閉じ込める

DTOとエンドポイントはAPIKitという新しくつくったモジュール内に生成しています。 ApplicationモジュールはAppBaseモジュールを通じてのみAPIKitを参照するので、ApplicationモジュールはAPIKit内のネットワーク層の実装を意識しなくても良いようになっています。

便利にするための小技

今回ネットワーク層を自動生成する過程で用いた便利な小技をいくつかご紹介します。

Sourceryでpublic initを自動生成する

Mapperを記述する過程で既存のオブジェクトにpublicなinit関数が必要になりました。 これを毎度手書きしているとなかなか大きな手間になるので、Sourceryで自動生成する仕組みを導入しました。

// sourcery: autoPublicInit

StructやClassの定義の一行上に上記のように記述すると下記以下のようなpublic initを生成してくれます。これによって面倒な記述を減らすだけでなく、抜け漏れなども起きなくなりました。

// sourcery:inline:auto:Recipe.autoPublicInit
    public init(
        id: Int,
        title: String
    ) {
        self.id = id
        self.title = title
    }
// sourcery:end

GitHub ActionsでスキーマをWeb・Android・iOSにデプロイする

OpenAPIのスキーマは独立したレポジトリで管理されていて、これによって生成されたスキーマファイルはWeb・Android・iOSプロジェクトの各レポジトリ内でそれぞれに保持しています。 これらの同期を簡単にするため、OpenAPIスキーマのレポジトリからGitHub Actionsでそれぞれのレポジトリにデプロイし、プルリクエストを立てる仕組みを用意しました。

まとめと今後

今回ネットワーク層を自動生成したことによって、iOSプロジェクトもより確実に最新のAPIの最新の定義に追従できるようになりました。また、ネットワーク層を自動生成するようになったことで、よりアプリケーション層の実装に注力できるようになったと考えています。

さらにDTOをOpenAPIのスキーマから自動生成する過程で、より良いスキーマ定義について考える機会となったということも今回のプロジェクトの副産物でした。

現時点では全てのDTOとエンドポイントを利用できているわけではなく、一部APIのスキーマを改善しながら継続して既存の仕組みとの置き換えを進めてていく必要があります。 導入にあたっては私の所属していたGlobal Mobile Platform(モバイル基盤)チームで主導しましたが、現在ではProductチームのiOSエンジニアにもそれぞれの担当している機能に導入をお願いするなど、全iOSエンジニアで進めていけるように継続的にコミュニケーションを取っています。

海外版Cookpadを開発するブリストルオフィスのチームには機能の開発をするプロダクトチームと、今回のOpenAPI Specificationsのような基盤の改善をするプラットフォームチームに分かれています。 両チーム共にiOSエンジニアを募集しているので興味のあるかたはぜひご連絡ください。

今回私はHorizonという新卒向け研修プログラムでブリストルオフィスに出向しました。Horizonについての詳細は以下の記事をご覧いただければと思います。

英国派遣プログラム「Horizon」 責任者に聞く制度への思い(前編)

最後までご覧いただきありがとうございました。