Abdulrhman Elsaed
Abdulrhman Elsaed

Abdulrhman Elsaed

The Observer Pattern

A Complete guide into observer pattern and its best practices

Abdulrhman Elsaed's photo
Abdulrhman Elsaed
·May 19, 2022·

7 min read

Table of contents

  • Introduction
  • Using the EventEmitter
  • EventEmitter and memory leaks
  • Synchronous and asynchronous events
  • Combining callbacks and events

Introduction

The Observer pattern is considered the ideal solution for modeling the reactive nature of Node.js. It works by defining an object (called subject) that can notify a set of observers (or listeners) when a change in its state occurs.

The main difference from the Callback pattern is that the subject can actually notify multiple observers, while a traditional callback will usually propagate its result to only one listener, the callback.

In traditional OOP, The Observer pattern requires interfaces, classes and a hierarchy. In Node.js, it's much simpler. The Observer pattern is already built into the core and is available through the EventEmitter class which allows us to register one or more functions as listeners that will be called when a specific event is fired.

2.png

The EventEmitter class is defined and exposed by the events core module. Some of its important methods are :

  • on( event: String, listener: Function ) : adds the listener function to the listeners array of the event type. note that multiple calls passing the same combination of event and listener will result in the listener being added and called multiple times when the event is emitted.
  • once( event: String, listener: Function ) : adds a one-time listener to the given event, which will then removed after the event is emitted for the first time.
  • emit( event: String, [...args] ) : Synchronously calls each of the listeners registered for the given event, in the order they were registered, passing the supplied arguments to each.
  • removeListener( event: String, listener: Function ) : removes a listener for the specified event.

All the preceding methods will return the EventEmitter instance to allow chaining.

Using the EventEmitter

Let's now see how we can use an EventEmitter in practice. The simplest way is to create a new instance using new EventEmitter() and using it directly but it is not flexible when we want to create something that goes beyond of only creating new events. so we will go with extending the EventEmitter class to make an object fully observable and inherit the capabilities of the EventEmitter.

import { EventEmitter } from 'events'
import { readFile } from 'fs'

class FindRegex extends EventEmitter {
    constructor (regex) {
        super()
        this.regex = regex
        this.files = []
    }

   addFile (file) {
       this.files.push(file)
       return this
    }

   find () {
        for (const file of this.files) {
            readFile(file, 'utf8', (err, content) => {
                if (err) {
                return this.emit('error', err)
                }
               this.emit('fileread', file)
               const match = content.match(this.regex)
               if (match) {
                   match.forEach(elem => this.emit('found', file, elem))
               }
          })
       }
       return this
    }
}
  • In the preceding code, we created FindRegex class to notify its subscribers in real time when a particular regular expression is matched in a given list of files.

  • Any instance of the class we just defined will produce three events :

    • fileread, when a file is being read.
    • found, when a match has been found.
    • error, when an error occurs during reading the file.

The following code snippet is how to use the class we just defined:

const findRegexInstance = new FindRegex(/hello/g)
findRegexInstance
    .addFile('fileA.txt')
    .addFile('fileB.json')
    .find()
    .on('fileread', (file) => console.log(`${file} was read`))
    .on('found', (file, match) => console.log(`Matched "${match}" in file ${file}`))
    .on('error', err => console.error(`Error emitted ${err.message}`))
  • The EventEmitter treats the error event in a special way. It will automatically throw an exception and exit from the application if such an event is emitted and no associated listener is found. For this reason, it is recommended to always register a listener for the error event.

  • You will notice how the FindRegex object also provides the on() method, which is inherited from the EventEmitter . This is a pretty common pattern in the Node.js ecosystem. For example, the Server object of the core HTTP module inherits from the EventEmitter, thus allowing it to produce events such as request (when a new request is received), connection (when a new connection is established), or closed (when the server socket is closed).

EventEmitter and memory leaks

When subscribing to observables with a long life span, it is extremely important that we unsubscribe our listeners once they are no longer needed. This allows us to release the memory used by the objects in a listener's scope and prevent memory leaks.

A memory leak is a software defect whereby memory that is no longer needed is not released.

  • Problem 1: : Unreleased EventEmitter listeners
const thisTakesMemory = 'A big string....'
const listener = () => {
    console.log(thisTakesMemory)
}
emitter.on('an_event', listener)

The variable thisTakesMemory is referenced in the listener and therefore its memory is retained until the listener is released from emitter , or until the emitter itself is garbage collected, which can only happen when there are no more active references to it, making it unreachable.

  • Solution: Releasing the listener with the removeListener() method
    emitter.removeListener('an_event', listener)
    

  • Problem 2: : Exceeding maximum listeners limit

The warning possible EventEmitter memory leak detected happens when you have more than 10 listeners (by default) attached to an event.

  • Solution :

First of all, try to understand why this warning appeared. Most of the times it’s a naive mistake (e.g. registering a listener inside a loop) that can be solved easily by cleaning up the code.

If it's needed and intended, you can overwrite the max listeners limit using the following method:

emitter.setMaxListeners(n)     // to determine a specific limit
emitter.setMaxListeners(0)     // to disable the warning

Synchronous and asynchronous events

As with callbacks, events can also be emitted synchronously or asynchronously with respect to the moment the tasks that produce them are triggered.

It is crucial that we never mix the two approaches in the same EventEmitter, but even more importantly, we should never emit the same event type using a mix of synchronous and asynchronous code, to avoid producing the same problems described in the Unleashing Zalgo Blog.

The main difference between emitting synchronous and asynchronous events lies in the way listeners can be registered.

  • When events are emitted asynchronously , we can register new listeners, even after the task that produces the events is triggered, up until the current stack yields to the event loop.

The FindRegex() class we defined previously emits its events asynchronously after the find() method is invoked. This is why we can register the listeners after the find() method is invoked, as shown in the following code:

findRegexInstance
    .addFile(...)
    .find()
    .on('found', ...)
  • On the other hand, if we emit our events synchronously after the task is launched, we have to register all the listeners before we launch the task, or we will miss all the events. let's modify the FindRegex class we defined previously and make the find() method synchronous:
find () {
    for (const file of this.files) {
    let content
    try {
        content = readFileSync(file, 'utf8')
    } catch (err) {
        this.emit('error', err)
    }
    this.emit('fileread', file)
    const match = content.match(this.regex)
    if (match) {
        match.forEach(elem => this.emit('found', file, elem))
    }
  }
return this
}

Let's try to register a listener before and after the find() task:

const findRegexSyncInstance = new FindRegexSync(/hello/g)
findRegexSyncInstance
    .addFile('fileA.txt')
    // this listener is invoked
    .on('found', (file, match) => console.log(`[Before] Matched "${match}"`))
    .find()
    // this listener is never invoked
    .on('found', (file, match) => console.log(`[After] Matched "${match}"`))

As expected, the listener that was registered after the invocation of the find() task is never called.

Combining callbacks and events

There are some particular circumstances where the EventEmitter can be used in conjunction with a callback. This pattern is extremely powerful as it allows us to pass a result asynchronously using a traditional callback, and at the same time return an EventEmitter , which can be used to provide a more detailed account on the status of an asynchronous process.

An example of this pattern is offered by the glob package, a library to perform file searches.

const eventEmitter = glob(pattern, [options], callback)

The function takes a pattern as the first argument, a set of options , and a callback that is invoked with the list of all the files matching the provided pattern. At the same time, the function returns an EventEmitter , which provides a more fine-grained report about the state of the search process. For example, it is possible to be notified in real time when a match occurs by listening to the match event, to obtain the list of all the matched files with the end event.

import glob from 'glob'
glob('data/*.txt', (err, files) => {
    if (err) {
        return console.error(err)
    }
   console.log(`All files found: ${JSON.stringify(files)}`)
})
  .on('match', match => console.log(`Match found: ${match}`))

Combining an EventEmitter with traditional callbacks is an elegant way to offer two different approaches to the same API. One approach is usually meant to be simpler and more immediate to use, while the other is targeted at more advanced scenarios.

 
Share this