Previewing CombineGRPC, a library that integrates Swift gRPC and Combine to enable responsive SwiftUI apps

I was pretty excited when Apple announced SwiftUI and Combine at WWDC this year. I have experimented with nested components and RxSwift in the past and I have been wanting to use something like this on Apple’s platforms.

I am a big fan of gRPC. gRPC and Protocol Buffers have been game changers for us at WiseTime. In terms of developer ergonomics, they give us a very lightweight way of defining APIs, implementing them and calling them. And the built-in streaming support means that it is just as easy to implement asynchronous messaging (push) as it is to implement request/response (pull).

I have been keeping an eye on the Swift gRPC project, and when they released their first 1.0.0-alpha version based on SwiftNIO, I decided that the world was ready for CombineGRPC, a library that integrates Swift gRPC and the Combine framework. I dreamt of beautiful, responsive UIs; of streaming data straight to my lists as the user scrolled. Then I woke up and got to work.

import CombineGRPC

Writing CombineGRPC required some experimentation. Documentation on the NIO branch of Swift gRPC isn’t fully fleshed out yet, and there aren’t many resources on the Combine framework at this time. I mostly followed the types, asked dumb questions, and validated my hypotheses with test scenarios. I found and reported one bug in Swift gRPC. It was very quickly fixed upstream.

Taking CombineGRPC Out for a Spin

Here are the steps for specifying, implementing and calling a gRPC service:

  1. Write your service definition using the Protocol Buffers interface definition language
  2. Use the protoc compiler and the Swift Protobuf plugin to generate the Swift types for the messages defined in your .proto file
  3. Use the protoc compiler and the Swift gRPC plugin to generate the service protocols and Swift client that you can use to call the service
  4. Use the handle functions that are provided by CombineGRPC to implement your RPCs by making use of Combine publishers
  5. Use the CombineGRPC call functions to interact with your gRPC service using Combine publishers

Let’s see what this looks like in practice. In the following example, we define an EchoService that simply echoes back all the requests messages that it receives. We’ll use it to demonstrate how easy it is to set up bidirectional streaming between a server and a client.

syntax = "proto3";

/*
 * A simple bidirectional streaming RPC that takes a request stream
 * as input and echoes back all the messages in an output stream.
 */
service EchoService {
  rpc SayItBack (stream EchoRequest) returns (stream EchoResponse);
}

message EchoRequest {
  string message = 1;
}

message EchoResponse {
  string message = 1;
}

To generate Swift code from the protobuf, first install the protoc plugins for Swift and Swift gRPC, and then run:

protoc echo_service.proto --swift_out=Generated/
protoc echo_service.proto --swiftgrpc_out=Generated/

Let’s Write a gRPC Server

If you are using SPM, you can add CombineGRPC to your project by listing it as a dependency in Package.swift:

dependencies: [
  .package(url: "https://github.com/vyshane/grpc-swift-combine.git", from: "0.1.1"),
],

You are now ready to implement the server-side gRPC service. To do so, implement the Swift gRPC generated protocol for the service, and use the CombineGRPC handle function. You provide it with a handler function that accepts a Combine publisher of requests AnyPublisher<EchoRequest, Error> and returns a publiser of responses AnyPublisher<EchoResponse, GRPCStatus>. Notice that the output stream may fail with a GRPCStatus error.

import Foundation
import Combine
import CombineGRPC
import GRPC
import NIO

class EchoServiceProvider: EchoProvider {
  
  func sayItBack(context: StreamingResponseCallContext<EchoResponse>) ->
    EventLoopFuture<(StreamEvent<EchoRequest>) -> Void>
  {
    handle(context) { requests in
      requests
        .map { req in
          EchoResponse.with { $0.message = req.message }
        }
        .mapError { _ in .processingError }
        .eraseToAnyPublisher()
    }
  }
}

Our implementation is simple enough. We map over the request stream and write the input messages into the output stream. CombineGRPC provides handle functions for each RPC type. There is a version for unary, server streaming, client streaming and bidirectional streaming RPCs.

To start the gRPC server, we use the Swift gRPC incantation:

let configuration = Server.Configuration(
  target: ConnectionTarget.hostAndPort("localhost", 8080),
  eventLoopGroup: PlatformSupport.makeEventLoopGroup(loopCount: 1),
  serviceProviders: [EchoServiceProvider()]
)
_ = try Server.start(configuration: configuration).wait()

Let it Flow: Calling our Bidirectional Streaming RPC

Now let’s setup our client. Again, it’s the same process that you would go through when using Swift gRPC.

let configuration = ClientConnection.Configuration(
  target: ConnectionTarget.hostAndPort("localhost", 8080),
  eventLoopGroup: PlatformSupport.makeEventLoopGroup(loopCount: 1)
)
let echoClient = EchoServiceClient(connection: ClientConnection(configuration: configuration))

To call the service, use call. call is curried. You first configure it with the RPC that you want to call - echoClient.sayItBack. The client with the method sayItBack was generated from our protobuf definition by Swift gRPC.

The bidirectional streaming version of the call function then takes as parameter a stream of requests AnyPublisher<Request, Error> and returns a stream AnyPublisher<Response, GRPCStatus> of responses from the server. Let’s verify that our server does what it’s supposed to do:

let requests = repeatElement(EchoRequest.with { $0.message = "hello"}, count: 10)
let requestStream: AnyPublisher<EchoRequest, Error> =
  Publishers.Sequence(sequence: requests).eraseToAnyPublisher()

call(echoClient.sayItBack)(requestStream)
  .filter { $0.message == "hello" }
  .count()
  .sink(receiveValue: { count in
    assert(count == 10)
  })

That’s it! You have set up bidirectional streaming between a gRPC server and client.

The Types of CombineGRPC

CombineGRPC provides versions of call and handle for all four RPC styles. call and handle are symmetrical. What you provide to call is given to your handler via handle, and what you output from your handler is what call will return when you call your RPC. Therefore, everything that you need to know about CombineGRPC is in the following table.

RPC Style Input and Output Types
Unary Request -> AnyPublisher<Response, GRPCStatus>
Server streaming Request -> AnyPublisher<Response, GRPCStatus>
Client streaming AnyPublisher<Request, Error> -> AnyPublisher<Response, GRPCStatus>
Bidirectional streaming AnyPublisher<Request, Error> -> AnyPublisher<Response, GRPCStatus>

When you make a unary call, you provide a request message, and get back a response publisher. The response publisher will either publish a single response, or fail with a GRPCStatus error. Similarly, if you are handling a unary RPC call, you provide a handler that takes a request parameter and returns an AnyPublisher<Response, GRPCStatus>.

You can follow the same intuition to understand the types for the other RPC styles. The only difference is that publishers for the streaming RPCs may publish zero or more messages instead of the single response message that is expected from the unary response publisher.

(flat)Map All the Things?

I’m sold, should I use CombineGRPC in my app? Not yet. The Combine framework is still in beta. The NIO version of Swift gRPC is still in alpha. All the operating systems that support Combine are currently in beta. I consider CombineGRPC to be in preview stage. It’s reached the point where it’s fleshed out enough that I feel comfortable soliciting feedback without wasting people’s time.

So, do let me know if you like the direction, have any questions or have suggestions.

The repository is hosted on GitHub at https://github.com/vyshane/grpc-swift-combine.

comments powered by Disqus