7.5 KiB
irpc: Design Patterns and Usage Examples
Pattern 1: Actor Model (Most Common)
The primary usage pattern is an actor that receives messages and processes them sequentially:
struct StorageActor {
recv: tokio::sync::mpsc::Receiver<StorageMessage>,
state: BTreeMap<String, String>,
}
impl StorageActor {
pub fn spawn() -> StorageApi {
let (tx, rx) = tokio::sync::mpsc::channel(16);
let actor = Self { recv: rx, state: BTreeMap::new() };
tokio::task::spawn(actor.run());
StorageApi { inner: Client::local(tx) }
}
async fn run(mut self) {
while let Some(msg) = self.recv.recv().await {
self.handle(msg).await;
}
}
async fn handle(&mut self, msg: StorageMessage) {
match msg {
StorageMessage::Get(wc) => {
let WithChannels { inner, tx, .. } = wc;
tx.send(self.state.get(&inner.key).cloned()).await.ok();
}
StorageMessage::Set(wc) => {
let WithChannels { inner, tx, .. } = wc;
self.state.insert(inner.key, inner.value);
tx.send(()).await.ok();
}
}
}
}
Key points:
- The actor owns state and processes messages sequentially
Client::local(tx)wraps the sender side of the mpsc channelWithChannelsdestructuring gives access toinner(the request data),tx(response channel), andrx(update channel)- The
..pattern ignoresrxwhen it'sNoReceiverandspan(withspansfeature)
Pattern 2: Concurrent Task Per Request
For long-running or independent requests, spawn a task per message:
async fn run(mut self) {
while let Ok(Some(msg)) = self.recv.recv().await {
tokio::task::spawn(async move {
if let Err(cause) = Self::handle(msg).await {
eprintln!("Error: {cause}");
}
});
}
}
This is useful for CPU-intensive or I/O-bound requests that shouldn't block other requests.
Pattern 3: Local-Only Usage
irpc can be used without any RPC feature for pure in-process communication:
// Cargo.toml: default-features = false, features = ["derive"]
#[rpc_requests(message = StorageMessage, no_rpc, no_spans)]
#[derive(Serialize, Deserialize, Debug)]
enum StorageProtocol {
#[rpc(tx=oneshot::Sender<Option<String>>)]
Get(Get),
#[rpc(tx=oneshot::Sender<()>)]
Set(Set),
}
The no_rpc flag prevents RemoteService from being generated, and no_spans removes the tracing dependency. This leaves only the local channel mechanism, with minimal dependencies (serde, tokio, tokio-util).
Pattern 4: API Type Wrapping Client
The recommended pattern is to wrap Client<S> in a higher-level API type:
struct StorageApi {
inner: Client<StorageProtocol>,
}
impl StorageApi {
// Local
pub fn spawn() -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::task::spawn(StorageActor::new(rx).run());
Self { inner: Client::local(tx) }
}
// Remote (noq)
pub fn connect(endpoint: noq::Endpoint, addr: SocketAddr) -> Self {
Self { inner: Client::noq(endpoint, addr) }
}
// Remote (iroh)
pub fn connect_iroh(endpoint: iroh::Endpoint, addr: EndpointAddr) -> Self {
Self { inner: irpc_iroh::client(endpoint, addr, ALPN) }
}
// Type-safe methods that work for both local and remote
pub async fn get(&self, key: String) -> irpc::Result<Option<String>> {
self.inner.rpc(Get { key }).await
}
pub async fn set(&self, key: String, value: String) -> irpc::Result<()> {
self.inner.rpc(Set { key, value }).await
}
pub async fn list(&self) -> irpc::Result<mpsc::Receiver<String>> {
self.inner.server_streaming(List, 16).await
}
}
This encapsulates the protocol details and provides a clean, type-safe API. The same StorageApi works identically whether connected locally or remotely.
Pattern 5: Server Setup
With noq
fn serve(api: &StorageApi, endpoint: noq::Endpoint) -> Result<JoinHandle<()>> {
let local = api.inner.as_local().context("cannot listen on remote service")?;
let handler = StorageProtocol::remote_handler(local);
Ok(tokio::task::spawn(irpc::rpc::listen(endpoint, handler)))
}
With iroh
fn serve(api: &StorageApi, endpoint: iroh::Endpoint) -> Result<Router> {
let local = api.inner.as_local().context("cannot listen on remote service")?;
let protocol = IrohProtocol::with_sender(local);
Ok(Router::builder(endpoint).accept(ALPN, protocol).spawn())
}
Pattern 6: Low-Level Request Handling
For more control than the Client methods provide, use request() directly:
async fn custom_request(&self, msg: Get) -> anyhow::Result<oneshot::Receiver<Option<String>>> {
match self.inner.request().await? {
Request::Local(request) => {
let (tx, rx) = oneshot::channel();
request.send((msg, tx)).await?;
Ok(rx)
}
Request::Remote(request) => {
let (_tx, rx) = request.write(msg).await?;
Ok(rx.into())
}
}
}
This allows custom channel creation logic, e.g., different buffer sizes for local vs remote.
Pattern 7: Channel Filtering and Mapping
irpc channels support filtering and mapping, which work for both local and remote channels:
// Server-side: filter responses to only include values > 10
let filtered_tx = wc.tx.with_filter(|v: &i64| *v > 10);
// Server-side: transform responses
let mapped_tx = wc.tx.with_map(|v: i64| v * 2);
// Client-side: filter received updates
let filtered_rx = rx.filter(|update: &Update| update.is_relevant());
For remote channels, these create boxed wrappers. For local channels, they also create boxed wrappers. The overhead is negligible for remote (network latency dominates) but present for local.
Pattern 8: Using the wrap Attribute
The #[wrap] attribute generates named structs from variant fields:
#[rpc_requests(message = StoreMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum StoreProtocol {
#[rpc(tx=oneshot::Sender<Option<String>>)]
#[wrap(GetRequest, derive(Clone))]
Get(String), // Generates: pub struct GetRequest(pub String);
#[rpc(tx=oneshot::Sender<()>)]
#[wrap(SetRequest)]
Set { key: String, value: String }, // Generates: pub struct SetRequest { pub key: String, pub value: String }
}
Benefits:
- Named request types can be imported and constructed by name
- Additional derives (e.g.,
Clone) can be added - Custom visibility can be specified:
#[wrap(pub(crate) GetRequest)] - The generated struct inherits the enum's visibility by default
Pattern 9: 0-RTT Connections
For reduced latency on reconnections with iroh:
// Client side
let result = client.rpc_0rtt(Get { key: "x".into() }).await?;
// Server side (iroh)
let protocol = Iroh0RttProtocol::with_sender(local_sender);
let router = Router::builder(endpoint).accept(ALPN, protocol).spawn();
Important: Only use 0-RTT for idempotent operations, as the data may be replayed by an attacker.
Pattern 10: Shared State in Actor
For actors that need shared state accessible from multiple handlers:
struct Actor {
recv: tokio::sync::mpsc::Receiver<Message>,
state: Arc<Mutex<SharedState>>,
}
Or use the actor pattern with internal mutation:
struct Actor {
recv: tokio::sync::mpsc::Receiver<Message>,
db: HashMap<String, String>, // owned state
}
Since the actor processes messages sequentially, no internal synchronization is needed.