Motivation

In this chapter, I will tell you why I started this project.

Message Passing

To share state between async processes, these two possible solutions can be considered

  1. Shared memory
  2. Message passing

The benefit of message passing is the processes are isolated and only communicated using defined messages. Each process typically holds some resources like storage or connection to external service as a sole owner and encapsulates the direct access to the resource from other processes. This makes developing async applications easy because your interest is minimized.

You can also read this documentation from Tokio. https://tokio.rs/tokio/tutorial/channels

Problem: Boilarplate

Let's design your async application by message passing. In this case, you have to define your own message types for request and response by hand and may have to write some logics that consumes messages from channel or send response to the sender using oneshot channel. From the Tokio documentation this could be like this:


#![allow(unused_variables)]
fn main() {
use tokio::sync::oneshot;
use bytes::Bytes;

/// Multiple different commands are multiplexed over a single channel.
#[derive(Debug)]
enum Command {
    Get {
        key: String,
        resp: Responder<Option<Bytes>>,
    },
    Set {
        key: String,
        val: Bytes,
        resp: Responder<()>,
    },
}

/// Provided by the requester and used by the manager task to send
/// the command response back to the requester.
type Responder<T> = oneshot::Sender<mini_redis::Result<T>>;
}

#![allow(unused_variables)]
fn main() {
while let Some(cmd) = rx.recv().await {
    match cmd {
        Command::Get { key, resp } => {
            let res = client.get(&key).await;
            // Ignore errors
            let _ = resp.send(res);
        }
        Command::Set { key, val, resp } => {
            let res = client.set(&key, val).await;
            // Ignore errors
            let _ = resp.send(res);
        }
    }
}
}

However, writing such codes is really tedious.

Solution: Code generation

The solution is to generate code so you can focus on the logics rather than the boilarplates.

With norpc, you can define your in-memory microservice like this and this will generate all the other tedious codes.


#![allow(unused_variables)]
fn main() {
#[norpc::service]
trait YourService {
    fn get(key: String) -> Option<Bytes>;
    fn set(key: String, val: Bytes);
}
}

Previous Work

Tarpc is a previous work in this area.

You can define your service by trait and the macro tarpc::service generates all the rest of the codes.


#![allow(unused_variables)]
fn main() {
#[tarpc::service]
pub trait World {
    async fn hello(name: String) -> String;
}
}

Problems

However, I found two problems in Tarpc.

1. Tarpc isn't optimized for in-memory channel

The goal of tarpc is providing RPC through TCP channel and the direct competitor is RPC framework like Tonic or jsonrpc.

Tarpc only allows to use in-memory channel under the same abstraction so the implementation isn't optimized for in-memory channel.

2. Tarpc doesn't use Tower

Tower is a framework like Scala's Finagle which provides a abstraction called Service which is like a function from request to response and decorator stacks to add more functionality on top of the abstraction.

If we design a RPC framework from scratch with the current Rust ecosystem, we will 100% choose to depend on Tower to implement functionalities like rate-limiting or timeout which is essential in doing RPC. In fact, Tonic does so.

However, Tarpc's started a long ago before the current Rust ecosystem is established and it doesn't use Tower but implements those functionalities by itself.

Architecture

norpc utilizes Tower ecosystem.

The core of the Tower ecosystem is an abstraction called Service which is like a function from request to response. The ecosystem has many decorator stacks to add new behavior to an existing Service.

In the diagram, client requests are coming from the top-left of the stacks and flow down to the bottom-right. The client and server is connected by async channel driven by some async runtime (like Tokio) so there is no overhead for the serialization and copying because the messages just "move".

Compiler

The stacks in red are generated by the compiler which is implemented by the procedural macros.

You can define your service by norpc::service macro which generates code in compile time. The generated code includes message type, client type and server-side Service type.


#![allow(unused_variables)]
fn main() {
#[norpc::service]
trait HelloWorld {
    fn hello(s: String) -> String;
}
}

Note that these types are runtime-agnostic which means you can write your own norpc runtime to run these codes for a specific case.

More Examples

Option or Result

You can return Option or Result types to propagate some failure back to the client.


#![allow(unused_variables)]
fn main() {
#[norpc::service]
trait YourService {
    fn read(id: u64) -> Option<Bytes>;
    fn write(id: u64, b: Bytes) -> Result<usize, ()>;
}
}

Non-Send

You can generate non-Send service by add ?Send parameter to norpc::service macro.

This is useful when you want to run the service in pinned thread.


#![allow(unused_variables)]
fn main() {
#[norpc::service(?Send)]
trait YourService {
    // Rc<T> is !Send
    fn echo(s: Rc<String>) -> Rc<String>;
}
}

Runtime

The yellow and blue parts are called "norpc runtime".

As mentioned in the earlier section, the code generated by the compiler (in red) are runtime-agnostic.

To run the service on a runtime, you need to implement the trait generated by the compiler.


#![allow(unused_variables)]
fn main() {
struct HelloWorldApp;
#[async_trait::async_trait]
impl HelloWorld for HelloWorldApp {
    async fn hello(&self, s: String) -> String {
        format!("Hello, {}", s)
    }
}
}

And then you can build a server and a channel. After spawning the server on a async runtime, you can send requests to the server through the channel. Note that the server is async runtime agnostic so you can choose any async runtime to execute the server.


#![allow(unused_variables)]
fn main() {
use norpc::runtime::*;
let app = HelloWorldApp;
let builder = ServerBuilder::new(HelloWorldService::new(app));
let (chan, server) = builder.build();

tokio::spawn(server.serve(TokioExecutor));

let mut cli = HelloWorldClient::new(chan);
assert_eq!(cli.hello("World".to_owned()).await, "Hello, World");
}