[iOS] SwiftでProtocol Buffersを使ってみたい

2022.08.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。きんくまです。

今回はProtocol BuffersをSwiftでも使ってみたい!です。

Protocol Buffersとは?

公式サイト

よく知らなかったんですが、いろいろと調べてみるとProtocol Buffersはざっくりこんな感じ

  • Googleが使っているデータをやりとりするときのデータ構造を表す(XMLとかJSONとかみたいにデータ構造を表すことが可能)
  • gRPCとセットで使われることが多い
  • バイナリ形式で通信できるので、文字列形式のJSONと比べてデータ量が少なくて済む

gRPCとRESTの違いなどは検索してみると勉強になります

手順の概要

準備編
1. コマンド用のProtocol Buffersをインストール
2. Swift Protobufからprotoc-gen-swiftプラグインをインストール

使用編
1. terminal上でprotoコマンドで定義ファイルの.protoをコンパイル
2. 1で生成された.pb.swiftファイルをXcodeのプロジェクトに追加
3. XcodeにSwift Protobufをライブラリとして追加
4. 使用する

使ってみよう!準備編

1. コマンド用のProtocol Buffersをインストール

2022/08/17現在最新版はv21.5

ダウンロードサイト(GirHub)

私の環境はM1 Macなので、armのバイナリを落としてきました

  • protoc-21.5-osx-aarch_64.zip

もしIntel Macだったら、protoc-21.5-osx-x86_64.zip を落とせばよいと思います。
面倒だったら protoc-21.5-osx-universal_binary.zip がどちらのMacでも使えると思うのでそれにすれば良さそう。

パスを通すのですが、次のステップでまとめてやります。

2. Swift Protobufからprotoc-gen-swiftプラグインをインストール

apple / swift-protobuf(GitHub。Apple公式のリポジトリがあるんですね!)

上記リポジトリのREADMEを読むと以下のようにすれば良いみたい

git clone https://github.com/apple/swift-protobuf.git
cd swift-protobuf
swift build -c release

3行目でコンパイルしています。うまくいくと以下のディレクトリが作成されます。

.build/release

この中に protoc-gen-swift というバイナリができていればOK

パスを通します。下のサンプルはシェルにzshを使った場合なので、他の環境の場合は適宜読み替えてください

vi ~/.zshrc

どこかに以下を記述

# Protocol Buffers
export PATH="/パスを記述/protoc-21.5-osx-aarch_64/bin:$PATH"
export PATH="/パスを記述/.build/arm64-apple-macosx/release:$PATH"

上の行は 手順1のprotocが入っているbinディレクトリまでのパス です 。
下の行は 手順2のprotoc-gen-swiftが入っているreleaseディレクトリまでのパス です。

保存したら設定ファイルを読み込み直します

source ~/.zshrc

以下のコマンドでパスが通っているか確認

which protoc

もういっこ

which protoc-gen-swift

パスがうまく通っていれば、not foundにならずコマンドのバイナリが置いてある場所が表示されます

使ってみよう!使用編

1. terminal上でprotoコマンドで定義ファイルの.protoをコンパイル

定義ファイルを作ります。定義ファイルは.protoファイルになります。

BookInfo.proto

syntax = "proto3";

message BookInfo {
   int64 id = 1;
   string title = 2;
   string author = 3;
}

上記.protoファイルが置いてあるディレクトリにcdで移動してからコンパイルします。

protoc --swift_out=. BookInfo.proto

生成されたファイル。このファイル自体は自動生成されるのであんまり見る必要はないと思います。

BookInfo.pb.swift

// DO NOT EDIT.
// swift-format-ignore-file
//
// Generated by the Swift generator plugin for the protocol buffer compiler.
// Source: BookInfo.proto
//
// For information on using the generated types, please see the documentation:
//   https://github.com/apple/swift-protobuf/

import Foundation
import SwiftProtobuf

// If the compiler emits an error on this type, it is because this file
// was generated by a version of the `protoc` Swift plug-in that is
// incompatible with the version of SwiftProtobuf to which you are linking.
// Please ensure that you are building against the same version of the API
// that was used to generate this file.
fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
  struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
  typealias Version = _2
}

struct BookInfo {
  // SwiftProtobuf.Message conformance is added in an extension below. See the
  // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
  // methods supported on all messages.

  var id: Int64 = 0

  var title: String = String()

  var author: String = String()

  var unknownFields = SwiftProtobuf.UnknownStorage()

  init() {}
}

// MARK: - Code below here is support for the SwiftProtobuf runtime.

extension BookInfo: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
  static let protoMessageName: String = "BookInfo"
  static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
    1: .same(proto: "id"),
    2: .same(proto: "title"),
    3: .same(proto: "author"),
  ]

  mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
    while let fieldNumber = try decoder.nextFieldNumber() {
      switch fieldNumber {
      case 1: try decoder.decodeSingularInt64Field(value: &self.id)
      case 2: try decoder.decodeSingularStringField(value: &self.title)
      case 3: try decoder.decodeSingularStringField(value: &self.author)
      default: break
      }
    }
  }

  func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
    if self.id != 0 {
      try visitor.visitSingularInt64Field(value: self.id, fieldNumber: 1)
    }
    if !self.title.isEmpty {
      try visitor.visitSingularStringField(value: self.title, fieldNumber: 2)
    }
    if !self.author.isEmpty {
      try visitor.visitSingularStringField(value: self.author, fieldNumber: 3)
    }
    try unknownFields.traverse(visitor: &visitor)
  }

  static func ==(lhs: BookInfo, rhs: BookInfo) -> Bool {
    if lhs.id != rhs.id {return false}
    if lhs.title != rhs.title {return false}
    if lhs.author != rhs.author {return false}
    if lhs.unknownFields != rhs.unknownFields {return false}
    return true
  }
}

2. 1で生成された.pb.swiftファイルをXcodeのプロジェクトに追加

Xcodeのプロジェクトに BookInfo.pb.swift を追加します

3. XcodeにSwift Protobufをライブラリとして追加

Xcode 13.1の場合

File > Add Packages

検索欄に protobuf と入れると SwiftProtobuf が表示される

SwiftProtobufだけチェックを入れます。(ちなみに他にチェックを入れると他はコマンドライン用のものなのでコンパイルエラーになります。ハマったw)

4. 使用する

使ってみます

    func sample() {
        var info = BookInfo()
        info.id = 1734
        info.title = "何かすごいことが書いてある本 a book with something amazing"
        info.author = "田中太郎"
        
        if let binData = try? info.serializedData() {
            print("data \(binData)")
        }
        
        if let jsonStrData = try? info.jsonUTF8Data(),
           let jsonStr = String(data: jsonStrData, encoding: .utf8) {
            print("str \(jsonStr)")
            print("jsondata \(jsonStrData)")
        }
    }

出力です

data 91 bytes

str {"id":"1734","title":"何かすごいことが書いてある本 a book with something amazing","author":"田中太郎"}
jsondata 120 bytes

JSON文字列でも出力できるので、比べてみました。通常のバイナリにエンコードした場合は91バイトですが、JSONにエンコードした場合は120バイトでした。
たしかにJSONよりはデータサイズが小さくなっていました。

感想

Protocol Buffers自体はWikiによると14年も前からあるものでした

JSONと違って、型が定義できたりoptionalに対応しているのが良さそうでした。
一方フィールドの番号決めをしっかりやらないと、のちのちに大変になりそうだなという感じ。

ふだんはREST API + JSON形式 でサーバーとやりとりしているので、gRPC + Protocol Buffersというものがあることが知れて良かったです。