1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

ActiveSupport::CurrentAttributes provides a thread-isolated attributes singleton (#29180)

* Add ActiveSupport::CurrentAttributes to provide a thread-isolated attributes singleton

* Need to require first

* Move stubs into test namespace.

Thus they won't conflict with other Current and Person stubs.

* End of the line for you, whitespace!

* Support super in attribute methods.

Define instance level accessors in an included module such that
`super` in an overriden accessor works, akin to Active Model.

* Spare users the manual require.

Follow the example of concerns, autoload in the top level Active Support file.

* Add bidelegation support

* Rename #expose to #set. Simpler, clearer

* Automatically reset every instance.

Skips the need for users to actively embed something that resets
their CurrentAttributes instances.

* Fix test name; add tangible name value when blank.

* Try to ensure we run after a request as well.

* Delegate all missing methods to the instance

This allows regular `delegate` to serve, so we don't need bidelegate.

* Properly test resetting after execution cycle.

Also remove the stale puts debugging.

* Update documentation to match new autoreset
This commit is contained in:
David Heinemeier Hansson 2017-05-26 20:00:27 +02:00 committed by GitHub
parent b404764e15
commit 24a864437e
6 changed files with 385 additions and 0 deletions

View file

@ -1,3 +1,8 @@
* Add ActiveSupport::CurrentAttributes to provide a thread-isolated attributes singleton.
Primary use case is keeping all the per-request attributes easily available to the whole system.
*DHH*
* Fix implicit coercion calculations with scalars and durations
Previously calculations where the scalar is first would be converted to a duration

View file

@ -32,6 +32,7 @@ module ActiveSupport
extend ActiveSupport::Autoload
autoload :Concern
autoload :CurrentAttributes
autoload :Dependencies
autoload :DescendantsTracker
autoload :ExecutionWrapper

View file

@ -0,0 +1,190 @@
module ActiveSupport
# Abstract super class that provides a thread-isolated attributes singleton, which resets automatically
# before and after reach request. This allows you to keep all the per-request attributes easily
# available to the whole system.
#
# The following full app-like example demonstrates how to use a Current class to
# facilitate easy access to the global, per-request attributes without passing them deeply
# around everywhere:
#
# # app/models/current.rb
# class Current < ActiveSupport::CurrentAttributes
# attribute :account, :user
# attribute :request_id, :user_agent, :ip_address
#
# resets { Time.zone = nil }
#
# def user=(user)
# super
# self.account = user.account
# Time.zone = user.time_zone
# end
# end
#
# # app/controllers/concerns/authentication.rb
# module Authentication
# extend ActiveSupport::Concern
#
# included do
# before_action :authenticate
# end
#
# private
# def authenticate
# if authenticated_user = User.find(cookies.signed[:user_id])
# Current.user = authenticated_user
# else
# redirect_to new_session_url
# end
# end
# end
#
# # app/controllers/concerns/set_current_request_details.rb
# module SetCurrentRequestDetails
# extend ActiveSupport::Concern
#
# included do
# before_action do
# Current.request_id = request.uuid
# Current.user_agent = request.user_agent
# Current.ip_address = request.ip
# end
# end
# end
#
# class ApplicationController < ActionController::Base
# include Authentication
# include SetCurrentRequestDetails
# end
#
# class MessagesController < ApplicationController
# def create
# Current.account.messages.create(message_params)
# end
# end
#
# class Message < ApplicationRecord
# belongs_to :creator, default: -> { Current.user }
# after_create { |message| Event.create(record: message) }
# end
#
# class Event < ApplicationRecord
# before_create do
# self.request_id = Current.request_id
# self.user_agent = Current.user_agent
# self.ip_address = Current.ip_address
# end
# end
#
# A word of caution: It's easy to overdo a global singleton like Current and tangle your model as a result.
# Current should only be used for a few, top-level globals, like account, user, and request details.
# The attributes stuck in Current should be used by more or less all actions on all requests. If you start
# sticking controller-specific attributes in there, you're going to create a mess.
class CurrentAttributes
include ActiveSupport::Callbacks
define_callbacks :reset
class << self
# Returns singleton instance for this class in this thread. If none exists, one is created.
def instance
Thread.current[:"current_attributes_for_#{name}"] ||= new.tap do |instance|
current_instances << instance
end
end
# Declares one or more attributes that will be given both class and instance accessor methods.
def attribute(*names)
generated_attribute_methods.module_eval do
names.each do |name|
define_method(name) do
attributes[name.to_sym]
end
define_method("#{name}=") do |attribute|
attributes[name.to_sym] = attribute
end
end
end
names.each do |name|
define_singleton_method(name) do
instance.public_send(name)
end
define_singleton_method("#{name}=") do |attribute|
instance.public_send("#{name}=", attribute)
end
end
end
# Calls this block after #reset is called on the instance. Used for resetting external collaborators, like Time.zone.
def resets(&block)
set_callback :reset, :after, &block
end
delegate :set, :reset, to: :instance
def reset_all # :nodoc:
current_instances.each(&:reset)
end
private
def generated_attribute_methods
@generated_attribute_methods ||= Module.new.tap { |mod| include mod }
end
def current_instances
Thread.current[:current_attributes_instances] ||= []
end
def method_missing(name, *args, &block)
# Caches the method definition as a singleton method of the receiver.
#
# By letting #delegate handle it, we avoid an enclosure that'll capture args.
singleton_class.delegate name, to: :instance
send(name, *args, &block)
end
end
attr_accessor :attributes
def initialize
@attributes = {}
end
# Expose one or more attributes within a block. Old values are returned after the block concludes.
# Example demonstrating the common use of needing to set Current attributes outside the request-cycle:
#
# class Chat::PublicationJob < ApplicationJob
# def perform(attributes, room_number, creator)
# Current.set(person: creator) do
# Chat::Publisher.publish(attributes: attributes, room_number: room_number)
# end
# end
# end
def set(set_attributes)
old_attributes = compute_attributes(set_attributes.keys)
assign_attributes(set_attributes)
yield
ensure
assign_attributes(old_attributes)
end
# Reset all attributes. Should be called before and after actions, when used as a per-request singleton.
def reset
run_callbacks :reset do
self.attributes = {}
end
end
private
def assign_attributes(new_attributes)
new_attributes.each { |key, value| public_send("#{key}=", value) }
end
def compute_attributes(keys)
keys.collect { |key| [ key, public_send(key) ] }.to_h
end
end
end

View file

@ -7,6 +7,11 @@ module ActiveSupport
config.eager_load_namespaces << ActiveSupport
initializer "active_support.reset_all_current_attributes_instances" do |app|
app.executor.to_run { ActiveSupport::CurrentAttributes.reset_all }
app.executor.to_complete { ActiveSupport::CurrentAttributes.reset_all }
end
initializer "active_support.deprecation_behavior" do |app|
if deprecation = app.config.active_support.deprecation
ActiveSupport::Deprecation.behavior = deprecation

View file

@ -0,0 +1,96 @@
require "abstract_unit"
class CurrentAttributesTest < ActiveSupport::TestCase
Person = Struct.new(:name, :time_zone)
class Current < ActiveSupport::CurrentAttributes
attribute :world, :account, :person, :request
delegate :time_zone, to: :person
resets { Time.zone = "UTC" }
def account=(account)
super
self.person = "#{account}'s person"
end
def person=(person)
super
Time.zone = person.try(:time_zone)
end
def request
"#{super} something"
end
def intro
"#{person.name}, in #{time_zone}"
end
end
setup { Current.reset }
test "read and write attribute" do
Current.world = "world/1"
assert_equal "world/1", Current.world
end
test "read overwritten attribute method" do
Current.request = "request/1"
assert_equal "request/1 something", Current.request
end
test "set attribute via overwritten method" do
Current.account = "account/1"
assert_equal "account/1", Current.account
assert_equal "account/1's person", Current.person
end
test "set auxiliary class via overwritten method" do
Current.person = Person.new("David", "Central Time (US & Canada)")
assert_equal "Central Time (US & Canada)", Time.zone.name
end
test "resets auxiliary class via callback" do
Current.person = Person.new("David", "Central Time (US & Canada)")
assert_equal "Central Time (US & Canada)", Time.zone.name
Current.reset
assert_equal "UTC", Time.zone.name
end
test "set attribute only via scope" do
Current.world = "world/1"
Current.set(world: "world/2") do
assert_equal "world/2", Current.world
end
assert_equal "world/1", Current.world
end
test "set multiple attributes" do
Current.world = "world/1"
Current.account = "account/1"
Current.set(world: "world/2", account: "account/2") do
assert_equal "world/2", Current.world
assert_equal "account/2", Current.account
end
assert_equal "world/1", Current.world
assert_equal "account/1", Current.account
end
test "delegation" do
Current.person = Person.new("David", "Central Time (US & Canada)")
assert_equal "Central Time (US & Canada)", Current.time_zone
assert_equal "Central Time (US & Canada)", Current.instance.time_zone
end
test "all methods forward to the instance" do
Current.person = Person.new("David", "Central Time (US & Canada)")
assert_equal "David, in Central Time (US & Canada)", Current.intro
assert_equal "David, in Central Time (US & Canada)", Current.instance.intro
end
end

View file

@ -0,0 +1,88 @@
require "isolation/abstract_unit"
require "rack/test"
class CurrentAttributesIntegrationTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Isolation
include Rack::Test::Methods
setup do
build_app
app_file "app/services/current.rb", <<-RUBY
class Current < ActiveSupport::CurrentAttributes
attribute :customer
resets { Time.zone = "UTC" }
def customer=(customer)
super
Time.zone = customer.try(:time_zone)
end
end
RUBY
app_file "app/models/customer.rb", <<-RUBY
class Customer < Struct.new(:name)
def time_zone
"Copenhagen"
end
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get "/customers/:action", controller: :customers
end
RUBY
app_file "app/controllers/customers_controller.rb", <<-RUBY
class CustomersController < ApplicationController
def set_current_customer
Current.customer = Customer.new("david")
render :index
end
def set_no_customer
render :index
end
end
RUBY
app_file "app/views/customers/index.html.erb", <<-RUBY
<%= Current.customer.try(:name) || 'noone' %>,<%= Time.zone.name %>
RUBY
app_file "app/executor_intercept.rb", <<-RUBY
check_state = -> { puts [ Current.customer.try(:name) || "noone", Time.zone.name ].join(",") }
check_state.call
Rails.application.executor.wrap do
Current.customer = Customer.new("david")
check_state.call
end
check_state.call
RUBY
require "#{app_path}/config/environment"
end
teardown :teardown_app
test "current customer is assigned and cleared" do
get "/customers/set_current_customer"
assert_equal 200, last_response.status
assert_match(/david,Copenhagen/, last_response.body)
get "/customers/set_no_customer"
assert_equal 200, last_response.status
assert_match(/noone,UTC/, last_response.body)
end
test "resets after execution" do
Dir.chdir(app_path) do
assert_equal "noone,UTC\ndavid,Copenhagen\nnoone,UTC\n", `bin/rails runner app/executor_intercept.rb`
end
end
end