Announcing CombineGRPC, a library that integrates Swift gRPC and Combine to enable responsive SwiftUI apps
12 September 2019
I have updated the code examples in this blog post to match version 0.6.0 of CombineGRPC.
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. The built-in streaming support means that it is just as easy to implement asynchronous messaging (push) as it is to implement request/response.
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 wasn’t fully fleshed out yet, and there were not many resources on the Combine framework at the 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:
- Write your service definition using the Protocol Buffers interface definition language
- Use the protoc compiler and the Swift Protobuf plugin to generate the Swift types for the messages defined in your .proto file
- 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
- Use the
handle
functions that are provided by CombineGRPC to implement your RPCs by making use of Combine publishers - Create a
GRPCExecutor
and use itscall
methods to interact with your gRPC service
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.6.0"),
],
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 }
}
.setFailureType(to: GRPCStatus.self)
.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, create a GRPCExecutor
and use its call
method. 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()
let grpc = GRPCExecutor()
grpc.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 its API is 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.