Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there a more direct way to do a pub/sub pattern in Rails than Observers?

I have a model which has a dependency on a separate, joined model.

class Magazine < ActiveRecord::Base
  has_one :cover_image, dependent: :destroy, as: :imageable
end

class Image < ActiveRecord::Base
  belongs_to :imageable, polymorphic: true
end

Images are polymorphic and can be attached to many objects (pages and articles) not just magazines.

The magazine needs to update itself when anything about its associated image changes

The magazine also stores a screenshot of itself that can be used for publicising it:

class Magazine < ActiveRecord::Base
  has_one :cover_image, dependent: :destroy, as: :imageable
  has_one :screenshot

  def generate_screenshot
    # go and create a screenshot of the magazine
  end
end

Now if the image changes, the magazine also needs to update its screenshot. So the magazine really needs to know when something happens to the image.

So we could naively trigger screenshot updates directly from the cover image

class Image < ActiveRecord::Base
  belongs_to :imageable, polymorphic: true
  after_save { update_any_associated_magazine }

  def update_any_associated_magazine
    # figure out if this belongs to a magazine and trigger
    # screenshot to regenerate
  end
end

...however the image shouldn't be doing stuff on behalf of the magazine

However the image could be used in lots of different objects and really shouldn't be doing actions specific to the Magazine as it's not the Image's responsibility to worry about. The image might be attached to pages or articles as well and doesn't need to be doing all sorts of stuff for them.

The 'normal' rails approach is to use an observer

If we were to take a Rails(y) approach then we could create a third party observer that would then trigger an event on the associated magazine:

class ImageObserver < ActiveRecord::Observer
  observe :image

  def after_save image
    Magazine.update_magazine_if_includes_image image
  end
end

However this feels like a bit of a crappy solution to me.

We've avoided the Image being burdened by updating the magazine which was great but we've really just punted the problem downstream. It's not obvious that this observer exists, it's not clear inside the Magazine object that the update to the Image will in fact trigger an update to the magazine and we've got a weird floating object which has logic that really just belongs in Magazine.

I don't want an observer - I just want one object to be able to subscribe to events on another object.

Is there any way to subscribe to one model's changes directly from another?

What I would much rather do is have the magazine subscribe directly to events on the image. So the code would instead look like:

class Magazine < ActiveRecord::Base
  ...

  Image.add_after_save_listener Magazine, :handle_image_after_save

  def self.handle_image_after_save image
    # determine if image belongs to magazine and if so update it
  end
end

class Image < ActiveRecord::Base
  ...

  def self.add_after_save_listener class_name, method
    @@after_save_listeners << [class_name, method]
  end

  after_save :notify_after_save_listeners

  def notify_after_save_listeners
    @@after_save_listeners.map{ |listener|
      class_name = listener[0]
      listener_method = listener[1]
      class_name.send listener_method
    }
end

Is this a valid approach and if not why not?

This pattern seems sensible to me. It uses class variables and methods so doesn't make any assumptions of particular instances being available.

However, I'm old enough and wise enough now to know that if something seemingly obvious hasn't been done already in Rails there's probably a good reason for it.

This seems cool to me. What's wrong with it though? Why do all the other solutions I see all draft in a third party object to deal with things? Would this work?

like image 212
Peter Nixey Avatar asked Jul 25 '14 18:07

Peter Nixey


3 Answers

I use Redis:

In an initializer I set up Redis:

# config/initializers/redis.rb
uri = URI.parse ENV.fetch("REDISTOGO_URL", 'http://127.0.0.1:6379')
REDIS_CONFIG = { host: uri.host, port: uri.port, password: uri.password }
REDIS = Redis.new REDIS_CONFIG

It'll default to my local redis installation in development but on Heroku it'll use Redis To Go.

Then I publish using model callbacks:

class MyModel < ActiveRecord::Base
  after_save { REDIS.publish 'my_channel', to_json }
end

Then I can subscribe from anywhere, such as a controller I'm using to push events using Event Source

class Admin::EventsController < Admin::BaseController
  include ActionController::Live

  def show
    response.headers["Content-Type"] = "text/event-stream"

    REDIS.psubscribe params[:event] do |on|
      on.pmessage do |pattern, event, data|
        response.stream.write "event: #{event}\n"
        response.stream.write "data: #{data}\n\n"
      end
    end
  rescue IOError => e
    logger.info "Stream closed: #{e.message}"
  ensure
    redis.quit
    response.stream.close
  end
end

Redis is great for flexible pub/sub. That code I have in the controller can be placed anywhere, let's say in an initializer:

# config/initializers/subscribers.rb

REDIS.psubscribe "image_update_channel" do |on|
  on.pmessage do |pattern, event, data|
    image = Image.find data['id']
    image.imageable # update that shiz
  end
end

Now that will handle messages when you update your image:

class Image < ActiveRecord::Base
  belongs_to :imageable, polymorphic: true
  after_save { REDIS.publish 'image_update_channel', to_json }
end
like image 101
DiegoSalazar Avatar answered Nov 09 '22 22:11

DiegoSalazar


There is ActiveSupport Notifications mechanism for implementing pub/sub in Rails.

First, you should define instrument which will publish events:

class Image < ActiveRecord::Base
  ...

  after_save :publish_image_changed

  private

  def publish_image_changed
    ActiveSupport::Notifications.instrument('image.changed', image: self)
  end
end

Then you should subscribe for this event (you can put this code in initializer):

ActiveSupport::Notifications.subscribe('image.changed') do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  image = event.payload[:image]

  # If you have no other cases than magazine, you can check it when you publish event.
  return unless image.imageable.is_a?(Magazine)

  MagazineImageUpdater.new(image.imageable).run
end
like image 25
Sergei Baranov Avatar answered Nov 09 '22 23:11

Sergei Baranov


I'll give it a shot...

Use public_send to notify the parent class of a change:

class BaseModel < ActiveRecord::Base
  has_one :child_model

  def respond_to_child
    # now generate the screenshot
  end
end


class ChildModel < ActiveRecord::Base                                                                           
  belongs_to :base_model

  after_update :alert_base                                                                                      

  def alert_base                                                                                                
    self.base_model.public_send( :respond_to_child )                                                            
  end                                                                                                           

end
like image 45
Carl Avatar answered Nov 09 '22 23:11

Carl