Brainguy

Observer, AKA "Brain Guy"

Brainguy is an Observer library for Ruby.

Synopsis

<%= File.read("examples/synopsis.rb") %>

Introduction

Well, here we are again.

Back with another of those block-rockin' READMEs!

You know, I can just leave now.

Sorry. It won't happen again.

So, "Brainguy", huh. What's the deal this time?

This is an Observer pattern library for Ruby. The name is a play on the character from Mystery Sci---

Yeah yeah blah blah nerd nerd very clever. What's it do?

In a nutshell, it's a decoupling mechanism. It lets "observer" objects subscribe to events generated by other objects.

Kind of like the observer Ruby standard library?"

Yeah, exactly. But this library is a little bit fancier. It adds a number of conveniences that you otherwise might have to build yourself on top of observer.

Such as?

Well, the most important feature it has is named event types. Instead of a single "update" event, events have symbolic names. Observers can choose which events they care about, and ignore the rest.

Defining some terms

What exactly is a "observer"? Is it a special kind of object?

Not really, no. Fundamentally a observer is any object which responds to #call. The most obvious example of such an object is a Proc. Here's an example of using a proc as a simple observer:

<%= File.read("examples/proc_observer.rb") %>

Every time the emitter emits an event, the observer proc will receive #call with an Event object as an argument.

What's an "emitter"?

An Emitter serves dual roles: first, it manages subscriptions to a particular event source. And second, it can "emit" events to all of the observers currently subscribed to it.

What exactly is an "event", anyway?

Notionally an event is some occurrence in an object, which other objects might want to know about. What sort of occurrences might be depends on your problem domain. A User might have a :modified event. An WebServiceRequest might have a :success event. A Toaster might have a :pop event. And so on.

So an event is just a symbol?

An event is named with a symbol. But there is some other information that normally travels along with an event:

  • An event source, which is the observer object that generated the event.
  • An arbitrary list of arguments.

Extra arguments can be added to an event by passing extra arguments to the #emit, like this:

events.emit(:movie_sign, movie_title: "Giant Spider Invasion")

For convenience, the event name, source, and arguments are all bundled into an Event object before being disseminated to observers.

Making an object observable

OK, say I have an object that I want to make observable. How would I go about that?

Well, the no-magic way might go something like this:

<%= File.read("examples/manual_observable.rb") %>

Notice that we pass self to the new Emitter, so that it will know what object to set as the event source for emitted events.

That's pretty straightforward. Is there a more-magic way?

Of course! But it's not much more magic. There's an Observable module that just packages up the convention we used above into a reusable mixin you can use in any of your classes. Here's what that code would look like using the mixin:

<%= File.read("examples/include_observable.rb") %>

I see that instead of events.emit(...), now the class just uses emit(...). And the same with #on.

Very observant! Observable adds four methods to classes which mix it in:

  • #on, to quickly attach single-event handlers on the object.
  • #emit, a private method for conveniently emitting events inside the class.
  • #events, to access the Emitter object.
  • #with_subscription_scope, which we'll talk about later.

That's not a lot of methods added.

Nope! That's intentional. These are your classes, and I don't want to clutter up your API unnecessarily. #on and #emit are provided as conveniences for common actions. Anything else you need, you can get to via the Emitter returned from #events.

Constraining event types

I see that un-handled events are just ignored. Doesn't that make it easy to miss events because of a typo in the name?

Yeah, it kinda does. In order to help with that, there's an alternative kind of emitter: a ManifestEmitter. And to go along with it, there's a ManifestlyObservable mixin module. We customize the module with a list of known event names. Then if anything tries to either emit or subscribe to an unknown event name, the emitter outputs a warning.

Well, that's what it does by default. We can also customize the policy for how to handle unknown events, as this example demonstrates:

<%= File.read("examples/include_manifestly_observable.rb") %>

All about observers

I'm still a little confused about #on. Is that just another way to add an observer?

#on is really just a shortcut. Often we don't want to attach a whole observer to an observable object. We just want to trigger a particular block of code to be run when a specific event is detected. So #on makes it easy to hook up a block of code to a single event.

So it's a special case.

Yep!

Let's talk about the general case a bit more. You said an observer is just a callable object?

Yeah. Anything which will respond to #call and accept a single Event as an argument.

But what if I want my observer to do different things depending on what kind of event it receives? Do I have to write a case statement inside my #call method?

You could if you wanted to. But that's a common desire, so there are some conveniences for it.

Such as...?

Well, first off, there's OpenObserver. It's kinda like Ruby's OpenObject, but for observer objects. You can use it to quickly put together a reusable observer object. For instance, here's an example where we have two different observable objects, observed by a single OpenObserver.

<%= File.read("examples/open_observer.rb") %>

There are a few other ways to instantiate an OpenObserver; check out the source code and tests for more information.

What if my observer needs are more elaborate? What if I want a dedicated class for observing an event stream?

There's a helper for that as well. Here's an example where we have a Poem class that can recite a poem, generating events along the way. And then we have an HtmlFormatter which observes those events and incrementally constructs some HTML text as it does so.

<%= File.read("examples/include_observer.rb") %>

So including Observer automatically handles the dispatching of events from #call to the various #on_ methods?

Yes, exactly. And through some metaprogramming, it is able to do this in a way that is just as performant as a hand-written case statement.

How do you know it's that fast?

You can run the proof-of-concept benchmark for yourself! It's in the scripts directory.

Managing subscription lifetime

You know, it occurs to me that in the Poem example, it really doesn't make sense to have an HtmlFormatter plugged into a Poem forever. Is there a way to attach it before the call to #recite, and then detach it immediately after?

Of course. All listener registration methods return a Subscription object which can be used to manage the subscription of an observer to emitter. If we wanted to observe the Poem for just a single recital, we could do it like this:

p = Poem.new
f = HtmlFormatter.new
subscription = p.events.attach(f)
p.recite
subscription.cancel

OK, so I just need to remember to #cancel the subscriptions that I don't want sticking around.

That's one way to do it. But this turns out to be a common use case. It's often desirable to have observers that are in effect just for the length of a single method call.

Here's how we might re-write the "poem" example with event subscriptions scoped to just the #recite call:

<%= File.read("examples/scoped_subscription.rb") %>

In this example, the HtmlFormatter is only subscribed to poem events for the duration of the call to #recite. After that it is automatically detached.

Replacing return values with events

Interesting. I can see this being useful for more than just traditionally event-generating objects.

Indeed it is! This turns out to be a useful pattern for any kind of method which acts as a "command".

For instance, let's imagine a fictional HTTP request method. Different things happen over the course of a request:

  • headers come back
  • data comes back (possibly more than once, if it is a streaming-style connection)
  • an error may occur
  • otherwise, at some point it will reach a successful finish

Let's look at how that could be modeled using an "event-ful" method:

connection.request(:get, "/") do |events|
  events.on(:header){ ... }  # handle a header
  events.on(:data){ ... }    # handle data
  events.on(:error){ ... }   # handle errors
  events.on(:success){ ... } # finish up
end

This API has some interesting properties:

  • Notice how some of the events that are handled will only occur once (error, success), whereas others (data, header) may be called multiple times. The event-handler API style means that both singular and repeatable events can be handled in a consistent way.
  • A common headache in designing APIs is deciding how to handle errors. Should an exception be raised? Should there be an exceptional return value? Using events, the client code can set the error policy.

But couldn't you accomplish the same thing by returning different values for success, failure, etc?

Not easily. Sure, you could define a method that returned [:success, 200] on success, and [:error, 500] on failure. But what about the data events that may be emitted multiple times as data comes in? Libraries typically handle this limitation by providing separate APIs and/or objects for "streaming" responses. Using events handlers makes it possible to handle both single-return and streaming-style requests in a consistent way.

I don't like that blocks-in-a-block syntax

If you're willing to wait until a method call is complete before handling events, there's an alternative to that syntax. Let's say our #request method is implemented something like this:

class Connection
  include Brainguy::Observable

  def request(method, path, &block)
    with_subscription_scope(block) do
      # ...
    end
  end

  # ...
end

In that case, instead of sending it with a block, we can do this:

connection.request(:get, "/")
  .on(:header){ ... }  # handle a header
  .on(:data){ ... }    # handle data
  .on(:error){ ... }   # handle errors
  .on(:success){ ... } # finish up

How the heck does that work?

If the method is called without a block, events are queued up in an Brainguy::IdempotentEmitter. This is a special kind of emitter that "plays back" any events that an observer missed, as soon as it is attached.

Then it's wrapped in a special Brainguy::FluentEmitter before being returned. This enables the "chained" calling style you can see in the example above. Normally, sending #on would return a Brainguy::Subscription object, so that wouldn't work.

The upshot is that all the events are collected over the course of the method's execution. Then they are played back on each handler as it is added.

What if I **only* want eventful methods? I don't want my object to carry a long-lived list of observers around?*

Gotcha covered. You can use Brainguy.with_subscription_scope to add a temporary subscription scope to any method without first including Brainguy::Observable.

class Connection
  def request(method, path, &block)
    Brainguy.with_subscription_scope(self) do
      # ...
    end
  end

  # ...
end

This is a lot to take in. Anything else you want to tell me about?

We've covered most of the major features. One thing we haven't talked about is error suppression.

Suppressing errors

Why would you want to suppress errors?

Well, we all know that observers effect the thing being observed. But it can be nice to minimize that effect as much as possible. For instance, if you have a critical process that's being observed, you may want to ensure that spurious errors inside of observers don't cause it to crash.

Yeah, I could see where that could be a problem.

So there are some tools for setting a policy in place for what to do with errors in event handlers, including turning them into warnings, suppressing them entirely, or building a list of errors.

I'm not going to go over them in detail here in the README, but you should check out Brainguy::ErrorHandlingNotifier and Brainguy::ErrorCollectingNotifier, along with their spec files, for more information. They are pretty easy to use.

FAQ

Is this library like ActiveRecord callbacks? Or like Rails observers?

No. ActiveRecord enables callbacks to be enabled at a class level, so that every instance is implicitly being subscribed to. Rails "observers" enable observers to be added with no knowledge whatsoever on the part of the objects being observed.

Brainguy explicitly eschews this kind of "spooky action at a distance". If you want to be notified of what goes on inside an object, you have to subscribe to that object.

Is this an aspect-oriented programming library? Or a lisp-style "method advice" system?

No. Aspect-oriented programming and lisp-style method advice are more ways of adding "spooky action at a distance", where the code being advised may have no idea that it is having foreign logic attached to it. As well as no control over where that foreign logic is applied.

In Brainguy, by contrast, objects are explicitly subscribed-to, and events are explicitly emitted.

Is this a library for "Reactive Programming"?

Not in and of itself. It could potentially serve as the foundation for such a library though.

Is this a library for creating "hooks"?

Sort of. Observers do let you "hook" arbitrary handlers to events in other objects. However, this library is not aimed at enabling you to create hooks that modify the behavior of other methods. It's primarily intended to allow objects to be notified of significant events, without interfering in the processing of the object sending out the notifications.

Is this an asynchronous messaging or reactor system?

No. Brainguy events are processed synchronously have no awareness of concurrency.

Installation

Add this line to your application's Gemfile:

gem 'brainguy'

And then execute:

$ bundle

Or install it yourself as:

$ gem install brainguy

Usage

Coming soon!

Contributing

  1. Fork it ( https://github.com/avdi/brainguy/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request