Brainguy
Brainguy is an Observer library for Ruby.
Synopsis
require "brainguy"
class SatelliteOfLove
include Brainguy::Observable
def intro_song
emit(:robot_roll_call)
end
def send_the_movie
emit(:movie_sign)
end
end
class Crew
include Brainguy::Observer
end
class TomServo < Crew
def on_robot_roll_call(event)
puts "Tom: Check me out!"
end
end
class CrowTRobot < Crew
def on_robot_roll_call(event)
puts "Crow: I'm different!"
end
end
class MikeNelson < Crew
def on_movie_sign(event)
puts "Mike: Oh no we've got movie sign!"
end
end
sol = SatelliteOfLove.new
# Attach specific event handlers without a listener object
sol.on(:robot_roll_call) do
puts "[Robot roll call!]"
end
sol.on(:movie_sign) do
puts "[Movie sign flashes]"
end
sol.events.attach TomServo.new
sol.events.attach CrowTRobot.new
sol.events.attach MikeNelson.new
sol.intro_song
sol.send_the_movie
# >> [Robot roll call!]
# >> Tom: Check me out!
# >> Crow: I'm different!
# >> [Movie sign flashes]
# >> Mike: Oh no we've got movie sign!
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 an "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:
require "brainguy"
events = Brainguy::Emitter.new
observer = proc do |event|
puts "Got event: #{event.name}"
end
events.attach(observer)
events.emit(:ding)
# >> Got event: ding
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:
require "brainguy"
class Toaster
attr_reader :events
def initialize
@events = Brainguy::Emitter.new(self)
end
def make_toast
events.emit(:start)
events.emit(:pop)
end
end
toaster = Toaster.new
toaster.events.on(:pop) do
puts "Toast is done!"
end
toaster.make_toast
# >> Toast is done!
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:
require "brainguy"
class Toaster
include Brainguy::Observable
def make_toast
emit(:start)
emit(:pop)
end
end
toaster = Toaster.new
toaster.on(:pop) do
puts "Toast is done!"
end
toaster.make_toast
# >> Toast is done!
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 theEmitter
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:
require "brainguy"
class Toaster
include Brainguy::ManifestlyObservable.new(:start, :pop)
def make_toast
emit(:start)
emit(:lop)
end
end
toaster = Toaster.new
toaster.events.unknown_event_policy = :raise_error
toaster.on(:plop) do
puts "Toast is done!"
end
toaster.make_toast
# ~> Brainguy::UnknownEvent
# ~> #on received for unknown event type 'plop'
# ~>
# ~> xmptmp-in27856uxq.rb:14:in `<main>'
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
.
require "brainguy"
class VideoRender
include Brainguy::Observable
attr_reader :name
def initialize(name)
@name = name
end
def do_render
emit(:complete)
end
end
v1 = VideoRender.new("foo.mp4")
v2 = VideoRender.new("bar.mp4")
observer = Brainguy::OpenObserver.new do |o|
o.on_complete do |event|
puts "Video #{event.source.name} is done rendering!"
end
end
v1.events.attach(observer)
v2.events.attach(observer)
v1.do_render
v2.do_render
# >> Video foo.mp4 is done rendering!
# >> Video bar.mp4 is done rendering!
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.
require "brainguy"
class Poem
include Brainguy::Observable
def recite
emit(:title, "Jabberwocky")
emit(:line, "'twas brillig, and the slithy toves")
emit(:line, "Did gyre and gimbal in the wabe")
end
end
class HtmlFormatter
include Brainguy::Observer
attr_reader :result
def initialize
@result = ""
end
def on_title(event)
@result << "<h1>#{event.args.first}</h1>"
end
def on_line(event)
@result << "#{event.args.first}</br>"
end
end
p = Poem.new
f = HtmlFormatter.new
p.events.attach(f)
p.recite
f.result
# => "<h1>Jabberwocky</h1>'twas brillig, and the slithy toves</br>Did gyre an...
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:
require "brainguy"
class Poem
include Brainguy::Observable
def recite(&block)
with_subscription_scope(block) do
emit(:title, "Jabberwocky")
emit(:line, "'twas brillig, and the slithy toves")
emit(:line, "Did gyre and gimbal in the wabe")
end
end
end
class HtmlFormatter
include Brainguy::Observer
attr_reader :result
def initialize
@result = ""
end
def on_title(event)
@result << "<h1>#{event.args.first}</h1>"
end
def on_line(event)
@result << "#{event.args.first}</br>"
end
end
p = Poem.new
f = HtmlFormatter.new
p.recite do |events|
events.attach(f)
end
f.result
# => "<h1>Jabberwocky</h1>'twas brillig, and the slithy toves</br>Did gyre an...
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 a {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 affect 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.
Is there somewhere I can learn more about using observers to keep responsibilities separate?
Yes. The book Growing Object-Oriented Software, Guided by Tests was a big inspiration for this library.
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
- Fork it ( https://github.com/avdi/brainguy/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request