-
Notifications
You must be signed in to change notification settings - Fork 321
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
Caching #177
Caching #177
Changes from 18 commits
7f749a9
0900813
0b4c444
4310b9e
2533e01
e7f753b
c4f1e45
75d8068
b3499bd
153fe3b
69a343d
40fb8b6
bbfd8c3
2a57ce3
31a60f6
08df766
f2346bd
4c10ea8
bcf947e
69dfa0a
156ce9d
0adc2a1
cd49187
d22eaa5
a33190f
71bc884
5814dbb
429a5a0
79cdbb7
7153878
cac374d
042d904
9956494
182d5db
2d1f847
833deae
88dc181
48f3e5c
eeeb2c8
86741e2
c234d49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -59,3 +59,6 @@ Style/StringLiterals: | |
|
||
Style/TrivialAccessors: | ||
Enabled: false | ||
|
||
Style/AlignParameters: | ||
Enabled: false | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,7 +23,7 @@ end | |
require "yardstick/rake/verify" | ||
Yardstick::Rake::Verify.new do |verify| | ||
verify.require_exact_threshold = false | ||
verify.threshold = 58 | ||
verify.threshold = 58.1 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this not support There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is. And |
||
end | ||
|
||
task :default => [:spec, :rubocop, :verify_measurements] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
require "time" | ||
require "http/cache/cache_control" | ||
require "http/cache/response_with_cache_behavior" | ||
require "http/cache/request_with_cache_behavior" | ||
|
||
module HTTP | ||
class Cache | ||
# NoOp cache. Always makes the request. | ||
class NullCache | ||
# @return [Response] the result of the provided block | ||
# @yield [request, options] so that the request can actually be made | ||
def perform(request, options) | ||
yield(request, options) | ||
end | ||
end | ||
|
||
# @return [Response] a cached response that is valid for the request or | ||
# the result of executing the provided block | ||
# | ||
# @yield [request, options] on cache miss so that an actual | ||
# request can be made | ||
def perform(request, options, &request_performer) | ||
req = RequestWithCacheBehavior.coerce(request) | ||
|
||
invalidate_cache(req) if req.invalidates_cache? | ||
|
||
get_response(req, options, request_performer) | ||
end | ||
|
||
protected | ||
|
||
# @return [Response] the response to the request, either from the | ||
# cache or by actually making the request | ||
def get_response(req, options, request_performer) | ||
cached_resp = cache_lookup(req) | ||
return cached_resp if cached_resp && !cached_resp.stale? | ||
|
||
# cache miss | ||
|
||
actual_req = if cached_resp | ||
req.conditional_on_changes_to(cached_resp) | ||
else | ||
req | ||
end | ||
actual_resp = make_request(actual_req, options, request_performer) | ||
|
||
handle_response(cached_resp, actual_resp, req) | ||
end | ||
|
||
# @returns [Response] the most useful of the responses after | ||
# updating the cache as appropriate | ||
def handle_response(cached_resp, actual_resp, req) | ||
if actual_resp.status.not_modified? && cached_resp | ||
cached_resp.validated!(actual_resp) | ||
store_in_cache(req, cached_resp) | ||
return cached_resp | ||
|
||
elsif req.cacheable? && actual_resp.cacheable? | ||
store_in_cache(req, actual_resp) | ||
return actual_resp | ||
|
||
else | ||
return actual_resp | ||
end | ||
end | ||
|
||
# @return [ResponseWithCacheBehavior] the actual response returned | ||
# by request_performer | ||
def make_request(req, options, request_performer) | ||
req.sent_at = Time.now | ||
ResponseWithCacheBehavior.coerce(request_performer.call(req, options)).tap do |resp| | ||
resp.received_at = Time.now | ||
resp.requested_at = req.sent_at | ||
end | ||
end | ||
|
||
# @return [ResponseWithCacheBehavior] the cached response for the | ||
# request or nil if there isn't one | ||
def cache_lookup(request) | ||
return nil if request.skips_cache? | ||
c = @cache_adapter.lookup(request) | ||
if c | ||
ResponseWithCacheBehavior.coerce(c) | ||
else | ||
nil | ||
end | ||
end | ||
|
||
# Store response in cache | ||
# | ||
# @return [nil] | ||
def store_in_cache(request, response) | ||
@cache_adapter.store(request, response) | ||
nil | ||
end | ||
|
||
# Invalidate all response from the requested resource | ||
# | ||
# @return [nil] | ||
def invalidate_cache(request) | ||
@cache_adapter.invalidate(request.uri) | ||
end | ||
|
||
# Inits a new instance | ||
def initialize(adapter = HTTP::Cache::InMemoryCache.new) | ||
@cache_adapter = adapter | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
module HTTP | ||
class Cache | ||
# Convenience methods around cache control headers. | ||
class CacheControl | ||
# @return [Boolean] does this message force revalidation | ||
def forces_revalidation? | ||
must_revalidate? || max_age == 0 | ||
end | ||
|
||
# @return [Boolean] does the cache control include 'must-revalidate' | ||
def must_revalidate? | ||
matches?(/\bmust-revalidate\b/i) | ||
end | ||
|
||
# @return [Boolean] does the cache control include 'no-cache' | ||
def no_cache? | ||
matches?(/\bno-cache\b/i) | ||
end | ||
|
||
# @return [Boolean] does the cache control include 'no-stor' | ||
def no_store? | ||
matches?(/\bno-store\b/i) | ||
end | ||
|
||
# @return [Boolean] does the cache control include 'public' | ||
def public? | ||
matches?(/\bpublic\b/i) | ||
end | ||
|
||
# @return [Boolean] does the cache control include 'private' | ||
def private? | ||
matches?(/\bprivate\b/i) | ||
end | ||
|
||
# @return [Numeric] the max number of seconds this message is | ||
# considered fresh. | ||
def max_age | ||
explicit_max_age || seconds_til_expires || Float::INFINITY | ||
end | ||
|
||
# @return [Boolean] is the vary header set to '*' | ||
def vary_star? | ||
headers.get("Vary").any? { |v| "*" == v.strip } | ||
end | ||
|
||
protected | ||
|
||
attr_reader :headers | ||
|
||
# @return [Boolean] true when cache-control header matches the pattern | ||
def matches?(pattern) | ||
headers.get("Cache-Control").any? { |v| v =~ pattern } | ||
end | ||
|
||
# @return [Numeric] number of seconds until the time in the | ||
# expires header is reached. | ||
# | ||
# --- | ||
# Some servers send a "Expire: -1" header which must be treated as expired | ||
def seconds_til_expires | ||
headers.get("Expires") | ||
.map(&method(:to_time_or_epoch)) | ||
.compact | ||
.map { |e| e - Time.now } | ||
.map { |a| a < 0 ? 0 : a } # age is 0 if it is expired | ||
.max | ||
end | ||
|
||
# @return [Time] parses t_str at a time; if that fails returns epoch time | ||
def to_time_or_epoch(t_str) | ||
Time.httpdate(t_str) | ||
rescue ArgumentError | ||
Time.at(0) | ||
end | ||
|
||
# @return [Numeric] the value of the max-age component of cache control | ||
def explicit_max_age | ||
headers.get("Cache-Control") | ||
.map { |v| (/max-age=(\d+)/i).match(v) } | ||
.compact | ||
.map { |m| m[1].to_i } | ||
.max | ||
end | ||
|
||
# Inits a new instance | ||
def initialize(message) | ||
@headers = message.headers | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
require "http/headers" | ||
|
||
module HTTP | ||
class Cache | ||
# Collection of all entries in the cache. | ||
class CacheEntryCollection | ||
include Enumerable | ||
|
||
# @yield [CacheEntry] each successive entry in the cache. | ||
def each(&block) | ||
@entries.each(&block) | ||
end | ||
|
||
# @return [Response] the response for the request or nil if | ||
# there isn't one. | ||
def [](request) | ||
entry = detect { |e| e.valid_for?(request) } | ||
entry.response if entry | ||
end | ||
|
||
# @return [Response] the specified response after inserting it | ||
# into the cache. | ||
def []=(request, response) | ||
@entries.delete_if { |entry| entry.valid_for?(request) } | ||
@entries << CacheEntry.new(request, response) | ||
response | ||
end | ||
|
||
protected | ||
|
||
def initialize | ||
@entries = [] | ||
end | ||
end | ||
|
||
# An entry for a single response in the cache | ||
class CacheEntry | ||
attr_reader :request, :response | ||
|
||
# @return [Boolean] true iff this entry is valid for the | ||
# request. | ||
def valid_for?(request) | ||
request.uri == @request.uri && | ||
select_request_headers.all? do |key, value| | ||
request.headers[key] == value | ||
end | ||
end | ||
|
||
protected | ||
|
||
# @return [Hash] the headers that matter of matching requests to | ||
# this response. | ||
def select_request_headers | ||
headers = HTTP::Headers.new | ||
|
||
@response.headers.get("Vary").flat_map { |v| v.split(",") }.uniq.each do |name| | ||
name.strip! | ||
headers[name] = @request.headers[name] if @request.headers[name] | ||
end | ||
|
||
headers.to_h | ||
end | ||
|
||
def initialize(request, response) | ||
@request, @response = request, response | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
require "thread" | ||
require "http/cache/collection" | ||
|
||
module HTTP | ||
class Cache | ||
class InMemoryCache | ||
# @return [Response] the response for the request or nil if one | ||
# isn't found | ||
def lookup(request) | ||
@mutex.synchronize do | ||
response = @collection[request.uri.to_s][request] | ||
response.authoritative = false if response | ||
response | ||
end | ||
end | ||
|
||
# Stores response to be looked up later. | ||
def store(request, response) | ||
@mutex.synchronize do | ||
@collection[request.uri.to_s][request] = response | ||
end | ||
end | ||
|
||
# Invalidates the all responses from the specified resource. | ||
def invalidate(uri) | ||
@mutex.synchronize do | ||
@collection.delete(uri.to_s) | ||
end | ||
end | ||
|
||
protected | ||
|
||
def initialize | ||
@mutex = Mutex.new | ||
@collection = Hash.new { |h, k| h[k] = CacheEntryCollection.new } | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please do not disable this cop.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. I think this rule encourages harder to read code but it is your house. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It encourages consistency of alignment 😜