Skip to content

avdi/brainguy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

89 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Brainguy

Observer, AKA "Brain Guy"

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 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:

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

  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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages