25 Type System How to Design and Architect Systems Around Traits

25 Type System: How to design and architect around traits? #

Hello, I’m Chen Tian.

Trait, trait, trait, why keep talking about traits? How old are you?

I hope you haven’t grown tired of our endless discussions about traits. Because trait’s status in Rust development can never be overstated.

In fact, not just Rust’s traits, but any concept related to interfaces in any language, are among the most important concepts in the use of that language. The entire act of software development can basically be described as the continual creation and iteration of interfaces upon which implementations are built.

In this process, some interfaces are standardized and set in stone, like rebar, bricks, screws, nails, sockets, etc.—the materials used are consistent regardless of what kind of house is being built. Once these standard component interfaces are determined, they do not change. They are like the standard traits in the Rust standard library.

And some interfaces are closely related to the house being built, such as doors, windows, electrical appliances, furniture, etc. They are like the traits you design in your system, capable of connecting various parts of the system together to ultimately present a complete user experience.

We have discussed the basics of traits before and introduced how to use traits and trait objects in practice. Today, we will spend one lecture looking at how to design and architect systems around traits.

Since discussing architecture and design inevitably involves introducing requirements, which I need to explain in detail, and then provide design ideas, as well as introduce the role of traits, but doing so, the content of one lecture can barely cover a single system design. So let’s change our approach, comb through previously designed systems, review their trait design, and examine their ideas and trade-offs.

Using traits to make code naturally comfortable and user-friendly #

In [Lecture 5], within the thumbor project, I designed a SpecTransform trait that unifies handling of any type of spec describing how we wish to process images:

// A spec can include one of the processing methods mentioned above (this is defined in protobuf)
message Spec {
  oneof data {
    Resize resize = 1;
    Crop crop = 2;
    Flipv flipv = 3;
    Fliph fliph = 4;
    Contrast contrast = 5;
    Filter filter = 6;
    Watermark watermark = 7;
  }
}

The definition of the SpecTransform trait (code):

// SpecTransform: In the future, if more specs are added, simply implement this trait
pub trait SpecTransform<T> {
    // Apply transform to the image using op
    fn transform(&mut self, op: T);
}

It can be used to process an image with a particular spec.

However, if you read the source code on GitHub, you may notice an unused file imageproc.rs with a similar trait (code):

pub trait ImageTransform {
    fn transform(&self, image: &mut PhotonImage);
}

This trait is the first version. I still keep it to show the design choices of trait design.

When you examine this code, you might feel that the design of this trait is somewhat hasty. If the incoming image is from a different image processing engine and is not of type PhotonImage, then the interface would not be usable, right?

Hmm, that’s a significant design issue. Think about it, with our current knowledge, how would you solve this problem? What can help us delay the decision that the image must be a PhotonImage?

Exactly, generics. We can use a generic trait to modify the previous code:

// Using a trait to unify the interface that can be handled, any number of features can be added in the future, just add new Specs and implement the ImageTransform interface
pub trait ImageTransform<Image> {
    fn transform(&self, image: &mut Image);
}

By abstracting the type of the incoming image to a generic type, we delay the decision and support for the image type, making it more versatile.

However, if you continue to compare the current ImageTransform with the previously written SpecTransform, you’ll find that the data structures implementing the trait and the generic parameters used on the trait have been swapped.

Look at this, the PhotonImage below for the implementation of ImageTransform for Contrast:

impl ImageTransform<PhotonImage> for Contrast {
    fn transform(&self, image: &mut Image) {
        effects::adjust_contrast(image, self.contrast);
    }
}

And similarly, for PhotonImage under Contract's SpecTransform implementation:

impl SpecTransform<&Contrast> for Photon {
    fn transform(&mut self, op: &Contrast) {
        effects::adjust_contrast(&mut self.0, op.contrast);
    }
}

These two methods are essentially equivalent, but one revolves around Spec, the other around Image: - Image

So which design is better?

Neither has functional or performance advantages.

Then why did I choose the design of SpecTransform? In the first design, when I hadn’t considered Engine, it was centered around Spec. But after considering Engine, I redesigned it with Engine as the center. The advantage of doing this is that when developing a new Engine, it feels more natural to use the SpecTransform trait.

Hmm, convenient, natural. The design of the interface must pay attention to the user experience. An interface that feels natural and comfortable to use is a better interface. Because it means the code can be written naturally without needing to refer to the documentation.

Take, for example, Python code:

df[df["age"] > 10]

is more natural and comfortable than:

df.filter(df.col("age").gt(10))

With the former code, you can quickly learn to write it just by watching how others use it, while with the latter, you need to understand what the filter function is and how to use the col() and gt() methods.

Let’s look at another two pieces of Rust code. This line of code using the From/Into trait:

let url = generate_url_with_spec(image_spec.into());

is much more concise and natural than:

let data = image_spec.encode_to_vec();
let s = encode_config(data, URL_SAFE_NO_PAD);
let url = generate_url_with_spec(s);

It hides the implementation details, allowing the user to focus on their concerns. - So when designing traits, in addition to focusing on functionality, we must also pay attention to whether they are user-friendly and easy to use. This is also why when introducing the KV server, we keep stressing that after designing the trait, don’t rush to write code that implements the trait, but it’s best to first write some test code for using the trait.

Your experience writing this test code is the real experience of people using your traits to build systems. If it feels awkward to use and is not easy to use correctly without looking at the documentation, then the trait itself still needs further iteration.

Using traits for bridging #

Most of the time in software development, we’re not building all parts of a system from scratch. Just like building a house, we can’t start from a handful of dirt or a roof tile. We depend on components that already exist in the ecosystem.

As an architect, your responsibility is to find the right components in the ecosystem, combine them with the parts you’ve created, and form a product. So, when you encounter components whose interfaces don’t match what you expected and you can’t change those components to meet your expectations, what should you do?

That’s where bridging comes in.

Just like the appliance needing a two-pin connector, but the nearby wall outlet only has a three-pin, we can’t just modify the appliance or the wall outlet, can we? The right approach is to use a multi-outlet adaptor.

In Rust, bridging can be done through functions, but it’s best done through traits. Continuing to look at the Engine trait in [Lecture 5] (code):

// Engine trait: more engines can be added in the future, and the main process only needs to replace the engine
pub trait Engine {
    // Process the engine according to a series of ordered specs
    fn apply(&mut self, specs: &[Spec]);
    // Generate the target image from the engine, note this uses `self` rather than a reference of `self`
    fn generate(self, format: ImageOutputFormat) -> Vec<u8>;
}

Through the Engine trait, we connect a third-party library photon and our own Image Spec, so that we don’t need to know what exactly is behind the Engine, only needing to call apply and generate methods:

// Use the image engine for processing
let mut engine: Photon = data
    .try_into()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
engine.apply(&spec.specs);
let image = engine.generate(ImageOutputFormat::Jpeg(85));

In this snippet, as Photon implements TryFrom, you can directly call try_into() to get a photon engine:

// Converting from `Bytes` to the `Photon` structure
impl TryFrom<Bytes> for Photon {
    type Error = anyhow::Error;

    fn try_from(data: Bytes) -> Result<Self, Self::Error> {
        Ok(Self(open_image_from_bytes(&data)?))
    }
}

When it comes to bridging thumbor code and the photon crate, Engine performs well. It not only makes it very easy for us to use the photon crate, but it also allows us to readily replace the photon crate if necessary in the future.

However, the Engine doesn’t bridge as intuitively and naturally during construction. Without looking closely at the code or documentation, users may not understand how, on the third line of code, an engine structure that implements Engine can be obtained through TryFrom/TryInto. From this usage experience, we would hope that by using the Engine trait, any image engine could uniformly create Engine structures. What to do?

We could add a default create method to this trait:

// Engine trait: more engines can be added in the future, and the main process only needs to replace the engine
pub trait Engine {
    // Create a new engine
    fn create<T>(data: T) -> Result<Self>
    where
        Self: Sized,
        T: TryInto<Self>,
    {
        data.try_into()
            .map_err(|_| anyhow!("failed to create engine"))
    }
    // Process the engine according to a series of ordered specs
    fn apply(&mut self, specs: &[Spec]);
    // Generate the target image from the engine, note this uses `self` rather than a reference of `self`
    fn generate(self, format: ImageOutputFormat) -> Vec<u8>;
}

Note the new create method’s constraint: any T that implements the corresponding TryFrom/TryInto can use this default create() method to construct an Engine.

With this interface, the above code using the engine can be made more straightforward, omitting the third line’s try_into() handling:

// Use the image engine for processing
let mut engine = Photon::create(data)
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
engine.apply(&spec.specs);
let image = engine.generate(ImageOutputFormat::Jpeg(85));

Bridging is a very important concept in architecture, and we must grasp the essence of this idea.

For example, let’s say we want a system that can access a certain REST API and return a user’s Moments sorted in reverse chronological order. How would you write this code? The most straightforward and crude way is:

let secret_api = api_with_user_token(&user, params);
let data: Vec<Status> = reqwest::get(secret_api)?.json()?;

A better way is to use the FriendCircle trait to bridge and hide the implementation details:

pub trait FriendCircle {
    fn get_published(&self, user: &User) -> Result<Vec<Status>, FriendCircleError>;
    ... 
}

This way, our business logic code can be developed around this interface without worrying about whether its implementation is from a REST API or elsewhere. We also don’t need to worry about whether the implementation has caching, whether there’s a retry mechanism, or what specific errors might be returned (as FriendCircleError already provides all the possible errors).

Using traits for inversion of control #

Let’s continue to look at the Engine trait. The TryInto trait decouples between Engine and T, allowing callers to flexibly handle their T:

pub trait Engine {
    // Create a new engine
    fn create<T>(data: T) -> Result<Self>
    where
        Self: Sized,
        T: TryInto<Self>,
    {
        data.try_into()
            .map_err(|_| anyhow!("failed to create engine"))
    }
    ...
}

This also reflects another important role of traits in design: inversion of control.

By using traits, when we design lower-level libraries, we tell the upper layer: I need some data that satisfies trait X because I rely on the methods of trait X implemented in this data to accomplish certain features. But what exactly the data is and how it’s implemented, I don’t know and don’t care.

The newly built create method for Engine. T is a dependency required to implement Engine. We don’t know how T type data is generated in the context, nor do we care what T is exactly, as long as T implements TryInto. This is a typical example of inversion of control.

Another example of using traits for inversion of control is seen in [Lecture 6] with the Dialect trait (code):

pub trait Dialect: Debug + Any {
    /// Determine if a character starts a quoted identifier. The default
    /// implementation, accepting "double quoted" ids is both ANSI-compliant
    /// and appropriate for most dialects (with the notable exception of
    /// MySQL, MS SQL, and sqlite). You can accept one of the characters listed
    /// in `Word::matching_end_quote` here
    fn is_delimited_identifier_start(&self, ch: char) -> bool {
        ch == '"'
    }
    /// Determine if a character is a valid start character for an unquoted identifier
    fn is_identifier_start(&self, ch: char) -> bool;
    /// Determine if a character is a valid unquoted identifier character
    fn is_identifier_part(&self, ch: char) -> bool;
}

We just need to implement Dialect trait for our SQL dialect:

// Create our own SQL dialect. TyrDialect supports identifiers that can be simple urls
impl Dialect for TyrDialect {
    fn is_identifier_start(&self, ch: char) -> bool {
        ('a'..='z').contains(&ch) || ('A'..='Z').contains(&ch) || ch == '_'
    }

    // identifier can have ':', '/', '?', '&', '='
    fn is_identifier_part(&self, ch: char) -> bool {
        ('a'..='z').contains(&ch)
            || ('A'..='Z').contains(&ch)
            || ('0'..='9').contains(&ch)
            || [':', '/', '?', '&', '=', '-', '_', '.'].contains(&ch)
    }
}

And then have the sql parser parse our SQL dialect:

let ast = Parser::parse_sql(&TyrDialect::default(), sql.as_ref())?;

This is the powerful utility of the seemingly simple Dialect trait.

For us users, through the Dialect trait, we can conveniently inject our parsing functions to provide additional information for our SQL dialect. For the authors of sqlparser, through the Dialect trait, they do not need to care how many dialects will emerge in the future or what each dialect looks like, as long as the dialect’s author tells them how to tokenize an identifier.

Inversion of control is an often used feature in architecture that allows the relationship between the caller and callee to be reversed at a certain moment, with the callee calling back on the abilities provided by the caller, and both coordinating to accomplish some task.

For example, the architecture of MapReduce: what methods are used for map and reduce is unclear to MapReduce’s architecture designers, but callers can provide these methods to the MapReduce architecture to be called at the right time.

Of course, inversion of control is not only achieved through traits, but using traits for inversion of control is extremely flexible. The caller and callee only need to focus on their interface, rather than the specific data structure.

Using traits to implement SOLID principles #

In fact, using traits for inversion of control as we just introduced is at the heart of one of the SOLID principles often discussed in object-oriented design—the Dependency Inversion Principle DIP—a very important idea for building flexible systems.

In object-oriented design, we often discuss the SOLID principles:

  • SRP: The Single Responsibility Principle, which means that each module should only be responsible for a single functionality and should not combine multiple functionalities but should be composed together.
  • OCP: The Open-Closed Principle, which means that software systems should be closed to modifications but open to extensions.
  • LSP: The Liskov Substitution Principle, which means if components are replaceable, then these interchangeable components should follow the same constraints, or the same interface.
  • ISP: The Interface Segregation Principle, which means that users only need to know the methods they are interested in and should not be forced to understand and use methods or features that are useless to them.
  • DIP: The Dependency Inversion Principle, which means that in some cases, lower-level code should depend on higher-level code instead of higher-level code depending on lower-level code.

While Rust is not an object-oriented language, these ideas applied universally.

In previous lessons, I have always emphasized SRP and OCP. Look at the Fetch/Load trait in [Lecture 6], where each is only responsible for a very simple action:

#[async_trait]
pub trait Fetch {
    type Error;
    async fn fetch(&self) -> Result<String