⛄️

grpc-swiftの使い方ノート

2022/06/22に公開

https://github.com/grpc/grpc-swift

基本的にはアプリにgRPCを組み込むことを考えるとクライアントコードがメインになる。

protoファイルからswiftコードの生成

  • %.pb.swift - messageの型定義ファイル
  • %.grpc.swift - 関数などのインターフェイスの定義ファイル

プラグインはhomebrewでインストールできる

  • protoc-gen-swift
  • protoc-gen-grpc-swift
    $ brew install swift-protobuf grpc-swift
 PROTOC_GEN_SWIFT=/opt/homebrew/bin/protoc-gen-swift
 PROTOC_GEN_GRPC_SWIFT=/opt/homebrew/bin/protoc-gen-grpc-swift
 %.pb.swift: %.proto
 	protoc $< \
 		--proto_path=$(dir $<) \
 		--plugin=${PROTOC_GEN_SWIFT} \
 		--swift_opt=Visibility=Public \
 		--swift_out=$(dir $<)

 %.grpc.swift: %.proto
 	protoc $< \
 		--proto_path=$(dir $<) \
 		--plugin=${PROTOC_GEN_GRPC_SWIFT} \
 		--grpc-swift_opt=Visibility=Public \
 		--grpc-swift_out=$(dir $<)

適当に切り出したmakefile

# protoc plugins.
PROTOC_GEN_SWIFT=/opt/homebrew/bin/protoc-gen-swift
PROTOC_GEN_GRPC_SWIFT=/opt/homebrew/bin/protoc-gen-grpc-swift

%.pb.swift: %.proto
protoc $< \
  --proto_path=$(dir $<) \
  --plugin=${PROTOC_GEN_SWIFT} \
  --swift_opt=Visibility=Public\
  --swift_out=$(dir $<)

%.grpc.swift: %.proto
protoc $< \
  --proto_path=$(dir $<) \
  --plugin=${PROTOC_GEN_GRPC_SWIFT} \
  --grpc-swift_opt=Visibility=Public,Server=false,Client=true,TestClient=true \
  --grpc-swift_out=$(dir $<)

SERVICE_PROTO=Model/service.proto
SERVICE_PB=$(SERVICE_PROTO:.proto=.pb.swift)
SERVICE_GRPC=$(SERVICE_PROTO:.proto=.grpc.swift)

.PHONY:
generate-service: ${SERVICE_PB} ${SERVICE_GRPC}

[grpc-swift]の面白いところ,AsyncSequenceがしっかりと活用できる

// Interface exported by the server.
service RouteGuide {
  // A simple RPC.
  rpc GetFeature(Point) returns (Feature) {}

  // A server-to-client streaming RPC.
  rpc ListFeatures(Rectangle) returns (stream Feature) {}

  // A client-to-server streaming RPC.
  rpc RecordRoute(stream Point) returns (RouteSummary) {}

  // A Bidirectional streaming RPC.
  rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
}
  • Unary RPCは普通の関数呼び出しになる。
  • server2client streaming RPCは返り値がAsyncSequenceになる。
  • client2server streaming RPCはrequestStreamがCombineのSubjectのようにsend(_:)finish()を持つ。
  • Bi-directional streaming RPCはs2c, c2sの両方を組み合わせた性質になる

呼び出し方法

unary streaming

 let feature = try await self.routeGuide.getFeature(point)]

server2client streaming

AsyncSequenceが活用されている

for try await feature in self.routeGuide.listFeatures(rectangle) {
  print("Result #\(resultCount): \(feature)")
  resultCount += 1
}

client2server streaming

let recordRoute = self.routeGuide.makeRecordRouteCall()
for i in 1 ... featuresToVisit {
  if let feature = features.randomElement() {
    try await recordRoute.requestStream.send(feature.location)
    try await Task.sleep(nanoseconds: UInt64.random(in: UInt64(2e8) ... UInt64(1e9)))
  }
}
try await recordRoute.requestStream.finish()
let summary = try await recordRoute.response

bi-directional streaming

Structured Concurrencyが活用されている
withThrowingTaskGroup(of:task:)の処理で並行処理をしている。(of: Void.self)にはgroupに追加したタスクが返却できる返り値の型を表現させる。groupAsyncSequenceプロトコルに準拠しているのでfor await v in group {}として処理ができる。それぞれの返り値を処理する必要がない(VoidならwaitForAll()のように待つだけで良い)

try await withThrowingTaskGroup(of: Void.self) { group in
  let routeChat = self.routeGuide.makeRouteChatCall()

  // Add a task to send each message adding a small sleep between each.
  group.addTask {
    for note in notes {
      try await routeChat.requestStream.send(note)
      try await Task.sleep(nanoseconds: UInt64.random(in: UInt64(2e8) ... UInt64(1e9)))
    }

    try await routeChat.requestStream.finish()
  }

  // Add a task to print each message received on the response stream.
  group.addTask {
    for try await note in routeChat.responseStream {
      print("Received message '\(note.message)' at \(note.location)")
    }
  }

  try await group.waitForAll()
}

全体の呼び出し元
GRPCChannelPoolで接続先を指定している

// Load the features.
let features = try loadFeatures()

let group = PlatformSupport.makeEventLoopGroup(loopCount: 1)
defer {
  try? group.syncShutdownGracefully()
}

let channel = try GRPCChannelPool.with(
  target: .host("localhost", port: self.port),
  transportSecurity: .plaintext,
  eventLoopGroup: group
)
defer {
  try? channel.close().wait()
}

let routeGuide = Routeguide_RouteGuideAsyncClient(channel: channel)
let example = RouteGuideExample(routeGuide: routeGuide, features: features)
await example.run()

色々メソッドがあり,getFeatureのように呼び出してrecordRouterouteChatのrequestStreamを扱えるような仕組みがある。サンプルコードはmakeXXXCall()を利用している。Sequence,AsyncSequenceに準拠したものをRequestStreamに流すことができるコードが生成されている。

public func recordRoute<RequestStream>(
    _ requests: RequestStream,
    callOptions: CallOptions? = nil
  ) async throws -> Routeguide_RouteSummary where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Routeguide_Point

Connection周りはClientConnectionを使うといいっぽい。GRPCChannelPoolとの違いはよくわからない
実際にアプリを作る上でのサンプル実装はこれ

// Make EventLoopGroup for the specific platform (NIOTSEventLoopGroup for iOS)
// see https://github.com/grpc/grpc-swift/blob/main/docs/apple-platforms.md for more details
let group = PlatformSupport.makeEventLoopGroup(loopCount: 1)

// Setup a logger for debugging.
var logger = Logger(label: "gRPC", factory: StreamLogHandler.standardOutput(label:))
logger.logLevel = .debug

// Create a connection secured with TLS to Google's speech service running on our `EventLoopGroup`
let channel = ClientConnection
  .usingPlatformAppropriateTLS(for: group)
  .withBackgroundActivityLogger(logger)
  .connect(host: "speech.googleapis.com", port: 443)

// Specify call options to be used for gRPC calls
let callOptions = CallOptions(customMetadata: [
  "x-goog-api-key": Constants.apiKey,
], logger: logger)

// Now we have a client!
self.client = Google_Cloud_Speech_V1_SpeechClient(
  channel: channel,
  defaultCallOptions: callOptions
)

TestClientの追加のやり方 (参考にした記事記事)
ClientConnectionを利用するものとResponseをenqueueしたtestClientを使い分ければいいっぽい

container.register(Pb_HelloServiceClientProtocol.self) { _ in
  let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
  #if MOCK
  let testClient = Pb_HelloServiceTestClient()
  testClient.enqueueHelloResponse(Pb_HelloResponse.with {
      $0.hello = "mock message"
  })
  return testClient
  #else
  let channel = ClientConnection
      .insecure(group: group)
      .connect(host: "REQUEST_HOST_NAME", port: 5300)
  return Pb_HelloServiceClient(
      channel: channel
  )
  #endif

実際にアプリ上で動かしてみる

import SwiftUI
import GRPC

struct ContentView: View {
    let client: Routeguide_RouteGuideAsyncClient
    @State var feature: Routeguide_Feature?
    @State var count: Int = 0

    init() {
        let group = PlatformSupport.makeEventLoopGroup(loopCount: 1)
        let channel = ClientConnection
        //  .usingPlatformAppropriateTLS(for: group)
            .insecure(group: group)
            .connect(host: "localhost", port: 1234)
        self.client = Routeguide_RouteGuideAsyncClient(
            channel: channel
        )
    }

    var body: some View {
        VStack {
            Button {
                Task {
                    do {
                        let rectangle: Routeguide_Rectangle = .with {
                            $0.lo = .with {
                                $0.latitude = numericCast(400_000_000)
                                $0.longitude = numericCast(-750_000_000)
                            }
                            $0.hi = .with {
                                $0.latitude = numericCast(420_000_000)
                                $0.longitude = numericCast(-730_000_000)
                            }
                        }
                        for try await feature in self.client.listFeatures(rectangle) {
                            self.feature = feature
                            self.count += 1
                            try await Task.sleep(nanoseconds: UInt64(1e8))
                        }
                    } catch {
                        dump(error)
                    }
                }
            } label: {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundColor(.accentColor)
            }
            if let feature {
                Text("\(feature.location.longitude), \(feature.location.latitude), \(count)")
            } else {
                Text("No")
            }
        }
    }
}

Discussion