🤳

Protocol Buffers + Connect RPCで実現する型安全なiOSアプリ開発

に公開

はじめに

こんにちは。PIVOTでソフトウェアエンジニアとして、主にiOSアプリ開発を担当している@indiamela です。

動画配信iOSアプリの開発において、API通信基盤をREST APIからConnect RPCへ刷新しました。この記事では、Connect RPC導入の背景、実装パターン、そして得られた効果について詳しく解説します。

実際のプロダクション環境での導入事例として、具体的な実装コードやアーキテクチャ設計を紹介します。

本記事ではiOSアプリ開発に焦点を当てていますが、他のメンバーもConnect RPC導入に関連した記事を出していますので、合わせて読んでいただけると幸いです。

https://zenn.dev/pivotmedia/articles/pivot-connect-rpc-adoption

https://zenn.dev/pivotmedia/articles/56621af3970faa

既存のREST API実装の課題

これまでProtocol Buffersを使ったREST API通信を行っていましたが、いくつかの課題がありました:

  • 型安全性の不足: API呼び出し時の型チェックが弱く、実行時エラーのリスクがある
  • エラーハンドリングの一貫性: REST APIごとに異なるエラー処理パターン
  • コード生成の手動管理: .protoファイルの変更時に手動でコード生成が必要

これらの課題を解決し、より保守性の高いAPI通信基盤を構築するため、Connect RPCの導入を決定しました。

導入の成果

  • 完全自動化されたコード生成:.protoファイルから型安全なSwiftクライアントを自動生成
  • 開発効率の大幅向上:API実装とテストコード作成の時間を大幅に短縮
  • 型安全性の実現:コンパイル時の型チェックにより実行時エラーを削減
  • 統一的なエラーハンドリング:既存のエラー体系と統合し、段階的移行が可能
  • テストの容易化:モッククライアントが自動生成され、テスト作成が簡単に

Connect RPCとは

Connect RPCは、Connect Protocolをベースにした次世代のRPC(Remote Procedure Call)フレームワークです。gRPCの利点を継承しつつ、より柔軟性の高いプロトコルとして設計されています。

gRPC、REST、Connect RPCの違い

特徴 REST API gRPC Connect RPC
プロトコル HTTP/1.1 HTTP/2 HTTP/1.1, HTTP/2
シリアライゼーション JSON Protobuf JSON, Protobuf
ブラウザ互換性 △(gRPC-Web必要)
型安全性
ストリーミング

Connect Protocolの特徴

Connect Protocolは以下の特徴を持ちます:

  1. HTTP互換性: 既存のHTTPインフラをそのまま利用可能
  2. 柔軟なシリアライゼーション: JSONとProtobufの両方をサポート
  3. 段階的移行: REST APIからの移行が容易
  4. ブラウザネイティブ: 特別なプロキシなしでブラウザから直接呼び出し可能

iOS開発での具体的なメリット

  • async/await対応: Swiftの並行処理と自然に統合
  • 型安全性: コンパイル時の型チェックによるバグの早期発見
  • 自動コード生成: .protoファイルからクライアントコードとモックを自動生成
  • テスタビリティ: 自動生成されるモッククライアントでテストが容易

アーキテクチャ設計

Connect RPCを導入したPivotアプリのAPI通信アーキテクチャは以下のような構成になっています。

全体構成

ConnectClientProvider実装

ConnectClientProviderは、アプリ全体でConnect RPCクライアントを管理するシングルトンクラスです。

ConnectClientProvider.swift
@MainActor
final class ConnectClientProvider {
    static let shared = ConnectClientProvider()
    private let protocolClient: ProtocolClientInterface

    private init() {
        self.protocolClient = ProtocolClient(
            httpClient: URLSessionHTTPClient(urlSession: .shared),
            config: ProtocolClientConfig(
                host: Configuration.baseURL,
                networkProtocol: .connect,
                codec: ProtoCodec(),
                interceptors: [CommonHeadersInterceptor()]
            )
        )
    }

    // 型安全なクライアント取得
    func client<T>(for service: ServiceType) throws -> T {
        let client: Any
        switch service {
        case .content:
            client = ContentServiceClient(client: protocolClient)
        case .comment:
            client = CommentServiceClient(client: protocolClient)
        }

        guard let typedClient = client as? T else {
            throw ConnectClientError.invalidClientType
        }
        return typedClient
    }
}

設計のポイント:

  • シングルトンパターン: アプリ全体で単一のプロバイダーインスタンスを使用
  • 型安全なクライアント生成: ジェネリクスとswitchによるコンパイル時型チェック
  • 環境対応: Staging/Production環境の自動切り替え
  • Interceptor統合: 共通ヘッダーの自動付与

Interceptor実装

すべてのAPIリクエストに共通ヘッダーを自動付与するInterceptorを実装しています。

CommonHeadersInterceptor.swift
final class CommonHeadersInterceptor: UnaryInterceptor {
    @Sendable
    func handleUnaryRequest<Message: ProtobufMessage>(
        _ request: HTTPRequest<Message>,
        proceed: @escaping @Sendable (Result<HTTPRequest<Message>, ConnectError>) -> Void
    ) {
        Task { @MainActor in
            var headers = request.headers
            headers["Authorization"] = ["Bearer \(AuthManager.shared.accessToken)"]
            headers["X-App-Version"] = [AppInfo.version]

            var modifiedRequest = request
            modifiedRequest.headers = headers
            proceed(.success(modifiedRequest))
        }
    }
}

エラーハンドリング戦略

Connect RPCのConnectErrorを既存のアプリで使用しているAPIErrorに変換することで、エラーハンドリング層の変更を最小限に抑えています。

APIError+ConnectError.swift
extension APIError {
    public static func from(_ connectError: Connect.ConnectError) -> APIError {
        let message = connectError.message

        switch connectError.code {
        // ネットワークエラー
        case .unavailable, .deadlineExceeded:
            return .networkConnectionError

        // 認証エラー
        case .unauthenticated:
            return .unRegister(message)

        // 認可エラー
        case .permissionDenied:
            return .badRequest(message)

        // リソース未検出
        case .notFound:
            return .notFound(message)

        // レート制限
        case .resourceExhausted:
            return .tooManyRequests(message)

        // サーバーエラー
        case .internalError:
            return .internalError(message)

        // その他のエラー
        default:
            return .unknownError(message)
        }
    }
}

// ResponseMessageの拡張
extension ResponseMessage where Output: SwiftProtobuf.Message {
    func getMessage() throws -> Output {
        switch result {
        case .success(let message):
            return message
        case .failure(let error):
            throw APIError.from(error)
        }
    }
}

設計思想:

  • 既存エラー体系の維持: REST APIとConnect RPCで同じエラー型を使用
  • 段階的移行対応: エラーハンドリング層を変更せずに済む
  • HTTP標準準拠: ConnectErrorのgRPCコードをHTTP相当にマッピング

自動コード生成ワークフロー

Connect RPC導入の大きなメリットの一つが、.protoファイルから必要なコードを自動生成できることです。

プロジェクト構造

ios-app/
├── proto-definitions/              # Submodule(Protocol Buffers定義)
│   └── connect/
│       ├── content/
│       │   └── content_service.proto
│       ├── comment/
│       │   └── comment_service.proto
│       └── test/
│           └── test_service.proto
├── buf.gen.ios.proto.yaml         # Protobuf生成設定
├── buf.gen.ios.connect.yaml       # Connect RPC生成設定
├── generate_for_ios.sh            # 生成スクリプト
└── LocalPackage/
    └── Sources/Proto/
        ├── Proto/                  # 既存のProtobufコード(REST API用)
        └── Connect/                # 今回新規追加(Connect RPC用)

Buf CLI設定

Buf CLIを使用して、.protoファイルからSwiftコードを生成します。

buf.gen.ios.connect.yaml
version: v2
managed:
  enabled: true
plugins:
  # Protobuf messages generation
  - remote: buf.build/apple/swift:v1.30.0
    out: PivotLocalPackage/Sources/PivotProto/Connect
    opt:
      - Visibility=Public

  # Connect RPC client generation
  - remote: buf.build/connectrpc/swift
    out: PivotLocalPackage/Sources/PivotProto/Connect
    opt:
      - GenerateAsyncMethods=true
      - Visibility=Public

  # Mock client generation for testing
  - remote: buf.build/connectrpc/swift-mocks
    out: PivotLocalPackage/Sources/PivotProto/Connect
    opt:
      - GenerateAsyncMethods=true
      - Visibility=Public

設定のポイント:

  • 3つのプラグイン: Protobuf、ConnectRPC Client、Mock Clientを一括生成
  • async/await対応: GenerateAsyncMethods=trueで非同期メソッドを生成
  • Public visibility: モジュール外からアクセス可能

コード生成プロセス

生成スクリプト:

generate_for_ios.sh
#!/bin/bash
set -euo pipefail

PROTO_OUT_DIR="PivotLocalPackage/Sources/PivotProto/Proto"
CONNECT_OUT_DIR="PivotLocalPackage/Sources/PivotProto/Connect"

echo "🔄 Generating Protobuf messages..."
buf generate proto-definitions/Proto \
    --template buf.gen.ios.proto.yaml \
    --include-imports \
    --output ./

echo "🔄 Generating Connect RPC clients..."
buf generate proto-definitions/connect \
    --template buf.gen.ios.connect.yaml \
    --include-imports \
    --output ./

echo "✅ Code generation completed!"

生成されるコード

.protoファイルから以下の3種類のSwiftファイルが自動生成されます:

1. Protobufメッセージ(.pb.swift)

content_service.pb.swift
// リクエストメッセージ
public struct Pivot_Content_ListFeaturedTagsRequest: Sendable {
    public init() {}
}

// レスポンスメッセージ
public struct Pivot_Content_ListFeaturedTagsResponse: Sendable {
    public var tags: [Pivot_Content_Tag] = []
    public init() {}
}

public struct Pivot_Content_Tag: Sendable {
    public var id: String = ""
    public var name: String = ""
    public var imageURL: String = ""
    public init() {}
}

2. ConnectRPCクライアント(.connect.swift)

content_service.connect.swift
// クライアントプロトコル
public protocol Pivot_Content_ContentServiceClientInterface: Sendable {
    @available(iOS 13, *)
    func listFeaturedTags(
        request: Pivot_Content_ListFeaturedTagsRequest,
        headers: Connect.Headers
    ) async -> ResponseMessage<Pivot_Content_ListFeaturedTagsResponse>
}

// クライアント実装
public final class Pivot_Content_ContentServiceClient:
    Pivot_Content_ContentServiceClientInterface, Sendable {

    private let client: Connect.ProtocolClientInterface

    public init(client: Connect.ProtocolClientInterface) {
        self.client = client
    }

    public func listFeaturedTags(
        request: Pivot_Content_ListFeaturedTagsRequest,
        headers: Connect.Headers = [:]
    ) async -> ResponseMessage<Pivot_Content_ListFeaturedTagsResponse> {
        return await self.client.unary(
            path: "/pivot.content.ContentService/ListFeaturedTags",
            idempotencyLevel: .unknown,
            request: request,
            headers: headers
        )
    }
}

3. モッククライアント(.mock.swift)

content_service.mock.swift
open class Pivot_Content_ContentServiceClientMock:
    Pivot_Content_ContentServiceClientInterface, @unchecked Sendable {

    public init() {}

    public var mockAsyncListFeaturedTags = {
        (_: Pivot_Content_ListFeaturedTagsRequest) ->
        ResponseMessage<Pivot_Content_ListFeaturedTagsResponse> in
        .init(result: .success(.init()))
    }

    open func listFeaturedTags(
        request: Pivot_Content_ListFeaturedTagsRequest,
        headers: Connect.Headers = [:]
    ) async -> ResponseMessage<Pivot_Content_ListFeaturedTagsResponse> {
        return self.mockAsyncListFeaturedTags(request)
    }
}

自動生成の威力

.protoファイルから以下のコードが自動生成されます:

生成されるファイルの種類:

  • .connect.swift: RPCクライアント実装(サービスごと)
  • .mock.swift: テスト用モッククライアント(サービスごと)
  • .pb.swift: Protobufメッセージ定義(リクエスト/レスポンス/モデル)

自動生成の利点:

  • ✅ 手動実装が不要:.protoファイル変更時もmake proto_generate一発で更新
  • ✅ 型安全性保証:コンパイル時に型の整合性をチェック
  • ✅ テストが簡単:モッククライアントで即座にテスト作成可能
  • ✅ ドキュメント不要:.protoファイルがそのままAPI仕様書として機能

Makefileコマンド

開発ワークフローを効率化するため、Makefileコマンドを用意しています:

Makefile
# Buf CLIインストール
install-buf:
	@if ! command -v buf >/dev/null 2>&1; then \
		echo "📦 Installing buf CLI..."; \
		brew install bufbuild/buf/buf; \
	fi

# Protocol Buffers生成
proto_generate: install-buf
	@echo "🔄 Generating Protocol Buffers..."
	@git submodule init
	@git submodule update --remote
	@./generate_for_ios.sh

# 初回セットアップ(他のセットアップワークフローと一緒にコールされる)
install: install-buf proto_generate
	@echo "✅ Setup completed!"

使用方法:

# 初回セットアップ時
make install

# .protoファイル更新後の再生成
make proto_generate

Repository実装パターン

Connect RPC導入前後でのRepository実装の変化を見ていきます。

Before: REST API実装

SearchRepository.swift(Before)
final class SearchRepository {
    func fetchTags() async throws -> [Tag] {
        guard let response: ListTagsResponse =
            try await APIProvider.shared.api(.listTags)
        else {
            throw APIError.parseError
        }
        return response.tags.map { Tag(proto: $0) }
    }
}

課題: 型安全性が弱い、エラーハンドリングが冗長

After: Connect RPC実装

SearchRepository.swift(After)
final class SearchRepository {
    func fetchTags() async throws -> [Tag] {
        let request = ListTagsRequest()
        let client: ContentServiceClient =
            try await ConnectClientProvider.shared.client(for: .content)

        let response = try await client
            .listTags(request: request, headers: [:])
            .getMessage()

        return response.tags.map { Tag(proto: $0) }
    }
}

改善点:

型安全性: コンパイル時の型チェック
明示的なリクエスト: Protobufメッセージを明示的に構築
統一的なエラーハンドリング: .getMessage()で一貫した処理
可読性: メソッド名がAPI仕様と一致

Before/After比較表

項目 Before(REST API) After(Connect RPC)
型安全性 Optional返却、弱い型チェック 強い型チェック、Non-Optional
エラーハンドリング APIごとに異なるパターン 統一的な.getMessage()
可読性 エンドポイント文字列 メソッド名でAPI仕様を表現
保守性 手動でクライアント管理 自動生成、宣言的な実装
テスタビリティ 手動でモック作成 自動生成されたモック使用

実装例: 複数パラメータのAPI呼び出し

func searchEpisodes(keyword: String, limit: Int) async throws -> [Episode] {
    var request = SearchEpisodesRequest()
    request.keyword = keyword
    request.limit = Int32(limit)

    let client: ContentServiceClient =
        try await ConnectClientProvider.shared.client(for: .content)

    let response = try await client
        .searchEpisodes(request: request, headers: [:])
        .getMessage()

    return response.episodes.map { Episode(proto: $0) }
}

ポイント: リクエストメッセージで型安全にパラメータを設定

テスト戦略

Connect RPC導入により、テストの作成が大幅に容易になりました。

自動生成Mockの活用

Connect RPCでは、サービスクライアントと同時にモッククライアントも自動生成されます。これにより、手動でモックを作成する必要がなくなります。

SearchRepositoryTests.swift
@Test
func`fetchTags()が正常にタグを取得できること`() async throws {
    // Given: モックレスポンスの準備
    var tag = Tag()
    tag.id = "tag-1"
    tag.name = "テクノロジー"

    var response = ListTagsResponse()
    response.tags = [tag]

    let mockClient = ContentServiceClientMock()
    mockClient.mockAsyncListTags = { _ in
        ResponseMessage(result: .success(response))
    }

    // When: リポジトリメソッドを呼び出し
    let repository = SearchRepository(client: mockClient)
    let tags = try await repository.fetchTags()

    // Then: 期待通りのタグが取得できる
    #expect(tags.count == 1)
    #expect(tags[0].name == "テクノロジー")
}

クライアント生成のテスト

ConnectClientProviderTests.swift
@Test
func`client()が正しい型のクライアントを返すこと`() async throws {
    let provider = ConnectClientProvider.shared

    let contentClient: ContentServiceClient =
        try await provider.client(for: .content)
    #expect(contentClient is ContentServiceClient)
}

テストカバレッジ

Connect RPC導入により、以下の項目を網羅的にテスト可能になりました:

  • ✅ クライアント生成ロジック
  • ✅ 共通ヘッダー付与
  • ✅ エラーハンドリング(ConnectError → APIError変換)
  • ✅ レスポンスメッセージの取得
  • ✅ 各Repositoryのビジネスロジック

段階的移行戦略

Connect RPC導入にあたり、一度にすべてのAPIを置き換えるのではなく、段階的に移行する戦略を採用しました。

共存パターン

同一Repository内でREST APIとConnect RPCを併用することで、リスクを軽減しています。

SearchRepository.swift(ハイブリッド実装)
final class SearchRepository {
    // Connect RPC使用(新規実装)
    func searchEpisodes(keyword: String) async throws -> [Episode] {
        let request = SearchEpisodesRequest()
        request.keyword = keyword
        // Connect RPCで実装
    }

    // REST API使用(既存実装を維持)
    func loadWeeklyRanking(count: Int) async throws -> [Episode] {
        guard let response = try await APIProvider.shared
            .api(.ranking(type: .weekly, count: count))
        else {
            throw APIError.parseError
        }
        return response.rankings
    }
}

リスク軽減のアプローチ

段階的移行により、以下のリスクを軽減できます:

  1. 技術的リスク

    • 新しいAPI実装でバグが発生した場合、既存REST APIにロールバック可能
    • 機能単位での切り替えにより、影響範囲を限定
  2. 運用リスク

    • ユーザー影響を最小限に抑えながら移行
    • A/Bテストによる段階的な検証
  3. 学習コスト

    • チームメンバーが徐々にConnect RPCに慣れる時間を確保
    • 実装パターンの確立とドキュメント整備

導入効果と学び

Connect RPC導入により、多くの定量的・定性的な効果が得られました。

定量的・定性的効果

項目 改善内容
開発速度 新規API追加時の実装時間が大幅に短縮
コード品質 型安全性により実行時エラーリスクが削減
保守性 自動生成により手動メンテナンスが不要
テスト効率 モック自動生成でテスト作成が容易に
学習コスト .protoファイルを見れば仕様が理解できる

定性的効果

1. コード品質の向上

  • コンパイル時型チェック: Optional unwrapが不要になり、実行時エラーのリスクが低減
  • 統一的なエラーハンドリング: エラー処理パターンが統一され、保守性が向上
  • 可読性向上: メソッド名がAPI仕様と完全一致し、コードの意図が明確

2. 開発効率の改善

  • 自動生成ワークフロー: .protoファイル変更時にmake proto_generate一発で更新完了
  • テスト作成の簡略化: 自動生成されたモックで即座にテスト作成可能
  • ドキュメント不要: .protoファイルがそのままAPI仕様書として機能

3. チーム開発の円滑化

  • 明確なインターフェース: Protocol Buffersによる厳密な型定義
  • バックエンドとの連携: .protoファイルを共有することで、フロントエンド・バックエンド間の齟齬が減少
  • レビューの効率化: 自動生成コードはレビュー対象外、ビジネスロジックに集中

課題と対処

導入にあたり、課題もありました。

生成したコードをGit管理すべきか、CI/CDで生成するか

現在の方針: 生成コードをGit管理

理由:

  • ✅ チーム全体で確実に同じコードを共有できる
  • ✅ ビルド時間を短縮(毎回生成する必要がない)
  • ✅ .protoファイル変更時の影響が明確(差分レビューで確認可能)

GitHub ActionsのCI/CDでもmake proto_generateを実行し、生成コードの整合性を確認しています。

今後の検討事項:

  • ビルド時自動生成への移行も検討

2. デバッグの難しさ

課題: 自動生成コードが間に入るため、デバッグが複雑化

対処:

  • ログ出力の強化(Interceptorでリクエスト/レスポンスをログ)
  • エラーメッセージの充実化
  • テストによる事前検証の徹底

得られた知見

  1. 段階的移行の重要性: 一度に全てを置き換えるのではなく、段階的に移行することでリスクを軽減
  2. 自動生成の威力: コード生成により、開発効率が大幅に向上
  3. 型安全性の価値: コンパイル時型チェックにより、バグの早期発見が可能
  4. テストの容易さ: 自動生成されたモックにより、テスト作成が劇的に簡単に

まとめ

プロダクション環境でのConnect RPC導入は、API通信基盤を大きく改善する成功事例となりました。

Connect RPC導入の成功要因

  1. 段階的移行戦略: REST APIとの共存により、リスクを最小化
  2. 自動化されたワークフロー: Buf CLIによる効率的なコード生成
  3. 既存エラー体系との統合: エラーハンドリング層の変更を最小限に抑制
  4. 充実したテスト基盤: 自動生成されたモックによる高品質なテスト

Connect RPCは、型安全で保守性の高いiOSアプリ開発を実現する強力なツールです。Protocol Buffersの厳密な型定義と自動コード生成により、開発効率とコード品質の両面で大きなメリットがあります。

今後のiOSアプリ開発において、Connect RPCは有力な選択肢の一つとなるでしょう。


参考リンク


この記事が、iOS開発におけるAPI通信基盤の設計や、Connect RPC導入を検討している方の参考になれば幸いです。

質問や感想があれば、コメント欄でお気軽にお寄せください!

PIVOT Tech Blog

Discussion