🎞️

SwiftProtobufPluginの使い方

に公開

概要

本記事では、SwiftでProtobufを扱う際に、CLIツールを使わずにSwift Package Managerのプラグイン機能を利用してコード生成を行う手順を紹介します。swift-protobufを利用すると、依存関係とプラグインの設定だけで簡単にProtobufファイルをSwiftコードに変換できるので、その方法を備忘録としてまとめました。

結論

Package.swiftdependenciespluginsセクションに、swift-protobufを追加するだけでProtobufコードの自動生成が可能になります。

以下のように依存を追加します.

Package.swift
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "YourPackage",
    platforms: [.macOS(.v15)],
    dependencies: [
+       .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.0.0")
    ],
    targets: [
        .executableTarget(
            name: "YourPackage",
            dependencies: [
+               .product(name: "SwiftProtobuf", package: "swift-protobuf"),
            ],
            plugins: [
+               .plugin(name: "SwiftProtobufPlugin", package: "swift-protobuf"),
            ]
        )
    ]
)

設定ファイル

生成ターゲットのディレクトリ直下(例:Sourceディレクトリ配下、あるいはSource/Appディレクトリ配下など)に、以下のような設定ファイルを用意します。ここで指定したprotoファイルが、自動的にSwiftコードへ変換されます。

Source/swift-protobuf-config.json
{
  "invocations": [
    {
      "protoFiles": [
        "journal.proto"
      ],
      "visibility": "public"
    }
  ]
}

設定ファイルの型

下記のような型定義に沿ったJSONを書けば、他のオプションに関しても設定できます。

interface SwiftProtobufConfig {
    /** protocバイナリへのパス */
    protocPath?: string
    /** 実施一覧 */
    invocations: {
        /** 生成元protoファイル一覧 */
        protoFiles: string[]
        /** 生成コードのアクセス修飾子レベル(デフォルトはinternal) */
        visibility: "internal" | "public" | "package"
        /** 生成ファイルの命名パターン(デフォルトはFullPath) */
        fileNaming?: "FullPath" | "PathToUnderscores" | "DropPath"
        /**
         * trueに設定すると、内部のみで使用するモジュールに対して@_implementationOnly importを付与する。
         * 外部に公開しない型の取り扱いを容易にし、バイナリサイズの肥大化を抑えられる場合がある。
         */
        implementationOnlyImports?: boolean
        /**
         * import文の前にアクセス修飾子(public/internalなど)を付与するかどうか
         */
        useAccessLevelOnImports?: boolean
    }[]
}

使用例

.protoファイル例

Source/journal.proto
syntax = "proto3";

package sample;

import "google/protobuf/timestamp.proto";

message AccountEvent {
  oneof content {
    AccountCreated created = 1;
    AccountRenamed renamed = 2;
    AccountDeleted deleted = 3;
  }
}
message AccountCreated {
  string name = 1;
  string aid = 2;
  uint32 seq_nr = 3;
  google.protobuf.Timestamp occurred_at = 4;
  bool is_created = 5;
}
message AccountRenamed {
  string name = 1;
  string aid = 2;
  uint32 seq_nr = 3;
  google.protobuf.Timestamp occurred_at = 4;
  bool is_created = 5;
}
message AccountDeleted {
  string aid = 2;
  uint32 seq_nr = 3;
  google.protobuf.Timestamp occurred_at = 4;
  bool is_created = 5;
}

Swiftコード例

自動生成されるProtobuf用の型(Sample_AccountEventなど)を活用すると、次のようにエンコード・デコードを行うことができます。

Source/main.swift
import Foundation
import SwiftProtobuf

enum AccountEvent: Sendable, Hashable, Codable {
    case created(Created)
    case renamed(Renamed)
    case deleted(Deleted)
    
    struct Created: Sendable, Hashable, Codable {
        var aid: String
        var name: String
        var seqNr: Int
        var occurredAt: Date
        var isCreated: Bool
    }
    struct Renamed: Sendable, Hashable, Codable {
        var aid: String
        var name: String
        var seqNr: Int
        var occurredAt: Date
        var isCreated: Bool
    }
    struct Deleted: Sendable, Hashable, Codable {
        var aid: String
        var seqNr: Int
        var occurredAt: Date
        var isCreated: Bool
    }
    
    func protobufData() throws -> Data {
        var result = Sample_AccountEvent()
        switch self {
        case .created(let event):
            var created = Sample_AccountCreated()
            created.aid = event.aid
            created.name = event.name
            created.seqNr = UInt32(event.seqNr)
            created.occurredAt = .init(date: event.occurredAt)
            created.isCreated = event.isCreated
            result.created = created
            
        case .renamed(let event):
            var renamed = Sample_AccountRenamed()
            renamed.aid = event.aid
            renamed.name = event.name
            renamed.seqNr = UInt32(event.seqNr)
            renamed.occurredAt = .init(date: event.occurredAt)
            renamed.isCreated = event.isCreated
            result.renamed = renamed
            
        case .deleted(let event):
            var deleted = Sample_AccountDeleted()
            deleted.aid = event.aid
            deleted.seqNr = UInt32(event.seqNr)
            deleted.occurredAt = .init(date: event.occurredAt)
            deleted.isCreated = event.isCreated
            result.deleted = deleted
        }
        return try result.serializedData()
    }
    
    init(protobuf data: Data) throws {
        let protobuf = try Sample_AccountEvent(serializedBytes: data)
        guard let content = protobuf.content else {
            throw ProtobufDecodingError.valueNotFound(protobuf)
        }
        switch content {
        case .created(let created):
            self = .created(.init(
                aid: created.aid,
                name: created.name,
                seqNr: Int(created.seqNr),
                occurredAt: created.occurredAt.date,
                isCreated: created.isCreated
            ))
        case .renamed(let renamed):
            self = .renamed(.init(
                aid: renamed.aid,
                name: renamed.name,
                seqNr: Int(renamed.seqNr),
                occurredAt: renamed.occurredAt.date,
                isCreated: renamed.isCreated
            ))
        case .deleted(let deleted):
            self = .deleted(.init(
                aid: deleted.aid,
                seqNr: Int(deleted.seqNr),
                occurredAt: deleted.occurredAt.date,
                isCreated: deleted.isCreated
            ))
        }
    }
}

enum ProtobufDecodingError: Error {
    case valueNotFound(any Sendable)
}

// 実際にエンコード・デコードしてみる
let event: AccountEvent = .created(
    .init(
        aid: "hello",
        name: "Hello",
        seqNr: 1,
        occurredAt: .init(),
        isCreated: true
    )
)
print(event)
let data = try event.protobufData()
print("💚 Proto Buffers")
print(data)
print(data.base64EncodedString())
print("💚 Proto Buffers")
print(try AccountEvent(protobuf: data))

まとめ

  • swift-protobufPackage.swiftdependenciespluginsに追加し、swift-protobuf-config.jsonを用意するだけで、Protobufファイルのコンパイルを自動化できます。
  • わざわざCLIを叩かなくても、Swift Package Managerでビルドするたびにコード生成が実行されるので、手軽にProtobufを導入できます。

以上が、Swift Package Pluginを利用したProtobufコード生成方法の概要です。設定ファイルで細かいオプションを管理できるので、プロジェクト規模や運用方針に合わせて活用してみてください。

Discussion