Design Patterns in Rust: Mediator, or uncoupling objects made easy

Photo by Gio Galvis-Giron: https://www.pexels.com/photo/white-concrete-building-with-white-metal-railings-9399917/

Introduction

The mediator pattern is a pettern used when you want to simplify communication i.e. message dispatching in complex application. It involves building a mediator where each object can delegate its communication, it also routes the messages to the right receivers(s).

So, what does it look like? Well, like this:

An explanation of this diagram is needed:

  1. First there is the Mediator interface, which defines the message which can be routed through this Mediator.
  2. The ConcreteMediator implements the Mediator interface, and coordinates the communication between the ConcreteWorker objects. It knows about the ConcreteWorker objects and how they should communicate
  3. The Worker is an interface (or in some languages an abstract class) which is an interface for the communication between the ConcreteWorkers
  4. The ConcreteWorker(x) classes implement the Worker interface, and communicate with other ConcreteWorker classes through the Mediator.

Implementation in Rust

In an empty directory open your commandline or terminal and type:

cargo new mediator_pattern
cd mediator_pattern

Open this directory in your favourite IDE, and edit the main.rs file. In this example we will build some sort of very simple chat-system.

Add the following as the first line of your main.rs file:

use std::{rc::Rc, cell::RefCell, collections::HashMap};

We will need this in the rest of this example.

The Mediator trait

The Mediator trait is very simple:

trait Mediator {
    fn notify(&self, sender: &str, event: &str);
}

In it, we see one method: notify() which notifies all the registered receivers, that is the receivers which belong to this Mediator.

The Worker trait

The Worker trait looks as follows:

trait Worker {
    fn set_mediator(&mut self, mediator: Rc<RefCell<dyn Mediator>>);
    fn send(&self,message:&str);
    fn receive(&self,sender:&str,message:&str);
}

It has three methods, send() and receive() are self-explanatory.

The set_mediator() method

The set_mediator() method gets two parameters:

  1. &self which is a reference to the object
  2. mediator, which is of type Rc<RefCell<dyn Mediator>>

Let us start with the dyn Mediator: because Mediator is a trait, we need to specify that we need dynamic dispatch.

Then we get to the Rc and RefCell. Since the mediator is shared among implementations, which is quite un-idiomatic for Rust, we need some form of reference counting, and that is what this does.

During the making of this example, this was the biggest problem I encountered, and even this solution is not quite elegant in my opinion.

The ChatMediator trait

The ChatMediator trait looks as follows:

struct ChatMediator {
    workers: HashMap<String,Rc<RefCell<dyn Worker>>>,
}

We are using a HashMap here with a String as key, which is logical as each User has a name. The value however is Rc<RefCell<dyn Worker>>>. This is because Worker implementation might be shared among different Mediators and we need some kind of reference counting mechanism.

The ChatMediator implementation

We need to implement only one method here, namely the notify() method:

impl Mediator for ChatMediator {
    fn notify(&self, sender: &str, event: &str) {
        for (key,worker) in self.workers.iter() {
            if key!=sender {
                worker.borrow().receive(sender, event);
            }
        }
    }
}

All this method does is iterate over all the workers, then test if the sender and receiver are not the same. If so, then a message can be sent.

The User struct

Have a look at the User struct :

struct User {
    name: String,
    mediator: Option<Rc<RefCell<dyn Mediator>>>,
}

The User struct has two fields:

  1. Each User has a name, as mentioned before.
  2. Each User has a Mediator object to send messages to. This is of the Rc<RefCell<dyn Mediator>>, as we have seen before, as the Mediator can be shared among more workers, and some kind of reference counting is needed. However, we wrapped this in an Option simply because when a User is constructed, the mediator can be None.

The User implementation

Implementing the Worker trait for User is quite straightforward:

impl Worker for User {
    fn set_mediator(&mut self, mediator: Rc<RefCell<dyn Mediator>>) {
        self.mediator = Some(mediator);
    }

    fn send(&self, message: &str) {
        if let Some(mediator)=&self.mediator {
            mediator.borrow().notify(&self.name,message);
        }
    }

    fn receive(&self, sender: &str, message: &str) {
        println!("{} received from {}: {}", self.name, sender, message);
    }
}

A short explanation:

  1. The set_mediator() method simply sets the mediator of the User to the passed-in parameter.
  2. The send() method first checks if there is a mediator to talk to. If there is, it explicitly borrows the mediator, and calls the notify() method, with the sender’s name and the message.
  3. The receive() method just gets the name of the sender and the message and prints it out.

Time to test

Now we can do some simple tests in the main() method:

fn main() {
    let mediator = Rc::new(RefCell::new(ChatMediator {
        workers: HashMap::new(),
    }));

    let alice = Rc::new(RefCell::new(User {
        name: String::from("Alice"),
        mediator: None,
    }));
    alice.borrow_mut().set_mediator(Rc::clone(&mediator) as Rc<RefCell<dyn Mediator>>);

    let bob = Rc::new(RefCell::new(User {
        name: String::from("Bob"),
        mediator: None,
    }));
    bob.borrow_mut().set_mediator(Rc::clone(&mediator)  as Rc<RefCell<dyn Mediator>>);

    mediator.borrow_mut().workers.insert(String::from("Alice"), Rc::clone(&alice) as Rc<RefCell<dyn Worker>>);
    mediator.borrow_mut().workers.insert(String::from("Bob"), Rc::clone(&bob) as Rc<RefCell<dyn Worker>>);

    alice.borrow().send("Hello, Bob!");
    bob.borrow().send("Hi, Alice!");
}

A short breakdown:

  1. First we construct an implementation of the Mediator trait , which is a ChatMediator in our case. We initialize it with an empty HashMap
  2. Next we construct a User, named ‘alice’, with her name, and no mediator yet
  3. We explicitly do a mutable borrow of alice, which we can so since it is an Rc, and set the mediator, to a clone of the mediator. The as clause is needed since Rc::clone does not return a trait but the concrete struct.
  4. We do the same for user ‘bob’
  5. Next we insert the workers, but first we need to do a mutable borrow of the mediator. It is also in an Rc so we can do that. We insert not only the name, but also an Rc::clone of the worker, which we need to cast, since Rc::clone does not return a trait.
  6. Now it is time to send message, using the explicitly borrowed instances of alice and bob

Conclusion

This is possibly one of the hardest patterns I had to implement, mainly because of the inherent object sharing in this pattern, which due to ownership-rules is quite unidiomatic for Rust. It took some research (using sites like ChatGPT and phind) but I finally managed to get it right. However, the implementation is not very elegant and certainly not thread-safe. There are alternatives: there is a crate called mediator which could be of help, I also understand it supports async scenarios.

If there is such a crate why take the trouble? Well, implementing these patterns I consider to be training, as I am still learning the language, and implementing such a thing by yourself can be quite instructive.

Leave a Reply

Your email address will not be published. Required fields are marked *