🍧

ConnectのAPIをiOSアプリから利用する

に公開

株式会社バニッシュ・スタンダードのヒダです。弊社で提供している「STAFF START」というtoBのサービスのiOSアプリ開発を担当しています。

今回はこれまでの記事にも何度か登場したConnectについて、iOSの実装でどのように使っているかと、ハマったところを紹介します。

Connectとは?

これについては弊社の過去記事でも触れているので説明は省きますが、弊社のiOSアプリが叩いているバックエンドAPIについて、一部はこのConnectを利用して実装されています。

弊社の過去のConnectに関する記事はこちら:
https://zenn.dev/vs_blog/articles/b7cb50f89b39a2
https://zenn.dev/vs_blog/articles/d2cf991cb10117
https://zenn.dev/vs_blog/articles/c73f2957b328ad

そもそもなぜ使っているのか

先に弊社のアプリでConnectを使っている理由やメリットについて触れておきます。
理由はずばり、1ソースでバックエンドとアプリの両方のインターフェースと実装(※)が生成できるから です。(多分。私はメインの選定者じゃないので😅)

※ バックエンドの場合はサービス実装のベース、アプリの場合はクライアント実装のベース

似たようなところでいうとOpenAPIという仕様があり、こちらも何らかのライブラリやツールを利用すれば1ソースでGoやSwiftの実装を生成できますが、そのソースを何で書くか(OpenAPI or Protocol Buffers)の好みと、あとはバックエンドのGoとの相性、みたいなところでConnectが選ばれたと思っています。

どうやって通信しているのか

Connectのライブラリを利用して通信する時は、gRPC、gRPC-Web、そしてConnect独自のプロトコルのいずれかを利用できますが、弊社で使っているのはConnect独自のプロトコルです。

3つの違いやメリット・デメリットは他の記事にまかせますが、既存のgRPCのサーバと通信したい、みたいな理由がない限りはConnectのプロトコルを使っていれば問題ないと思います。

1点だけ、独自のプロトコルでは(設定次第では)普通にJSONでやりとりするのですが、このJSONを自分でパースする、みたいな使い方はやめた方がいいです。数値なのに文字列でやりとりされていたり、色々罠があるのでConnectを使うメリットが薄れます。

どうやって開発していくのか(概要)

「1ソースでバックエンドとアプリの実装を生成する」がやりたいことなので、まず「1ソース」を用意します。

これはProtocol Buffersを使って書いていきます。APIのエンドポイントとリクエスト/レスポンスの型を .proto という拡張子のファイルに書きます。

このprotoファイルを弊社では共有ファイル専用のGitレポジトリで管理していて、バックエンドやiOSアプリのGitレポジトリからサブモジュールとして取り込むようにしています。

バックエンド、アプリの両方でprotoを取り込み、それぞれでコード生成をすれば開発を開始できます。この時点でバックエンド側で実際のAPIの処理をまだ実装していなくても、インターフェースさえあればアプリ側で実装を開始できます。モックを生成することもできますし、モックを使わずともリクエストとレスポンスの型は分かっているので、実際の通信部分だけコメントアウトしてダミーの値を返すようにしておけばなんとかなります。

アプリ側はどうやって開発しているのか(具体)

サブモジュールでprotoファイルを取り込んでいるのは説明した通りですが、そこからのコード生成について

  1. どこに置いているのか
  2. どうやって生成しているのか

を説明していきます。

1. どこに置いているのか

コンパイルできる場所ならどこでもいいのですが、弊社のプロジェクトではローカルのSwiftパッケージを用意してその中に含めるようにしています。そのパッケージのPackage.swiftはこんな感じです。このパッケージでだけConnectに依存するようにして、他のパッケージからはConnectに依存しないようにしています。

Pacakge.swift
let package = Package(
    // 略
    dependencies: [
        .package(url: "https://github.com/connectrpc/connect-swift.git", from: "1.0.0"),
        // 略
    ],
    targets: [
        .target(
            dependencies: [
                .product(name: "Connect", package: "connect-swift"),
                .product(name: "ConnectMocks", package: "connect-swift"),
                // 略
            ],
            // 略
        ),
        // 略
    ]
)

2. どうやって生成しているのか

生成には buf というツールを使います。インストール方法はここです。

このツールで buf generate すればSwiftのファイルが生成されますが、その前に2つ設定ファイルを用意します。場所はレポジトリのルートです:

  • buf.yaml
  • buf.gen.yaml

buf.yaml の内容はこうなっています。hogehoge/proto はあくまでも例で、hogehogeというGitレポジトリをサブモジュールとして追加していて、そのレポジトリの /proto にprotoファイルを置いているならこうします。

buf.yaml
version: v2
modules:
  - path: hogehoge/proto  # protoファイルの場所
deps:
  - buf.build/bufbuild/protovalidate
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE

buf.gen.yaml の方はこんな感じです。

buf.gen.yaml
version: v2
plugins:
  - remote: buf.build/connectrpc/swift
    out: ...(ローカルのSwiftパッケージ内のパス).../Generated  # (1)
    opt:
      - GenerateAsyncMethods=true
      - FileNaming=PathToUnderscores  # (2)
  - remote: buf.build/apple/swift
    out: ...(ローカルのSwiftパッケージ内のパス).../Generated  # (1)
    opt:
      - FileNaming=PathToUnderscores
  - remote: buf.build/connectrpc/swift-mocks
    out: ...(ローカルのSwiftパッケージ内のパス).../GeneratedMocks  # (1)
    opt:
      - GenerateAsyncMethods=true
      - FileNaming=PathToUnderscores  # (2)
inputs:
  - directory: hogehoge
    paths:
       - hogehoge/proto/aaa/bbb  # (3)

(1) でSwiftファイルの生成先を指定していて、これでGeneratedとGeneratedMocksの2つのディレクトリに出力されます。

(2) のオプションを追加することで全てのファイルがフラットに1つのディレクトリ下に生成されます。デフォルトだとディレクトリが切られるのですが、ファイル名が重複してコンパイルが通らなくなることがあったためこのオプションを使っています。

(3) のように設定することで、サブモジュールであるhogehogeレポジトリの /proto/aaa/bbb 以下のprotoファイルからだけ生成するようにできます。

ちなみに opt で Visibility=Public を指定すると生成された実装が全てpublicとして公開されますが、明示的に指定しないとinternalになり、パッケージ外からは生成された実装が見えなくなります。Connectの実装にパッケージ外から依存されないようにするために、デフォルトの設定のままにしています。

最後に生成時の注意点として、何らかのワークフローに組込むなら生成の前に既存のファイルを全部毎回消してから生成しなおすようにしておくと、開発中に変更されたりなどで不要になったファイルが消せていいと思います。

生成されたものをどう使うのか

基本的にはドキュメントに書いてある通りですが、protoからは2種類のSwiftファイルが生成されます:

  • xxx.connect
  • xxx.pb

xxx.connect の方にはサービスクライアントの定義が含まれていて、xxx.pb にはリクエストやレスポンスの型が含まれています。

使い方としてはこうなります:

  1. サービスクライアントを生成する
  2. 呼び出すメソッドに応じたリクエストデータを生成する
  3. サービスクライアントのメソッドに2で生成したリクエストデータを渡して呼び出す
  4. 返ってきたレスポンスの値またはエラーを処理する

1のサービスクライアントは xxx.connect に定義がありますが、インターフェースと実装の両方が用意されているので、サービスクライアントに依存する場所ではインターフェースの型を使っておくと実装をモックなどに差し替えられます。この辺はモックのドキュメントが参考になります。

そしてサービスクライアントの実装のinitには、ProtocolClientのインスタンスが必要になります。この辺はドキュメントに書いてある通りでいいです。

サービスクライアントを作ったら、あとはメソッドを呼び出すだけです。

(おまけ) 使ってみて分かったこと

ここまででざっくりとした使い方は網羅できたと思うので、使ってみて分かったことを2つ挙げて終わります。

リクエスト/レスポンスデータの作成には .with が便利

というかドキュメントに書いてあるのでそりゃそうなのですが、.with を使わない場合はこういう書き方になります:

var request = Eliza_V1_SayRequest()
request.sentence = "hello world"

生成してから値をセットしていく、というやり方ですが、.with を使うとrequestを不変にできます。ちなみにこれはSwiftProtoBufが用意しているメソッドです。これくらいだと行数でいうと増えているしメリットが分かりにくいですが、モックのレスポンスを作る際は入れ子のデータを作らないといけなかったりするのでこのメソッドが便利です。

let request = Eliza_V1_SayRequest.with {
    $0.sentence = "hello world"
}

optionalのフィールドは hasXXX で値があるか確認する

xxx.pb の中を見れば分かりますが、レスポンスの値について、それぞれのフィールドが必須のものでも任意のものでも、初期化時に何らかの値が入るようになっています。なのでoptionalの値についてはまず has[プロパティ名] のメソッドでそもそも値が存在するか確認した上でその値を使う必要があります。

例えばoptionalのStringのフィールドで "" が入っていたとして、hasXXXがtrueならそのまま空文字列として扱えばいいですが、hasXXXがfalseならnilとして扱う方が適切です。

おわりに

今回の記事では弊社のiOSアプリでConnectをどう使っているかの概要と、connect-swiftの公式ドキュメントの補足的な内容について書きました。

自分はgRPCやprotobufについてほとんど触ったことがない状態でConnectを使い出したので、今回の内容もかなり初歩的かもしれないですが、同じような人にそういったものをあまり意識しなくても使えますよ、ということが分かってもらえればいいなと思います。

株式会社バニッシュ・スタンダード

Discussion