Tutorial: Type-safe APIs with Swift gRPC
In this tutorial we’ll learn how to build a Swift gRPC server and client from scratch, configuring an interactive development environment using Xcode and a simple deployment mechanism using Docker. We will build a basic Echo service, where the server will reply with the same content as sent by the client.
We’ll also go over some shortcomings of the Swift ecosystem encountered while developing this tutorial, and “hackish” solutions to these issues.
Technologies we’ll use:
Before we start, we’ll need the following dependencies already installed in our machines:
Project Scaffolding
To start off, we’ll create an empty Swift project called Echo, and we’ll add some files and folders where we will add code during the tutorial:
$ mkdir Echo
$ cd Echo
$ swift package init --type empty
$ mkdir -p Sources/EchoServer
$ mkdir -p Sources/EchoClient
$ mkdir -p Sources/Protos
$ touch Sources/EchoServer/main.swift
$ touch Sources/EchoClient/main.swift
$ find .
./.gitignore
./Package.swift
./README.md
./Sources
./Sources/EchoClient
./Sources/EchoServer
./Sources/Protos
./Tests
This will create a basic and empty project structure where we’ll set up our code. I chose to use the Swift Package Manager as it provides a simple interface for building command line applications without having to rely on Xcode, while still allowing an interactive development experience by generating an Xcode project.
Protocol Buffers and gRPC
As Google states:
Protocol buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.
In practice, Protocol Buffers offer a shareable interface for exchanging structured data between different services. It is generally composed of two parts: a compiler that generates language bindings from.proto
files, and a language support library to support Protocol Buffers entities at runtime.
gRPC, on the other hand, is an RPC framework used to connect services across the Internet. We’ll use gRPC as the transport mechanism for our Protocol Buffer messages between the server and the client. As Protocol Buffers, it also has a compiler part which generates gRPC endpoint stubs for different languages from .proto
service definitions, and a language support library.
We’ll then add a service definition for our Echo service using the Protocol Buffers language under Echo/Sources/Protos/echo.proto
:
// Echo/Sources/Protos/echo.proto
syntax = "proto3";service EchoService {
rpc echo(EchoRequest) returns (EchoResponse);
}message EchoRequest {
string contents = 1;
}message EchoResponse {
string contents = 1;
}
In this file, we’re defining an EchoService
RPC service, which has 1 endpoint named echo
. This endpoint expects a message of type EchoRequest
and it replies with a message of type EchoResponse
. The definitions of EchoRequest
and EchoResponse
are below, and each define a string
field where the request and response values will be populated, respectively.
To generate Swift Protocol Buffer and gRPC sources, we’ll use the Swift gRPC Docker image. The benefit of using Docker to generate the sources is that we don’t have to install any of the dependencies on our machines. We just take advantage of an image that is already configured for us, and it’s also easier to clean once we’re done with it. To generate the Swift code generators we’ll run¹:
$ cd Sources/Protos
$ docker run -i -t -v `pwd`:/working_dir -w /working_dir \
sergiocampama/swift_grpc \
protoc --swift_out=. --swiftgrpc_out=. \
echo.proto
That’s one scary command! Let’s explain it. docker run
tells Docker that we want to run something using an image, in this case, one called sergiocampama/swift_grpc
. One of the advantages of Docker is that it will download the image from Docker Cloud upon the first reference to it. The -i
flag tells Docker to run in interactive mode by displaying the output of the command back to your terminal.
The -v `pwd`:/working_dir
flag tells Docker that when running that image, we want to mount the current directory where we’re running the command (as returned by pwd
) into a directory named /working_dir
inside the Docker image. The -w /working_dir
flag tells Docker that we want it to run inside the /working_dir
directory. These two flags have the effect of making the files inside the current folder available to the Docker image under the /working_dir
directory.
Finally we get to the actual Protocol Buffers generation command, which will be run inside the Docker image. protoc
is the code generator engine. The --swift_out=.
flag tells protoc
that we want to generate Swift bindings in the current directory for the Protocol Buffers message
s given, while --swiftgrpc_out=.
requests Swift gRPC code to be generated in the current directory.
After running this command, two files will appear in Echo/Sources/Protos
, echo.pb.swift
and echo.grpc.swift
. These will be the Protocol Buffers and gRPC bindings for the Echo service, respectively. We’ll compile both of these files into the server and client, so we’ll copy them into the Echo/Sources/EchoServer
and Echo/Sources/EchoClient
folders².
$ cp *.swift ../EchoServer
$ cp *.swift ../EchoClient
$ cd ../..
Implementing the business logic
Now that we have the Protocol Buffers and gRPC bindings generated, we can start implementing the server and the client. We’ll start by defining the EchoServer
and EchoClient
products in the package by modifying the Package.swift
file to the following:
// Echo/Package.swift
// swift-tools-version:4.1
import PackageDescriptionlet package = Package(
name: "Echo",
products: [
.executable(
name: "EchoServer",
targets: ["EchoServer"]
),
.executable(
name: "EchoClient",
targets: ["EchoClient"]
),
],
dependencies: [
.package(
url: "https://github.com/grpc/grpc-swift.git",
from: "0.4.1"
),
],
targets: [
.target(
name: "EchoServer",
dependencies: [
"SwiftGRPC",
]
),
.target(
name: "EchoClient",
dependencies: [
"SwiftGRPC",
]
),
]
)
At this point, we can already start compiling our code. For each target, Swift Package Manager will look for sources under the Sources/<TargetName>
directory, which for both EchoServer
and EchoClient
should already have the echo.pb.swift
and echo.grpc.swift
files:
$ swift build # First time takes a while as it compiles dependencies
Implementing EchoServer
But we haven’t done anything useful for now. Let’s implement the EchoServer
logic by creating Echo/Sources/EchoServer/main.swift
file with the following contents:
import Dispatch
import SwiftGRPCclass EchoProvider: EchoServiceProvider {
func echo(request: EchoRequest,
session: EchoServiceechoSession)
throws -> EchoResponse {
var response = EchoResponse()
response.contents = "You sent: \(request.contents)"
return response
}
}let address = "0.0.0.0:9000"
print("Starting server in \(address)")let server = ServiceServer(
address: address, serviceProviders: [EchoProvider()]
)server.start()dispatchMain()
Let’s step through what the code is doing. Skipping over the imports, we first see an EchoProvider
class, which implements the EchoServiceProvider
protocol. This EchoServiceProvider
protocol is defined in Echo/Sources/EchoServer/echo.grpc.swift
file. The purpose of the EchoProvider
class is to implement the logic behind the service EchoService
definition in Echo/Sources/Protos/echo.proto
. You can see that it only has one method, named echo
(just like the rpc
definition), and that it takes an EchoRequest
message as input and returns an EchoResponse
message, both defined in Echo/Sources/EchoServer/echo.pb.swift
. Because this is an echo service, the response is equal to the request prepended with You sent:
.
Then we define the address, which will be hardcoded to the local machine under port 9000, and create an instance of the ServiceServer
class provided by SwiftGRPC
with the address
and an instance of the EchoProvider
class. With this we’re configuring the server to respond to the EchoService.echo
commands using the EchoProvider
implementation.
Finally, we use a DispatchSemaphore
to keep the server running. This has the effect of keeping the server alive while yielding the execution to the ServiceServer
class whenever it gets a new connection.
We can run this server by running swift run EchoServer
and you’ll see the Starting server in 0.0.0.0:9000
message. You can close the server with Ctrl + C
.
Implementing EchoClient
Let’s implement a simple client that makes use of the EchoServer
we just implemented. Add the following contents into Echo/Sources/EchoClient/main.swift
:
let client = EchoServiceServiceClient(
address: "0.0.0.0:9000", secure: false
)var request = EchoRequest()
request.contents = "Hello, world!"
let response = try client.echo(request)
print(response.contents)
First, we make an instance of the EchoServiceServiceClient
class defined in Echo/Sources/EchoClient/echo.grpc.swift
, then we create an EchoRequest
, defined in Echo/Sources/EchoClient/echo.pb.swift
and set Hello, world!
as its contents. We finally invoke the echo
endpoint in EchoService
with the request, which returns an EchoResponse
, and we print its contents.
Now, make sure the EchoServer
is running on a terminal as described above, and on a different terminal navigate to the Echo project and run swift run EchoClient
. After compiling EchoClient
, it will run and if everything went ok, it should print You sent: Hello, world!
. We’ve successfully ran a gRPC server and client connection!
Interactive Development
With what we’ve accomplished at this point we could start building more complex servers and clients, but with more logic comes more potential issues that we’d have to debug. Wouldn’t it be nice to use a rich development IDE with debugging support for implementing the client and the server?
Luckily for us, the Swift Package Manager has support for generating an Xcode project based on the Package.swift
project definition³. We can generate and open this project in Xcode with:
$ swift package generate-xcodeproj
$ open Echo.xcodeproj
We’re now presented with something that should look like this:
Let’s add a breakpoint in the echo
endpoint by clicking on the number 8 in the editor gutter, and then let’s run the EchoServer
target by clicking on the Play button on the top left. Make sure that to the right of that button the EchoServer
target is selected. Then, while the server is running, select the EchoClient
target in the dropdown list, and click the Play button again. Xcode will then run the client code in parallel with the server. This should make the client generate a connection to the server, and hit the breakpoint:
With the code paused on the breakpoint, we can now inspect the contents of the request and further debug our code.
Deploying to the cloud
During the tutorial, we’ve been compiling and running the server and client exclusively on a macOS machine. But deployment doesn’t usually involve a macOS server but rather a Linux based one, like Ubuntu. So let’s compile the server into a Linux binary that we can deploy on the cloud⁴:
$ docker run -i -t -v `pwd`:/working_dir -w /working_dir \
swiftdocker/swift:4.1 \
swift build --product EchoServer \
--build-path .release \
--configuration release
Once again, we’ll use Docker to do this. The -i -t -v -w
flags we’re already explained in the Protocol Buffers and gRPC section, but now we’re using the swiftdocker/swift:4.1 Docker image. This is an Ubuntu based image with the Swift toolchain already installed.
Then we run the swift build
command with release configuration flags, and specify the output to be in .release
. This will generate the EchoServer
Linux binary in Echo/.release/x86_64-unknown-linux/release/EchoServer
.
We can then package this binary into a Docker image by creating the Echo/Dockerfile
file⁵:
FROM swift:4.1ADD Echo/.release/x86_64-unknown-linux/release/EchoServer /app/EchoServerCMD /app/EchoServer
Finally we’ll build this image using docker build -t $USER/echoserver
. With this Docker image, you can now upload it to your preferred container registry and then use it from your cloud infrastructure.
Conclusions
At this point in the tutorial we’ve managed to:
- create a server and client system that communicates via gRPC and ProtocolBuffers,
- tested their functionality from the command line,
- added breakpoint and debugging support for both client and server using Xcode,
- compiled the server for Linux in the release configuration, ready to be deployed to the cloud.
All of this with no infrastructure dependencies other than Xcode and Docker.
It is important to note that the gRPC server presents a uniform interface to any gRPC connection. This means that the client code doesn’t necessarily need to be developed using Swift, and the server we’ve built can support any client language and platform also supported by gRPC (e.g. ObjC, Python, Java, Javascript, Android, iOS, Web).
Future work
One idea I’d like to explore more is statically linking the server binary to avoid needing the Swift runtime libraries installed in the host machine. We avoid this now by using the swift:4.1
Docker image as a default, but this limits us to where we can deploy our binary. Sadly, static linking of binaries for Linux is not quite ready for prime time yet, as there are some issues with the Swift toolchain for Linux.
Final words
This is my first time writing a public tutorial on building software. As such, my writing style or content could definitely be improved. Any feedback on the writing, content, or methodology described in this article would be very helpful for me to improve. Thanks.
Notes
- The Swift gRPC project does not offer an official Docker image configured for generating Swift Protocol Buffers and gRPC bindings, so I built and distributed my own image using their Dockerfile. I created a feature request on the grpc-swift project asking for one.
- Copying these files into each of the
EchoServer
andEchoClient
source directories is a workaround to Swift Package Manager not supporting sharing the same source files code between different targets. For these cases SwiftPM forces developers to create an intermediate target and then depend on that target. Sadly, the Swift generated code for Protocol Buffers and gRPC doesn’t set thepublic
modifiers on the classes, so they are not visible when linking against the intermediate target. - I developed this tutorial using Xcode 10b3. The swift version included in this Xcode has a bug that won’t add the
SWIFT_PACKAGE
config inSWIFT_ACTIVE_COMPILATION_CONDITIONS
‘s debug setting. Because of this, building on the debug configuration will be broken if you’re using Xcode 10b3. This bug has already been fixed in upstream, so it’s likely that the final version of Xcode 10 will have this fix included. - We use the
swiftdocker/swift:4.1
image explicitly as thelatest
tag hasn’t been updated and uses an older version. - I chose to build and package the
EchoServer
binary already compiled, as opposed to copying the sources in the Dockerfile and building the server inside of it, as I didn’t find appealing the idea of releasing a Docker image that contains the sources. By only packaging the output binaries, we avoid any errors that may lead to releasing your source code inside a Docker image.