Skip to content

EventBus

Christian R edited this page May 16, 2023 · 13 revisions

Here I present two alternatives for an Event Bus system. The first called "Hijacking the Logger" is easier to integrate into the existing architecture but it is not a architecturally clean solution. The second alternative called "More Indirection" is cleaner but more work.

No matter the alternative all event consuming objects should be able to register callbacks for specific event types. These callbacks should then be called when such an event is logged. The registration returns a handle that can be used for unregistration later.

sequenceDiagram
    activate EventConsumer
    EventConsumer->>+EventBus: register_callback(callback, event_type)
    EventBus-->>-EventConsumer: handle
    deactivate EventConsumer
Loading
sequenceDiagram
    activate EventConsumer
    EventConsumer->>+EventBus: unregister_callback(handle)
    EventBus-->>-EventConsumer:  
    deactivate EventConsumer
Loading

Hijacking the Logger

This solution requires minimal change in already existing code and is therefore very simple to integrate into the current architecture. The idea is to override the save method of LogEntry and to notify the EventBus there. The EventBus takes the LogEntry as argument and then looks for all registered callbacks associated with the entrys type and calls them passing the entry as argument again.

The downside is that it violates the single responsibility principle. The Logger is not responsible for managing an event system.

classDiagram
    class BaseModel {
        <<abstract>>
    }
    class LogEntry {
        <<abstract>>
        +save()
    }
    class Logger {
        +log(*args, **kwargs)
    }
    class Component {
    }
    class EventBus {
        <<singleton>>
        +callbacks: list[tuple[callable[[LogEntry], None], Type[LogEntry]]]
        +register_callback(callback: callable[[LogEntry], None], entry_type: Type[LogEntry]): UUID
        +unregister_callback(handle: UUID)
        +notify(entry: LogEntry)
    }
    class EventConsumer {
        +event_handle: UUID
        +on_event(entry: LogEntry)
    }

    Logger <-- Component: logs event
    LogEntry <-- Logger: creates
    BaseModel <|-- LogEntry
    EventBus <-- LogEntry: notifies
    EventBus <-- EventConsumer: (un)registers callbacks
    EventConsumer <-- EventBus: notifies
Loading
sequenceDiagram
    activate Component
    Component->>+Logger: log(*args, **kwargs)
    Logger->>+LogEntry: create(*args, **kwargs)
    LogEntry->>+LogEntry: save()
    LogEntry->>+EventBus: notify(self)
    EventBus->>+EventConsumer: on_event(entry)
    EventConsumer-->>-EventBus:      
    EventBus-->>-LogEntry:  
    LogEntry-->>-Logger:  
    deactivate LogEntry 
    Logger-->>-Component:  
    deactivate Component
Loading

More Indirection

This alternative abstracts the logger interface into ILogger and enables the usage of arbitrary many objects using this interface. Two of these objects are the already existing logger and also the EventBus. Objects inheriting from Component now communicate with the EventDistributor instead of the Logger using the same interface. The EventDistributor then forwards the call to Logger and EventBus. The EventBus then uses an Event having an EventType instead of the LogEntry. The Event holds all the data the LogEntry would hold. The rest of the mechanism works same as the first alternative presented above.

The disadvantage is that it is more work and one has to change more existing code. Especially the code concerning the interaction with the logger interface.

classDiagram
    class ILogger {
        <<abstract>>
        +log(*args, **kwargs)
    }
    class Logger {
    }
    class EventBus {
        <<singleton>>
        +callbacks: list[tuple[callable[[Event], None], EventType]]
        +register_callback(callback: callable[[Event], None], event_type: EventType): UUID
        +unregister_callback(handle: UUID)
    }
    class EventType {
        <<enum>>
    }
    class Event {
    }
    class EventConsumer {
        +event_handle: UUID
        +on_event(event: Event)
    }
    class EventDistributor {
    }
    class Component {
    }

    ILogger <|-- Logger
    ILogger <|-- EventBus
    ILogger <|-- EventDistributor
    EventDistributor "" o-- "n" ILogger
    Component "" *-- "1" EventDistributor
    Event "" *-- "1" EventType
    Event <-- EventBus: creates
    EventBus <-- EventConsumer: (un)registers callbacks
    EventConsumer <-- EventBus: notifies
Loading
sequenceDiagram
    activate Component
    Component->>+EventDistributor: log(*args, **kwargs)
    EventDistributor->>+Logger: log(*args, **kwargs)
    Logger-->>-EventDistributor:  
    EventDistributor->>+EventBus: log(*args, **kwargs)
    EventBus->>+Event: __init__(*args, **kwargs)
    Event-->>-EventBus: self
    EventBus->>+EventConsumer: on_event(event)
    EventConsumer-->>-EventBus:  
    EventBus-->>-EventDistributor:  
    EventDistributor-->>-Component:  
    deactivate Component
Loading

Updated version of "More Indirection"

This version addresses the issue that EventBus and EventDistributor are more or less the same. The Logger should be also an event consumer. The log method of EventBus stands for all methods the Logger currently has.

classDiagram
  class Component {
  }
  class EventBus {
    +callbacks: list[tuple[callable[[Event], None], EventType]]
    +register_callback(callback: callable[[Event], None], event_type: EventType): UUID
    +unregister_callback(handle: UUID)
    +log(*args, **kwargs)
  }
  class EventConsumer {
    +event_handle: UUID
    +on_event(event: Event)
  }
  class Event {
    +arguments: dict[str, any]
  }
  class EventType {
    <<enum>>
  }
  class Logger {
  }

  Component "" *-- "1" EventBus: event_bus
  EventBus --> Event: creates
  Event "" *-- "1" EventType
  EventBus --> EventConsumer: notifies
  EventConsumer --> EventBus: (un)registers callbacks
  Logger --> EventConsumer: is
Loading
sequenceDiagram
  activate Component
  Component->>+EventBus: log(*args, **kwargs)
  EventBus->>+Event: __init__(*args, **kwargs)
  Event-->>-EventBus:  
  EventBus->>+EventConsumer: on_event(event)
  EventConsumer-->-EventBus:  
  EventBus-->-Component:  
  deactivate Component
Loading

Usage

Emitting Events

Emitting of events is simple and replaces calling logger methods. So instead of for example calling self.logger.spawn_train(tick, train_id) you would now call self.event_bus.spawn_train(tick, train_id). All classes inheriting from Component therefore hold a reference to event_bus instead of a logger reference. When calling methods of event_bus they are automatically to the logger if the logger has subscribed to them.

Subscribing to Events

To subscribe to events you have to register a callback and an associated EventType at the EventBus. You therefore call in for example __init__ of your Component the method self.event_bus.register_callback(callback, EventType.TRAIN_SPAWN). callback here is a callable taking an Event as argument. The event has the attributes event_type and arguments where arguments holds a dictionary containing all arguments given to the method call to the EventBus that emitted the event.

When registering a callback you get back a handle. You can use this handle to unregister the callback by calling self.event_bus.unregister(handle).

Defining new Events

To define a new event you have to add a new entry to EventBus.EVENT_METHODS in src.event_bus.event_bus.py. The key is the name of the method one has to call on the EventBus to emit the new event. The value is a tuple containing a list of strings and the EventType associated with the event. The list contains the names of all arguments one needs to pass to the mentioned method call.

All Components that need to react to the new Event (for example the Logger) have to subscribe to it as described above.