Back to blog

Rama 101.2: Core Concepts

In this second lesson of Rama 101, we will explore some of the concepts that are core to Rama. While these were already mentioned by name or introduced implicitly in the previous article, this lesson will examine them in more depth before we move on to the server side of things.

This article is part of a series that introduces you to Rama and its core concepts, while also helping you pick up some network programming knowledge along the way.

To briefly recap, Rama is a modular service framework for Rust that provides a cohesive foundation for building network clients, servers, proxies, and combinations thereof. Its crates cover different protocols and features, but are designed and tested to work together as one framework, allowing you to compose the network service stack that your use case requires.

In the previous lesson (Rama 101.1), we used an HTTPS client to explore Rama's different layers of abstraction. We started with the high-level EasyHttpWebClient and gradually worked our way down through layers, connectors, DNS, TCP, TLS, and HTTP.

Along the way, we learned that all these components are services, and that any service with the right input and output types can be exposed through a convenient high-level API. In other words: everything is a service.

Next time, I want to show you how one might build service stacks on the server side and how, due to everything being a service, the separation between clients and servers can at times get a bit blurry.

Core Concepts

Initially, I was planning to immediately dive into an HTTPS server network stack and continue Rama's introduction that way. However, while preparing presentations to introduce Rama to the London Rust Project Group as well as the Copenhagen Rust Community, I realized that so far we hadn't actually covered the core concepts of Rama with dedication and care. With that realization behind us, let us do just that.

rama::Service

Rust declarationOpen edge docsrama#5960f3bc
pub trait Service<Input>: Sized + Send + Sync + 'static {    /// The type of the output returned by the service.    type Output: Send + 'static;    /// The type of error returned by the service.    type Error: Send + 'static;    /// Serve an output or an error for the given input    fn serve(        &self,        input: Input,    ) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send + '_;    /// Box this service to allow for dynamic dispatch.    fn boxed(self) -> BoxService<Input, Self::Output, Self::Error> {        BoxService::new(self)    }}

The Service trait is a direct descendant of tower's Service, which itself was derived from the Finagle (Java) project. We did our initial async tower experiments in the (now archived) async-tower crates. The lessons that came out of that formed the base for the Service trait we see here.

The Service trait is the minimal abstraction of the concept of something which takes an input and asynchronously returns a fallible output. Whether this is a struct or a function makes no difference to the trait itself.

Rama is not about the Service trait. It is, however, core to the framework, in much the same way as "functions", data records and traits are. All these concepts are the primitives that shape the contracts between the various modules and utilities that make up Rama. They also provide you with the ability to write your own modules and stack them in just the same way.

rama::Layer

Rust declarationOpen edge docsrama#5960f3bc
pub trait Layer<S>: Sized {    /// The service produced by the layer.    type Service;    /// Wrap the given service with the middleware, returning a new service.    fn layer(&self, inner: S) -> Self::Service;    /// Same as `layer` but consuming self after the service was created.    ///    /// This is useful in case you no longer need the Layer after the service    /// is created. By default this calls `layer` but if your `Layer` impl    /// requires cloning you can impl this method as well to avoid the cloning    /// for the cases where you no longer need the data in the `Layer` after    /// service ceation.    fn into_layer(self, inner: S) -> Self::Service {        self.layer(inner)    }}

This is the abstraction of the concept of something which wraps a service and produces another service. While nothing in the contract specifies that the input service actually has to be used or wrapped, it pretty much always does just that.

rama::error

Within network stacks, many things can go wrong, and thus the ability to express, propagate and enrich errors is very important to Rama. With an eye on our dependency graph, we ship our own utilities and abstractions around them.

Rust declarationOpen edge docsrama#5960f3bc
pub type BoxError = self::std::Box<dyn StdError + Send + Sync + 'static>;

For service stacks, the exact type of error is often not too important, or it might be impractical to represent appropriately, as is the case for errors resulting from deep within a nested service stack.

For Results and Options you can make use of the ErrorContext extension trait to add context to the error, such as a message in the form of a static str or key-value pairs. For the latter we support debug, display, hex and closure-origin values.

Similar context can be added directly on any error, by making use of the ErrorExt extension trait.

These utilities, together with a couple more internal ones, are sufficient for both the Rama framework itself and Rama users, in most cases, to have rich error propagation without having to add yet another set of dependencies to your projects.

rama::io

With Rama as the foundation for anything related to networking, we also operate on the OSI network layers where the inputs are bytes: basically, anything below the application layer (L7). This includes TLS and goes all the way down to transport layers (L4), such as TCP/UDP, with many other protocols somewhere in between. Within Rama we abstract this kind of input as IO:

Rust declarationOpen edge docsrama#5960f3bc
pub trait Io: AsyncRead + AsyncWrite + Send + 'static {}

This allows middleware and other services to operate on anything which satisfies this trait bound without having to worry about where the input actually comes from.

For example, while SOCKS5 usually operates on top of TCP streams, nothing stops you from wrapping it within TLS and thus operating on that as input instead. Providing security for your proxy tunnel. Because you deserve it. And with Rama it is possible, easily so.

Note

In light of io-uring and similar data flows, we are also working on an owned form of IO to allow zero-copy buffer flows. The details depend on the exact platform and workspace, but you can start exploring this work in progress by checking out IoBuf.

rama::extensions

Static state, such as struct properties and any kind of scoped state, is what you should aim to utilise as much as you can. It is here where Rust has your back to the fullest extent, catching all kinds of possible mistakes at compile time. This is especially useful once things get fairly complex.

There is, however, also plenty of optional state for which this is not suitable or even possible. For that we make use of the concept of Extensions within Rama:

Rust declarationOpen edge docsrama#5960f3bc
pub struct Extensions {    extensions: Arc<AppendOnlyVec<TypeErasedExtension, 12, 3>>,    parent: Option<Box<Self>>,}
Rust declarationOpen edge docsrama#5960f3bc
pub trait Extension: Any + Send + Sync + core::fmt::Debug + 'static {}
Rust declarationOpen edge docsrama#5960f3bc
pub trait ExtensionsRef {    /// Get reference to the underlying [`Extensions`] store    fn extensions(&self) -> &Extensions;}

Extensions are contained within Input that passes through Service stacks. Accessed through ExtensionsRef, they contain anything which implements the Extension trait.

Examples of optional data are:

  • State which may or may not be present based on middleware logic or user behaviour. This state can then be used by middleware and other types of services to define their own behaviour.
  • Breadcrumbs, each containing information about the input that passed through it. Examples are (protocol) handshake outcomes, identifier information such as authentication or socket information and more.

It is a very useful concept that is essential in the composition of stacks across protocol and OSI layer boundaries. And it is what provides the flexibility within an otherwise strict and static environment.

Conclusion

In the first part, we learned how one might create a network stack to produce a functioning HTTPS client. We learned how it was composed of modules and how flexible it was in its approach. In this article, we went over the core concepts on which everything else in Rama is built. In the next part of this Rama 101 series, we will continue to build upon these concepts, this time on the server side.

Try to make sure you understand these concepts well, as it will make it much easier to read the other parts of this series as well as use Rama in general. That said, it is also completely normal that some concepts may not be immediately clear—or clear at all. If so, just try to keep at least the names and references in mind for now and try to hook back into these once you have used Rama more or have learned more about it. Eventually it will click.

Note

Follow our blog by subscribing to our newsletter or following our RSS Feed so you do not miss it when we release a new article in Rama 101, a series of blog articles to introduce you to Rama. In case you are new to network programming, you might learn a thing or two about its nifty protocols and how they "work together" as well.