Wednesday, 25 May 2022

A super-quick Observer Pattern implementation in CFML, and I skip TDD. Or do I?

G'day:

There's a possible "Betteridge's law of headlines" situation there. I'm not actually sure yet.

A few days back I was chatting to someone about application-scope-usage, and how to trigger an event when any changes to the application scope took place. I mentioned that the application scope should never be accessed directly in one's application code, and it should be wrapped up in an adapter. And it's easy for one's adapter to trigger events if needs must. An implementation of the Observer Pattern would do the trick here.

Then it occurred to me that I have never actually implemented the Observer Pattern, and I thought I might give it a go.

I've read that Wiki article a few times, and know what it needs to do, but hadn't thought it through at all before. So I opened a file and settled in to work out what I'd need to do. Then I paused and went "Adam: tests. Don't let yerself down here". Grumble. But I reasoned that I didn't even know if I was gonna go anywhere with this code, so I decided to call it a spike (random code which will be thrown away, and no tests).

I decided to implement it as an ObserverService: an instance of it would be injected into other objects that needed to either attach a handler to an event, or trigger an event. Internally the service would have to maintain some data structure of events and handlers, and probably only needed a coupla methods. Following jQuery (my JS is very out of date, I admit this), I decided on on for the method to attach a handler to an event; and trigger to fire the event (and run the handlers). As I type this, I realise I'm already thinking of the bases for test cases. I shoulda just done the tests up front. Or at least made the test cases, eg:

describe("test for the on method",  () => {
    it("adds an event handler to an event", () => {
    })
})

describe("test for the trigger method",  () => {
    it("runs all the event handlers for the event", () => {
    })
})

Oh well.

Right so I spiked away, and it all came together rather more easily than I expected. Here's the first pass (untested):

component {

    variables.eventRegistry = {}

    public void function on(required string event, required function handler) {
        registerEvent(event)
        variables.eventRegistry[event].append(handler)
    }

    public boolean function trigger(required string event) {
        registerEvent(event)
        return variables.eventRegistry[event].some((handler) => handler())
    }

    private void function registerEvent(required string event) {
        if (!variables.eventRegistry.keyExists(event)) {
            variables.eventRegistry[event] = []
        }
    }
}

That seems to have everything I need to start with:

  • I can attach a handler to an event.
  • I can trigger the event, causing the handlers to run in sequence.
  • If any handler returns false, stop calling handlers.
  • A bug that never would have cropped up had I done TDD properly.

Right so the way to check if this thing works is still via tests, so I knocked some tests together now. I'll just list the cases, as the implementations don't matter so much. I wrote all of these before running any of them (on purpose because I was being a dick, not cos it's good practice).

import cfml.forBlog.observerService.ObserverService

component extends=Testbox.system.BaseSpec {
    function run() {
        describe("Tests for ObserverService", () => {
            describe("Tests for on method", () => {
                it("registers an event handler", () => {
                })

                it("registers multiple event handlers", () => {
                })

                it("registers multiple handlers for multiple events", () => {
                })
            })

            describe("tests for trigger method", () => {
                it("triggers a registered event", () => {
                })

                it("does nothing when triggering an unregistered event", () => {
                })

                it("triggers event handlers until one returns false", () => {
                })
            })
        })
    }
}

And I triumphantly ran them, and then…

… I did not feel very triumphant any more. That was the result of every single test. So that's the bug. Did you spot it, or can you see it now?

return variables.eventRegistry[event].some((handler) => handler())

The handlers I was attaching to the event didn't necessarily return true if they worked. Just "not erroring" is a good enough sign an event handler ran OK. EG:

observerService.on("testEvent", () => {
    eventResults.append("test event")
})

So here handler() returns an array. No good as a boolean.

This was easily fixed:

return variables.eventRegistry[event].some((handler) => handler() == false)

Had I done TDD I'd've first done the minimum implementation without the "returning false from a handler terminates the handler loop" feature, then I would have added a test for the feature, and then my attention would have been focused on what I was actually doing, and I'd not have made such a stupid mistake.

Ah well.

After that first iteration, I realised I needed to also be able to pass data to the handler, so I did that too. Via TDD this time. This was the end result:

component {

    variables.eventRegistry = {}

    public void function on(required string event, required function handler, any data) {
        registerEvent(event)
        variables.eventRegistry[event].append({
            handler = handler,
            data = arguments?.data
        })
    }

    public boolean function trigger(required string event) {
        registerEvent(event)
        return variables.eventRegistry[event].some((eventData) => eventData.handler({name=event, data=eventData?.data}) == false)
    }

    private void function registerEvent(required string event) {
        if (!variables.eventRegistry.keyExists(event)) {
            variables.eventRegistry[event] = []
        }
    }
}

I'm pretty pleased with this simple little solution. It does what it needs to do, and is only ~700 bytes of code.

I'll get to why I'm creating this thing in a coupla days, showing how I'll use dependency injection to inject an instance of it into both publisher and subscribers of events, and how I solve that "firing an event every time the application scope is changed" challenge I mentioned in the beginning of this article.

Righto.

--
Adam