Skip to content

Commit

Permalink
Introduce new event bus adapter
Browse files Browse the repository at this point in the history
We're deprecating `Spree::Event::Adapters::ActiveSupportNotifications`
for a couple of reasons:

- It uses the same bus that standard Rails notifications. It forces us
  to try to normalize the event names given through `Spree::Event.fire`
  & `Spree::Event.subscribe` to add a `.spree` suffix. However, it's not
  a complete solution as we're missing subscriptions placed through a
  regular expression (like `Spree::Event.subscribe /.*/`, which
  subscribes to all events, including Rails ones).

- It relies heavily on global state. Besides not being able to create
  two different buses (see the previous point), it makes it cumbersome
  to test events in isolation.

This commit introduces a new `Spree::Event::Adapters::Default` adapter,
which instances make for independent event buses. As our implementation
of the event bus system relies on a global bus for all Solidus events,
the idea is to initialize a single instance and configure it as
`Spree::Config.events.adapter`. However, we've added a new optional
`adapter:` parameter to all relevant methods in `Spree::Event` to inject
other buses when testing. E.g.:

```ruby
bus = Spree::Event::Adapters::Default.new
Spree::Event.subscribe('foo', adapter: bus) { do_something }
Spree::Event.fire('foo', adapter: bus)
```

The old adapter is still the default one due to backward compatibility
reasons. However, we display a deprecation warning to let users know
that they're encouraged to change. There're two critical updates that
they need to be aware of (they're also described in the deprecated
message):

- Event name normalization won't happen anymore. E.g. in order to
  subscribe to all Solidus events probably users will need to change
  from `Spree::Event.subscribe /\.spree$/` to `Spree::Event.subscribe
  /.*/`. There's no way to reliably warn when the `.spree` prefix is
  used, as users can still decide to use it as far as they're consistent
  when performing different operations. On top of that, we still can't
  inspect regular expressions with confidence.

- The old adapter allowed to provide a block on `Spree::Event.fire`, like in:

  ```ruby
  Spree::Event.fire 'foo', order: order do
    order.do_something
  end
  ```

  As this block is entirely unnecessary and it can lead to
  confusion (is it executed before or after the subscribers?), it's no
  longer supported in the new adapter. To provide a safer upgrade
  experience, we raise an `ArgumentError` when the new adapter is being
  used, and a block is provided. We also show a warning when provided on
  the legacy adapter.

The new `Default` adapter will be made the default one on the next
major Solidus release.

The old adapter is leaking the abstraction through the returned values
(`ActiveSupport::Notifications::Fanout::Subscribers::Timed` instances).
In the new adapter, we introduce `Spree::Event::Event` &
`Spree::Event::Listener` objects that the adapters need to return.

The main `Spree::Event` module will still keep a copy of the
subscriptions when the legacy adapter is used. This behavior was
introduced to differentiate Solidus events from others, but it can cause
inconsistencies if we compare it with the copy on the adapter. As
something no longer needed, we're branching to don't perform the logic
when the adapter is the new one.

We're also adapting the dummy app used in testing to use the future
default adapter not to see the deprecation messages. However, the old
tests that went through the legacy adapter are still in place.
  • Loading branch information
waiting-for-dev committed Jul 29, 2021
1 parent 7118543 commit e1662c2
Show file tree
Hide file tree
Showing 12 changed files with 879 additions and 75 deletions.
225 changes: 176 additions & 49 deletions core/lib/spree/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,88 @@
require_relative 'event/subscriber'

module Spree
# Event bus for Solidus.
#
# This module serves as the interface to access the Event Bus system in
# Solidus. You can use different underlying adapters to provide the core
# logic. It's recommended that you use {Spree::Event::Adapters::Default}.
#
# You use the {#fire} method to trigger an event:
#
# @example
# Spree::Event.fire 'order_finalized', order: order
#
# Then, you can use {#subscribe} to hook into the event:
#
# @example
# Spree::Event.subscribe 'order_finalized' do |event|
# # Take the order at event.payload[:order]
# end
#
# You can also subscribe to an event through a module including
# {Spree::Event::Subscriber}:
#
# @example
# module MySubscriber
# include Spree::Event::Subscriber
#
# event_action :order_finalized
#
# def order_finalized(event)
# # Again, take the order at event.payload[:order]
# end
# end
module Event
extend self

delegate :activate_autoloadable_subscribers, :activate_all_subscribers, :deactivate_all_subscribers, to: :subscriber_registry

# Allows to trigger events that can be subscribed using #subscribe. An
# optional block can be passed that will be executed immediately. The
# actual code implementation is delegated to the adapter.
# Allows to trigger events that can be subscribed using {#subscribe}.
#
# @param [String] event_name the name of the event. The suffix ".spree"
# will be added automatically if not present
# @param [Hash] opts a list of options to be passed to the triggered event
# The actual code implementation is delegated to the adapter.
#
# @param [String, Symbol] event_name the name of the event.
# @param [Hash] opts a list of options to be passed to the triggered event.
# They will be made available through the {Spree::Event::Event} instance
# that is yielded to the subscribers (see {Spree::Event::Event#payload}).
# However, take into account that the deprecated
# {Spree::Event::Adapters::ActiveSupportNotifications} adapter will yield a
# {ActiveSupport::Notifications::Fanout::Subscribers::Timed} instance
# instead.
# @option opts [Any] :adapter Reserved to indicate the adapter to use as
# event bus. Defaults to {#default_adapter}
# @return [Spree::Event::Event] an event object, unless the adapter is
# {Spree::Event::Adapters::ActiveSupportNotifications}
#
# @example Trigger an event named 'order_finalized'
# Spree::Event.fire 'order_finalized', order: @order do
# @order.finalize!
# end
def fire(event_name, opts = {})
adapter.fire normalize_name(event_name), opts do
yield opts if block_given?
end
#
# TODO: Change signature so that `opts` are keyword arguments, and include
# `adapter:` in them. We want to do that on Solidus 4.0. Spree::Deprecation
# can't be used because of this: https://github.com/solidusio/solidus/pull/4130#discussion_r668666924
def fire(event_name, opts = {}, &block)
adapter = opts.delete(:adapter) || default_adapter
handle_block_on_fire(block, opts, adapter) if block_given?
adapter.fire normalize_name(event_name), opts
end

# Subscribe to an event with the given name. The provided block is executed
# every time the subscribed event is fired.
# Subscribe to events matching the given name.
#
# @param [String, Regexp] event_name the name of the event.
# When String, the suffix ".spree" will be added automatically if not present,
# when using the default adapter for ActiveSupportNotifications.
# When Regexp, due to the unpredictability of all possible regexp combinations,
# adding the suffix is developer's responsibility (if you don't, you will
# subscribe to all notifications, including internal Rails notifications
# as well).
# The provided block is executed every time the subscribed event is fired.
#
# @see Spree::Event::Adapters::ActiveSupportNotifications#normalize_name
# @param [String, Symbol, Regexp] event_name the name of the event. When it's a
# {Regexp} it subscribes to all that match.
# @param [Any] adapter the event bus adapter to use.
# @yield block to execute when an event is triggered
#
# @return a subscription object that can be used as reference in order
# to remove the subscription
# @return [Spree::Event::Listener] a subscription object that can be used as
# reference in order to remove the subscription. However, take into account
# that the deprecated {Spree::Event::Adapters::ActiveSupportNotifications}
# adapter will return a
# {ActiveSupport::Notifications::Fanout::Subscribers::Timed} instance
# instead.
#
# @example Subscribe to the `order_finalized` event
# Spree::Event.subscribe 'order_finalized' do |event|
Expand All @@ -52,51 +96,81 @@ def fire(event_name, opts = {})
# end
#
# @see Spree::Event#unsubscribe
def subscribe(event_name, &block)
name = normalize_name(event_name)
listener_names << name
adapter.subscribe(name, &block)
def subscribe(event_name, adapter: default_adapter, &block)
event_name = normalize_name(event_name)
adapter.subscribe(event_name, &block).tap do
if legacy_adapter?(adapter)
listener_names << adapter.normalize_name(event_name)
end
end
end

# Unsubscribes a whole event or a specific subscription object
#
# @param [String, Object] subscriber the event name as a string (with
# or without the ".spree" suffix) or the subscription object
# When unsubscribing from an event, all previous listeners are deactivated.
# Still, you can add new subscriptions to the same event and they'll be
# called if the event is fired:
#
# @example
# Spree::Event.subscribe('foo') { do_something }
# Spree::Event.unsubscribe 'foo'
# Spree::Event.subscribe('foo') { do_something_else }
# Spree::Event.fire 'foo' # `do_something_else` will be called, but
# # `do_something` won't
#
# @param [String, Symbol, Spree::Event::Listener] subscriber_or_event_name the
# event name as a string or the subscription object. Take into account that
# if the deprecated {Spree::Event::Adapters::ActiveSupportNotifications}
# adapter is used, the subscription object will be a
# {ActiveSupport::Notifications::Fanout::Subscribers::Timed} object.
#
# @example Unsubscribe a single subscription
# subscription = Spree::Event.fire 'order_finalized'
# subscription = Spree::Event.subscribe('order_finalized') { do_something
# }
# Spree::Event.unsubscribe(subscription)
# @example Unsubscribe all `order_finalized` event subscriptions
# Spree::Event.unsubscribe('order_finalized')
# @example Unsubscribe an event by name with explicit prefix
# Spree::Event.unsubscribe('order_finalized.spree')
def unsubscribe(subscriber)
name_or_subscriber = subscriber.is_a?(String) ? normalize_name(subscriber) : subscriber
adapter.unsubscribe(name_or_subscriber)
def unsubscribe(subscriber_or_event_name, adapter: default_adapter)
if subscriber_or_event_name.is_a?(Listener) || subscriber_or_event_name.is_a?(ActiveSupport::Notifications::Fanout::Subscribers::Timed)
unsubscribe_listener(subscriber_or_event_name, adapter)
else
unsubscribe_event(subscriber_or_event_name, adapter)
end
end

# Lists all subscriptions currently registered under the ".spree"
# namespace. Actual implementation is delegated to the adapter
# Lists all subscriptions.
#
# @return [Hash] an hash with event names as keys and arrays of subscriptions
# as values
# Actual implementation is delegated to the adapter.
#
# @return [Hash<<String,Regexp>,Spree::Event::Listener>] an hash with
# patterns as keys and arrays of subscriptions as values. Take into account
# that the deprecated {Spree::Event::Adapters::ActiveSupportNotifications}
# adapter will map to
# {ActiveSupport::Notifications::Fanout::Subscribers::Timed} instances
# instead.
#
# @example Current subscriptions
# Spree::Event.listeners
# # => {"order_finalized.spree"=> [#<ActiveSupport...>],
# "reimbursement_reimbursed.spree"=> [#<ActiveSupport...>]}
def listeners
adapter.listeners_for(listener_names)
# # => {"order_finalized"=> [#<Spree::Event::Listener...>],
# "reimbursement_reimbursed"=> [#<Spree::Event::Listener...>]}
def listeners(adapter: default_adapter)
if legacy_adapter?(adapter)
adapter.listeners_for(listener_names)
else
init = Hash.new { |h, k| h[k] = [] }
adapter.listeners.each_with_object(init) do |listener, map|
map[listener.pattern] << listener
end
end
end

# The adapter used by Spree::Event, defaults to
# Spree::Event::Adapters::ActiveSupportNotifications
# The default adapter used by Spree::Event.
#
# @example Change the adapter
# Spree::Config.events.adapter = "Spree::EventBus.new"
# Spree::Config.events.adapter = "Spree::OtherAdapter.new"
#
# @see Spree::AppConfiguration
def adapter
# @see Spree::Event::Configuration
def default_adapter
Spree::Config.events.adapter
end

Expand All @@ -108,12 +182,65 @@ def subscriber_registry

private

def normalize_name(name)
adapter.normalize_name(name)
def unsubscribe_listener(listener, adapter)
adapter.unsubscribe(listener)
end

def unsubscribe_event(event_name, adapter)
adapter.unsubscribe(normalize_name(event_name))
end

def listener_names
@listeners_names ||= Set.new
end

def normalize_name(name)
case name
when Symbol
name.to_s
else
name
end
end

def handle_block_on_fire(block, opts, adapter)
example = <<~MSG
Please, instead of:
Spree::Event.fire 'event_name', order: order do
order.do_something
end
Use:
order.do_something
Spree::Event.fire 'event_name', order: order
MSG
if legacy_adapter?(adapter)
Spree::Deprecation.warn <<~MSG
Blocks on `Spree::Event.fire` are ignored in the new adapter
`Spree::Event::Adapters::Default`, and your current adapter
(`Spree::Event::Adapters::ActiveSupportNotifications`) is deprecated.
For an easier transition it's recommendable to update your code.
#{example}
MSG
block.call(opts)
else
raise ArgumentError, <<~MSG
Blocks passed to `Spree::Event.fire` are ignored unless the adapter is
`Spree::Event::Adapters::ActiveSupportNotifications` (which is
deprecated).
#{example}
MSG
end
end

def legacy_adapter?(adapter)
adapter == Adapters::ActiveSupportNotifications
end
end
end
38 changes: 22 additions & 16 deletions core/lib/spree/event/adapters/active_support_notifications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,54 @@
module Spree
module Event
module Adapters
# Deprecated adapter for the event bus system.
#
# Please, upgrade to {Spree::Event::Adapters::Default}.
#
# This adapter normalizes the event name so that it includes
# {Spree::Event::Configuration#suffix}.
# When the event name is a string or a symbol, if the suffix is missing,
# then it is added automatically. When the event name is a regexp, due
# to the huge variability of regexps, adding or not the suffix is
# developer's responsibility (if you don't, you will subscribe to all
# internal rails events as well). When the event type is not supported,
# an error is raised.
#
# The suffix can be changed through `config.events.suffix=` in `spree.rb`.
module ActiveSupportNotifications
class InvalidEventNameType < StandardError; end

extend self

# @api private
def fire(event_name, opts)
ActiveSupport::Notifications.instrument event_name, opts do
ActiveSupport::Notifications.instrument normalize_name(event_name), opts do
yield opts if block_given?
end
end

# @api private
def subscribe(event_name)
ActiveSupport::Notifications.subscribe event_name do |*args|
ActiveSupport::Notifications.subscribe normalize_name(event_name) do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
yield event
end
end

# @api private
def unsubscribe(subscriber_or_name)
subscriber_or_name = subscriber_or_name.is_a?(String) ? normalize_name(subscriber_or_name) : subscriber_or_name
ActiveSupport::Notifications.unsubscribe(subscriber_or_name)
end

# @api private
def listeners_for(names)
names.each_with_object({}) do |name, memo|
listeners = ActiveSupport::Notifications.notifier.listeners_for(name)
memo[name] = listeners if listeners.present?
end
end

# Normalizes the event name according to this specific adapter rules.
# When the event name is a string or a symbol, if the suffix is missing, then
# it is added automatically.
# When the event name is a regexp, due to the huge variability of regexps, adding
# or not the suffix is developer's responsibility (if you don't, you will subscribe
# to all internal rails events as well).
# When the event type is not supported, an error is raised.
#
# @param [String, Symbol, Regexp] event_name the event name, with or without the
# suffix (Spree::Config.events.suffix defaults to `.spree`).
def normalize_name(event_name)
case event_name
when Regexp
Expand All @@ -54,10 +63,7 @@ def normalize_name(event_name)
end
end

# The suffix used for namespacing event names, defaults to
# `.spree`
#
# @see Spree::Event::Configuration#suffix
# @api private
def suffix
Spree::Config.events.suffix
end
Expand Down
Loading

0 comments on commit e1662c2

Please sign in to comment.