Swift has changed significantly since gRPC Swift v1. The most significant change was the introduction of language level concurrency features in Swift 5.5. While gRPC Swift has long since had support for concurrency it builds on top of the older SwiftNIO
EventLoopFuture based API. While this works at the surface level there are a number of shortcomings to this approach.
Most notably the extent of the
async API is shallow: there are numerous parts which are still based on
EventLoopFutures such as the interceptor API and API for creating and managing clients and servers. This creates an incoherent user experience which is harder to use and reason about than it needs to be. The
async API also relies on unstructured tasks to build on top of the underlying SwiftNIO based guts. This means built-in Swift features like cancellation aren't fully supported.
Because gRPC Swift builds on top of SwiftNIO and predates Swift concurrency, it – and the rest of the Swift on Server ecosystem at the time – used SwiftNIO's
EventLoopFuture and other types pervasively for interoperability. As a result gRPC Swift became tightly coupled with SwiftNIO. While that was the right decision at the time, the ecosystem is trending towards using common types and patterns such as Swift HTTP Types and is adopting Swift concurrency in place of
EventLoopFuture based APIs.
As such this is the right time to consider the future gRPC Swift and chart the path towards the next major version, 2.0.0.
Some of the immediate high goals for the next major version include:
- Provide an idiomatic Swift framework for writing gRPC applications built on top of Swift concurrency features.
- Provide an RPC layer which is agnostic of its underlying transport. In other words: transports should be pluggable and SwiftNIO should be an implementation detail.
- Provide a high-performance HTTP/2 transport implementation.
- Make it easy to test clients and services and without a network.
To guide the design the following principles will be observed:
- Pluggability. Different components and layers in gRPC should be pluggable and extensible.
- Rationale: gRPC isn't required to use HTTP/2 nor is it required to use Protocol Buffers. While they're the de facto transport and message types, they're not requirements. gRPC has a defined protocol for HTTP/3 and Flatbuffers are commonly used in place of Protocol Buffers. When appropriate implementations are available it should be possible for users to swap these components out. Moreover, if users want to add additional capabilities such as metrics or tracing it should be possible to plug these in without modifying the underlying library.
- Lean on the ecosystem and what's gone before. Avoid reinventing features or tooling when the ecosystem may already have a solution.
- Rationale: gRPC has been around for some time and there are a wealth of supporting tools built around it. Most problems have had a significant amount of thought put into them and successful patterns have emerged and become established.
- Add features judiciously. gRPC Swift shouldn't become a kitchen sink and features should earn their place.
- Rationale: To remain maintainable and minimise technical debt features are only added if they provide a tangible benefit to a significant number of users. In the spirit of the pluggability principle, not all features should exist as part of the library; those used by some may exist as pluggable extensions provided by the library, those used by few should be implemented as pluggable extensions by those who need them.
Moving towards v2, we would like to solicit feedback from the community around the proposed design. An overarching design is too large for a single review so will be split into sections. These will be created as Pull Requests against the gRPC Swift repository and also posted in the gRPC Swift section of the forums. At a high level the library will have three distinct layers:
- Stub. The stub layer is the layer which most users interact with. It provides service specific interfaces generated from an interface definition language (IDL) such as Protocol Buffers. For clients this includes a concrete type per service for invoking the methods provided by that service. For services this includes a protocol which the service owner implements with the business logic for their service.
- Call. The call layer sits below the stub layer and allows clients to make calls to a named method on a service and servers to accept and route incoming calls to an appropriate call handler. It is typically called by the stub layer but users can also use it directly if they don't wish to generate stubs. This layer also provides concrete client and server types which provide appropriate API for making and handling calls.
- Transport. The transport layer provides a long-lived bidirectional communication channel with a remote peer. Users aren't expected to interact with this layer beyond instantiating a transport for their client or service.