Introduction
What's Canary?
Canary is a simple distributed systems communication framework.
It provides a simple interface for you to design and code without having to worry about the communication backend and more.
Unlike most distributed systems libraries, Canary has object-stream-based communication instead of rpc-based communication.
This means that Canary is usually more performant than other similar solutions such as gRPC and is comparable to websockets (even though the WASM target uses websockets) and native sockets, but offers a much better API.
Core principles
Canary's core principles are a high-level of abstraction and minimalism, while keeping configurability and flexibility. Canary also strives to be a zero-cost abstraction, being built around the principle of atomicity (the concept of atomicity is extremely important to the philosophy of Canary).
Canary is not opinionated on project structure and only provides tools to aid in creating distributed systems.
Canary strives to be an abstraction over the underlying communications protocols, and should provide an experience similar to programming to the interface, and not the implementation.
Still, this library may be a little too low-level for some use cases.
Should your project use Canary? Canary fits these use cases perfectly.
- You want to build a library for distributed systems
- You need to build a communications library that can run on both the browser and native platforms
- You want to build an RPC system or similar
- You want to build a distributed system and you need extreme amounts of control
- Anything that's got to do with distributed systems or communications
DISCLAIMER
Canary is not yet on 1.0, which means that the API is prone to changes. Nevertheless, the concepts should be stable BUT they are still prone to changes.
Concepts
Canary has few core concepts that need to be fully understood before anything.
The concepts are the following:
- Channels
- Providers
- Wire representation (optional)
These concepts will be explained in the following chapters.
Channels
Channels are a backend-agnostic way of communicating with peers. You can think of it as a wrapper around a stream (tcp, websockets or any other backend) that allows you to send and receive objects or messages.
For example, let's assume you have connected Alice's machine and Bob's machine.
#![allow(unused)] fn main() { use canary::Channel; use canary::Result; // result is equivalent to std::io::Result, but it implements `Serialize` / `Deserialize` // runs on Alice's machine async fn alice(mut chan: Channel) -> Result<()> { chan.send("Hey Bob!").await?; Ok(()) } // runs on Bob's machine async fn bob(mut chan: Channel) -> Result<()> { let message: String = chan.receive().await?; println!("alice says: `{}`", message); // alice says: `Hey Bob!` Ok(()) } }
You can send objects that implement Serialize
and receive objects that implement Deserialize
.
Channels by default use Bincode for serialization, but they support various other formats such as JSON, BSON and Postcard.
Providers
Providers are the most way to get channels. You can bind them and then simply iterate over their Channels.
#![allow(unused)] fn main() { let tcp = Tcp::bind("127.0.0.1:8080").await?; while let Ok(chan) = tcp.next().await { let mut chan = chan.encrypted().await?; // choose if the channel should be encrypted chan.send("hello!").await?; } }
There are also addresses which encapsulate providers.
#![allow(unused)] fn main() { let addr = "tcp@127.0.0.1:8080".parse::<Addr>()?; let mut chan = addr.connect().await?; chan.send("hello!").await?; }
At the moment, Canary supports the following providers:
- TCP (works on non-wasm platforms)
- Unix (works on unix platforms)
- WebSockets (works on all platforms)
Wire Representation
If you don't care about efficiency or serialization, skip this section. Wire representation is how your protocol looks like on the wire.
It's important to know about the wire representation and channel representation of the protocols you build on top of Canary, since ALL distributed systems and communications have a channel representation (what is sent through the wire and what is received through the wire in order) and a wire representation (what is sent through the wire and received through the wire but separated) even those that aren't built on top of Canary.
Wire and channel representations also help debug distributed systems and also help know which types are equivalent on the wire ( e.g. &str and String have an equivalent wire representation ).
The differences between the wire representation and channel representation is that the wire representation is divided into inbound (receiving) wire representation and outbound (sending) wire representation.
An example wire representation:
send:
# position 1
1 u16,
# position 3
3 u16,
receive:
# position 2
2 u16,
This outbound wire representation looks like this on the wire:
(outbound)
1 3
-> 0x0 0x0
(inbound)
2
<- 0x0
Common wire-equivalent types are:
Vec<T>
and[T]
String
andstr
u16
and[u8; 2]
u32
and[u8; 4]
u64
and[u8; 8]
Another example of a wire representation:
send:
1 [u8; 2],
2 String,
receive:
3 Vec<u16>,
4 u16
Astute readers may have noticed that we run into a problem when trying to
represent this wire representation:
What about String
and Vec<u8>
?
They are dynamically sized, so their length is sent first (as an u64),
and then they're serialized and sent, so they look like this:
->
1 # [u8; 2]
0x0 0x0
2 # length of serialized object
0x0 0x0 0x0 0x0
# a unit struct represents a dynamically sized object
()
<-
3 # Vec<u16>, dynamically sized
0x0 0x0 0x0 0x0 ()
4 0x0
A condensed version of this wire representation looks like this:
->
1 0x0 0x0
2 0x0 0x0 0x0 0x0 ()
<-
3 0x0 0x0 0x0 0x0 ()
4 0x0
Currently due to simplicity, Canary sends all objects (even wire statically-sized-types) as dynamically sized. This means that there is space for improvement, but it is still sufficiently efficient for most use cases.
NOTE ABOUT TYPES: Sending and receiving non-equivalent wire represented types can lead to messaging problems which can be really hard to debug.