├── .yardopts ├── lib ├── brainguy │ ├── version.rb │ ├── full_subscription.rb │ ├── event.rb │ ├── basic_notifier.rb │ ├── fluent_emitter.rb │ ├── error_collecting_notifier.rb │ ├── observable.rb │ ├── single_event_subscription.rb │ ├── idempotent_emitter.rb │ ├── subscription.rb │ ├── open_observer.rb │ ├── subscription_scope.rb │ ├── error_handling_notifier.rb │ ├── manifestly_observable.rb │ ├── manifest_emitter.rb │ ├── observer.rb │ └── emitter.rb └── brainguy.rb ├── Gemfile ├── .gitignore ├── examples ├── proc_observer.rb ├── include_observable.rb ├── manual_observable.rb ├── include_manifestly_observable.rb ├── open_observer.rb ├── include_observer.rb ├── scoped_subscription.rb └── synopsis.rb ├── spec ├── brainguy │ ├── observable_spec.rb │ ├── single_event_subscription_spec.rb │ ├── error_collecting_notifier_spec.rb │ ├── manifestly_observable_spec.rb │ ├── subscription_spec.rb │ ├── open_observer_spec.rb │ ├── emitter_spec.rb │ ├── observer_spec.rb │ ├── manifest_emitter_spec.rb │ ├── error_handling_notifier_spec.rb │ └── subscription_scope_spec.rb ├── support │ └── shared_examples_for_eventful_modules.rb └── features │ ├── idempotent_events_spec.rb │ ├── method_scoped_events_spec.rb │ └── basics_spec.rb ├── Rakefile ├── LICENSE.txt ├── brainguy.gemspec ├── scripts └── benchmark_listener_dispatch.rb ├── README.erb └── README.markdown /.yardopts: -------------------------------------------------------------------------------- 1 | --exclude README\\.markdown\\.erb 2 | --markup markdown 3 | - 4 | README.markdown 5 | -------------------------------------------------------------------------------- /lib/brainguy/version.rb: -------------------------------------------------------------------------------- 1 | module Brainguy 2 | # The library version 3 | VERSION = "0.0.1" 4 | end 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in brainguy.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | /.idea/ 16 | -------------------------------------------------------------------------------- /lib/brainguy/full_subscription.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/subscription" 2 | 3 | module Brainguy 4 | # A {Subscription} which transmits all events to the listener. 5 | class FullSubscription < Subscription 6 | 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /examples/proc_observer.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | events = Brainguy::Emitter.new 4 | observer = proc do |event| 5 | puts "Got event: #{event.name}" 6 | end 7 | events.attach(observer) 8 | events.emit(:ding) 9 | 10 | # >> Got event: ding 11 | -------------------------------------------------------------------------------- /spec/brainguy/observable_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/observable" 3 | require "support/shared_examples_for_eventful_modules" 4 | 5 | module Brainguy 6 | RSpec.describe Observable do 7 | include_examples "an eventful module", Observable 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /examples/include_observable.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | class Toaster 4 | include Brainguy::Observable 5 | 6 | def make_toast 7 | emit(:start) 8 | emit(:pop) 9 | end 10 | end 11 | 12 | toaster = Toaster.new 13 | toaster.on(:pop) do 14 | puts "Toast is done!" 15 | end 16 | toaster.make_toast 17 | 18 | # >> Toast is done! 19 | -------------------------------------------------------------------------------- /examples/manual_observable.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | class Toaster 4 | attr_reader :events 5 | 6 | def initialize 7 | @events = Brainguy::Emitter.new(self) 8 | end 9 | 10 | def make_toast 11 | events.emit(:start) 12 | events.emit(:pop) 13 | end 14 | end 15 | 16 | toaster = Toaster.new 17 | toaster.events.on(:pop) do 18 | puts "Toast is done!" 19 | end 20 | toaster.make_toast 21 | 22 | # >> Toast is done! 23 | -------------------------------------------------------------------------------- /lib/brainguy/event.rb: -------------------------------------------------------------------------------- 1 | module Brainguy 2 | # An event. Bundles up a symbolic `name`, an originating object (`source`). 3 | # and a list of event-defined `args`. 4 | Event = Struct.new(:name, :source, :args) do 5 | # @param name [Symbol] the event name 6 | # @param source [Object] the originating object 7 | # @param args [Array] a list of event-specific arguments 8 | def initialize(*) 9 | super 10 | self.args ||= [] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /examples/include_manifestly_observable.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | class Toaster 4 | include Brainguy::ManifestlyObservable.new(:start, :pop) 5 | 6 | def make_toast 7 | emit(:start) 8 | emit(:lop) 9 | end 10 | end 11 | 12 | toaster = Toaster.new 13 | toaster.events.unknown_event_policy = :raise_error 14 | toaster.on(:plop) do 15 | puts "Toast is done!" 16 | end 17 | toaster.make_toast 18 | 19 | # ~> Brainguy::UnknownEvent 20 | # ~> #on received for unknown event type 'plop' 21 | # ~> 22 | # ~> xmptmp-in27856uxq.rb:14:in `
' 23 | -------------------------------------------------------------------------------- /lib/brainguy/basic_notifier.rb: -------------------------------------------------------------------------------- 1 | module Brainguy 2 | # A notifier encapsulates various strategies for notifying subscriptions of 3 | # events. This is the most basic form of notifier. It just passes the event 4 | # on with no extra logic. 5 | class BasicNotifier 6 | # Notify a subscription of an event 7 | # 8 | # @return (see Subscription#handle) 9 | def notify(subscription, event) 10 | subscription.handle(event) 11 | end 12 | 13 | # Some notifiers have interesting results. This one just returns nil. 14 | # @return nil 15 | def result 16 | nil 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/brainguy/single_event_subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/single_event_subscription" 3 | 4 | module Brainguy 5 | RSpec.describe SingleEventSubscription do 6 | it "is not equal to another subscription with a different event" do 7 | owner = double("owner") 8 | listener = double("listener") 9 | sub1 = SingleEventSubscription.new(owner, listener, double) 10 | sub2 = SingleEventSubscription.new(owner, listener, double) 11 | expect(sub1).not_to eq(sub2) 12 | expect(sub1.hash).not_to eq(sub2.hash) 13 | expect(sub1).not_to eql(sub2) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /examples/open_observer.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | class VideoRender 4 | include Brainguy::Observable 5 | attr_reader :name 6 | def initialize(name) 7 | @name = name 8 | end 9 | 10 | def do_render 11 | emit(:complete) 12 | end 13 | end 14 | 15 | v1 = VideoRender.new("foo.mp4") 16 | v2 = VideoRender.new("bar.mp4") 17 | 18 | observer = Brainguy::OpenObserver.new do |o| 19 | o.on_complete do |event| 20 | puts "Video #{event.source.name} is done rendering!" 21 | end 22 | end 23 | 24 | v1.events.attach(observer) 25 | v2.events.attach(observer) 26 | 27 | v1.do_render 28 | v2.do_render 29 | 30 | # >> Video foo.mp4 is done rendering! 31 | # >> Video bar.mp4 is done rendering! 32 | -------------------------------------------------------------------------------- /lib/brainguy/fluent_emitter.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/emitter" 2 | require "delegate" 3 | 4 | module Brainguy 5 | # A wrapper for a {Emitter} that enables a "fluent API" by 6 | # returning `self` from each method. 7 | # 8 | # @example Enables code like this: 9 | # 10 | # kitten.on(:purr) do 11 | # # handle purr... 12 | # end.on(:mew) do 13 | # # handle mew... 14 | # end 15 | class FluentEmitter < DelegateClass(Emitter) 16 | # (see Emitter#on) 17 | # @return `self` 18 | def on(*) 19 | super 20 | self 21 | end 22 | 23 | # (see Emitter#attach) 24 | # @return `self` 25 | def attach(*) 26 | super 27 | self 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/brainguy/error_collecting_notifier_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/error_collecting_notifier" 3 | 4 | module Brainguy 5 | RSpec.describe ErrorCollectingNotifier do 6 | it "collects errors into a list" do 7 | inner = instance_spy(BasicNotifier, "inner") 8 | outer = ErrorCollectingNotifier.new(inner) 9 | error1 = StandardError.new("Whoopsie") 10 | error2 = StandardError.new("O NOES") 11 | allow(inner).to receive(:notify).and_raise(error1) 12 | outer.notify(sub1 = double("subscription1"), double) 13 | allow(inner).to receive(:notify).and_raise(error2) 14 | outer.notify(sub2 = double("subscription2"), double) 15 | expect(outer.result) 16 | .to eq({sub1 => error1, sub2 => error2}) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | begin 4 | require 'rspec/core/rake_task' 5 | RSpec::Core::RakeTask.new(:spec) 6 | rescue LoadError 7 | end 8 | 9 | task :default => :spec 10 | 11 | require "yard" 12 | 13 | task :build => :readme 14 | 15 | desc "Build the README" 16 | task :readme => "README.markdown" 17 | 18 | file "README.markdown" => "README.erb" do 19 | puts "Generating README.markdown" 20 | require "erb" 21 | template = IO.read("README.erb") 22 | IO.write("README.markdown", ERB.new(template).result) 23 | end 24 | 25 | YARD::Rake::YardocTask.new do |t| 26 | # t.files = ['lib/**/*.rb', OTHER_PATHS] # optional 27 | # t.options = ['--any', '--extra', '--opts'] # optional 28 | # t.stats_options = ['--list-undoc'] # optional 29 | end 30 | task :yard => :readme 31 | -------------------------------------------------------------------------------- /examples/include_observer.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | class Poem 4 | include Brainguy::Observable 5 | def recite 6 | emit(:title, "Jabberwocky") 7 | emit(:line, "'twas brillig, and the slithy toves") 8 | emit(:line, "Did gyre and gimbal in the wabe") 9 | end 10 | end 11 | 12 | class HtmlFormatter 13 | include Brainguy::Observer 14 | 15 | attr_reader :result 16 | 17 | def initialize 18 | @result = "" 19 | end 20 | 21 | def on_title(event) 22 | @result << "

#{event.args.first}

" 23 | end 24 | 25 | def on_line(event) 26 | @result << "#{event.args.first}
" 27 | end 28 | end 29 | 30 | p = Poem.new 31 | f = HtmlFormatter.new 32 | p.events.attach(f) 33 | p.recite 34 | 35 | f.result 36 | # => "

Jabberwocky

'twas brillig, and the slithy toves
Did gyre an... 37 | -------------------------------------------------------------------------------- /lib/brainguy/error_collecting_notifier.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/error_handling_notifier" 2 | 3 | module Brainguy 4 | # A notifier wrapper that captures exceptions and collects them into a Hash. 5 | class ErrorCollectingNotifier < ErrorHandlingNotifier 6 | # (see ErrorHandlingNotifier#initialize) 7 | def initialize(notifier) 8 | super(notifier, method(:add_error)) 9 | @errors = {} 10 | end 11 | 12 | # Add another error to the list 13 | # @api private 14 | def add_error(subscription, error) 15 | @errors[subscription] = error 16 | end 17 | 18 | # Return list of errors captured while notifying subscriptions. One entry 19 | # for every subscription that raised an error. 20 | # 21 | # @return [Hash{Subscription => Exception}] 22 | def result 23 | @errors 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /examples/scoped_subscription.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | class Poem 4 | include Brainguy::Observable 5 | def recite(&block) 6 | with_subscription_scope(block) do 7 | emit(:title, "Jabberwocky") 8 | emit(:line, "'twas brillig, and the slithy toves") 9 | emit(:line, "Did gyre and gimbal in the wabe") 10 | end 11 | end 12 | end 13 | 14 | class HtmlFormatter 15 | include Brainguy::Observer 16 | 17 | attr_reader :result 18 | 19 | def initialize 20 | @result = "" 21 | end 22 | 23 | def on_title(event) 24 | @result << "

#{event.args.first}

" 25 | end 26 | 27 | def on_line(event) 28 | @result << "#{event.args.first}
" 29 | end 30 | end 31 | 32 | p = Poem.new 33 | f = HtmlFormatter.new 34 | p.recite do |events| 35 | events.attach(f) 36 | end 37 | 38 | f.result 39 | # => "

Jabberwocky

'twas brillig, and the slithy toves
Did gyre an... 40 | -------------------------------------------------------------------------------- /spec/support/shared_examples_for_eventful_modules.rb: -------------------------------------------------------------------------------- 1 | module Brainguy 2 | RSpec.shared_examples "an eventful module" do |the_module| 3 | let(:coffee_maker_class) { 4 | Class.new do 5 | 6 | include the_module 7 | 8 | def make_coffee 9 | emit(:heat) 10 | emit(:drip) 11 | emit(:done) 12 | end 13 | end 14 | } 15 | 16 | it "adds an #events attribute" do 17 | cm = coffee_maker_class.new 18 | cm.respond_to?(:events) 19 | end 20 | 21 | it "adds an #on method for attaching listeners" do 22 | cm = coffee_maker_class.new 23 | probe = spy 24 | cm.on(:done) do 25 | probe.coffee_done 26 | end 27 | cm.make_coffee 28 | expect(probe).to have_received(:coffee_done) 29 | end 30 | 31 | it "provides the #emit helper privately" do 32 | cm = coffee_maker_class.new 33 | expect { cm.emit(:foo) }.to raise_error(NoMethodError) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/brainguy/observable.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "brainguy/emitter" 3 | 4 | module Brainguy 5 | # A convenience module for making client classes observable. 6 | module Observable 7 | extend Forwardable 8 | 9 | # @return [Emitter] the {Emitter} managing all 10 | # subscriptions to this object's events. 11 | def events 12 | @brainguy_subscriptions ||= Emitter.new(self) 13 | end 14 | 15 | # Create a temporary scope for transient subscriptions. Useful for 16 | # making a single method listenable. See {file:README.md} for usage 17 | # examples. 18 | # 19 | # @param listener_block (see Brainguy.with_subscription_scope) 20 | def with_subscription_scope(listener_block, &block) 21 | Brainguy.with_subscription_scope(self, listener_block, events, &block) 22 | end 23 | 24 | # @!method on(name_or_handlers, &block) 25 | # (see {Emitter#on}) 26 | def_delegator :events, :on 27 | 28 | # @!method emit 29 | # (see {Emitter#emit}) 30 | def_delegator :events, :emit 31 | private :emit 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Avdi Grimm 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/brainguy/single_event_subscription.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/subscription" 2 | 3 | module Brainguy 4 | # A subscription to a single type (name) of event. The `listener` will only 5 | # be notified if the event name matches `subscribed_event_name`. 6 | # 7 | # See {Emitter#on} for where this class is used. 8 | class SingleEventSubscription < Subscription 9 | # @param owner [Emitter] the owning {Emitter} 10 | # @param listener [:call] some callable that should be called when the 11 | # named event occurs 12 | # @param subscribed_event_name [Symbol] the event to subscribe to 13 | def initialize(owner, listener, subscribed_event_name) 14 | @subscribed_event_name = subscribed_event_name 15 | super(owner, listener) 16 | end 17 | 18 | # Call listener if the event name is the one being subscribed to. 19 | # @param event [Event] the event to (maybe) dispatch 20 | def handle(event) 21 | return unless event.name == @subscribed_event_name 22 | @listener.call(*event.args) 23 | end 24 | 25 | protected 26 | 27 | def equality_components 28 | super + [@subscribed_event_name] 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/brainguy.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/version" 2 | require "brainguy/event" 3 | require "brainguy/emitter" 4 | require "brainguy/idempotent_emitter" 5 | require "brainguy/observable" 6 | require "brainguy/manifestly_observable" 7 | 8 | require "brainguy/subscription_scope" 9 | require "brainguy/fluent_emitter" 10 | require "brainguy/observer" 11 | 12 | # Namespace for the `brainguy` gem. See {file:README.md} for usage instructions. 13 | module Brainguy 14 | # Execute passed block with a temporary subscription scope. See README for 15 | # examples. 16 | # 17 | # @param source the object initiating the event 18 | # @param listener_block [:call] an optional callable that should hook up 19 | # listeners 20 | # @param subscription_set [Emitter] an existing subscription set to 21 | # layer on top of 22 | def self.with_subscription_scope( 23 | source, 24 | listener_block = nil, 25 | subscription_set = IdempotentEmitter.new(source)) 26 | subscription_set.with_subscription_scope do |scope| 27 | listener_block.call(scope) if listener_block 28 | yield scope 29 | end 30 | unless listener_block 31 | FluentEmitter.new(subscription_set) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /examples/synopsis.rb: -------------------------------------------------------------------------------- 1 | require "brainguy" 2 | 3 | class SatelliteOfLove 4 | include Brainguy::Observable 5 | 6 | def intro_song 7 | emit(:robot_roll_call) 8 | end 9 | 10 | def send_the_movie 11 | emit(:movie_sign) 12 | end 13 | end 14 | 15 | class Crew 16 | include Brainguy::Observer 17 | end 18 | 19 | class TomServo < Crew 20 | def on_robot_roll_call(event) 21 | puts "Tom: Check me out!" 22 | end 23 | end 24 | 25 | class CrowTRobot < Crew 26 | def on_robot_roll_call(event) 27 | puts "Crow: I'm different!" 28 | end 29 | end 30 | 31 | class MikeNelson < Crew 32 | def on_movie_sign(event) 33 | puts "Mike: Oh no we've got movie sign!" 34 | end 35 | end 36 | 37 | sol = SatelliteOfLove.new 38 | # Attach specific event handlers without a listener object 39 | sol.on(:robot_roll_call) do 40 | puts "[Robot roll call!]" 41 | end 42 | sol.on(:movie_sign) do 43 | puts "[Movie sign flashes]" 44 | end 45 | sol.events.attach TomServo.new 46 | sol.events.attach CrowTRobot.new 47 | sol.events.attach MikeNelson.new 48 | 49 | sol.intro_song 50 | sol.send_the_movie 51 | 52 | # >> [Robot roll call!] 53 | # >> Tom: Check me out! 54 | # >> Crow: I'm different! 55 | # >> [Movie sign flashes] 56 | # >> Mike: Oh no we've got movie sign! 57 | -------------------------------------------------------------------------------- /lib/brainguy/idempotent_emitter.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/emitter" 2 | 3 | module Brainguy 4 | # A type of {Emitter} that records and "plays back" events to new 5 | # listeners. That way a listener will never miss an event, even if it 6 | # subscribes late. 7 | # 8 | # This class is probably best used in short-lived scopes, since the log of 9 | # events will continually grow. 10 | class IdempotentEmitter < Emitter 11 | # (see Emitter#initialize) 12 | def initialize(*) 13 | super 14 | @event_log = [] 15 | end 16 | 17 | # Emit an event and record it in the log. 18 | # @event_name (see Emitter#emit) 19 | # @extra_args (see Emitter#emit) 20 | # @return (see Emitter#emit) 21 | def emit(event_name, *extra_args) 22 | @event_log.push(Event.new(event_name, @event_source, extra_args)) 23 | super 24 | end 25 | 26 | # Add a new subscription, and immediately play back any missed events to 27 | # it. 28 | # 29 | # @param subscription (see Emitter#<<) 30 | # @return (see Emitter#<<) 31 | def <<(subscription) 32 | super 33 | @event_log.each do |event| 34 | subscription.handle(event) 35 | end 36 | end 37 | 38 | alias_method :add, :<< 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /brainguy.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'brainguy/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "brainguy" 8 | spec.version = Brainguy::VERSION 9 | spec.authors = ["Avdi Grimm"] 10 | spec.email = ["avdi@avdi.org"] 11 | spec.summary = %q{An Observer pattern library} 12 | spec.description = %q{A somewhat fancy observer pattern library with 13 | features like named events and scoped subscriptions.} 14 | spec.homepage = "https://github.com/avdi/brainguy" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0") 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_development_dependency "bundler", "~> 1.7" 23 | spec.add_development_dependency "rake", "~> 10.0" 24 | spec.add_development_dependency "rspec", "~> 3.2" 25 | spec.add_development_dependency "benchmark-ips" 26 | spec.add_development_dependency "yard", "~> 0.8.7" 27 | spec.add_development_dependency "seeing_is_believing", "~> 2.2" 28 | end 29 | -------------------------------------------------------------------------------- /spec/brainguy/manifestly_observable_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/manifestly_observable" 3 | require "support/shared_examples_for_eventful_modules" 4 | 5 | module Brainguy 6 | RSpec.describe ManifestlyObservable do 7 | include_examples "an eventful module", 8 | ManifestlyObservable.new(:heat, :drip, :done) 9 | 10 | it "adds a ManifestEmitter to the class" do 11 | klass = Class.new do 12 | include ManifestlyObservable.new(:red, :green) 13 | end 14 | obj = klass.new 15 | expect(obj.events).to be_a(ManifestEmitter) 16 | end 17 | 18 | it "adds the specified event names to the known list" do 19 | klass = Class.new do 20 | include ManifestlyObservable.new(:red, :green) 21 | end 22 | obj = klass.new 23 | expect(obj.events.known_types).to eq([:red, :green]) 24 | end 25 | 26 | it "inherits event names" do 27 | parent = Class.new do 28 | include ManifestlyObservable.new(:red, :green) 29 | end 30 | child = Class.new(parent) do 31 | include ManifestlyObservable.new(:yellow) 32 | end 33 | obj = child.new 34 | expect(obj.events.known_types).to eq([:red, :green, :yellow]) 35 | end 36 | 37 | it "stringifies meaningfully" do 38 | mod = ManifestlyObservable.new(:red, :green) 39 | expect(mod.to_s).to eq("ManifestlyObservable(:red, :green)") 40 | end 41 | 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/brainguy/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/subscription" 3 | 4 | module Brainguy 5 | RSpec.describe Subscription do 6 | 7 | it "is equal to another subscription with the same owner and listener" do 8 | listener = double("listener") 9 | owner = double("owner") 10 | sub1 = Subscription.new(owner, listener) 11 | sub2 = Subscription.new(owner, listener) 12 | expect(sub1).to eq(sub2) 13 | expect(sub1.hash).to eq(sub2.hash) 14 | expect(sub1).to eql(sub2) 15 | 16 | set = Set.new([sub1]) 17 | expect(set.add?(sub2)).to be_nil 18 | expect(set.size).to eq(1) 19 | set.delete(sub2) 20 | expect(set.size).to eq(0) 21 | end 22 | 23 | it "is not equal to another subscription with a different owner" do 24 | listener = double("listener") 25 | sub1 = Subscription.new(double, listener) 26 | sub2 = Subscription.new(double, listener) 27 | expect(sub1).not_to eq(sub2) 28 | expect(sub1.hash).not_to eq(sub2.hash) 29 | expect(sub1).not_to eql(sub2) 30 | end 31 | 32 | it "is not equal to another subscription with a different listener" do 33 | owner = double("owner") 34 | sub1 = Subscription.new(owner, double) 35 | sub2 = Subscription.new(owner, double) 36 | expect(sub1).not_to eq(sub2) 37 | expect(sub1.hash).not_to eq(sub2.hash) 38 | expect(sub1).not_to eql(sub2) 39 | end 40 | 41 | it "is frozen" do 42 | s = Subscription.new(double, double) 43 | expect(s).to be_frozen 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/brainguy/open_observer_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/open_observer" 3 | 4 | module Brainguy 5 | RSpec.describe OpenObserver do 6 | it "can be constructed from a hash" do 7 | probe = spy("probe") 8 | ol = OpenObserver.new(foo: ->(e) { probe.handle_foo(e) }, 9 | bar: ->(e) { probe.handle_bar(e) }) 10 | source = double("source") 11 | ol.call(e1 = Event[:foo]) 12 | ol.call(e2 = Event[:bar]) 13 | expect(probe).to have_received(:handle_foo).with(e1) 14 | expect(probe).to have_received(:handle_bar).with(e2) 15 | end 16 | 17 | it "can be constructed using on_* methods" do 18 | probe = spy("probe") 19 | ol = OpenObserver.new 20 | ol.on_foo do |e| 21 | probe.handle_foo(e) 22 | end 23 | .on_bar do |e| 24 | probe.handle_bar(e) 25 | end 26 | source = double("source") 27 | ol.call(e1 = Event[:foo]) 28 | ol.call(e2 = Event[:bar]) 29 | expect(probe).to have_received(:handle_foo).with(e1) 30 | expect(probe).to have_received(:handle_bar).with(e2) 31 | end 32 | 33 | it "yields self on init" do 34 | probe = spy("probe") 35 | ol = OpenObserver.new do |l| 36 | l.on_foo do |e| 37 | probe.handle_foo(e) 38 | end 39 | l.on_bar do |e| 40 | probe.handle_bar(e) 41 | end 42 | end 43 | source = double("source") 44 | ol.call(e1 = Event[:foo]) 45 | ol.call(e2 = Event[:bar]) 46 | expect(probe).to have_received(:handle_foo).with(e1) 47 | expect(probe).to have_received(:handle_bar).with(e2) 48 | end 49 | 50 | it "has correct #respond_to? behavior" do 51 | ol = OpenObserver.new 52 | expect(ol).to respond_to(:on_blub) 53 | expect(ol).to_not respond_to(:blub) 54 | end 55 | end 56 | end 57 | 58 | -------------------------------------------------------------------------------- /spec/brainguy/emitter_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/emitter" 3 | 4 | module Brainguy 5 | RSpec.describe Emitter do 6 | it "uses notifier result for return value of #emit" do 7 | notifier = instance_double(BasicNotifier, result: "THE RESULT") 8 | e = Emitter.new(double("event source"), 9 | notifier_maker: ->() { notifier }) 10 | expect(e.emit(:foo)).to eq("THE RESULT") 11 | end 12 | 13 | it "can set up multiple handlers at once with a hash of lambdas" do 14 | e = Emitter.new(double) 15 | probe = spy("probe") 16 | result = e.on(foo: proc { probe.handle_foo }, 17 | bar: proc { probe.handle_bar }) 18 | expect(result.listener).to be_a(OpenObserver) 19 | e.emit(:foo) 20 | e.emit(:bar) 21 | expect(probe).to have_received(:handle_foo) 22 | expect(probe).to have_received(:handle_bar) 23 | end 24 | 25 | it "allows a client to subscribe to have a specific message sent" do 26 | e = Emitter.new(double) 27 | probe = spy("probe") 28 | e.on :shazam, send: :abracadabra, to: probe 29 | e.emit(:shazam) 30 | expect(probe).to have_received(:abracadabra) 31 | end 32 | 33 | it "allows callback subscriptions to include arguments" do 34 | e = Emitter.new(double) 35 | probe = spy("probe") 36 | e.on :shazam, send: [:abracadabra, {x: 3, y: 5}], to: probe 37 | e.emit(:shazam) 38 | expect(probe).to have_received(:abracadabra).with(x: 3, y: 5) 39 | end 40 | 41 | it "allows callback subscriptions to include arguments using a keyword" do 42 | e = Emitter.new(double) 43 | probe = spy("probe") 44 | e.on :shazam, send: :abracadabra, to: probe, with: [{x: 3, y: 5}] 45 | e.emit(:shazam) 46 | expect(probe).to have_received(:abracadabra).with(x: 3, y: 5) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/brainguy/subscription.rb: -------------------------------------------------------------------------------- 1 | module Brainguy 2 | # A value object that represents a single subscription to an event source. 3 | # 4 | # It ties together an event `source` to a `listener`. A listener is simply 5 | # some call-able object. 6 | class Subscription 7 | include Comparable 8 | 9 | # @!attribute [r] owner 10 | # @return [Emitter] the owning {Emitter} 11 | # @!attribute [r] listener 12 | # @return [:call] a callable listener object 13 | attr_reader :owner, :listener 14 | 15 | # @param owner [Emitter] the owning {Emitter} 16 | # @param listener [:call] a callable listener object 17 | def initialize(owner, listener) 18 | @owner = owner 19 | @listener = listener 20 | freeze 21 | end 22 | 23 | # Dispatch `event` to the listener. 24 | # @param event [Event] the event to dispatch 25 | # @return whatever the listener returns 26 | def handle(event) 27 | @listener.call(event) 28 | end 29 | 30 | # Cancel the subscription (remove it from the `owner`) 31 | def cancel 32 | @owner.delete(self) 33 | end 34 | 35 | # Compare this to another subscription 36 | def <=>(other) 37 | return nil unless other.is_a?(Subscription) 38 | equality_components <=> other.equality_components 39 | end 40 | 41 | # The hash value is based on the identity (but not the state) of `owner` 42 | # and `listener`. Two `Subscription` objects with the same `owner` and 43 | # `listener` are considered to be equivalent. 44 | def hash 45 | [self.class, *equality_components].hash 46 | end 47 | 48 | # @!method eql?(other) 49 | # @param other [Subscription] the other subscription to compare to 50 | # @return [Boolean] 51 | alias_method :eql?, :== 52 | 53 | protected 54 | 55 | def equality_components 56 | [owner.object_id, listener.object_id] 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/brainguy/open_observer.rb: -------------------------------------------------------------------------------- 1 | module Brainguy 2 | # A quick and dirty way to set up a reusable listener object. Like an 3 | # OpenObject, only for event listening! 4 | class OpenObserver 5 | # @param handlers [Hash{Symbol => [:call]}] a Hash of event names to 6 | # callable handlers 7 | # @yield [self] if a block is given 8 | # @example Initializing and then adding handlers dynamically 9 | # ol = OpenObserver.new 10 | # ol.on_foo do 11 | # # ... 12 | # end 13 | # ol.on_bar do 14 | # # ... 15 | # end 16 | # @example Initializing with a block 17 | # listener = OpenObserver.new do |ol| 18 | # ol.on_foo do 19 | # # ... 20 | # end 21 | # ol.on_bar do 22 | # # ... 23 | # end 24 | # end 25 | # @example Initializing from a hash 26 | # listener = OpenObserver.new(foo: ->{...}, bar: ->{...}) 27 | def initialize(handlers = {}) 28 | @handlers = handlers 29 | yield self if block_given? 30 | end 31 | 32 | # Dispatch the event to the appropriate handler, if one has been set. 33 | # Events without handlers are ignored. 34 | # @param event [Event] the event to be handled 35 | def call(event) 36 | if (handler = @handlers[event.name]) 37 | handler.call(event) 38 | end 39 | end 40 | 41 | # Enable setting up event handlers dynamically using `on_*` message sends. 42 | # @example 43 | # ol = OpenObserver.new 44 | # ol.on_foo do 45 | # # ... 46 | # end 47 | # ol.on_bar do 48 | # # ... 49 | # end 50 | def method_missing(method_name, *_args, &block) 51 | if method_name.to_s =~ /\Aon_(.+)/ 52 | @handlers[$1.to_sym] = block 53 | self 54 | else 55 | super 56 | end 57 | end 58 | 59 | # true if the method starts with `on_` 60 | # @return [Boolean] 61 | def respond_to_missing?(method_name, _include_private) 62 | method_name.to_s =~ /\Aon_./ 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/brainguy/subscription_scope.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/emitter" 2 | require "delegate" 3 | 4 | module Brainguy 5 | # A scope for subscriptions with a limited lifetime. 6 | # 7 | # Sometimes it's useful to have a set of subscriptions with a limited 8 | # lifetime. For instance, a set of observers which are only valid over the 9 | # course of a single method call. This class wraps an existing 10 | # {Emitter}, and exposes the same API. But when a client sends the 11 | # `#close` message, all listeners subscribed through this object will be 12 | # immediately unsubscribed. 13 | class SubscriptionScope < DelegateClass(Emitter) 14 | 15 | # Create a new scope and yield it to the block. Closes the scope 16 | # (unsubscribing any listeners attached using the scope) at the end of 17 | # block execution 18 | # @param (see #initialize) 19 | # @yield [SubscriptionScope] the subscription scope 20 | def self.open(subscription_set) 21 | scope = self.new(subscription_set) 22 | yield scope 23 | ensure 24 | scope.close 25 | end 26 | 27 | # @param subscription_set [Emitter] the subscription set to wrap 28 | def initialize(subscription_set) 29 | super(subscription_set) 30 | @subscriptions = [] 31 | end 32 | 33 | # (see Emitter#on) 34 | def on(*) 35 | super.tap do |subscription| 36 | @subscriptions << subscription 37 | end 38 | end 39 | 40 | # (see Emitter#attach) 41 | def attach(*) 42 | super.tap do |subscription| 43 | @subscriptions << subscription 44 | end 45 | end 46 | 47 | # Detach every listener that was attached via this scope. 48 | # @return [void] 49 | def close 50 | @subscriptions.each(&:cancel) 51 | @subscriptions.clear 52 | end 53 | end 54 | 55 | class Emitter 56 | # @yield [SubscriptionScope] a temporary subscription scope layered on top 57 | # of this {Emitter} 58 | def with_subscription_scope(&block) 59 | SubscriptionScope.open(self, &block) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/brainguy/observer_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/observer" 3 | 4 | module Brainguy 5 | RSpec.describe Observer do 6 | class AccountListener 7 | def on_open(event) 8 | end 9 | 10 | include Observer 11 | 12 | def on_deposit(event) 13 | end 14 | 15 | def on_withdrawal(event) 16 | end 17 | end 18 | 19 | it "routes events to their appropriate handler methods" do 20 | listener = AccountListener.new 21 | allow(listener).to receive(:on_deposit) 22 | allow(listener).to receive(:on_withdrawal) 23 | 24 | listener.call(e1 = Event[:deposit]) 25 | expect(listener).to have_received(:on_deposit).with(e1) 26 | 27 | listener.call(e2 = Event[:withdrawal]) 28 | expect(listener).to have_received(:on_withdrawal).with(e2) 29 | end 30 | 31 | it "picks up handler methods defined before module inclusion" do 32 | klass = Class.new do 33 | def on_open(event) 34 | 35 | end 36 | 37 | include Observer 38 | end 39 | listener = klass.new 40 | allow(listener).to receive(:on_open) 41 | listener.call(e2 = Event[:open]) 42 | expect(listener).to have_received(:on_open).with(e2) 43 | end 44 | 45 | it "ignores events which lack handler methods" do 46 | listener = AccountListener.new 47 | expect { listener.call(Event[:not_defined]) }.to_not raise_error 48 | end 49 | 50 | it "doesn't blow up on a handler-less class" do 51 | expect do 52 | Class.new do 53 | include Observer 54 | end 55 | end.to_not raise_error 56 | end 57 | 58 | it "passes unknown events to a catch-all method" do 59 | events = [] 60 | klass = Class.new do 61 | include Observer 62 | define_method :event_handler_missing do |event| 63 | events << event 64 | end 65 | end 66 | listener = klass.new 67 | listener.call(unknown_event = Event[:unknown_event]) 68 | expect(events).to eq([unknown_event]) 69 | end 70 | 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/brainguy/error_handling_notifier.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/basic_notifier" 2 | 3 | module Brainguy 4 | # It is possible that errors may be raised when notifying listeners. This 5 | # notifier wrapper class provides some leeway in how to handle those 6 | # errors. It does this by capturing errors and applying a policy to them. 7 | # 8 | # Includes a selection of common error policies. 9 | class ErrorHandlingNotifier < DelegateClass(BasicNotifier) 10 | # The suppression strategy. Throws errors away. 11 | SUPPRESS_STRATEGY = ->(_subscription, _error) do 12 | # NOOP 13 | end 14 | 15 | # The warning strategy. Turns errors into warnings. 16 | WARN_STRATEGY = ->(_subscription, error) do 17 | warn "#{error.class}: #{error.message}" 18 | end 19 | 20 | # The raise strategy. Re-raises errors as if they had never been captured. 21 | RAISE_STRATEGY = ->(_subscription, error) do 22 | raise error 23 | end 24 | 25 | # @param notifier [#notify] the notifier to wrap 26 | # @param error_handler [:call] a callable that determined error policy 27 | # There are some symbolic shortcuts available here: 28 | # - `:suppress`: Suppress errors completely. 29 | # - `:warn`: Convert errors to warnings. 30 | # - `:raise`: Re-raise errors. 31 | def initialize(notifier, error_handler = RAISE_STRATEGY) 32 | super(notifier) 33 | @error_handler = resolve_error_handler(error_handler) 34 | end 35 | 36 | # Notify a `subscription` of an event, and apply the error policy to any 37 | # exception that is raised. 38 | # 39 | # @return [@Object] whatever the underlying notifier returned 40 | def notify(subscription, *) 41 | super 42 | rescue => error 43 | @error_handler.call(subscription, error) 44 | end 45 | 46 | # @return [nil] 47 | def result 48 | nil 49 | end 50 | 51 | private 52 | 53 | def resolve_error_handler(handler) 54 | case handler 55 | when :suppress then SUPPRESS_STRATEGY 56 | when :warn then WARN_STRATEGY 57 | when :raise then RAISE_STRATEGY 58 | when Symbol then fail ArgumentError, "Unknown mnemonic: #{handler}" 59 | else handler 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/brainguy/manifestly_observable.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/observable" 2 | require "brainguy/manifest_emitter" 3 | 4 | module Brainguy 5 | # A custom {Module} subclass which acts like {Observable}, except with an 6 | # event type manifest. This is a way to define an observable cladd with a 7 | # known list of possible event types. 8 | # 9 | # @example 10 | # class MyApiRequest 11 | # include ManifestlyObservable.new(:success, :unauthorized, :error) 12 | # # ... 13 | # end 14 | # 15 | class ManifestlyObservable < Module 16 | 17 | # Generate a new mixin module with a custom list of known event types. 18 | def initialize(*known_events) 19 | # Look out, this gets a bit gnarly... 20 | @known_events = known_events 21 | # Define the module body 22 | super() do 23 | # First off, let's make sure we have basic Observable functionality 24 | include Observable 25 | 26 | # Now, we override #events to wrap it in some extra goodness 27 | define_method :events do 28 | 29 | # Let's see what the current subscription set object is 30 | subscription_set = super() 31 | 32 | # If there is already another ManifestlyObservable included further 33 | # up the chain... 34 | if subscription_set.is_a?(ManifestEmitter) 35 | # just add our event types to its subscription set 36 | subscription_set.known_types.concat(known_events) 37 | # But if this is the first ManifestlyObservable included... 38 | else 39 | # Wrap the subscription set in a ManifestEmitter 40 | @brainguy_events = ManifestEmitter.new(subscription_set) 41 | # Set up the known event types 42 | @brainguy_events.known_types.concat(known_events) 43 | end 44 | 45 | # No need to do all this every time. Once we've set everything up, 46 | # redefine the method on a per-instance level to be a simple getter. 47 | define_singleton_method :events do 48 | @brainguy_events 49 | end 50 | # Don't forget to return a value the first time around 51 | @brainguy_events 52 | end 53 | end 54 | end 55 | 56 | # Return a meaningful description of the generated module. 57 | # @return [String] 58 | def to_s 59 | "ManifestlyObservable(#{@known_events.map(&:inspect).join(', ')})" 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/features/idempotent_events_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy" 3 | 4 | module Brainguy 5 | class WebRequest 6 | attr_reader :events 7 | 8 | def initialize 9 | @events = Brainguy::IdempotentEmitter.new(self) 10 | end 11 | 12 | def successful_response 13 | events.emit(:data, "Hello, ") 14 | events.emit(:data, "World!") 15 | events.emit(:success) 16 | end 17 | end 18 | 19 | RSpec.describe "idempotent events" do 20 | it "replays events when a listener subscribes late" do 21 | r = WebRequest.new 22 | r.successful_response 23 | listener = spy("listener") 24 | r.events.attach(listener) 25 | expect(listener).to have_received(:call) 26 | .with(Event[:data, r, ["Hello, "]]) 27 | .ordered 28 | expect(listener).to have_received(:call) 29 | .with(Event[:data, r, ["World!"]]) 30 | .ordered 31 | expect(listener).to have_received(:call) 32 | .with(Event[:success, r]) 33 | .ordered 34 | end 35 | 36 | it "replays events when a single-event handler subscribes late" do 37 | r = WebRequest.new 38 | r.successful_response 39 | expect do |b| 40 | r.events.on(:data, &b) 41 | end.to yield_successive_args("Hello, ", "World!") 42 | end 43 | 44 | it "passes events on to existing listeners immediately" do 45 | r = WebRequest.new 46 | listener = spy("listener") 47 | r.events.attach(listener) 48 | r.successful_response 49 | expect(listener).to have_received(:call) 50 | .with(Event[:data, r, ["Hello, "]]) 51 | .ordered 52 | expect(listener).to have_received(:call) 53 | .with(Event[:data, r, ["World!"]]) 54 | .ordered 55 | expect(listener).to have_received(:call) 56 | .with(Event[:success, r]) 57 | .ordered 58 | end 59 | 60 | it "does not repeat events to preexisting listeners" do 61 | r = WebRequest.new 62 | listener = spy("listener") 63 | r.events.attach(listener) 64 | r.successful_response 65 | r.events.attach(spy("new listener")) 66 | expect(listener).to have_received(:call).exactly(3).times 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/brainguy/manifest_emitter.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/emitter" 2 | require "delegate" 3 | 4 | module Brainguy 5 | # Raised for an event that is not listed in the manifest. 6 | class UnknownEvent < StandardError 7 | end 8 | 9 | # A {Emitter} wrapper which "locks down" a subscription set to a 10 | # known list of event names. Useful for preventing typos in event names. 11 | class ManifestEmitter < DelegateClass(Emitter) 12 | 13 | # A policy which outputs a warning on unrecognized event names 14 | WARN_POLICY = Kernel.method(:warn) 15 | 16 | # A policy which raises an exception on unrecognized evend names. 17 | RAISE_ERROR_POLICY = ->(message) do 18 | fail UnknownEvent, message, caller.drop_while{|l| l.include?(__FILE__)} 19 | end 20 | 21 | # The list of known event names 22 | # @return [Array] 23 | attr_reader :known_types 24 | 25 | # @param subscription_set [Emitter] the set to be wrapped 26 | # @param options [Hash] a hash of options 27 | # @option options [:call, Symbol] :policy the policy for what to do on 28 | # unknown event names. A callable or a mnemonic symbol. 29 | # The following mnemonics are supported: 30 | # 31 | # - `:warn`: Output a warning 32 | # - `:raise_error`: Raise an exception 33 | def initialize(subscription_set, options = {}) 34 | super(subscription_set) 35 | @known_types = [] 36 | policy = options.fetch(:policy) { :warn } 37 | @policy = resolve_policy(policy) 38 | end 39 | 40 | 41 | def unknown_event_policy=(new_policy) 42 | @policy = resolve_policy(new_policy) 43 | end 44 | 45 | # (see Emitter#on) 46 | def on(event_name, &block) 47 | check_event_name(event_name, __callee__) 48 | super 49 | end 50 | 51 | # (see Emitter#emit) 52 | def emit(event_name, *) 53 | check_event_name(event_name, __callee__) 54 | super 55 | end 56 | 57 | private 58 | 59 | def check_event_name(event_name, method_name) 60 | unless @known_types.include?(event_name) 61 | message = 62 | "##{method_name} received for unknown event type '#{event_name}'" 63 | @policy.call(message) 64 | end 65 | end 66 | 67 | def resolve_policy(policy) 68 | case policy 69 | when :warn 70 | WARN_POLICY 71 | when :raise_error 72 | RAISE_ERROR_POLICY 73 | else 74 | fail ArgumentError, "Invalid policy: #{policy}" 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/brainguy/manifest_emitter_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/manifest_emitter" 3 | 4 | module Brainguy 5 | RSpec.describe ManifestEmitter do 6 | it "starts with an empty set of known events" do 7 | set = ManifestEmitter.new(instance_double(Emitter)) 8 | expect(set.known_types).to be_empty 9 | end 10 | 11 | it "warns when a handler is added for an unknown event" do 12 | set = ManifestEmitter.new( 13 | instance_double(Emitter, on: nil)) 14 | expect { set.on(:foo) {} } 15 | .to output(/unknown event type 'foo'/i).to_stderr 16 | end 17 | 18 | it "warns when an unknown event is emitted" do 19 | set = ManifestEmitter.new( 20 | instance_double(Emitter, emit: nil)) 21 | expect { set.emit(:foo) {} } 22 | .to output(/unknown event type 'foo'/i).to_stderr 23 | end 24 | 25 | it "does not warn when a handler is added for an known event" do 26 | set = ManifestEmitter.new( 27 | instance_double(Emitter, on: nil)) 28 | set.known_types << :foo 29 | expect { set.on(:foo) {} } 30 | .not_to output.to_stderr 31 | end 32 | 33 | it "does not warn when an known event is emitted" do 34 | set = ManifestEmitter.new( 35 | instance_double(Emitter, emit: nil)) 36 | set.known_types << :foo 37 | expect { set.emit(:foo) {} } 38 | .not_to output.to_stderr 39 | end 40 | 41 | context "configured to raise errors" do 42 | it "fails when a handler is added for an unknown event" do 43 | set = ManifestEmitter.new( 44 | instance_double(Emitter, on: nil), 45 | policy: :raise_error) 46 | expect { set.on(:foo) {} } 47 | .to raise_error(UnknownEvent, /unknown event type 'foo'/i) 48 | end 49 | 50 | it "fails when an unknown event is emitted" do 51 | set = ManifestEmitter.new( 52 | instance_double(Emitter, emit: nil), 53 | policy: :raise_error) 54 | expect { set.emit(:foo) {} } 55 | .to raise_error(UnknownEvent, /unknown event type 'foo'/i) 56 | end 57 | 58 | it "reports exception from the send point" do 59 | set = ManifestEmitter.new( 60 | instance_double(Emitter, emit: nil), 61 | policy: :raise_error) 62 | error = set.emit(:foo) rescue $!; line = __LINE__ 63 | file = File.basename(__FILE__) 64 | expect(error.backtrace[0]).to include("#{file}:#{line}") 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/brainguy/observer.rb: -------------------------------------------------------------------------------- 1 | require "brainguy/event" 2 | 3 | module Brainguy 4 | 5 | # A mixin for formal "listener" classes. Events received by the including 6 | # class will be dispatched to `#on_` methods, if they exist. If 7 | # no event-specific handler method is found, it will be sent to 8 | # {#event_handler_missing}. 9 | module Observer 10 | module ClassMethods 11 | HANDLER_METHOD_PATTERN = /\Aon_(.+)\z/ 12 | 13 | def method_added(method_name) 14 | if method_name.to_s =~ HANDLER_METHOD_PATTERN 15 | regenerate_dispatch_method 16 | end 17 | super 18 | end 19 | 20 | # [Re]generate a #call method containing a fast dispatch table from 21 | # event names to handler method names. The code generation strategy here 22 | # has been benchmarked as equivalent to hand-written dispatch code 23 | # on Ruby 2.2. See {file:scripts/benchmark_listener_dispatch.rb} for 24 | # the relevant benchmark. 25 | def regenerate_dispatch_method 26 | dispatch_table = instance_methods 27 | .map(&:to_s) 28 | .grep(HANDLER_METHOD_PATTERN) { |method_name| 29 | event_name = $1 30 | "when :#{event_name} then #{method_name}(event)" 31 | }.join("\n") 32 | code = %{ 33 | def call(event) 34 | case event.name 35 | #{dispatch_table} 36 | # Note: following case is mainly here to keep the parser happy 37 | # when there are no other cases (i.e. no handler methods defined 38 | # yet). 39 | when nil then fail ArgumentError, "Event cannot be nil" 40 | else event_handler_missing(event) 41 | end 42 | end 43 | } 44 | class_eval code 45 | rescue SyntaxError => error 46 | # SyntaxErrors suck when you can't look at the syntax 47 | error.message << "\n\nGenerated code:\n\n#{code}" 48 | raise error 49 | end 50 | end 51 | 52 | def self.included(klass) 53 | klass.extend(ClassMethods) 54 | klass.regenerate_dispatch_method 55 | end 56 | 57 | 58 | # Empty placeholder in case the client class doesn't supply a custom 59 | # `#event_handler_missing` 60 | def event_handler_missing(event) 61 | # just a placeholder 62 | end 63 | 64 | # @!method call(event) 65 | # Dispatch events to handler methods. 66 | # 67 | # This method is automatically [re]generated by 68 | # {ClassMethods#regenerate_dispatch_method} every time a new 69 | # `#on_*` method is defined. 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/brainguy/error_handling_notifier_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/error_handling_notifier" 3 | 4 | module Brainguy 5 | RSpec.describe ErrorHandlingNotifier do 6 | it "passes notifications through" do 7 | inner = instance_spy(BasicNotifier, "inner") 8 | outer = ErrorHandlingNotifier.new(inner) 9 | outer.notify(double, double) 10 | expect(inner).to have_received(:notify) 11 | end 12 | 13 | it "enables exceptions to be handled by an arbitrary callable" do 14 | handler = double("handler", :call => nil) 15 | inner = instance_spy(BasicNotifier, "inner") 16 | outer = ErrorHandlingNotifier.new(inner, handler) 17 | error = StandardError.new("Whoopsie") 18 | allow(inner).to receive(:notify).and_raise(error) 19 | outer.notify(sub = double("subscription"), double) 20 | expect(handler).to have_received(:call).with(sub, error) 21 | end 22 | 23 | it "re-raises errors if no handler is supplied" do 24 | inner = instance_spy(BasicNotifier, "inner") 25 | outer = ErrorHandlingNotifier.new(inner) 26 | error = StandardError.new("Whoopsie") 27 | allow(inner).to receive(:notify).and_raise(error) 28 | expect{outer.notify(double, double)}.to raise_error(error) 29 | end 30 | 31 | it "provides a mnemonic for the default re-raise behavior" do 32 | inner = instance_spy(BasicNotifier, "inner") 33 | outer = ErrorHandlingNotifier.new(inner, :raise) 34 | error = StandardError.new("Whoopsie") 35 | allow(inner).to receive(:notify).and_raise(error) 36 | expect{outer.notify(double, double)}.to raise_error(error) 37 | end 38 | 39 | it "provides a mnemonic for suppressing errors" do 40 | inner = instance_spy(BasicNotifier, "inner") 41 | outer = ErrorHandlingNotifier.new(inner, :suppress) 42 | error = StandardError.new("Whoopsie") 43 | allow(inner).to receive(:notify).and_raise(error) 44 | expect{outer.notify(double, double)}.to_not raise_error 45 | end 46 | 47 | it "provides a mnemonic for converting errors to warnings" do 48 | inner = instance_spy(BasicNotifier, "inner") 49 | outer = ErrorHandlingNotifier.new(inner, :warn) 50 | error = StandardError.new("Whoopsie") 51 | allow(inner).to receive(:notify).and_raise(error) 52 | expect{outer.notify(double, double)} 53 | .to output(/StandardError: Whoopsie/).to_stderr 54 | end 55 | 56 | it "has a result of nil" do 57 | inner = instance_spy(BasicNotifier, "inner") 58 | outer = ErrorHandlingNotifier.new(inner) 59 | outer.notify(double, double) 60 | expect(outer.result).to be_nil 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/features/method_scoped_events_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | 3 | require "brainguy" 4 | 5 | describe "method-scoped subscription set" do 6 | class Kitten 7 | def play(&block) 8 | Brainguy.with_subscription_scope(self, block) do |events| 9 | events.emit(:mew) 10 | events.emit(:mew) 11 | events.emit(:pounce) 12 | events.emit(:hiss) 13 | events.emit(:purr) 14 | end 15 | end 16 | end 17 | 18 | it "executes handlers hooked up inside a passed block" do 19 | kitten = Kitten.new 20 | event_log = [] 21 | kitten.play do |events| 22 | events.on(:hiss) do 23 | event_log << :hiss 24 | end 25 | events.on(:pounce) do 26 | event_log << :pounce 27 | end 28 | events.on(:mew) do 29 | event_log << :mew 30 | end 31 | end 32 | expect(event_log).to eq([:mew, :mew, :pounce, :hiss]) 33 | end 34 | 35 | it "executes handlers chained onto the method return value" do 36 | kitten = Kitten.new 37 | event_log = [] 38 | listener = spy("listener") 39 | kitten.play.on(:purr) do 40 | event_log << :purr 41 | end.on(:pounce) do 42 | event_log << :pounce 43 | end.on(:mew) do 44 | event_log << :mew 45 | end.attach(listener) 46 | expect(event_log).to eq([:purr, :pounce, :mew, :mew]) 47 | expect(listener).to have_received(:call).with(Brainguy::Event[:mew, kitten]).twice 48 | expect(listener).to have_received(:call).with(Brainguy::Event[:pounce, kitten]) 49 | expect(listener).to have_received(:call).with(Brainguy::Event[:hiss, kitten]) 50 | expect(listener).to have_received(:call).with(Brainguy::Event[:purr, kitten]) 51 | end 52 | 53 | it "returns nil when passed a block" do 54 | kitten = Kitten.new 55 | result = kitten.play do 56 | end 57 | expect(result).to be_nil 58 | end 59 | 60 | class Dog 61 | include Brainguy::Observable 62 | 63 | def play_fetch 64 | emit(:chase_stick) 65 | end 66 | 67 | def play(&block) 68 | with_subscription_scope(block) do |events| 69 | emit(:bark) 70 | play_fetch 71 | emit(:pant) 72 | end 73 | end 74 | end 75 | 76 | it "can be used in conjunction with an Observable object" do 77 | dog = Dog.new 78 | lifetime_listener = spy("lifetime listener") 79 | scoped_listener = spy("scoped listener") 80 | dog.events.attach(lifetime_listener) 81 | dog.play do |events| 82 | events.attach(scoped_listener) 83 | end 84 | dog.play_fetch 85 | expect(lifetime_listener) 86 | .to have_received(:call).with(Brainguy::Event[:chase_stick, dog]).twice 87 | expect(scoped_listener) 88 | .to have_received(:call).with(Brainguy::Event[:chase_stick, dog]).once 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/brainguy/subscription_scope_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy/subscription_scope" 3 | 4 | module Brainguy 5 | RSpec.describe SubscriptionScope do 6 | it "proxies subscriptions to an underlying subscription set" do 7 | scope = SubscriptionScope.new(set = instance_double(Emitter)) 8 | allow(set).to receive(:on).and_return(sub1 = double("sub1")) 9 | allow(set).to receive(:attach).and_return(sub2 = double("sub2")) 10 | expect(scope.on(:foo)).to be(sub1) 11 | expect(scope.attach(arg2 = double)).to be(sub2) 12 | expect(set).to have_received(:on).with(:foo) 13 | expect(set).to have_received(:attach).with(arg2) 14 | end 15 | 16 | it "removes subscriptions on close" do 17 | scope = SubscriptionScope.new(set = instance_double(Emitter)) 18 | allow(set).to receive(:on) 19 | .and_return(sub1 = instance_spy(Subscription)) 20 | allow(set).to receive(:attach) 21 | .and_return(sub2 = instance_spy(Subscription)) 22 | scope.on(:foo) 23 | scope.attach(double) 24 | scope.close 25 | expect(sub1).to have_received(:cancel) 26 | expect(sub2).to have_received(:cancel) 27 | end 28 | 29 | it "removes subscriptions only once" do 30 | scope = SubscriptionScope.new(set = instance_double(Emitter)) 31 | allow(set).to receive(:on) 32 | .and_return(sub1 = instance_spy(Subscription)) 33 | scope.on(:foo) 34 | scope.close 35 | scope.close 36 | expect(sub1).to have_received(:cancel).once 37 | end 38 | 39 | it "supports a block form" do 40 | set = instance_double(Emitter) 41 | allow(set).to receive(:on) 42 | .and_return(sub1 = instance_spy(Subscription)) 43 | allow(set).to receive(:attach) 44 | .and_return(sub2 = instance_spy(Subscription)) 45 | 46 | SubscriptionScope.open(set) do |scope| 47 | scope.on(:foo) 48 | scope.attach(double) 49 | end 50 | 51 | expect(sub1).to have_received(:cancel) 52 | expect(sub2).to have_received(:cancel) 53 | end 54 | 55 | it "ensures subscriptions are cancelled despite errors in block" do 56 | set = instance_double(Emitter) 57 | allow(set).to receive(:on) 58 | .and_return(sub1 = instance_spy(Subscription)) 59 | allow(set).to receive(:attach) 60 | .and_return(sub2 = instance_spy(Subscription)) 61 | 62 | SubscriptionScope.open(set) do |scope| 63 | scope.on(:foo) 64 | scope.attach(double) 65 | fail "Whoopsie" 66 | end rescue nil 67 | 68 | expect(sub1).to have_received(:cancel) 69 | expect(sub2).to have_received(:cancel) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/features/basics_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "brainguy" 3 | 4 | module Brainguy 5 | class AuctionBid 6 | attr_reader :events 7 | 8 | def initialize 9 | @events = Brainguy::Emitter.new(self) 10 | end 11 | 12 | def reject_bid 13 | events.emit(:rejected) 14 | end 15 | 16 | def make_counter_offer 17 | events.emit(:rejected, counter_amount: 101, valid_for_minutes: 60) 18 | end 19 | 20 | def accept_bid 21 | events.emit(:accepted) 22 | end 23 | end 24 | 25 | RSpec.describe Brainguy do 26 | it "enables objects to publish simple events" do 27 | mike = spy("mike") 28 | bid = AuctionBid.new 29 | bid.events.on(:rejected) do 30 | mike.handle_rejection 31 | end 32 | bid.reject_bid 33 | expect(mike).to have_received(:handle_rejection) 34 | end 35 | 36 | it "allows multiple listeners to attach" do 37 | mike = spy("mike") 38 | crow = spy("crow") 39 | bid = AuctionBid.new 40 | bid.events.on(:rejected) do 41 | mike.handle_rejection 42 | end 43 | bid.events.on(:rejected) do 44 | crow.handle_rejection 45 | end 46 | bid.reject_bid 47 | expect(mike).to have_received(:handle_rejection) 48 | expect(crow).to have_received(:handle_rejection) 49 | end 50 | 51 | it "allows multiple events to be emitted" do 52 | crow = spy("crow") 53 | bid = AuctionBid.new 54 | bid.events.on(:rejected) do 55 | crow.handle_rejection 56 | end 57 | bid.events.on(:accepted) do 58 | crow.handle_acceptance 59 | end 60 | bid.reject_bid 61 | expect(crow).to have_received(:handle_rejection) 62 | expect(crow).to_not have_received(:handle_acceptance) 63 | bid.accept_bid 64 | expect(crow).to have_received(:handle_acceptance) 65 | end 66 | 67 | it "allows for zero subscribers" do 68 | bid = AuctionBid.new 69 | expect { bid.reject_bid }.to_not raise_error 70 | end 71 | 72 | it "passes extra event args on to the handler block" do 73 | bid = AuctionBid.new 74 | expect do |b| 75 | bid.events.on(:rejected, &b) 76 | bid.make_counter_offer 77 | end.to yield_with_args({counter_amount: 101, 78 | valid_for_minutes: 60}) 79 | end 80 | 81 | it "allows a subscription to be removed" do 82 | bid = AuctionBid.new 83 | expect do |b| 84 | subscription = bid.events.on(:rejected, &b) 85 | subscription.cancel 86 | bid.reject_bid 87 | end.to_not yield_control 88 | end 89 | 90 | it "allows an object to listen to all events" do 91 | bid = AuctionBid.new 92 | listener = spy("listener") 93 | bid.events.attach(listener) 94 | 95 | bid.reject_bid 96 | expect(listener).to have_received(:call).with(Event[:rejected, bid]) 97 | 98 | bid.accept_bid 99 | expect(listener).to have_received(:call).with(Event[:accepted, bid]) 100 | end 101 | 102 | it "allows a listener to be unsubscribed" do 103 | bid = AuctionBid.new 104 | listener = spy("listener") 105 | subscription = bid.events.attach(listener) 106 | 107 | bid.reject_bid 108 | expect(listener).to have_received(:call).with(Event[:rejected, bid]) 109 | 110 | subscription.cancel 111 | 112 | bid.accept_bid 113 | expect(listener).not_to have_received(:call).with(Event[:accepted, bid]) 114 | end 115 | 116 | it "allows a listener to be unsubscribed by identity" do 117 | bid = AuctionBid.new 118 | listener = spy("listener") 119 | bid.events.attach(listener) 120 | 121 | bid.reject_bid 122 | expect(listener).to have_received(:call).with(Event[:rejected, bid]) 123 | 124 | bid.events.detach(listener) 125 | expect(bid.events).to be_empty 126 | 127 | bid.accept_bid 128 | expect(listener).not_to have_received(:call).with(Event[:accepted, bid]) 129 | end 130 | 131 | it "does not allow the same listener to be subscribed twice" do 132 | bid = AuctionBid.new 133 | listener = spy("listener") 134 | bid.events.attach(listener) 135 | bid.events.attach(listener) 136 | bid.reject_bid 137 | expect(listener).to have_received(:call).once 138 | end 139 | 140 | it "provides scoped subscriptions" do 141 | bid = AuctionBid.new 142 | probe = spy("probe") 143 | bid.events.with_subscription_scope do |scope| 144 | scope.on(:rejected) do 145 | probe.handle_rejection 146 | end 147 | bid.reject_bid 148 | end 149 | bid.reject_bid 150 | expect(probe).to have_received(:handle_rejection).once 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /scripts/benchmark_listener_dispatch.rb: -------------------------------------------------------------------------------- 1 | require "benchmark/ips" 2 | 3 | Event = Struct.new(:name, :source, :args) 4 | 5 | class TestBed 6 | attr_reader :event_log 7 | 8 | def initialize 9 | @event_log = [] 10 | end 11 | end 12 | 13 | class HardcodeTestbed < TestBed 14 | def call(event) 15 | case event.name 16 | when :foo 17 | handle_foo(event) 18 | when :bar 19 | handle_bar(event) 20 | when :baz 21 | handle_baz(event) 22 | end 23 | end 24 | 25 | def handle_foo(event) 26 | event_log << event 27 | end 28 | 29 | def handle_bar(event) 30 | event_log << event 31 | end 32 | 33 | def handle_baz(event) 34 | event_log << event 35 | end 36 | end 37 | 38 | class SendTestbed < TestBed 39 | def call(event) 40 | handler_name = "handle_#{event.name}" 41 | __send__(handler_name, event) if respond_to?(handler_name) 42 | end 43 | 44 | def handle_foo(event) 45 | event_log << event 46 | end 47 | 48 | def handle_bar(event) 49 | event_log << event 50 | end 51 | 52 | def handle_baz(event) 53 | event_log << event 54 | end 55 | end 56 | 57 | class SendTableTestbed < TestBed 58 | def self.method_added(method_name) 59 | if method_name.to_s =~ /\Ahandle_(.+)\z/ 60 | handler_methods[$1.to_sym] = method_name.to_sym 61 | end 62 | super 63 | end 64 | 65 | def self.handler_methods 66 | @handler_methods ||= {} 67 | end 68 | 69 | def call(event) 70 | if (handler_method = self.class.handler_methods[event.name]) 71 | __send__(handler_method, event) 72 | end 73 | end 74 | 75 | def handle_foo(event) 76 | event_log << event 77 | end 78 | 79 | def handle_bar(event) 80 | event_log << event 81 | end 82 | 83 | def handle_baz(event) 84 | event_log << event 85 | end 86 | end 87 | 88 | class BindTableTestbed < TestBed 89 | def self.method_added(method_name) 90 | if method_name.to_s =~ /\Ahandle_(.+)\z/ 91 | handler_methods[$1.to_sym] = instance_method(method_name) 92 | end 93 | super 94 | end 95 | 96 | def self.handler_methods 97 | @handler_methods ||= {} 98 | end 99 | 100 | def call(event) 101 | if (handler_method = self.class.handler_methods[event.name]) 102 | handler_method.bind(self).call(event) 103 | end 104 | end 105 | 106 | def handle_foo(event) 107 | event_log << event 108 | end 109 | 110 | def handle_bar(event) 111 | event_log << event 112 | end 113 | 114 | def handle_baz(event) 115 | event_log << event 116 | end 117 | end 118 | 119 | class CodeGenTestbed < TestBed 120 | def self.method_added(method_name) 121 | if method_name.to_s =~ /\Ahandle_(.+)\z/ 122 | handler_methods << $1 123 | regenerate_dispatch_method 124 | end 125 | super 126 | end 127 | 128 | def self.handler_methods 129 | @handler_methods ||= [] 130 | end 131 | 132 | def self.regenerate_dispatch_method 133 | dispatch_table = handler_methods.map { |event_name| 134 | "when :#{event_name} then handle_#{event_name}(event)" 135 | }.join("\n") 136 | class_eval %{ 137 | def call(event) 138 | case event.name 139 | #{dispatch_table} 140 | end 141 | end 142 | } 143 | end 144 | 145 | def handle_foo(event) 146 | event_log << event 147 | end 148 | 149 | def handle_bar(event) 150 | event_log << event 151 | end 152 | 153 | def handle_baz(event) 154 | event_log << event 155 | end 156 | end 157 | 158 | class IfCodeGenTestbed < TestBed 159 | def self.method_added(method_name) 160 | if method_name.to_s =~ /\Ahandle_(.+)\z/ 161 | handler_methods << $1 162 | regenerate_dispatch_method 163 | end 164 | super 165 | end 166 | 167 | def self.handler_methods 168 | @handler_methods ||= [] 169 | end 170 | 171 | def self.regenerate_dispatch_method 172 | dispatch_table = handler_methods.map { |event_name| 173 | "event.name.equal?(:#{event_name}) then handle_#{event_name}(event)" 174 | }.join("\nelsif ") 175 | class_eval %{ 176 | def call(event) 177 | if #{dispatch_table} 178 | end 179 | end 180 | } 181 | end 182 | 183 | def handle_foo(event) 184 | event_log << event 185 | end 186 | 187 | def handle_bar(event) 188 | event_log << event 189 | end 190 | 191 | def handle_baz(event) 192 | event_log << event 193 | end 194 | end 195 | 196 | def do_test(klass) 197 | testbed = klass.new 198 | testbed.call(e1 = Event[:foo]) 199 | testbed.call(e2 = Event[:bar]) 200 | testbed.call(e3 = Event[:baz]) 201 | testbed.call(Event[:buz]) 202 | unless testbed.event_log == [e1, e2, e3] 203 | fail "#{klass}: #{testbed.event_log.inspect}" 204 | end 205 | end 206 | 207 | classes = [ 208 | HardcodeTestbed, 209 | SendTestbed, 210 | SendTableTestbed, 211 | BindTableTestbed, 212 | CodeGenTestbed, 213 | IfCodeGenTestbed, 214 | ] 215 | width = classes.map(&:name).map(&:size).max 216 | 217 | Benchmark.ips do |x| 218 | classes.each do |klass| 219 | x.report(klass.name) { do_test(klass) } 220 | end 221 | x.compare! 222 | end 223 | -------------------------------------------------------------------------------- /lib/brainguy/emitter.rb: -------------------------------------------------------------------------------- 1 | require "delegate" 2 | require "set" 3 | require "brainguy/full_subscription" 4 | require "brainguy/single_event_subscription" 5 | require "brainguy/event" 6 | require "brainguy/basic_notifier" 7 | require "brainguy/open_observer" 8 | 9 | module Brainguy 10 | # This object keeps track of all the listeners (observers) subscribed to a 11 | # particular event source object. 12 | class Emitter < DelegateClass(Set) 13 | DEFAULT_NOTIFIER = BasicNotifier.new 14 | 15 | # Create a new {Emitter} that shares its inner dataset with an 16 | # existing one. This exists so that it's possible to generate temporary 17 | # copies of a {Emitter} with different, specialized semantics; 18 | # for instance, an {IdempotentEmitter} that shares the same 19 | # set of subscriptions as an existing {Emitter}. 20 | # @param event_source [Object] the event-originating object 21 | # @param subscription_set [Emitter] the existing set to share 22 | # subscriptions with 23 | # @return [Emitter] 24 | def self.new_from_existing(event_source, subscription_set) 25 | new(event_source, subscriptions: subscription_set.subscriptions) 26 | end 27 | 28 | # @param event_source [Object] the event-originating object 29 | # @option options [Set] :subscriptions (Set.new) the 30 | # underlying set of subscriptions 31 | # @option options [:call] :notifier_maker a factory for notifiers. 32 | def initialize(event_source = self, options = {}) 33 | super(options[:subscriptions] || Set.new) 34 | @event_source = event_source 35 | @notifier_maker = options.fetch(:notifier_maker) { 36 | ->() { DEFAULT_NOTIFIER } 37 | } 38 | end 39 | 40 | # @return [Set] the underlying set of subscription objects 41 | def subscriptions 42 | __getobj__ 43 | end 44 | 45 | # Attach a new object to listen for events. A listener is expected to be 46 | # call-able, and it will receive the `#call` message with an {Event} each 47 | # time one is emitted. 48 | # @param new_listener [:call] 49 | # @return [Subscription] a subscription object which can be used to 50 | # cancel the subscription. 51 | def attach(new_listener) 52 | FullSubscription.new(self, new_listener).tap do |subscription| 53 | self << subscription 54 | end 55 | end 56 | 57 | # Detach a listener. This locates the subscription corresponding to the 58 | # given listener (if any), and removes it. 59 | # @param [:call] listener a listener to be unsubscribed 60 | # @return [void] 61 | def detach(listener) 62 | delete(FullSubscription.new(self, listener)) 63 | end 64 | 65 | # Attach a block of code or a callback to handle named events. 66 | # @overload on(name, &block) 67 | # Attach a block to be called for a specific event. The block will be 68 | # called with the event arguments (not the event object). 69 | # @param name [Symbol] 70 | # @param block [Proc] what to do when the event is emitted 71 | # @example 72 | # traffic_light.on :green do 73 | # my_car.go! 74 | # end 75 | # @overload on(name, callback_info={}) 76 | # Request that a selector be sent as a message to an object, 77 | # optionally with arguments, when `name` occurs. 78 | # @param name [Symbol] the name of the event 79 | # @option callback_info [Symbol] :send the name of the message 80 | # @option callback_info [Object] :to the object it should be 81 | # sent to 82 | # @option callback_info [Array] :with optional arguments to pass 83 | # @example 84 | # traffic_light.on(:green, send: :go, to: my_car, with: [{speed: 20}]) 85 | # @overload on(name, callback_info={}) 86 | # Request that a message, including arguments, be sent to an object 87 | # @param name [Symbol] the name of the event 88 | # @option callback_info [Array] :send an array of `[:selector, args...]` 89 | # @option callback_info [Object] :to the object it should be 90 | # sent to 91 | # @example 92 | # traffic_light.on(:green, send: [:go, "fast!"], to: my_car) 93 | # @overload on(handlers) 94 | # Attach multiple event-specific handlers at once. 95 | # @param handlers [Hash{Symbol => [:call]}] a map of event names to 96 | # callable handlers. 97 | # @example 98 | # traffic_light.on green: ->{ my_car.go! }, 99 | # red: ->{ my_car.stop! } 100 | # @return (see #attach) 101 | def on(name_or_handlers, callback_info={}, &block) 102 | case name_or_handlers 103 | when Symbol 104 | attach_to_single_event(name_or_handlers, callback_info, block) 105 | when Hash 106 | attach(OpenObserver.new(name_or_handlers)) 107 | else 108 | fail ArgumentError, "Event name or Hash required" 109 | end 110 | end 111 | 112 | 113 | 114 | # Emit an event to be distributed to all interested listeners. 115 | # @param event_name [Symbol] the name of the event 116 | # @param extra_args [Array] any extra arguments that should accompany the 117 | # event 118 | # @return the notifier's result value 119 | def emit(event_name, *extra_args) 120 | notifier = @notifier_maker.call 121 | each do |subscription| 122 | event = Event.new(event_name, @event_source, extra_args) 123 | notifier.notify(subscription, event) 124 | end 125 | notifier.result 126 | end 127 | 128 | private 129 | 130 | def attach_to_single_event(name_or_handlers, callback_info, block) 131 | message, receiver = extract_callback_info(callback_info) 132 | if message 133 | attach_callback(name_or_handlers, receiver, message) 134 | else 135 | attach_block_to_single_event(name_or_handlers, block) 136 | end 137 | end 138 | 139 | def extract_callback_info(callback_info) 140 | selector = callback_info[:send] or return nil 141 | receiver = callback_info.fetch(:to) do 142 | fail ArgumentError, "Must specify a receiver object with :to" 143 | end 144 | args = Array(callback_info[:with]) 145 | message = Array(selector).concat(args) 146 | return message, receiver 147 | end 148 | 149 | def attach_callback(name, receiver, message) 150 | callback = proc do 151 | receiver.public_send(*message) 152 | end 153 | attach_block_to_single_event(name, callback) 154 | end 155 | 156 | def attach_block_to_single_event(event_name, block) 157 | SingleEventSubscription.new(self, block, event_name).tap do 158 | |subscription| 159 | self << subscription 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /README.erb: -------------------------------------------------------------------------------- 1 | # Brainguy 2 | 3 | ![Observer, AKA "Brain Guy"](http://static.tvtropes.org/pmwiki/pub/images/MST3K_Brain_Guy_7093.jpg) 4 | 5 | Brainguy is an Observer library for Ruby. 6 | 7 | ## Synopsis 8 | 9 | ```ruby 10 | <%= File.read("examples/synopsis.rb") %> 11 | ``` 12 | 13 | ## Introduction 14 | 15 | *Well, here we are again.* 16 | 17 | Back with another of those block-rockin' READMEs! 18 | 19 | *You know, I can just leave now.* 20 | 21 | Sorry. It won't happen again. 22 | 23 | *So, "Brainguy", huh. What's the deal this time?* 24 | 25 | This is an Observer pattern library for Ruby. The name is a play on the 26 | character from Mystery Sci--- 27 | 28 | *Yeah yeah blah blah nerd nerd very clever. What's it do?* 29 | 30 | In a nutshell, it's a decoupling mechanism. It lets "observer" objects 31 | subscribe to events generated by other objects. 32 | 33 | *Kind of like the `observer` Ruby standard library?"* 34 | 35 | Yeah, exactly. But this library is a little bit fancier. It adds a 36 | number of conveniences that you otherwise might have to build yourself on top of `observer`. 37 | 38 | *Such as?* 39 | 40 | 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. 41 | 42 | ### Defining some terms 43 | 44 | *What exactly is an "observer"? Is it a special kind of object?* 45 | 46 | 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: 47 | 48 | ```ruby 49 | <%= File.read("examples/proc_observer.rb") %> 50 | ``` 51 | 52 | Every time the emitter emits an event, the observer proc will receive `#call` with an `Event` object as an argument. 53 | 54 | *What's an "emitter"?* 55 | 56 | 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. 57 | 58 | *What exactly is an "event", anyway?* 59 | 60 | 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. 61 | 62 | *So an event is just a symbol?* 63 | 64 | An event is *named* with a symbol. But there is some other information that normally travels along with an event: 65 | 66 | - An event *source*, which is the observer object that generated the event. 67 | - An arbitrary list of *arguments*. 68 | 69 | Extra arguments can be added to an event by passing extra arguments to the `#emit`, like this: 70 | 71 | ```ruby 72 | events.emit(:movie_sign, movie_title: "Giant Spider Invasion") 73 | ``` 74 | 75 | For convenience, the event name, source, and arguments are all bundled into an `Event` object before being disseminated to observers. 76 | 77 | ### Making an object observable 78 | 79 | *OK, say I have an object that I want to make observable. How would I go about that?* 80 | 81 | Well, the no-magic way might go something like this: 82 | 83 | ```ruby 84 | <%= File.read("examples/manual_observable.rb") %> 85 | ``` 86 | 87 | 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. 88 | 89 | *That's pretty straightforward. Is there a more-magic way?* 90 | 91 | 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: 92 | 93 | ```ruby 94 | <%= File.read("examples/include_observable.rb") %> 95 | ``` 96 | 97 | *I see that instead of `events.emit(...)`, now the class just uses `emit(...)`. And the same with `#on`.* 98 | 99 | Very observant! `Observable` adds four methods to classes which mix it in: 100 | 101 | - `#on`, to quickly attach single-event handlers on the object. 102 | - `#emit`, a private method for conveniently emitting events inside the class. 103 | - `#events`, to access the `Emitter` object. 104 | - `#with_subscription_scope`, which we'll talk about later. 105 | 106 | *That's not a lot of methods added.* 107 | 108 | 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`. 109 | 110 | ### Constraining event types 111 | 112 | *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?* 113 | 114 | 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. 115 | 116 | Well, that's what it does by default. We can also customize the policy for how to handle unknown events, as this example demonstrates: 117 | 118 | ```ruby 119 | <%= File.read("examples/include_manifestly_observable.rb") %> 120 | ``` 121 | 122 | ### All about observers 123 | 124 | *I'm still a little confused about `#on`. Is that just another way to add an observer?* 125 | 126 | `#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. 127 | 128 | *So it's a special case.* 129 | 130 | Yep! 131 | 132 | *Let's talk about the general case a bit more. You said an observer is just a callable object?* 133 | 134 | Yeah. Anything which will respond to `#call` and accept a single `Event` as an argument. 135 | 136 | *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?* 137 | 138 | You could if you wanted to. But that's a common desire, so there are some conveniences for it. 139 | 140 | *Such as...?* 141 | 142 | 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`. 143 | 144 | ```ruby 145 | <%= File.read("examples/open_observer.rb") %> 146 | ``` 147 | 148 | There are a few other ways to instantiate an `OpenObserver`; check out the source code and tests for more information. 149 | 150 | *What if my observer needs are more elaborate? What if I want a dedicated class for observing an event stream?* 151 | 152 | 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. 153 | 154 | ```ruby 155 | <%= File.read("examples/include_observer.rb") %> 156 | ``` 157 | 158 | *So including `Observer` automatically handles the dispatching of events from `#call` to the various `#on_` methods?* 159 | 160 | 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. 161 | 162 | *How do you know it's that fast?* 163 | 164 | You can run the proof-of-concept benchmark for yourself! It's in the `scripts` directory. 165 | 166 | ### Managing subscription lifetime 167 | 168 | *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?* 169 | 170 | 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: 171 | 172 | ```ruby 173 | p = Poem.new 174 | f = HtmlFormatter.new 175 | subscription = p.events.attach(f) 176 | p.recite 177 | subscription.cancel 178 | ``` 179 | 180 | *OK, so I just need to remember to `#cancel` the subscriptions that I don't want sticking around.* 181 | 182 | 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. 183 | 184 | Here's how we might re-write the "poem" example with event subscriptions scoped to just the `#recite` call: 185 | 186 | ```ruby 187 | <%= File.read("examples/scoped_subscription.rb") %> 188 | ``` 189 | 190 | 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. 191 | 192 | ### Replacing return values with events 193 | 194 | *Interesting. I can see this being useful for more than just traditionally event-generating objects.* 195 | 196 | Indeed it is! This turns out to be a useful pattern for any kind of method which acts as a "command". 197 | 198 | For instance, let's imagine a fictional HTTP request method. Different things happen over the course of a request: 199 | 200 | - headers come back 201 | - data comes back (possibly more than once, if it is a streaming-style connection) 202 | - an error may occur 203 | - otherwise, at some point it will reach a successful finish 204 | 205 | Let's look at how that could be modeled using an "event-ful" method: 206 | 207 | ```ruby 208 | connection.request(:get, "/") do |events| 209 | events.on(:header){ ... } # handle a header 210 | events.on(:data){ ... } # handle data 211 | events.on(:error){ ... } # handle errors 212 | events.on(:success){ ... } # finish up 213 | end 214 | ``` 215 | 216 | This API has some interesting properties: 217 | 218 | - 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. 219 | - 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. 220 | 221 | *But couldn't you accomplish the same thing by returning different values for success, failure, etc?* 222 | 223 | 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. 224 | 225 | *I don't like that blocks-in-a-block syntax* 226 | 227 | 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: 228 | 229 | ```ruby 230 | class Connection 231 | include Brainguy::Observable 232 | 233 | def request(method, path, &block) 234 | with_subscription_scope(block) do 235 | # ... 236 | end 237 | end 238 | 239 | # ... 240 | end 241 | ``` 242 | 243 | In that case, instead of sending it with a block, we can do this: 244 | 245 | ```ruby 246 | connection.request(:get, "/") 247 | .on(:header){ ... } # handle a header 248 | .on(:data){ ... } # handle data 249 | .on(:error){ ... } # handle errors 250 | .on(:success){ ... } # finish up 251 | ``` 252 | 253 | *How the heck does that work?* 254 | 255 | 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. 256 | 257 | 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. 258 | 259 | 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. 260 | 261 | *What if I **only** want eventful methods? I don't want my object to carry a long-lived list of observers around?* 262 | 263 | Gotcha covered. You can use {Brainguy.with_subscription_scope} to add a temporary subscription scope to any method without first including {Brainguy::Observable}. 264 | 265 | ```ruby 266 | class Connection 267 | def request(method, path, &block) 268 | Brainguy.with_subscription_scope(self) do 269 | # ... 270 | end 271 | end 272 | 273 | # ... 274 | end 275 | ``` 276 | 277 | *This is a lot to take in. Anything else you want to tell me about?* 278 | 279 | We've covered most of the major features. One thing we haven't talked about is error suppression. 280 | 281 | ### Suppressing errors 282 | 283 | *Why would you want to suppress errors?* 284 | 285 | 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. 286 | 287 | *Yeah, I could see where that could be a problem.* 288 | 289 | 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. 290 | 291 | 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. 292 | 293 | ## FAQ 294 | 295 | *Is this library like ActiveRecord callbacks? Or like Rails observers?* 296 | 297 | 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. 298 | 299 | 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. 300 | 301 | *Is this an aspect-oriented programming library? Or a lisp-style "method advice" system?* 302 | 303 | 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. 304 | 305 | In Brainguy, by contrast, objects are explicitly subscribed-to, and events are explicitly emitted. 306 | 307 | *Is this a library for "Reactive Programming"?* 308 | 309 | Not in and of itself. It could potentially serve as the foundation for such a library though. 310 | 311 | *Is this a library for creating "hooks"?* 312 | 313 | 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. 314 | 315 | *Is this an asynchronous messaging or reactor system?* 316 | 317 | No. Brainguy events are processed synchronously have no awareness of concurrency. 318 | 319 | *Is there somewhere I can learn more about using observers to keep responsibilities separate?* 320 | 321 | Yes. The book [*Growing Object-Oriented Software, Guided by Tests*](http://www.growing-object-oriented-software.com/) was a big inspiration for this library. 322 | 323 | ## Installation 324 | 325 | Add this line to your application's Gemfile: 326 | 327 | ```ruby 328 | gem 'brainguy' 329 | ``` 330 | 331 | And then execute: 332 | 333 | $ bundle 334 | 335 | Or install it yourself as: 336 | 337 | $ gem install brainguy 338 | 339 | ## Usage 340 | 341 | Coming soon! 342 | 343 | ## Contributing 344 | 345 | 1. Fork it ( https://github.com/avdi/brainguy/fork ) 346 | 2. Create your feature branch (`git checkout -b my-new-feature`) 347 | 3. Commit your changes (`git commit -am 'Add some feature'`) 348 | 4. Push to the branch (`git push origin my-new-feature`) 349 | 5. Create a new Pull Request 350 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Brainguy 2 | 3 | ![Observer, AKA "Brain Guy"](http://static.tvtropes.org/pmwiki/pub/images/MST3K_Brain_Guy_7093.jpg) 4 | 5 | Brainguy is an Observer library for Ruby. 6 | 7 | ## Synopsis 8 | 9 | ```ruby 10 | require "brainguy" 11 | 12 | class SatelliteOfLove 13 | include Brainguy::Observable 14 | 15 | def intro_song 16 | emit(:robot_roll_call) 17 | end 18 | 19 | def send_the_movie 20 | emit(:movie_sign) 21 | end 22 | end 23 | 24 | class Crew 25 | include Brainguy::Observer 26 | end 27 | 28 | class TomServo < Crew 29 | def on_robot_roll_call(event) 30 | puts "Tom: Check me out!" 31 | end 32 | end 33 | 34 | class CrowTRobot < Crew 35 | def on_robot_roll_call(event) 36 | puts "Crow: I'm different!" 37 | end 38 | end 39 | 40 | class MikeNelson < Crew 41 | def on_movie_sign(event) 42 | puts "Mike: Oh no we've got movie sign!" 43 | end 44 | end 45 | 46 | sol = SatelliteOfLove.new 47 | # Attach specific event handlers without a listener object 48 | sol.on(:robot_roll_call) do 49 | puts "[Robot roll call!]" 50 | end 51 | sol.on(:movie_sign) do 52 | puts "[Movie sign flashes]" 53 | end 54 | sol.events.attach TomServo.new 55 | sol.events.attach CrowTRobot.new 56 | sol.events.attach MikeNelson.new 57 | 58 | sol.intro_song 59 | sol.send_the_movie 60 | 61 | # >> [Robot roll call!] 62 | # >> Tom: Check me out! 63 | # >> Crow: I'm different! 64 | # >> [Movie sign flashes] 65 | # >> Mike: Oh no we've got movie sign! 66 | 67 | ``` 68 | 69 | ## Introduction 70 | 71 | *Well, here we are again.* 72 | 73 | Back with another of those block-rockin' READMEs! 74 | 75 | *You know, I can just leave now.* 76 | 77 | Sorry. It won't happen again. 78 | 79 | *So, "Brainguy", huh. What's the deal this time?* 80 | 81 | This is an Observer pattern library for Ruby. The name is a play on the 82 | character from Mystery Sci--- 83 | 84 | *Yeah yeah blah blah nerd nerd very clever. What's it do?* 85 | 86 | In a nutshell, it's a decoupling mechanism. It lets "observer" objects 87 | subscribe to events generated by other objects. 88 | 89 | *Kind of like the `observer` Ruby standard library?"* 90 | 91 | Yeah, exactly. But this library is a little bit fancier. It adds a 92 | number of conveniences that you otherwise might have to build yourself on top of `observer`. 93 | 94 | *Such as?* 95 | 96 | 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. 97 | 98 | ### Defining some terms 99 | 100 | *What exactly is an "observer"? Is it a special kind of object?* 101 | 102 | 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: 103 | 104 | ```ruby 105 | require "brainguy" 106 | 107 | events = Brainguy::Emitter.new 108 | observer = proc do |event| 109 | puts "Got event: #{event.name}" 110 | end 111 | events.attach(observer) 112 | events.emit(:ding) 113 | 114 | # >> Got event: ding 115 | 116 | ``` 117 | 118 | Every time the emitter emits an event, the observer proc will receive `#call` with an `Event` object as an argument. 119 | 120 | *What's an "emitter"?* 121 | 122 | 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. 123 | 124 | *What exactly is an "event", anyway?* 125 | 126 | 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. 127 | 128 | *So an event is just a symbol?* 129 | 130 | An event is *named* with a symbol. But there is some other information that normally travels along with an event: 131 | 132 | - An event *source*, which is the observer object that generated the event. 133 | - An arbitrary list of *arguments*. 134 | 135 | Extra arguments can be added to an event by passing extra arguments to the `#emit`, like this: 136 | 137 | ```ruby 138 | events.emit(:movie_sign, movie_title: "Giant Spider Invasion") 139 | ``` 140 | 141 | For convenience, the event name, source, and arguments are all bundled into an `Event` object before being disseminated to observers. 142 | 143 | ### Making an object observable 144 | 145 | *OK, say I have an object that I want to make observable. How would I go about that?* 146 | 147 | Well, the no-magic way might go something like this: 148 | 149 | ```ruby 150 | require "brainguy" 151 | 152 | class Toaster 153 | attr_reader :events 154 | 155 | def initialize 156 | @events = Brainguy::Emitter.new(self) 157 | end 158 | 159 | def make_toast 160 | events.emit(:start) 161 | events.emit(:pop) 162 | end 163 | end 164 | 165 | toaster = Toaster.new 166 | toaster.events.on(:pop) do 167 | puts "Toast is done!" 168 | end 169 | toaster.make_toast 170 | 171 | # >> Toast is done! 172 | 173 | ``` 174 | 175 | 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. 176 | 177 | *That's pretty straightforward. Is there a more-magic way?* 178 | 179 | 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: 180 | 181 | ```ruby 182 | require "brainguy" 183 | 184 | class Toaster 185 | include Brainguy::Observable 186 | 187 | def make_toast 188 | emit(:start) 189 | emit(:pop) 190 | end 191 | end 192 | 193 | toaster = Toaster.new 194 | toaster.on(:pop) do 195 | puts "Toast is done!" 196 | end 197 | toaster.make_toast 198 | 199 | # >> Toast is done! 200 | 201 | ``` 202 | 203 | *I see that instead of `events.emit(...)`, now the class just uses `emit(...)`. And the same with `#on`.* 204 | 205 | Very observant! `Observable` adds four methods to classes which mix it in: 206 | 207 | - `#on`, to quickly attach single-event handlers on the object. 208 | - `#emit`, a private method for conveniently emitting events inside the class. 209 | - `#events`, to access the `Emitter` object. 210 | - `#with_subscription_scope`, which we'll talk about later. 211 | 212 | *That's not a lot of methods added.* 213 | 214 | 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`. 215 | 216 | ### Constraining event types 217 | 218 | *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?* 219 | 220 | 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. 221 | 222 | Well, that's what it does by default. We can also customize the policy for how to handle unknown events, as this example demonstrates: 223 | 224 | ```ruby 225 | require "brainguy" 226 | 227 | class Toaster 228 | include Brainguy::ManifestlyObservable.new(:start, :pop) 229 | 230 | def make_toast 231 | emit(:start) 232 | emit(:lop) 233 | end 234 | end 235 | 236 | toaster = Toaster.new 237 | toaster.events.unknown_event_policy = :raise_error 238 | toaster.on(:plop) do 239 | puts "Toast is done!" 240 | end 241 | toaster.make_toast 242 | 243 | # ~> Brainguy::UnknownEvent 244 | # ~> #on received for unknown event type 'plop' 245 | # ~> 246 | # ~> xmptmp-in27856uxq.rb:14:in `
' 247 | 248 | ``` 249 | 250 | ### All about observers 251 | 252 | *I'm still a little confused about `#on`. Is that just another way to add an observer?* 253 | 254 | `#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. 255 | 256 | *So it's a special case.* 257 | 258 | Yep! 259 | 260 | *Let's talk about the general case a bit more. You said an observer is just a callable object?* 261 | 262 | Yeah. Anything which will respond to `#call` and accept a single `Event` as an argument. 263 | 264 | *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?* 265 | 266 | You could if you wanted to. But that's a common desire, so there are some conveniences for it. 267 | 268 | *Such as...?* 269 | 270 | 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`. 271 | 272 | ```ruby 273 | require "brainguy" 274 | 275 | class VideoRender 276 | include Brainguy::Observable 277 | attr_reader :name 278 | def initialize(name) 279 | @name = name 280 | end 281 | 282 | def do_render 283 | emit(:complete) 284 | end 285 | end 286 | 287 | v1 = VideoRender.new("foo.mp4") 288 | v2 = VideoRender.new("bar.mp4") 289 | 290 | observer = Brainguy::OpenObserver.new do |o| 291 | o.on_complete do |event| 292 | puts "Video #{event.source.name} is done rendering!" 293 | end 294 | end 295 | 296 | v1.events.attach(observer) 297 | v2.events.attach(observer) 298 | 299 | v1.do_render 300 | v2.do_render 301 | 302 | # >> Video foo.mp4 is done rendering! 303 | # >> Video bar.mp4 is done rendering! 304 | 305 | ``` 306 | 307 | There are a few other ways to instantiate an `OpenObserver`; check out the source code and tests for more information. 308 | 309 | *What if my observer needs are more elaborate? What if I want a dedicated class for observing an event stream?* 310 | 311 | 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. 312 | 313 | ```ruby 314 | require "brainguy" 315 | 316 | class Poem 317 | include Brainguy::Observable 318 | def recite 319 | emit(:title, "Jabberwocky") 320 | emit(:line, "'twas brillig, and the slithy toves") 321 | emit(:line, "Did gyre and gimbal in the wabe") 322 | end 323 | end 324 | 325 | class HtmlFormatter 326 | include Brainguy::Observer 327 | 328 | attr_reader :result 329 | 330 | def initialize 331 | @result = "" 332 | end 333 | 334 | def on_title(event) 335 | @result << "

#{event.args.first}

" 336 | end 337 | 338 | def on_line(event) 339 | @result << "#{event.args.first}
" 340 | end 341 | end 342 | 343 | p = Poem.new 344 | f = HtmlFormatter.new 345 | p.events.attach(f) 346 | p.recite 347 | 348 | f.result 349 | # => "

Jabberwocky

'twas brillig, and the slithy toves
Did gyre an... 350 | 351 | ``` 352 | 353 | *So including `Observer` automatically handles the dispatching of events from `#call` to the various `#on_` methods?* 354 | 355 | 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. 356 | 357 | *How do you know it's that fast?* 358 | 359 | You can run the proof-of-concept benchmark for yourself! It's in the `scripts` directory. 360 | 361 | ### Managing subscription lifetime 362 | 363 | *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?* 364 | 365 | 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: 366 | 367 | ```ruby 368 | p = Poem.new 369 | f = HtmlFormatter.new 370 | subscription = p.events.attach(f) 371 | p.recite 372 | subscription.cancel 373 | ``` 374 | 375 | *OK, so I just need to remember to `#cancel` the subscriptions that I don't want sticking around.* 376 | 377 | 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. 378 | 379 | Here's how we might re-write the "poem" example with event subscriptions scoped to just the `#recite` call: 380 | 381 | ```ruby 382 | require "brainguy" 383 | 384 | class Poem 385 | include Brainguy::Observable 386 | def recite(&block) 387 | with_subscription_scope(block) do 388 | emit(:title, "Jabberwocky") 389 | emit(:line, "'twas brillig, and the slithy toves") 390 | emit(:line, "Did gyre and gimbal in the wabe") 391 | end 392 | end 393 | end 394 | 395 | class HtmlFormatter 396 | include Brainguy::Observer 397 | 398 | attr_reader :result 399 | 400 | def initialize 401 | @result = "" 402 | end 403 | 404 | def on_title(event) 405 | @result << "

#{event.args.first}

" 406 | end 407 | 408 | def on_line(event) 409 | @result << "#{event.args.first}
" 410 | end 411 | end 412 | 413 | p = Poem.new 414 | f = HtmlFormatter.new 415 | p.recite do |events| 416 | events.attach(f) 417 | end 418 | 419 | f.result 420 | # => "

Jabberwocky

'twas brillig, and the slithy toves
Did gyre an... 421 | 422 | ``` 423 | 424 | 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. 425 | 426 | ### Replacing return values with events 427 | 428 | *Interesting. I can see this being useful for more than just traditionally event-generating objects.* 429 | 430 | Indeed it is! This turns out to be a useful pattern for any kind of method which acts as a "command". 431 | 432 | For instance, let's imagine a fictional HTTP request method. Different things happen over the course of a request: 433 | 434 | - headers come back 435 | - data comes back (possibly more than once, if it is a streaming-style connection) 436 | - an error may occur 437 | - otherwise, at some point it will reach a successful finish 438 | 439 | Let's look at how that could be modeled using an "event-ful" method: 440 | 441 | ```ruby 442 | connection.request(:get, "/") do |events| 443 | events.on(:header){ ... } # handle a header 444 | events.on(:data){ ... } # handle data 445 | events.on(:error){ ... } # handle errors 446 | events.on(:success){ ... } # finish up 447 | end 448 | ``` 449 | 450 | This API has some interesting properties: 451 | 452 | - 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. 453 | - 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. 454 | 455 | *But couldn't you accomplish the same thing by returning different values for success, failure, etc?* 456 | 457 | 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. 458 | 459 | *I don't like that blocks-in-a-block syntax* 460 | 461 | 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: 462 | 463 | ```ruby 464 | class Connection 465 | include Brainguy::Observable 466 | 467 | def request(method, path, &block) 468 | with_subscription_scope(block) do 469 | # ... 470 | end 471 | end 472 | 473 | # ... 474 | end 475 | ``` 476 | 477 | In that case, instead of sending it with a block, we can do this: 478 | 479 | ```ruby 480 | connection.request(:get, "/") 481 | .on(:header){ ... } # handle a header 482 | .on(:data){ ... } # handle data 483 | .on(:error){ ... } # handle errors 484 | .on(:success){ ... } # finish up 485 | ``` 486 | 487 | *How the heck does that work?* 488 | 489 | 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. 490 | 491 | 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. 492 | 493 | 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. 494 | 495 | *What if I **only** want eventful methods? I don't want my object to carry a long-lived list of observers around?* 496 | 497 | Gotcha covered. You can use {Brainguy.with_subscription_scope} to add a temporary subscription scope to any method without first including {Brainguy::Observable}. 498 | 499 | ```ruby 500 | class Connection 501 | def request(method, path, &block) 502 | Brainguy.with_subscription_scope(self) do 503 | # ... 504 | end 505 | end 506 | 507 | # ... 508 | end 509 | ``` 510 | 511 | *This is a lot to take in. Anything else you want to tell me about?* 512 | 513 | We've covered most of the major features. One thing we haven't talked about is error suppression. 514 | 515 | ### Suppressing errors 516 | 517 | *Why would you want to suppress errors?* 518 | 519 | 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. 520 | 521 | *Yeah, I could see where that could be a problem.* 522 | 523 | 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. 524 | 525 | 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. 526 | 527 | ## FAQ 528 | 529 | *Is this library like ActiveRecord callbacks? Or like Rails observers?* 530 | 531 | 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. 532 | 533 | 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. 534 | 535 | *Is this an aspect-oriented programming library? Or a lisp-style "method advice" system?* 536 | 537 | 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. 538 | 539 | In Brainguy, by contrast, objects are explicitly subscribed-to, and events are explicitly emitted. 540 | 541 | *Is this a library for "Reactive Programming"?* 542 | 543 | Not in and of itself. It could potentially serve as the foundation for such a library though. 544 | 545 | *Is this a library for creating "hooks"?* 546 | 547 | 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. 548 | 549 | *Is this an asynchronous messaging or reactor system?* 550 | 551 | No. Brainguy events are processed synchronously have no awareness of concurrency. 552 | 553 | *Is there somewhere I can learn more about using observers to keep responsibilities separate?* 554 | 555 | Yes. The book [*Growing Object-Oriented Software, Guided by Tests*](http://www.growing-object-oriented-software.com/) was a big inspiration for this library. 556 | 557 | ## Installation 558 | 559 | Add this line to your application's Gemfile: 560 | 561 | ```ruby 562 | gem 'brainguy' 563 | ``` 564 | 565 | And then execute: 566 | 567 | $ bundle 568 | 569 | Or install it yourself as: 570 | 571 | $ gem install brainguy 572 | 573 | ## Usage 574 | 575 | Coming soon! 576 | 577 | ## Contributing 578 | 579 | 1. Fork it ( https://github.com/avdi/brainguy/fork ) 580 | 2. Create your feature branch (`git checkout -b my-new-feature`) 581 | 3. Commit your changes (`git commit -am 'Add some feature'`) 582 | 4. Push to the branch (`git push origin my-new-feature`) 583 | 5. Create a new Pull Request 584 | --------------------------------------------------------------------------------