Udostępnij za pośrednictwem


Design patterns: How do you notify components about events that happen in different components?

I recently had to solve these kind of requirements: some components execute actions and at some point in time need to notify other components that certain events happened, so the other components can execute code related to these events. For example, class A executes method ChangeStateOfObjectX, and needs to notify class B when X is changed; then B does a certain action (for example, increments a perf counter that illustrates the number of inner objects in X, or writes an event entry or plays a song) when it’s notified. Let’s call class A the Events Generator (because it generates events :)) and B the Events Consumer (because it takes actions in response to the events generated by A).

Let’s take a concrete example: the class ProjectProgressTracker keeps track of a project progress – this is the Events Generator; when a task is added, completed or blocked (all internal states that the user doesn’t have access to), it needs to let a monitor system know – the Events Consumer.

 // generates events when a task is added, completed, blocked etc
class ProjectProgressTracker {
    static Random r = new Random((int)DateTime.Now.Ticks);
    List<Task> tasks;

    public ProjectProgressTracker(string[] initialTasks) {
        this.tasks = new List<Task>();
        if (initialTasks != null)
            foreach (string s in initialTasks)
                this.tasks.Add(new Task(s));
    }

    public void NextAction() {
        int randomNumber = r.Next(10);
        if (randomNumber < 3)
            // a new task was added
        else if (randomNumber > 6)
            // a task is blocked
        else
            // the task is done
    }

    class Task {
        string name;
        public Task(string name)
            this.name = name;
        // other properties & methods
    }
}

This monitor system – StatusUpdatePublisher, sends mails and updates calendars.

 // the class that reacts to events generated by ProjectProgressTracker
class StatusUpdatePublisher {
    public void OnTaskAdded(string taskName) {
        this.UpdateCalendar(true);
        this.SendMail(taskName, "added");
    }
    public void OnTaskBlocked(string taskName) {
        this.SendMail(taskName, "blocked");
    }
    public void OnTaskCompleted(string taskName) {
        this.UpdateCalendar(false);
        this.SendMail(taskName, "completed");
    }

    void SendMail(string taskName, string action) {
        Console.WriteLine("Task {0} was {1}", taskName, action);
    }
    void UpdateCalendar(bool taskAdded) {
        Console.WriteLine("Task was {0}", taskAdded ? "added" : "removed");
    }
}

The 2 functionalities (generator / consumer) are completely separate.

1. One way to implement this is to pass an Events Consumer object when constructing the Events Generator; then the generator class simply uses the consumer instance to call the desired methods.

 class ProjectProgressTracker {
    static Random r = new Random((int)DateTime.Now.Ticks);
    List<Task> tasks;
    readonly StatusUpdatePublisher publisher;

    public ProjectProgressTracker(
        string[] initialTasks, StatusUpdatePublisher publisher) {
        this.publisher = publisher;
        // other functionality, see above
    }

    public void NextAction() {
        // This should be replaced with the actual logic
        int randomNumber = r.Next(10);
        if (randomNumber < 3)
            publisher.OnTaskAdded(randomNumber.ToString());
        else if (randomNumber > 6)
            publisher.OnTaskBlocked(randomNumber.ToString());
        else
            publisher.OnTaskCompleted(randomNumber.ToString());
    }

    // other methods & classes
}

This works, but makes the events generator class tightly coupled with the events consumer. Consider now that we want to add another monitor system that generates documentation tasks. If we follow the style above, we should pass it too inside the events generator class so it can call the correct methods. Of course, this model doesn’t scale very well.

2. Another option is to raise some events at the correct time.

 class TaskEventArgs : EventArgs {
    public TaskEventArgs(string taskName) {
        this.TaskName = taskName;
    }
    public string TaskName { get; private set; }
}

delegate void TaskEventHandler(object sender, TaskEventArgs e);

The events generator class has 3 events – for tasks added, completed and blocked.

 class ProjectProgressTracker {
    public event TaskEventHandler OnTaskAdded;
    public event TaskEventHandler OnTaskCompleted;
    public event TaskEventHandler OnTaskBlocked;

    public void NextAction() {
        // This should be replaced with the actual logic
        int randomNumber = r.Next(10);
        TaskEventArgs eventArgs = new TaskEventArgs(
            randomNumber.ToString());
        if (randomNumber < 3)
            this.OnTaskAdded(this, eventArgs);
        else if (randomNumber > 6)
            this.OnTaskBlocked(this, eventArgs);
        else
            this.OnTaskCompleted(this, eventArgs);
    }

    // other methods & classes
}

Then the user of the class needs to hook up the events to the correct methods in the consumer class.

 ProjectProgressTracker tracker = new ProjectProgressTracker(
    new string[] {"Gather requirements", "Prototype"});
tracker.OnTaskAdded += 
    (object o, TaskEventArgs e) => publisher.OnTaskAdded(e.TaskName);
tracker.OnTaskCompleted += 
    (object o, TaskEventArgs e) => publisher.OnTaskCompleted(e.TaskName);
tracker.OnTaskBlocked += 
    (object o, TaskEventArgs e) => publisher.OnTaskBlocked(e.TaskName); 

The benefit of this approach is that the generator class is not modified, no matter how many consumer classes we want to add to it. The disadvantage is that the user class needs to hook up all events to the correct implementation in consumers.

3. Another solution is to use the Observable/Observer pattern. The Observer-Observable design pattern is a a very useful pattern for maintaining one-way communication between one object and a set of other objects. The observers receive communications from the Observable. Observers do not have a reference back to the Observable, so the communication is strictly from the Observable to the Observers. The observable is the object the Observers are "watching". The Observable maintains some sort of  list of references to Observers. That way, the Observable can send a message to all the Observers by calling each of their update methods.

This is a known pattern, but since it’s not implemented in .Net Framework 3.5, I am just going to define some interfaces that suit my needs.

Here, the class that generates events is the IObservable<T> and the class that consumes events in IObserver<T>:

 interface IObservable<T> {
    void Subscribe(IObserver<T> observer);
    bool Unsubscribe(IObserver<T> observer);
}

interface IObserver<T> {
    void OnNext(T value);
}

In our case, T is a class that contains the state of the task – the name of the task and the action that was taken:

 enum TaskAction { Added, Completed, Blocked }
class TaskState {
    public TaskState(string taskName, TaskAction taskAction) {
        this.TaskAction = taskAction;
        this.TaskName = taskName;
    }
    public string TaskName { get; private set; }
    public TaskAction TaskAction { get; private set; }
}

So our classes change accordingly: the ProjectProgressTracker implements the IObservable interface, and the status update class implements the IObserver interface.

 class ProjectProgressTracker : IObservable<TaskState> {
    List<IObserver<TaskState>> observers = 
        new List<IObserver<TaskState>>();

    public void Subscribe(IObserver<TaskState> observer) {
        this.observers.Add(observer);
    }
    public bool Unsubscribe(IObserver<TaskState> observer) {
        return this.observers.Remove(observer);
    }

    public void NextAction() {
        // This should be replaced with the actual logic
        int randomNumber = r.Next(10);
        TaskEventArgs eventArgs = new TaskEventArgs(
            randomNumber.ToString());
        if (randomNumber < 3)
            foreach (IObserver<TaskState> observer in this.observers)
                observer.OnNext(
                    new TaskState(randomNumber.ToString(), TaskAction.Added));
        else if (randomNumber > 6)
            foreach (IObserver<TaskState> observer in this.observers)
                observer.OnNext(
                    new TaskState(randomNumber.ToString(), TaskAction.Blocked));
        else
            foreach (IObserver<TaskState> observer in this.observers)
                observer.OnNext(
                    new TaskState(randomNumber.ToString(), TaskAction.Completed));
    }

    // other methods & classes
}

class StatusUpdatePublisher : IObserver<TaskState> {
    public void OnNext(TaskState state) {
        switch (state.TaskAction) {
            case TaskAction.Added:
                this.OnTaskAdded(state.TaskName); break;
            case TaskAction.Blocked:
                this.OnTaskBlocked(state.TaskName); break;
            case TaskAction.Completed:
                this.OnTaskCompleted(state.TaskName); break;
        }
    }

    // the other methods
}

 

Now the user just constructs the classes and registers the desired consumers:

 StatusUpdatePublisher publisher = new StatusUpdatePublisher();
ProjectProgressTracker tracker = new ProjectProgressTracker(
    new string[] {"Gather requirements", "Prototype"});
tracker.Subscribe(publisher);

This pattern has a couple of advantages:

  • Multiple consumers can be added easily
  • The events generator doesn’t need to know anything about the consumers; no change in the generator is needed when one or more of the consumers is changed.

But we pay the penalty of wrapping the state in a class and then analyzing it in the Observer to see what concrete method to implement.

4. Another possible implementation is the IRegistrar pattern (Disclaimer: I’m not aware of a pattern with this name described. The name is something I came up with). This is somewhat similar with the IObservable pattern, but instead of a class that wraps the state when the event occurs, we specify an interface for the type of events.

 interface IProjectEvents {
    void OnTaskAdded(string taskName);
    void OnTaskBlocked(string taskName);
    void OnTaskCompleted(string taskName);
}

interface IRegistrar<T> {
    void Subscribe(T consumer);
    bool Unsubscribe(T consumer);
}

The events consumers implement this interface:

 class StatusUpdatePublisher : IProjectEvents {
    // the original methods
}

The generator class can register consumers that know to consume events of type T (IProjectEvents in our case).

 class ProjectProgressTracker : IRegistrar<IProjectEvents> {
    List<IProjectEvents> consumers = new List<IProjectEvents>();

    public void Subscribe(IProjectEvents consumer) {
        this.consumers.Add(consumer);
    }
    public bool Unsubscribe(IProjectEvents consumer) {
        return this.consumers.Remove(consumer);
    }

    public void NextAction() {
        // This should be replaced with the actual logic
        int randomNumber = r.Next(10);
        TaskEventArgs eventArgs = new TaskEventArgs(
            randomNumber.ToString());
        if (randomNumber < 3)
            foreach (IProjectEvents consumer in this.consumers)
                consumer.OnTaskAdded(randomNumber.ToString());
        else if (randomNumber > 6)
            foreach (IProjectEvents consumer in this.consumers)
                consumer.OnTaskBlocked(randomNumber.ToString());
        else
            foreach (IProjectEvents consumer in this.consumers)
                consumer.OnTaskCompleted(randomNumber.ToString());
    }

    // other methods & classes
}

The usage is just as the one for the IObservable pattern.

In our case, it seems that this pattern works the best, because we get all the previous advantages plus strongly typing when calling consumer methods.

Depending on your project requirements, you may choose one of these solutions or look for a better one. Some requirements may be:

  • Loosely coupling between the events generator and the consumers
    • Changing the consumer doesn’t affect the generator
  • One or more consumers can be added
  • Producers can have inner producers and the consumers must be propagated
  • Etc… who knows?