Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 18 commits into from May 26, 2017

Conversation

dhh
Copy link
Member

@dhh dhh commented May 22, 2017

Abstract super class that provides a thread-isolated attributes singleton.
Primary use case is keeping 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 :set_current_authenticated_user
  end

  private
    def set_current_authenticated_user
      Current.user = User.find(cookies.signed[:user_id])
    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.

@dhh dhh added this to the 5.2.0 milestone May 22, 2017
Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor details, but 👍 on the concept.

end

def person=(person)
attributes[:person] = person
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading this I'd think super would just work here. Should we make that work or too much of a hassle?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do take a swing and see what contortions are needed, if you'd like 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, not sure about that. We declare the attributes in the same class. They didn't come from super. I think super is too clever.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We make it work in AR/AMo, where they similarly belong to the current class.

It just means that instead of defining the built-in methods in the class directly, we put them into an anonymous module included for that purpose.

It's cleverer than we need to be, but I think it's simple enough that it's worth the gain in "do what I mean" expressiveness. It'll help people who do expect it to work, and I can't think of any way it'd manage to unpleasantly surprise someone who doesn't.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed a commit for this. Opted to not define the singleton methods within the module, since those should just be left be by users.

Person = Struct.new(:name, :time_zone)

class Current < ActiveSupport::CurrentAttributes
attribute :world, :account, :person
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be conflated with attribute from Active Model and Active Record?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same concept, so I think it's good that it's using the same name.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think we could confuse people into thinking that the Active Model attributes type casting stuff is available here.

So since these are mostly attributes in name only — there's no dirty tracking, aliasing or other goodies from Active Model — perhaps we should just think of them as accessors?

class Current < ActiveSupport::CurrentAccessors
  accesses :world, :account, :person # Or maybe even `attr_accessor`?
end


Person = Struct.new(:name, :time_zone)

class Current < ActiveSupport::CurrentAttributes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we namespace this and Person so it won't clash with other tests?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

# end
# end
# end
def expose(exposed_attributes)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps make this **exposed_attributes so we get the type features for free (and the method won't NoMethodError on keys.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand why not just do this:

def expose(exposed_attributes)
  old_attributes, @attributes = @attributes, exposed_attributes
  yield
ensure
  @attributes = old_attributes
end

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't call the writers, we don't trigger stuff like Time.zone =.

# around everywhere:
#
# # app/services/current.rb
# require 'active_support/current_attributes'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

active_support/all should include this, so the require won't be necessary.

Copy link
Contributor

@kaspth kaspth May 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just set CurrentAttributes up as an autoload in the top level active_support.rb mirroring ActiveSupport::Concern.

@cristianbica
Copy link
Member

Since thread_mattr_accessor appeared I'm using something like:

module Current
  thread_mattr_accessor :user

  module_function
    def reset
      Current.user = nil
    end
end

and in controllers I'm doing the hygiene:

class ApplicationController < ActionController::Base
  around_action :reset_current

  def reset_current
    Current.reset
    yield
  ensure
    Current.reset
  end
end

What would be nice is if we provide a fire-and-forget functionality which will do the resetting for each request. I'm thinking either a class flag (resets_on_action_controller_request ... or similar) or a subclass in actionpack (ActionController::CurrentRequestAttributes). Implementation-wise I would subscribe to start_processing.action_controller and process_action.action_controller.

Thus they won't conflict with other Current and Person stubs.
@kaspth
Copy link
Contributor

kaspth commented May 22, 2017

@dhh was toying with the idea of generating app/services/current.rb by default. But we'd most likely just generate an app/controllers/concerns/current/reset.rb auto included in ApplicationController and a setup { Current.reset } in test_helper.rb, rather than hooking into log subscribing.

@matthewd
Copy link
Member

I believe we'll be able to reset fully automatically through the executor.

@kaspth
Copy link
Contributor

kaspth commented May 22, 2017

@matthewd ahh, right, of course!

@kaspth
Copy link
Contributor

kaspth commented May 22, 2017

Right, we could keep track of the subclasses:

class ActiveSupport::CurrentAttributes
  mattr_accessor(:subclasses) { [] }

  class << self
    def inherited(subclass)
      subclasses << subclass
    end

    def reset_all
      subclasses.each(&:reset)
    end
  end
end

Then schedule an ActiveSupport::CurrentAttributes.reset_all executor hook in Active Support's railtie.

Define instance level accessors in an included module such that
`super` in an overriden accessor works, akin to Active Model.
Follow the example of concerns, autoload in the top level Active Support file.
@maclover7
Copy link
Contributor

Two quick things:

  1. This seems like something that is fairly app specific, and something that could live in a gem/outside of core elsewhere. As a sidenote to this, I'm more in favor of current_user and similar controller methods, rather than this globals approach.

  2. Is the idea to provide an API similar to that of the Active Record attributes API? I think using the attribute macro might trip people up.

@guilleiguaran
Copy link
Member

guilleiguaran commented May 25, 2017

I'm more in favor of current_user and similar controller methods, rather than this globals approach

I think both approach aren't incompatible at all, in the app I'm working currently we have current_user and similar controller methods but we have a couple of globals depending on the location (e.g Region.current) of the user making the request and many business rules depending on this. We reference those globals in many models, views, controllers, helpers, mailers, concerns and using the global definitely makes the code cleaner than passing the current_region around all the classes of the stack.

As @dhh said this should be used carefully and in a few cases.

Copy link
Member

@guilleiguaran guilleiguaran left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 but would like to see the recommendation by @matthewd / @kaspth in #29180 (comment) done before of merge

@dhh
Copy link
Member Author

dhh commented May 25, 2017

When does that executor run? Want to make sure we're clearing both before and after a request starts, just to ensure nothing is leaking anywhere.

Also, I think we can make it explicit that a CurrentAttributes class participates in this auto-resetting scheme. Maybe just have resets_on_request or something like that that adds it to the auto reset pool.

@dhh
Copy link
Member Author

dhh commented May 25, 2017

Jon,

  1. I don't see anything app specific about tracking current user/account/region/request_id. Every app I've worked on has had some homegrown way of doing this.
  2. Attribute is a generic name, like Object. I don't think there's any expectation of that having a global definition based on an implementation in AR.

@cristianbica
Copy link
Member

cristianbica commented May 25, 2017

@dhh executor runs before and after the request in a middleware

@cristianbica
Copy link
Member

@dhh IMO this is great addition to the framework. I'm using everywhere class Current and thread_mattr_accessor but I need to do the cleanup in controllers.
As probably @maclover7 is trying to say there are some features that I believe are too much. The parts that I don't think I'm going to use are:

  • bidelegate ... I don't any real usage for it; I wouldn't instantiate the Current class; I would even add a delegate_missing_to :instance and create the attributes methods only on the instance
  • set ... I don't have a use case for it; Plus I would remove your example with using Current in jobs :)
  • resets callbacks; seems overkill for the object. A reset method that people can override in subclasses and call super should be more than enough. Callbacks are useful if people want to have the opportunity to act on reset from outside the object.

Skips the need for users to actively embed something that resets
their CurrentAttributes instances.
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if I could figure out how to test that the customer and Time.zone is reset after the request as well. Any bright ideas? 😄

Also remove the stale puts debugging.
@kaspth
Copy link
Contributor

kaspth commented May 26, 2017

Got the auto-reset setup!

One thing that bothers me about CurrentAttributes is that they aren't attributes in the Active Model sense, there's no dirty tracking, aliasing etc. It's true they're roughly the same concept, so some overlap is warranted.

But these attributes are more purely about broadening the places where they're accessible within an app. They're accessors.

How about:

class Current < ActiveSupport::CurrentAccessors
  accesses :world, :account, :person # Or maybe even `attr_accessor`?
end

@dhh
Copy link
Member Author

dhh commented May 26, 2017

Awesome, @kaspth! The attributes thing doesn't bother me the slightest. Accessors is just beating around the bush, and imo less clear. Ruby itself uses attr_accessor, which is short for attribute_accessor. Here, we're actually declaring the attributes.

Anyway, this is all looking good to ship. I'll update the docs and merge. Much appreciate the API debate and the implementation upgrades 🙏👏🎉

setup do
build_app

app_file "app/services/current.rb", <<-RUBY
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency

-app_file "app/services/current.rb", <<-RUBY
+app_file "app/models/current.rb", <<-RUBY

https://github.com/rails/rails/pull/29180/files#diff-3c3c0f647bc4702f9453c173a707aa06R10

end

def compute_attributes(keys)
keys.collect { |key| [ key, public_send(key) ] }.to_h
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-keys.collect { |key| [ key, public_send(key) ] }.to_h
+keys.collect { |key, _| [ key, public_send(key) ] }.to_h

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? We're iterating over just an array of the keys, not a full hash.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dhh dhh merged commit 24a8644 into master May 26, 2017
@dhh dhh deleted the current-attributes branch May 26, 2017 18:00
@kaspth
Copy link
Contributor

kaspth commented May 27, 2017

Ruby itself uses attr_accessor, which is short for attribute_accessor

Ah yeah, good point. 👍 on sticking with attribute.

I noticed that storing instances in Thread.current means CurrentAttributes can't support dev reloading. I'm looking into clearing those on a reload to support that.

@clairity
Copy link

@kaspth do you have any idea on timeline for this feature to be released? i'm currently using a hacky homegrown implementation that i'd love to replace right away with this implementation (especially since i'm working on some related changes in my codebase right now).

@kaspth
Copy link
Contributor

kaspth commented Jun 19, 2017

Rails 5.2 sometime this year.

@radar
Copy link
Contributor

radar commented Jun 22, 2017

I am very opposed to this feature but after what happened last time I opposed a change on Rails I am not willing to fight this fight. It is a massive footgun and you're doing a massive disservice to the framework to introduce a global state.

Instead, I have stated my case in a blog post: http://ryanbigg.com/2017/06/current-considered-harmful. Afterall, this is what DHH encouraged last time.

If you really want to add oomph to the outrage engine, you could also post a medium article about how this is making you consider not using Rails (or was the final straw that made you leave). Both are popular choices for discharging bile, and I encourage them over reopening old tickets.

Choose to interpret it as bile if you wish. I meant it in a well-meaning fashion. Everyone's entitled to their own opinions, right?

@dhh
Copy link
Member Author

dhh commented Jun 22, 2017 via email

@zernie
Copy link

zernie commented Jun 24, 2017

@dhh I agree that we can enjoy the rest of the menu together. But it's the overall direction of the framework that a lot of people are worried about; meanwhile the rest of the programming community shies away from bad design patterns, Rails happily embraces them.

@dhh
Copy link
Member Author

dhh commented Jun 24, 2017

Rails has been embracing patterns that some people consider to be "bad" since inception, yet we are somehow still here and in better shape than ever. I'm going to go with yesterday's weather and predict that the inclusion of Current will not change that conclusion one iota either.

Anyway, enjoy the rest of the menu!

@ahazem
Copy link

ahazem commented Jul 7, 2017

@clairity We have extracted the feature into a gem: https://github.com/coup-mobility/activesupport-current_attributes. Can be used with Rails >= 4.2.

@dhh
Copy link
Member Author

dhh commented Jul 7, 2017 via email

@clairity
Copy link

clairity commented Jul 7, 2017

yes thanks @ahazem! i dropped it into my app and it's looking good so far!

@rafaelfranca
Copy link
Member

I opened an issue to track the required license change in that gem https://github.com/coup-mobility/activesupport-current_attributes/issues/2

@pavel-jurasek
Copy link

@rafaelfranca @dhh please check https://github.com/coup-mobility/activesupport-current_attributes/commit/1cd6ffc0cfed385e2385c26e68e6403db4039ffa this commit should fix copyright issue please just confirm!

ruby_rails_is_love

@ahazem
Copy link

ahazem commented Jul 7, 2017

@dhh Thanks for the pointer! The license file was auto-generated by bundler gem, which picked up my name from git config. We have updated the file.

@clairity Glad you find it helpful. :)

@kaspth
Copy link
Contributor

kaspth commented Jul 9, 2017

@ahazem you might want to pull in its latest incarnation from the master branch. I've made some extra commits since merge. https://github.com/rails/rails/commits/master/activesupport/lib/active_support/current_attributes.rb :)

@ahazem
Copy link

ahazem commented Jul 10, 2017

@kaspth Nice, will pull them in! Thanks for pointing that out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet