Permalink
Browse files

Extract ActionController::Caching::Pages

  • Loading branch information...
1 parent 8715b73 commit 74e8334c36d9ab306064efac666d5a1ec9c54910 Francesco Rodriguez committed Oct 4, 2012
View
2 actionpack-page_caching.gemspec
@@ -15,4 +15,6 @@ Gem::Specification.new do |gem|
gem.require_paths = ['lib']
gem.add_dependency 'actionpack', '>= 4.0.0.beta', '< 5.0'
+
+ gem.add_development_dependency 'mocha'
end
View
190 lib/action_controller/caching/pages.rb
@@ -0,0 +1,190 @@
+require 'fileutils'
+require 'active_support/core_ext/class/attribute_accessors'
+
+module ActionController
+ module Caching
+ # Page caching is an approach to caching where the entire action output of is
+ # stored as a HTML file that the web server can serve without going through
+ # Action Pack. This is the fastest way to cache your content as opposed to going
+ # dynamically through the process of generating the content. Unfortunately, this
+ # incredible speed-up is only available to stateless pages where all visitors are
+ # treated the same. Content management systems -- including weblogs and wikis --
+ # have many pages that are a great fit for this approach, but account-based systems
+ # where people log in and manipulate their own data are often less likely candidates.
+ #
+ # Specifying which actions to cache is done through the +caches_page+ class method:
+ #
+ # class WeblogController < ActionController::Base
+ # caches_page :show, :new
+ # end
+ #
+ # This will generate cache files such as <tt>weblog/show/5.html</tt> and
+ # <tt>weblog/new.html</tt>, which match the URLs used that would normally trigger
+ # dynamic page generation. Page caching works by configuring a web server to first
+ # check for the existence of files on disk, and to serve them directly when found,
+ # without passing the request through to Action Pack. This is much faster than
+ # handling the full dynamic request in the usual way.
+ #
+ # Expiration of the cache is handled by deleting the cached file, which results
+ # in a lazy regeneration approach where the cache is not restored before another
+ # hit is made against it. The API for doing so mimics the options from +url_for+ and friends:
+ #
+ # class WeblogController < ActionController::Base
+ # def update
+ # List.update(params[:list][:id], params[:list])
+ # expire_page action: 'show', id: params[:list][:id]
+ # redirect_to action: 'show', id: params[:list][:id]
+ # end
+ # end
+ #
+ # Additionally, you can expire caches using Sweepers that act on changes in
+ # the model to determine when a cache is supposed to be expired.
+ module Pages
+ extend ActiveSupport::Concern
+
+ included do
+ # The cache directory should be the document root for the web server and is
+ # set using <tt>Base.page_cache_directory = "/document/root"</tt>. For Rails,
+ # this directory has already been set to Rails.public_path (which is usually
+ # set to <tt>Rails.root + "/public"</tt>). Changing this setting can be useful
+ # to avoid naming conflicts with files in <tt>public/</tt>, but doing so will
+ # likely require configuring your web server to look in the new location for
+ # cached files.
+ class_attribute :page_cache_directory
+ self.page_cache_directory ||= ''
+
+ # The compression used for gzip. If +false+ (default), the page is not compressed.
+ # If can be a symbol showing the ZLib compression method, for example, <tt>:best_compression</tt>
+ # or <tt>:best_speed</tt> or an integer configuring the compression level.
+ class_attribute :page_cache_compression
+ self.page_cache_compression ||= false
+ end
+
+ module ClassMethods
+ # Expires the page that was cached with the +path+ as a key.
+ #
+ # expire_page '/lists/show'
+ def expire_page(path)
+ return unless perform_caching
+ path = page_cache_path(path)
+
+ instrument_page_cache :expire_page, path do
+ File.delete(path) if File.exist?(path)
+ File.delete(path + '.gz') if File.exist?(path + '.gz')
+ end
+ end
+
+ # Manually cache the +content+ in the key determined by +path+.
+ #
+ # cache_page "I'm the cached content", '/lists/show'
+ def cache_page(content, path, extension = nil, gzip = Zlib::BEST_COMPRESSION)
+ return unless perform_caching
+ path = page_cache_path(path, extension)
+
+ instrument_page_cache :write_page, path do
+ FileUtils.makedirs(File.dirname(path))
+ File.open(path, 'wb+') { |f| f.write(content) }
+ if gzip
+ Zlib::GzipWriter.open(path + '.gz', gzip) { |f| f.write(content) }
+ end
+ end
+ end
+
+ # Caches the +actions+ using the page-caching approach that'll store
+ # the cache in a path within the +page_cache_directory+ that
+ # matches the triggering url.
+ #
+ # You can also pass a <tt>:gzip</tt> option to override the class configuration one.
+ #
+ # # cache the index action
+ # caches_page :index
+ #
+ # # cache the index action except for JSON requests
+ # caches_page :index, if: Proc.new { !request.format.json? }
+ #
+ # # don't gzip images
+ # caches_page :image, gzip: false
+ def caches_page(*actions)
+ return unless perform_caching
+ options = actions.extract_options!
+
+ gzip_level = options.fetch(:gzip, page_cache_compression)
+ gzip_level = case gzip_level
+ when Symbol
+ Zlib.const_get(gzip_level.upcase)
+ when Fixnum
+ gzip_level
+ when false
+ nil
+ else
+ Zlib::BEST_COMPRESSION
+ end
+
+ after_filter({only: actions}.merge(options)) do |c|
+ c.cache_page(nil, nil, gzip_level)
+ end
+ end
+
+ private
+ def page_cache_file(path, extension)
+ name = (path.empty? || path == '/') ? '/index' : URI.parser.unescape(path.chomp('/'))
+ unless (name.split('/').last || name).include? '.'
+ name << (extension || self.default_static_extension)
+ end
+ return name
+ end
+
+ def page_cache_path(path, extension = nil)
+ page_cache_directory.to_s + page_cache_file(path, extension)
+ end
+
+ def instrument_page_cache(name, path)
+ ActiveSupport::Notifications.instrument("#{name}.action_controller", path: path){ yield }
+ end
+ end
+
+ # Expires the page that was cached with the +options+ as a key.
+ #
+ # expire_page controller: 'lists', action: 'show'
+ def expire_page(options = {})
+ return unless self.class.perform_caching
+
+ if options.is_a?(Hash)
+ if options[:action].is_a?(Array)
+ options[:action].each do |action|
+ self.class.expire_page(url_for(options.merge(only_path: true, action: action)))
+ end
+ else
+ self.class.expire_page(url_for(options.merge(only_path: true)))
+ end
+ else
+ self.class.expire_page(options)
+ end
+ end
+
+ # Manually cache the +content+ in the key determined by +options+. If no content is provided,
+ # the contents of response.body is used. If no options are provided, the url of the current
+ # request being handled is used.
+ #
+ # cache_page "I'm the cached content", controller: 'lists', action: 'show'
+ def cache_page(content = nil, options = nil, gzip = Zlib::BEST_COMPRESSION)
+ return unless self.class.perform_caching && caching_allowed?
+
+ path = case options
+ when Hash
+ url_for(options.merge(only_path: true, format: params[:format]))
+ when String
+ options
+ else
+ request.path
+ end
+
+ if (type = Mime::LOOKUP[self.content_type]) && (type_symbol = type.symbol).present?
+ extension = ".#{type_symbol}"
+ end
+
+ self.class.cache_page(content || response.body, path, extension, gzip)
+ end
+ end
+ end
+end
View
9 lib/action_controller/page_caching.rb
@@ -0,0 +1,9 @@
+module ActionController
+ module Caching
+ eager_autoload do
+ autoload :Pages
+ end
+
+ include Pages
+ end
+end
View
1 lib/actionpack/page_caching.rb
@@ -0,0 +1 @@
+require 'action_controller/page_caching'
View
22 test/abstract_unit.rb
@@ -0,0 +1,22 @@
+require 'bundler/setup'
+require 'minitest/autorun'
+require 'action_controller'
+require 'action_controller/page_caching'
+
+SharedTestRoutes = ActionDispatch::Routing::RouteSet.new
+
+module ActionController
+ class Base
+ include SharedTestRoutes.url_helpers
+ end
+
+ class TestCase
+ def setup
+ @routes = SharedTestRoutes
+
+ @routes.draw do
+ get ':controller(/:action)'
+ end
+ end
+ end
+end
View
259 test/caching_test.rb
@@ -0,0 +1,259 @@
+require 'abstract_unit'
+
+CACHE_DIR = 'test_cache'
+# Don't change '/../temp/' cavalierly or you might hose something you don't want hosed
+FILE_STORE_PATH = File.join(File.dirname(__FILE__), '/../temp/', CACHE_DIR)
+
+class CachingMetalController < ActionController::Metal
+ abstract!
+
+ include ActionController::Caching
+
+ self.page_cache_directory = FILE_STORE_PATH
+ self.cache_store = :file_store, FILE_STORE_PATH
+end
+
+class PageCachingMetalTestController < CachingMetalController
+ caches_page :ok
+
+ def ok
+ self.response_body = 'ok'
+ end
+end
+
+class PageCachingMetalTest < ActionController::TestCase
+ tests PageCachingMetalTestController
+
+ def setup
+ super
+
+ FileUtils.rm_rf(File.dirname(FILE_STORE_PATH))
+ FileUtils.mkdir_p(FILE_STORE_PATH)
+ end
+
+ def teardown
+ FileUtils.rm_rf(File.dirname(FILE_STORE_PATH))
+ end
+
+ def test_should_cache_get_with_ok_status
+ get :ok
+ assert_response :ok
+ assert File.exist?("#{FILE_STORE_PATH}/page_caching_metal_test/ok.html"), 'get with ok status should have been cached'
+ end
+end
+
+ActionController::Base.page_cache_directory = FILE_STORE_PATH
+
+class CachingController < ActionController::Base
+ abstract!
+
+ self.cache_store = :file_store, FILE_STORE_PATH
+end
+
+class PageCachingTestController < CachingController
+ self.page_cache_compression = :best_compression
+
+ caches_page :ok, :no_content, if: Proc.new { |c| !c.request.format.json? }
+ caches_page :found, :not_found
+ caches_page :about_me
+ caches_page :default_gzip
+ caches_page :no_gzip, gzip: false
+ caches_page :gzip_level, gzip: :best_speed
+
+ def ok
+ head :ok
+ end
+
+ def no_content
+ head :no_content
+ end
+
+ def found
+ redirect_to action: 'ok'
+ end
+
+ def not_found
+ head :not_found
+ end
+
+ def custom_path
+ render text: 'Super soaker'
+ cache_page('Super soaker', '/index.html')
+ end
+
+ def default_gzip
+ render text: 'Text'
+ end
+
+ def no_gzip
+ render text: 'PNG'
+ end
+
+ def gzip_level
+ render text: 'Big text'
+ end
+
+ def expire_custom_path
+ expire_page('/index.html')
+ head :ok
+ end
+
+ def trailing_slash
+ render text: 'Sneak attack'
+ end
+
+ def about_me
+ respond_to do |format|
+ format.html { render text: 'I am html' }
+ format.xml { render text: 'I am xml' }
+ end
+ end
+end
+
+class PageCachingTest < ActionController::TestCase
+ def setup
+ super
+
+ @request = ActionController::TestRequest.new
+ @request.host = 'hostname.com'
+ @request.env.delete('PATH_INFO')
+
+ @controller = PageCachingTestController.new
+ @controller.perform_caching = true
+ @controller.cache_store = :file_store, FILE_STORE_PATH
+
+ @response = ActionController::TestResponse.new
+
+ @params = { controller: 'posts', action: 'index', only_path: true }
+
+ FileUtils.rm_rf(File.dirname(FILE_STORE_PATH))
+ FileUtils.mkdir_p(FILE_STORE_PATH)
+ end
+
+ def teardown
+ FileUtils.rm_rf(File.dirname(FILE_STORE_PATH))
+ @controller.perform_caching = false
+ end
+
+ def test_page_caching_resources_saves_to_correct_path_with_extension_even_if_default_route
+ with_routing do |set|
+ set.draw do
+ get 'posts.:format', to: 'posts#index', as: :formatted_posts
+ get '/', to: 'posts#index', as: :main
+ end
+ @params[:format] = 'rss'
+ assert_equal '/posts.rss', @routes.url_for(@params)
+ @params[:format] = nil
+ assert_equal '/', @routes.url_for(@params)
+ end
+ end
+
+ def test_should_cache_get_with_ok_status
+ get :ok
+ assert_response :ok
+ assert_page_cached :ok, 'get with ok status should have been cached'
+ end
+
+ def test_should_cache_with_custom_path
+ get :custom_path
+ assert File.exist?("#{FILE_STORE_PATH}/index.html")
+ end
+
+ def test_should_expire_cache_with_custom_path
+ get :custom_path
+ assert File.exist?("#{FILE_STORE_PATH}/index.html")
+
+ get :expire_custom_path
+ assert !File.exist?("#{FILE_STORE_PATH}/index.html")
+ end
+
+ def test_should_gzip_cache
+ get :custom_path
+ assert File.exist?("#{FILE_STORE_PATH}/index.html.gz")
+
+ get :expire_custom_path
+ assert !File.exist?("#{FILE_STORE_PATH}/index.html.gz")
+ end
+
+ def test_should_allow_to_disable_gzip
+ get :no_gzip
+ assert File.exist?("#{FILE_STORE_PATH}/page_caching_test/no_gzip.html")
+ assert !File.exist?("#{FILE_STORE_PATH}/page_caching_test/no_gzip.html.gz")
+ end
+
+ def test_should_use_config_gzip_by_default
+ @controller.expects(:cache_page).with(nil, nil, Zlib::BEST_COMPRESSION)
+ get :default_gzip
+ end
+
+ def test_should_set_gzip_level
+ @controller.expects(:cache_page).with(nil, nil, Zlib::BEST_SPEED)
+ get :gzip_level
+ end
+
+ def test_should_cache_without_trailing_slash_on_url
+ @controller.class.cache_page 'cached content', '/page_caching_test/trailing_slash'
+ assert File.exist?("#{FILE_STORE_PATH}/page_caching_test/trailing_slash.html")
+ end
+
+ def test_should_obey_http_accept_attribute
+ @request.env['HTTP_ACCEPT'] = 'text/xml'
+ get :about_me
+ assert File.exist?("#{FILE_STORE_PATH}/page_caching_test/about_me.xml")
+ assert_equal 'I am xml', @response.body
+ end
+
+ def test_cached_page_should_not_have_trailing_slash_even_if_url_has_trailing_slash
+ @controller.class.cache_page 'cached content', '/page_caching_test/trailing_slash/'
+ assert File.exist?("#{FILE_STORE_PATH}/page_caching_test/trailing_slash.html")
+ end
+
+ def test_should_cache_ok_at_custom_path
+ @request.env['PATH_INFO'] = '/index.html'
+ get :ok
+ assert_response :ok
+ assert File.exist?("#{FILE_STORE_PATH}/index.html")
+ end
+
+ [:ok, :no_content, :found, :not_found].each do |status|
+ [:get, :post, :patch, :put, :delete].each do |method|
+ unless method == :get && status == :ok
+ define_method "test_shouldnt_cache_#{method}_with_#{status}_status" do
+ send(method, status)
+ assert_response status
+ assert_page_not_cached status, "#{method} with #{status} status shouldn't have been cached"
+ end
+ end
+ end
+ end
+
+ def test_page_caching_conditional_options
+ get :ok, format: 'json'
+ assert_page_not_cached :ok
+ end
+
+ def test_page_caching_directory_set_as_pathname
+ begin
+ ActionController::Base.page_cache_directory = Pathname.new(FILE_STORE_PATH)
+ get :ok
+ assert_response :ok
+ assert_page_cached :ok
+ ensure
+ ActionController::Base.page_cache_directory = FILE_STORE_PATH
+ end
+ end
+
+ private
+
+ def assert_page_cached(action, message = "#{action} should have been cached")
+ assert page_cached?(action), message
+ end
+
+ def assert_page_not_cached(action, message = "#{action} shouldn't have been cached")
+ assert !page_cached?(action), message
+ end
+
+ def page_cached?(action)
+ File.exist? "#{FILE_STORE_PATH}/page_caching_test/#{action}.html"
+ end
+end
View
54 test/log_subscriber_test.rb
@@ -0,0 +1,54 @@
+require 'abstract_unit'
+require 'active_support/log_subscriber/test_helper'
+require 'action_controller/log_subscriber'
+
+module Another
+ class LogSubscribersController < ActionController::Base
+ abstract!
+
+ self.perform_caching = true
+
+ def with_page_cache
+ cache_page('Super soaker', '/index.html')
+ render nothing: true
+ end
+ end
+end
+
+class ACLogSubscriberTest < ActionController::TestCase
+ tests Another::LogSubscribersController
+ include ActiveSupport::LogSubscriber::TestHelper
+
+ def setup
+ super
+
+ @routes = SharedTestRoutes
+ @routes.draw do
+ get ':controller(/:action)'
+ end
+
+ @cache_path = File.expand_path('../temp/test_cache', File.dirname(__FILE__))
+ ActionController::Base.page_cache_directory = @cache_path
+ @controller.cache_store = :file_store, @cache_path
+ ActionController::LogSubscriber.attach_to :action_controller
+ end
+
+ def teardown
+ ActiveSupport::LogSubscriber.log_subscribers.clear
+ FileUtils.rm_rf(@cache_path)
+ end
+
+ def set_logger(logger)
+ ActionController::Base.logger = logger
+ end
+
+ def test_with_page_cache
+ get :with_page_cache
+ wait
+
+ logs = @logger.logged(:info)
+ assert_equal 3, logs.size
+ assert_match(/Write page/, logs[1])
+ assert_match(/\/index\.html/, logs[1])
+ end
+end

0 comments on commit 74e8334

Please sign in to comment.