#async-client #multi-threading #handle #async-channel #macro #resources #thread

bin+lib client-handle

A macro to generate client handles when using multithreaded / asynchronous code

2 unstable releases

0.2.0 Jan 20, 2023
0.1.0 Jan 14, 2023

#1590 in Asynchronous

MIT license

9KB

client-handle

A common pattern with writting multithreaded / asynchronous code is to allow a thread / task to own a resource and to send messages through a channel to access it. e.g. From the tokio redis example: https://tokio.rs/tokio/tutorial/channels.

The pattern is along the lines of:

  • Create a message enum.
  • Create a channel
  • Spawn a background task to read from the rx Receiver.
  • Send messages from one or more tx Senders
  • Use a oneshot channel sent with the message to return the reponse

To provide an ergonomic handle, I also often end up wrapper the tx Sender and duplicating all of the client functions. As shown below, this results in a lot of boiler plate.

// Generate the message enum
enum Command {
    Get {
        reponse: oneshot::Sender<String>,
        key: String,
    }
}

// Create a channel and
// Spawn a receiver task
let (tx, mut rx) = mpsc::channel(32);
tokio::spawn(async move {
    while let Some(cmd) = rx.recv().await {
    use Command::*;

    match cmd {
        Get { reponse, key } => {
            let value = get_value(&key).await;
            let _ = response.send(value);
        }
    }
}

// Send messages to the channel using an ergonic client
struct Handle {
    tx: mpsc::Sender<Command>,
}

impl Handle {
    async fn get(&self, key: &String) {
        let (resp_tx, resp_rx) = oneshot::channel();
        let cmd = Command::Get {
            key: key.to_string(),
            resp: resp_tx,
        };

        // Send the GET request
        tx.send(cmd).await.unwrap();

        // Await the response
        let res = resp_rx.await;
        println!("GOT = {:?}", res);
    }
}

The boiler plate in question is the duplication in:

  • The receiving code to unpack the message and call the actual implementation
  • The definition of the enum
  • The impl of the client handle

It should be possible to provide only one of the above parts code and derive the others. This is where client-handle comes in as it will derive the mesage format based on a trait that the receiving code has to adere to.

In short, the code above could be replaced with the following:

use client_handle::async_tokio_handle;

#[async_tokio_handle]
trait KvCommand {
    fn get(&self, key: String) -> String {
        self.get_value(&key)
    }
}

And it can be used as follows:

// create a struct for the trait
struct KvReceiver { /* data owned by the receiver */ };

impl KvCommand for KvReceiver {
    // Nothing to do here as the trait has default implemenations
}

#[tokio::main]
async fn main() {
    let receiver = KvReceiver;
    let handle = receiver.to_async_handle();
    let result = handle.get("foo".to_string()).await;
}

Crate Features

  • tokio - Enables use of the async_tokio_handle macro.

There are other examples in the code. For the full details of the code generated, please see the unit tests in the client-handle-core crate.

Why create a sync trait?

It was chosen to place the macro on the trait for the following reasons:

  • Decorating the enum would have involved having users create "magic strings" for return values.
  • Using a trait allows for tools like automock to be used for testing

Acknowledgements

Please see the notes file for details on resources used to create this proc macro.

Dependencies

~1.7–8MB
~65K SLoC