Search code examples
rustobserver-pattern

Extended Observer pattern in rust with dynamic payloads


I'm trying to implement a variant of the observer pattern. Currently I have it like this (after the example on Refactoring Guru here):

#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum Event {
    ElementACreated,
    ElementADeleted,
    ElementBCreated,
    ElementBDeleted,
}

pub type Subscriber = fn(event: Event);

#[derive(Default)]
pub struct Publisher {
    events: HashMap<Event, Vec<Subscriber>>,
}

impl Publisher {
    pub fn subscribe(&mut self, event_type: Event, listener: Subscriber) {
        self.events.entry(event_type.clone()).or_default();
        self.events.get_mut(&event_type).unwrap().push(listener);

        debug!("Subscribed to event: {:?}", event_type);
    }

    pub fn unsubscribe(&mut self, event_type: Event, listener: Subscriber) {
        self.events.get_mut(&event_type).unwrap().retain(|&x| x != listener);

        debug!("Unsubscribed from event: {:?}", event_type);
    }

    pub(crate) fn notify(&self, event_type: Event) {
        let listeners = self.events.get(&event_type).unwrap();
        for listener in listeners {
            listener(event_type.clone());
        }

        debug!("Notified {} listeners about event: {:?}", listeners.len(), event_type);
    }
}

I would like to extend it in a way that would allow me to also send event-specific payloads along in the notify function.

Example:

#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum Event {
    ElementACreated // <- Also sends ElementA in notify,
    ElementADeleted // <- Also sends ElementAId in notify,
    ElementBCreated // <- Also sends ElementB in notify,
    ElementBDeleted // <- Also sends ElementAId in notify,
}

pub type Subscriber = fn(event: Event, payload: <ElementA|ElementAId|ElementB|ElementBId>); // <-- This would need some kind of extension

#[derive(Default)]
pub struct Publisher {
    events: HashMap<Event, Vec<Subscriber>>,
}

impl Publisher {
    pub fn subscribe(&mut self, event_type: Event, listener: Subscriber) {
        self.events.entry(event_type.clone()).or_default();
        self.events.get_mut(&event_type).unwrap().push(listener);

        debug!("Subscribed to event: {:?}", event_type);
    }

    pub fn unsubscribe(&mut self, event_type: Event, listener: Subscriber) {
        self.events.get_mut(&event_type).unwrap().retain(|&x| x != listener);

        debug!("Unsubscribed from event: {:?}", event_type);
    }

    pub(crate) fn notify(&self, event_type: Event, payload: <ElementA|ElementAId|ElementB|ElementBId>) { // <-- This as well
        let listeners = self.events.get(&event_type).unwrap();
        for listener in listeners {
            listener(event_type.clone(), payload);
        }

        debug!("Notified {} listeners about event: {:?}", listeners.len(), event_type);
    }
}

I tried extenting the Event Enum like so:

#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum Event {
    ElementACreated(ElementA),
    ElementADeleted(ElementAId),
    ElementBCreated(ElementB),
    ElementBDeleted(ElementBId),
}

But then the subscribers would only ever listen to one Id or one struct as the Hash in the Publisher::events HashMap would be to restricted.

Any ideas on how to glue Event Type and Payload together to create an ergonomic and slick solution would be greatly appreciated!


Solution

  • First of all, let's take a look at how we could make HashMap behave with enum variants with the same values even if the underlying data is different.

    #[derive(Debug, Clone)]
    pub enum Event {
        ElementACreated(ElementA),
        ElementADeleted(ElementAId),
        ElementBCreated(ElementB>),
        ElementBDeleted(ElementBId),
    }
    

    It's fairly simple. To accomplish it need to implement Hash, PartialEq, and Eq with our own implementation instead of the derived one.

    impl Hash for Event {
        fn hash<H: Hasher>(&self, state: &mut H) {
            match self {
                Event::ElementACreated(_) => {
                    "ElementACreated".hash(state);
                }
                Event::ElementADeleted(_) => {
                    "ElementADeleted".hash(state);
                }
                Event::ElementBCreated(_) => {
                    "ElementBCreated".hash(state);
                }
                Event::ElementBDeleted(_) => {
                    "ElementBDeleted".hash(state);
                }
            }
        }
    }
    
    // discriminant function returns a value uniquely identifying the enum variant
    impl PartialEq for Event {
        fn eq(&self, other: &Self) -> bool {
            std::mem::discriminant(self) == std::mem::discriminant(other)
        }
    }
    
    impl Eq for Event {}
    

    Then Event could be used as a key type for HashMap and as a data container. But here is a catch. Publisher accepts event in a subscribe function. pub fn subscribe(&mut self, event_type: Event, listener: Subscriber)

    That leads to very inconvenient(bad) API design. The user must pass the event instance to the subscribe function.

    let mut p = Publisher::default();
    
    p.subscribe(Event::ElementACreated(...), |ev| {
         println!("Received event: {:?}", ev);
    });
    

    To resolve this situation let's refactor the current design of the event to two enums and combine the structure with these two enums.

    #[derive(Debug, Clone)]
    pub enum EventData {
        ElementA { id: ElementAId, name: String },
        ElementAId(u64),
        ElementB { id: ElementBId, name: String },
        ElementBId(u64),
    }
    
    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
    pub enum EventType {
        ElementACreated,
        ElementADeleted,
        ElementBCreated,
        ElementBDeleted,
    }
    
    #[derive(Debug, Clone)]
    pub struct Event {
        event_type: EventType,
        data: EventData,
    }
    

    Then subscribe will look like this

    pub fn subscribe(&mut self, event_type: EventType, listener: Subscriber) {
        self.events
            .entry(event_type.clone())
            .or_default()
            .push(listener);
    
        println!("Subscribed to event: {:?}", event_type);
    }
    

    In notify, only data should be passed, and then the necessary event for that data can be determined.

    pub(crate) fn notify(&self, data: EventData) {
        let event_type = match &data {
            EventData::ElementA { .. } => EventType::ElementACreated,
            EventData::ElementAId(_) => EventType::ElementADeleted,
            EventData::ElementB { .. } => EventType::ElementBCreated,
            EventData::ElementBId(_) => EventType::ElementBDeleted,
        };
    
        let listeners = self.events.get(&event_type).unwrap();
        for listener in listeners {
            listener(Event {
                event_type: event_type.clone(),
                data: data.clone(),
            });
        }
    
        println!(
            "Notified {} listeners about event: {:?}",
            listeners.len(),
            event_type
        );
    }
    

    P.S.

    Also, Event could be obliterated, and Subscriber will pass EventType and EventData.

    pub type Subscriber = fn(event_type: EventType, data: EventData);