Rust-gprc: advice needed

Hi, everyone.

I'm currently implementing gRPC in rust.

Currently it does only basic things: synchronous client and synchronous server, and no streaming.

I could continue developing synchronous version of client, but I think it would be wise to implement asynchronous client and server, and make synchronous versions thin wrappers around asynchronous versions.

I have experience in implementing asynchronous things in C++ and Java, but I practically never written any asynchronous in Rust.

So. I basically have two questions: what an interface of gRPC should be, and how should it be implemented.

I've looked at sources of several libraries in rust, and seems like my choice is futures-rs for the API, mio+futures-mio for the event loop and solicit for the implementation of HTTP/2 protocol.

So, given service defition

service FileService {
    rpc listFiles(ListFilesRequest) returns ListFilesResponse {};
    rpc uploadFile(stream UploadFileRequest) returns UploadFileResponse {};
    rpc downloadFile(stream DownloadFileRequest) returns stream DownloadFileResponse {};
}

rust-grpc compiler should generate a trait:

type GrpcFuture<T> = Box<Future<T, GrpcError>>;
type GrpcStream<T> = Box<Stream<T, GrpcError>>;

trait FileServiceAsync {
    GrpcFuture<ListFilesResponse> listFiles(&self, req: ListFilesRequest);
    GrpcFuture<UploadFileResponse> uploadFile(&self, 
req: GrpcStream<UploadFileRequest>);
    GrpcStream<DownloadFileResponse> downloadFile(&self, 
req: DownloadFileRequest);
}

class FileServiceAsyncClient {
    ... generated client
}

This trait would be used by both client and server. rust-gprc compiler will generate stub FileServiceAsyncClient, and user should imeplement this trait for the server.

So, given this interface for generated code, and choice of libraries, singlethread event loop implementation is pretty much straighforward (although wordy).

BTW, I've looked at C++ version of gRPC, it looks like it was designed by aliens for predators (whereas Go and Java asynchronous interfaces are simple and clear). I think simpler and easier to use interface is possible in low-level language. Or I simply miss something important.

So, that's it. Any advice, before I started writing a lot of code, is welcome.

2 Likes

I would be so happy to have a rust gRPC client, it's the biggest barrier I have from being able to use Rust at work. Let me know if there's anything I can do to help.

I have one suggestion based on recent experience with the Go client: allow codec implementations over Reader/Writer/Stream/etc. There are a number of use cases where it would be useful to keep everything streaming: proxying requests, appending multiple requests into a single response, logging requests to disk, etc.

allow codec implementations over Reader/Writer/Stream/etc

Could you please explain? I'm not familiar with Go gRPC implementation.

Let me know if there's anything I can do to help.

Actually, yes, there are a lot of things where I could get help (beside writing code):

  • coment on API and generated code to make sure all use cases are covered
  • review the code (I don't undertstand future-rs and tokio internals, and I could miss some important gRPC details)
  • help to test it (currently it basically works except client streaming is not implemented, but gRPC is hard, and I'm sure there are tons of bugs in the implementation)

Could you please explain?

In theory gRPC doesn't specify how argument/return types are serialized, so it probably makes sense to provide something like a Codec trait. This could be done a couple ways, but here are two options for the sake of discussion:

trait SliceCodec<E> {
    fn encode(&self) -> Result<&[u8], E>
    fn decode(&[u8]) -> Result<T, E>
}
trait StreamCodec<E> {
    fn encode(&self) -> Result<io::Read, E>
    fn decode(io::Read) -> Result<Self, E>
}

The SliceCodec allows you to call encode() on a type implementing the trait and returns a byte slice. The StreamCodec instead returns an io::Read-er which would presumably result in the save value if you collected it into a slice (ie encode().bytes().collect::<&[u8]>()). The difference is that the former forces memory allocation, while the latter doesn't. So for instance if I wanted to implement a proxy that exposed your FileServiceAsync service and simply passed each RPC call to another process, returning a stream could allow the proxy to pass bytes directly from the proxied response to the caller.

How would re-using the trait for clients & servers work? It seems like the code-gen would want to generate a client stub exposing the methods and implementing the network calls. It might make sense for the client methods to have &mut self receivers so clients can re-use a decoding buffer.

I'm not sure what the implications of this would be, but using the impl Trait features that were merged recently could make signatures a bit cleaner and eliminate trait-object overhead, something like:

trait FileServiceAsync {
    fn listFiles(&self req: ListFilesRequest) -> impl Future<ListFilesResponse, GrpcError>;
    // ...
}

-Ryan

So, you are going to use grpc-protobuf on the client and server, and you also need a grpc-proxy, that should work without schema with raw byte messages?

Yes. An example use case would be a load balancer routing requests across a cluster of servers by consistent hashing a key set in the request metadata. This would allow each server in the cluster to keep local caches ONLY for a subset of the "keys". In this case the routing decision could be made by examining the HTTP/2 headers and the body could be passed through unmodified and unparsed. This would have the side benefit of decoupling the router from the data definition (or even its encoding scheme)

I think the more important issue is to avoid the assumption that Protocol Buffers will be used to encode data. Protobufs aren't designed to be decoded from a stream, but some encoding schemes are - for instance some JSON parsers can operate incrementally from a reader, and Cap'N'Proto claims to be well-suited to being parsed from a stream - there could even be use cases for transferring unencoded binary blobs. Google's Flatbuffer compiler recently added the ability to generate gRPC stubs.

I think the more important issue is to avoid the assumption that Protocol Buffers will be used to encode data. Protobufs aren't designed to be decoded from a stream

rust-grpc implementation separates grpc implementation from message encoding.

gRPC supports streaming as part of protocol, where request or response is a sequence of messages (e. g. protobuf messages).

However, in grpc-rust, message decoder is called only when the whole message is read from the network. I don't think it is an issue, because of streaming feature of gRPC.