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

Implement H2 Early Hints for Rails #30744

Merged
merged 1 commit into from Oct 4, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions actionpack/CHANGELOG.md
@@ -1,3 +1,11 @@
* Add ability to enable Early Hints for HTTP/2

If supported by the server, and enabled in Puma this allows H2 Early Hints to be used.

The `javascript_include_tag` and the `stylesheet_link_tag` automatically add Early Hints if requested.

*Eileen M. Uchitelle*, *Aaron Patterson*

* Simplify cookies middleware with key rotation support

Use the `rotate` method for both `MessageEncryptor` and
Expand Down
17 changes: 17 additions & 0 deletions actionpack/lib/action_dispatch/http/request.rb
Expand Up @@ -199,6 +199,23 @@ def headers
@headers ||= Http::Headers.new(self)
end

# Early Hints is an HTTP/2 status code that indicates hints to help a client start
# making preparations for processing the final response.
#
# If the env contains +rack.early_hints+ then the server accepts HTTP2 push for Link headers.
#
# The +send_early_hints+ method accepts an hash of links as follows:
#
# send_early_hints("Link" => "</style.css>; rel=preload; as=style\n</script.js>; rel=preload")
#
# If you are using +javascript_include_tag+ or +stylesheet_link_tag+ the
# Early Hints headers are included by default if supported.
def send_early_hints(links)
return unless env["rack.early_hints"]

env["rack.early_hints"].call(links)
Copy link
Contributor

Choose a reason for hiding this comment

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

could avoid calling doing the same lookup twice:

env["rack.early_hints"]&.call(links)

or

callback = env["rack.early_hints"]
callback.call(links) if callback

Copy link
Member

Choose a reason for hiding this comment

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

It's a hash lookup. I'm sure it'll be fine.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've considered this but agree with @matthewd.

Drying things up isn't a benefit if the code is harder to read and understand and the performance impact of calling a hash lookup twice will be negligible.

Copy link
Contributor

Choose a reason for hiding this comment

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

I was just curious about the performance impact, so I did a quick benchmarking.

TL;DR

... the performance impact of calling a hash lookup twice will be negligible.

Details

# 1.18x slower than v2 if env["rack.early_hints"]
def v1
  return unless env["rack.early_hints"]
  env["rack.early_hints"].call(links)
end

# 1.09x slower than v1 IF NOT env["rack.early_hints"]
def v2
  callback = env["rack.early_hints"]
  callback.call(links) if callback
end
Click to show code
#!/usr/bin/env ruby

require 'benchmark/ips'
require 'date'

LOOKUP = { "foo" => ->() {} }

def happy_case
  return unless LOOKUP["foo"]

  LOOKUP["foo"].call()
end

def happy_case_with_assignment
  callback = LOOKUP["foo"]

  callback.call() if callback
end

def alternase_case
  return unless LOOKUP["bar"]

  LOOKUP["bar"].call()
end

def alternase_case_with_assignment
  callback = LOOKUP["bar"]

  callback.call() if callback
end


# == Result
#
# happy_case: 1.18x slower
Benchmark.ips do |x|
  # Configure the number of seconds used during
  # the warmup phase (default 2) and calculation phase (default 5)
  x.config(time: 5, warmup: 2)

  x.report("happy_case") { happy_case }
  x.report("happy_case_with_assignment") { happy_case_with_assignment }

  # Compare the iterations per second of the various reports!
  x.compare!
end

# == Result
# 
# alternase_case_with_assignment: 1.09x slower
Benchmark.ips do |x|
  # Configure the number of seconds used during
  # the warmup phase (default 2) and calculation phase (default 5)
  x.config(time: 5, warmup: 2)

  x.report("alternase_case") { alternase_case }
  x.report("alternase_case_with_assignment") { alternase_case_with_assignment }

  # Compare the iterations per second of the various reports!
  x.compare!
end

__END__

Calculating -------------------------------------
          happy_case   111.305k i/100ms
happy_case_with_assignment
                       127.680k i/100ms
-------------------------------------------------
          happy_case      3.704M (± 2.1%) i/s -     18.588M
happy_case_with_assignment
                          4.353M (± 1.2%) i/s -     21.833M

Comparison:
happy_case_with_assignment:  4353466.9 i/s
          happy_case:  3704451.9 i/s - 1.18x slower

Calculating -------------------------------------
      alternase_case   146.388k i/100ms
alternase_case_with_assignment
                       154.871k i/100ms
-------------------------------------------------
      alternase_case      7.631M (± 2.9%) i/s -     38.207M
alternase_case_with_assignment
                          6.977M (± 6.8%) i/s -     34.846M

Comparison:
      alternase_case:  7630584.3 i/s
alternase_case_with_assignment:  6977239.4 i/s - 1.09x slower

Choose a reason for hiding this comment

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

interesting, would have been nice to see how the &. syntax compares.

end

# Returns a +String+ with the last requested path including their params.
#
# # get '/foo'
Expand Down
15 changes: 15 additions & 0 deletions actionpack/test/dispatch/request_test.rb
Expand Up @@ -1304,3 +1304,18 @@ class RequestFormData < BaseRequestTest
assert !request.form_data?
end
end

class EarlyHintsRequestTest < BaseRequestTest
def setup
super
@env["rack.early_hints"] = lambda { |links| links }
@request = stub_request
end

test "when early hints is set in the env link headers are sent" do
early_hints = @request.send_early_hints("Link" => "</style.css>; rel=preload; as=style\n</script.js>; rel=preload")
expected_hints = { "Link" => "</style.css>; rel=preload; as=style\n</script.js>; rel=preload" }

assert_equal expected_hints, early_hints
end
end
30 changes: 26 additions & 4 deletions actionview/lib/action_view/helpers/asset_tag_helper.rb
Expand Up @@ -37,6 +37,9 @@ module AssetTagHelper
# When the Asset Pipeline is enabled, you can pass the name of your manifest as
# source, and include other JavaScript or CoffeeScript files inside the manifest.
#
# If the server supports Early Hints header links for these assets will be
# automatically pushed.
#
# ==== Options
#
# When the last parameter is a hash you can add HTML attributes using that
Expand Down Expand Up @@ -77,12 +80,20 @@ module AssetTagHelper
def javascript_include_tag(*sources)
options = sources.extract_options!.stringify_keys
path_options = options.extract!("protocol", "extname", "host", "skip_pipeline").symbolize_keys
sources.uniq.map { |source|
early_hints_links = []

sources_tags = sources.uniq.map { |source|
href = path_to_javascript(source, path_options)
early_hints_links << "<#{href}>; rel=preload; as=script"
tag_options = {
"src" => path_to_javascript(source, path_options)
"src" => href
}.merge!(options)
content_tag("script".freeze, "", tag_options)
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it make sense to change this to tag.script(src: href, **options) while we're here? (Though that would probably necessitate shifting to symbolize_keys above).

Copy link
Member

Choose a reason for hiding this comment

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

Is that desirable in general in internal stuff? The improvement for user code is nice, but it feels like fairly pointless (and comparatively allocation-heavy) indirection for us.

Copy link
Contributor

Choose a reason for hiding this comment

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

Right, it's a bit of a back and forth. I was focusing on removing the freeze call. Let's defer to some other time, since it's out of scope for this particular thing 👍

}.join("\n").html_safe

request.send_early_hints("Link" => early_hints_links.join("\n"))

sources_tags
end

# Returns a stylesheet link tag for the sources specified as arguments. If
Expand All @@ -92,6 +103,9 @@ def javascript_include_tag(*sources)
# to "screen", so you must explicitly set it to "all" for the stylesheet(s) to
# apply to all media types.
#
# If the server supports Early Hints header links for these assets will be
# automatically pushed.
#
# stylesheet_link_tag "style"
# # => <link href="/assets/style.css" media="screen" rel="stylesheet" />
#
Expand All @@ -113,14 +127,22 @@ def javascript_include_tag(*sources)
def stylesheet_link_tag(*sources)
options = sources.extract_options!.stringify_keys
path_options = options.extract!("protocol", "host", "skip_pipeline").symbolize_keys
sources.uniq.map { |source|
early_hints_links = []

sources_tags = sources.uniq.map { |source|
href = path_to_stylesheet(source, path_options)
early_hints_links << "<#{href}>; rel=preload; as=stylesheet"

Choose a reason for hiding this comment

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

According to the example described in https://w3c.github.io/preload/#as-attribute, it seems that the as target attribute for stylesheets should be style instead of stylesheet

tag_options = {
"rel" => "stylesheet",
"media" => "screen",
"href" => path_to_stylesheet(source, path_options)
"href" => href
}.merge!(options)
tag(:link, tag_options)
}.join("\n").html_safe

request.send_early_hints("Link" => early_hints_links.join("\n"))

sources_tags
end

# Returns a link tag that browsers and feed readers can use to auto-detect
Expand Down
5 changes: 4 additions & 1 deletion actionview/test/template/asset_tag_helper_test.rb
Expand Up @@ -19,6 +19,7 @@ def protocol() "http://" end
def ssl?() false end
def host_with_port() "localhost" end
def base_url() "http://www.example.com" end
def send_early_hints(links) end
end.new

@controller.request = @request
Expand Down Expand Up @@ -653,7 +654,9 @@ def setup
@controller = BasicController.new
@controller.config.relative_url_root = "/collaboration/hieraki"

@request = Struct.new(:protocol, :base_url).new("gopher://", "gopher://www.example.com")
@request = Struct.new(:protocol, :base_url) do
def send_early_hints(links); end
end.new("gopher://", "gopher://www.example.com")
@controller.request = @request
end

Expand Down
4 changes: 4 additions & 0 deletions actionview/test/template/javascript_helper_test.rb
Expand Up @@ -6,11 +6,15 @@ class JavaScriptHelperTest < ActionView::TestCase
tests ActionView::Helpers::JavaScriptHelper

attr_accessor :output_buffer
attr_reader :request

setup do
@old_escape_html_entities_in_json = ActiveSupport.escape_html_entities_in_json
ActiveSupport.escape_html_entities_in_json = true
@template = self
@request = Class.new do
def send_early_hints(links) end
end.new
end

def teardown
Expand Down
8 changes: 7 additions & 1 deletion railties/lib/rails/commands/server/server_command.rb
Expand Up @@ -127,6 +127,7 @@ class ServerCommand < Base # :nodoc:
class_option "dev-caching", aliases: "-C", type: :boolean, default: nil,
desc: "Specifies whether to perform caching in development."
class_option "restart", type: :boolean, default: nil, hide: true
class_option "early_hints", type: :boolean, default: nil, desc: "Enables HTTP/2 early hints."

def initialize(args = [], local_options = {}, config = {})
@original_options = local_options
Expand Down Expand Up @@ -161,7 +162,8 @@ def server_options
daemonize: options[:daemon],
pid: pid,
caching: options["dev-caching"],
restart_cmd: restart_command
restart_cmd: restart_command,
early_hints: early_hints
Copy link
Contributor

Choose a reason for hiding this comment

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

use it directly like dev-caching / daemon ?

Copy link
Member Author

Choose a reason for hiding this comment

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

I put the documentation in the early hints option. It's passed in like --early-hints or can be set in the puma.config with early_hints(true).

}
end
end
Expand Down Expand Up @@ -227,6 +229,10 @@ def restart_command
"bin/rails server #{@server} #{@original_options.join(" ")} --restart"
end

def early_hints
options[:early_hints]
end

def pid
File.expand_path(options[:pid])
end
Expand Down
12 changes: 12 additions & 0 deletions railties/test/commands/server_test.rb
Expand Up @@ -81,6 +81,18 @@ def test_caching_with_option
assert_equal false, options[:caching]
end

def test_early_hints_with_option
args = ["--early-hints"]
options = parse_arguments(args)
assert_equal true, options[:early_hints]
end

def test_early_hints_is_nil_by_default
args = []
options = parse_arguments(args)
assert_nil options[:early_hints]
end

def test_log_stdout
with_rack_env nil do
with_rails_env nil do
Expand Down