solidusio / solidus_support

Common runtime helpers for Solidus extensions.
BSD 3-Clause "New" or "Revised" License
9 stars 23 forks source link

Zeitwerk-based, efficient, decorators loader #60

Open elia opened 3 years ago

elia commented 3 years ago

This work is built on the awesome work by @fxn on zeitwerk for Rails and @aldesantis on prependers for Solidus

The code in this PR has been tried successfully on a couple of real-world projects with multi-year legacy. The basic principle is that we should only load the decorators for the base classes that are required for the current request, finally playing nice with Rails own autoloading/reloading mechanisms.

One thing that was very interesting is seeing classes autoloaded from initializers (which is an anti-pattern) and the ripple effect they create in terms of loading their dependencies, fixing that kind of stuff would make Solidus stores startup virtually as fast as a pristine Rails app.

As a side note this work will be also proposed for the Rails guides that currently suggest eagerly loading all the decorators, we think, for lack of a better alternative.

from the inline documentation

SolidusSupport::Decorators is an efficient decorators loader that will extend the behavior of existing classes and modules without eagerly loading all of your decorators at each request.

It nicely falls back to reloading them all when zeitwerk is not available, but will only load the decorators for a given base class when it can. This means that, on an average Solidus application, instead of loading 171 decorators and 61 base classes with all their dependency it will just load the ones needed by the current request, or, if starting a console, almost nothing.

It will also prevent some nasty edge cases in which the use of Rails.application.config.to_prepare(&…) would do some things twice, messing up calls to super inside decorators (to_prepare is actually called twice under some circumstances).

Migrating from Prependers:

require 'solidus_support/decorators'
SolidusSupport::Decorators.autoload_decorators(Rails.root.join("app/prependers/**/*.rb"), autoprepend: true) do |path|
  relative = path.relative_path_from(Rails.root.join("app/prependers")) # models/spree/order/add_feature.rb
  parts = relative.to_s.split(File::SEPARATOR)
  {
    # remove models/
    # => "AcmeCorp::Spree::Order::AddFeature"
    decorator: parts[1..-1].join("/").sub(/\.rb$/,'').camelize, # "AcmeCorp::Spree::Order::AddFeature"

    # remove models/acme_corp/ and /add_feature.rb
    # => "Spree::Order"
    base: parts[2..-2].join("/").camelize, # "Spree::Order"
  }
end

Migrating from classic Solidus decorators

require 'solidus_support/decorators'
SolidusDecorators.autoload_decorators("#{Rails.root}/app/**/*_decorator.rb") do |path|
  relative_path = path.relative_path_from(Rails.root.join("app/")) # models/acme_corp/order_decorator.rb
  parts = relative_path.to_s.split(File::SEPARATOR)
  {
    # remove models/acme_corp/ and _decorator.rb, add spree/
    # => "Spree::Order"
    base: (["spree"] + parts[2..-1]).join("/").chomp("_decorator.rb").camelize,

    # remove models/
    # => "AcmeCorp::Spree::Order::AddFeature"
    decorator: parts[1..-1].join("/").chomp(".rb").camelize,
  }
end

A more complex example with legacy mixed behaviors

require 'solidus_support/decorators'
SolidusSupport::Decorators.autoload_decorators("#{Rails.root}/app/**/*_decorator.rb", autoprepend: false) do |path|
  case path.to_s
  when /lockable_decorator/
    nil # not a real decorator
  when /carton_decorator/
    {
      base: "Spree::Carton",
      decorator: "AcmeCorp::CartonDecorator",
    }
  when /devise_controller/
    {
      base: "DeviseController",
      decorator: "AcmeCorp::DeviseControllerDecorator",
    }
  when /inventory_unit_finalizer/
    {
      base: "Spree::Stock::InventoryUnitsFinalizer",
      decorator: "AcmeCorp::Stock::InventoryUnitFinalizerDecorator"
    }
  else
    relative_path = path.relative_path_from(Rails.root.join("app/")) # models/acme_corp/order_decorator.rb
    parts = relative_path.to_s.split(File::SEPARATOR)
    {
      base: (["spree"] + parts[2..-1]).join("/").chomp("_decorator.rb").camelize, # => "Spree::Order"
      decorator: parts[1..-1].join("/").chomp(".rb").camelize, # => "AcmeCorp::OrderDecorator"
    }
  end
end
kennyadsl commented 3 years ago

I like this a lot!

It's not clear if the current change is backward compatible though. Do we need to change all stores/extensions to make it work once we merge/release this?

elia commented 3 years ago

It supports both zeitwerk and classic and can be adapted to cover existing stores and extensions. That said, I think we should make it opt-in, like maybe a strong suggestion, but still a suggestion 😊

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.