🍭

Swiftでasync/awaitを使ってgRPCサーバをたてる

2021/11/03に公開

はじめに

macOS上でSwiftを用いてgRPCサーバを動作させ、かつSwift5.5で導入されたasync/awaitで記述します。
async/awaitを用いるにはiOS15やmacOS Montereyが必要ですが、iOSシミュレーターを用いればBig Surでもasync/awaitなSwiftコードを動かすことができます。
今回はmacOS上で動かすので、macOSをMontereyに上げる必要があります。どうしても上げたくない方はDcokerでLinuxイメージを使って動かすか、バックデプロイを待つなどする必要があります。

gRPCの実装にはgrpc-swiftを使います。
async/awaitのサポートは現時点ではExperimentalであるため、注意が必要です。今後インターフェイスなどに破壊的変更が入る可能性があります。

環境

  • macOS Monterey 12.0.1
  • Xcode 13.1
  • Swift 5.5.1

protocとgrpc用のprotocプラグインを用意する

.protoファイルからコード生成を行うにはprotocコマンドが必要です。
またSwift用のProtocol Buffersモデルのコードと、gRPCのサーバ・クライアントコードを生成するためにそれぞれプラグインが必要になります。

今回protocはbrewでインストールしました

$ brew install protobuf

Swift用プラグインの2種は自分でビルドして用意します。
冒頭で述べたようにgrpc-swiftのasync/awaitサポートは現時点ではまだExperimentalであり、mainブランチではサポートされていません。
よって専用ブランチに切り替えてからビルドを行います。

$ git clone https://github.com/grpc/grpc-swift.git 
$ cd grpc-swift
$ git switch 1.4.1-async-await
$ make plugins

これにより、 リポジトリルートに protoc-gen-swift protoc-gen-grpc-swift が生成されます。
(実態としてはSwiftPMでビルドされたファイルを.build/releaseからコピーしてきているだけです。)
この2つの実行可能ファイルに対してパスを通します。

SwiftPMプロジェクトの作成

パッケージの初期化

$ mkdir GrpcEcho
$ cd GrpcEcho
$ swift package init --type executable

Package.swiftの記述

これまで同様、async/awaitサポートはExperimentalであるため専用ブランチを指定します。
またasync/awaitを用いるためにplatformsに.macOS(.v12)の指定が必須です

Package.swift
// swift-tools-version:5.5

import PackageDescription

let package = Package(
    name: "GrpcEcho",
    platforms: [.macOS(.v12)],
    dependencies: [
        .package(url: "https://github.com/grpc/grpc-swift.git", .branch("1.4.1-async-await")),
    ],
    targets: [
        .executableTarget(
            name: "GrpcEcho",
            dependencies: [.product(name: "GRPC", package: "grpc-swift")]
        ),
    ]
)

.protoファイルの作成

練習用サーバのための.protoファイルを記述します。
今回は適当にパッケージのルートディレクトリに置いておきます。

echo.proto
syntax = "proto3";

package echo;

service Echo {
    rpc Hello(HelloRequest) returns (HelloResponse) {}
}

message HelloRequest {
    string name = 1;
}

message HelloResponse {
    string message = 1;
}

.protoファイルのビルド

protocを用いてコード生成をします。
async/await対応のサーバ用コードを生成するためにはExperimentalAsyncServerオプションを有効にする必要があります。
EventLoopをそのまま使う普通のサーバ用コードは不要なので、そちらは無効にしました。クライアント用コードも今回は実装しないので無効にしました。各自の目的にそって適宜設定します。

オプションについてはplugin.mdに記載されています。

$ mkdir -p Sources/GrpcEcho/Gen
$ protoc echo.proto --swift_out=Sources/GrpcEcho/Gen --grpc-swift_out=Sources/GrpcEcho/Gen --grpc-swift_opt=Server=false,Client=false,ExperimentalAsyncServer=true

生成物としてecho.grpc.swiftecho.pb.swiftSources/GrpcEcho/Genに出力されます。生成物は普通のSwiftコードです。

サーバ用コードの記述

各ServiceごとにProviderクラスを定義し、そのクラスをサーバに登録します。
今回はEchoサービスとHelloメソッドを定義したので、そのインターフェイスを元に自動生成されたプロトコルであるEcho_EchoAsyncProviderに対して具体的な実装を定義します。

main.swift
import GRPC
import NIO

class EchoProvider: Echo_EchoAsyncProvider {
    func hello(request: Echo_HelloRequest, context: GRPCAsyncServerCallContext) async throws -> Echo_HelloResponse {
        return Echo_HelloResponse.with {
            $0.message = "Hello, \(request.name)"
        }
    }
}

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
    try! group.syncShutdownGracefully()
}

let server = try Server.insecure(group: group)
    .withServiceProviders([
        EchoProvider(),
    ])
    .bind(host: "localhost", port: 8080)
    .wait()

print("started server: \(server.channel.localAddress!)")

try server.onClose.wait()

実行すると、サーバが起動してリクエストを待機した状態になります。

動作確認

適当なgRPCクライアントで先程立ち上げたサーバに対してリクエストを送ってみます。
今回はgrpcurlを用いました。

$ brew install grpcurl

実際にリクエストを投げてみます。

$ grpcurl -plaintext -proto echo.proto -d '{"name": "Iceman"}' localhost:8080 echo.Echo.Hello
{
  "message": "Hello, Iceman"
}

無事に動きました。
-plaintextがない場合、 Unable to determine http version, closing というエラーログが吐かれつつ無のレスポンスが返る( Failed to dial target host "localhost:8080": EOF と言われる)ので注意してください。
デフォルト設定ではサーバログは虚無に捨てられるので、適宜設定する必要があります。

おわりに

SwiftでgRPCサーバを動かすことができました。またSwift最新機能であるasync/awaitも用いることができました(今回のサンプルコードではとくに恩恵がありませんが)。

注意点として、Swiftのstructured concurrencyに対応しているといっても実態はSwiftNIOベースのEventLoopで動いているという点があります。
現時点のEventLoopはactorのcustom executorには対応しておらず、これらに特に連携がありません。例えばawaitした箇所によってはEventLoopの外側で処理が実行されることがあります。EventLoopで保護されたリソースをasyncコンテキストの中で触れるときは注意が必要です。
またawaitを使ってFutureの完了を待つなどのタイミングでは本質的に不要なスレッドのスイッチが起こる可能性があります。基本的に問題にならないでしょうが、パフォーマンス的には不利となります。パフォーマンスにシビアなAPIにおいては用いないほうが賢明だと思います。

今回の作業コードはこちらです https://github.com/sidepelican/GrpcEcho

Discussion