Skip to content

Commit d5e440b

Browse files
authored
Merge pull request #2 from splitwise/ar/update_readme
Update readme
2 parents fd95af1 + 4375ff5 commit d5e440b

5 files changed

Lines changed: 315 additions & 71 deletions

File tree

README.md

Lines changed: 174 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
By [Splitwise](https://www.splitwise.com)
44

5-
Cacheable is a gem which intends to add method caching in an [aspect-oriented programming (AOP)](https://en.wikipedia.org/wiki/Aspect-oriented_programming) fashion in Ruby. Its core goals are:
5+
Cacheable is a gem which adds method caching in Ruby following an [aspect-oriented programming (AOP)](https://en.wikipedia.org/wiki/Aspect-oriented_programming) paradigm. Its core goals are:
66

77
* ease of use (method annotation)
88
* flexibility (simple adaptability for any cache backend)
99
* portability (plain Ruby for use with any framework)
1010

11-
While Rails is not a requirement, Cacheable was built inside a mature Rails app and later extracted. This first release will seamlessly work in Rails and only includes an adapter for an in-memory cache backed by a simple Hash. This may be enough for your needs but it is more likely that additional cache adapters will need to be written.
11+
While using Ruby on Rails is not a requirement, Cacheable was built inside a mature Rails app and later extracted. The current release is designed for drop-in support in Rails, and includes an adapter for an in-memory cache backed by a simple hash. This may be enough for your needs, but it's more likely that additional cache adapters will need to be written for other projects.
1212

1313
See more about [Cache Adapters](cache-adapters.md).
1414

@@ -32,47 +32,58 @@ Cacheable.cache_adapter = :memory
3232

3333
### Simple Implementation Example
3434

35-
Cacheable is designed to work seamlessly with your already existing codebase. Consider the following contrived class:
35+
Cacheable is designed to work seamlessly with your already existing codebase. Consider the following example where we fetch the star count for Cacheable from GitHub's API. Feel free to copy/paste it into your IRB console or use the code in `examples/simple_example.rb`.
3636

3737
```ruby
38-
class SimpleExample
39-
def expensive_calculation
40-
puts 'beginning expensive method'
41-
42-
return 'my_result'
38+
require 'json'
39+
require 'net/http'
40+
41+
class GitHubApiAdapter
42+
def star_count
43+
puts "Fetching data from GitHub"
44+
url = 'https://api.github.com/repos/splitwise/cacheable'
45+
46+
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
4347
end
4448
end
4549
```
4650

47-
To cache this method and it's result, simply add the following:
51+
To cache this method and its result, simply add the following:
4852

4953
```ruby
54+
# From examples/simple_example.rb
55+
5056
require 'cacheable' # this may not be necessary depending on your autoloading system
57+
require 'json'
58+
require 'net/http'
5159

52-
class SimpleExample
60+
class GitHubApiAdapter
5361
include Cacheable
5462

55-
cacheable :expensive_calculation
63+
cacheable :star_count
64+
65+
def star_count
66+
puts "Fetching data from GitHub"
67+
url = 'https://api.github.com/repos/splitwise/cacheable'
68+
5669

57-
def expensive_calculation
58-
puts 'beginning expensive method'
59-
60-
return 'my_result'
70+
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
6171
end
6272
end
6373
```
6474

65-
**That's it!** There's some complex Ruby magic going on under the hood but to the end user you can simply call `expensive_calculation` and the result will be retrieved from the cache, if available, or generated and placed into the cache. To confirm it is working, fire up an IRB console try the following:
75+
**That's it!** There's some complex Ruby magic going on under the hood but to the end user you can simply call `star_count` and the result will be retrieved from the cache, if available, or fetched from the network and placed into the cache. To confirm it is working, fire up an IRB console try the following:
6676

6777
```irb
68-
> s = SimpleExample.new
69-
> s.expensive_calculation
70-
beginning expensive method
71-
=> "my_result"
72-
> s.expensive_calculation
73-
=> "my_result"
78+
> a = GitHubApiAdapter.new
79+
> a.star_count
80+
Fetching data from GitHub
81+
=> 2
82+
> a.star_count
83+
=> 2
7484
7585
# Notice that the `puts` was not output the 2nd time the method was invoked.
86+
# The network call and result parsing would also not be performed again.
7687
```
7788

7889
### Additional Methods
@@ -84,97 +95,153 @@ Cacheable also adds two useful methods to your class.
8495
The cache can intentionally be skipped by appending `_without_cache` to the method name. This invocation will neither check the cache nor populate it. It is as if you called the original method and never used Cacheable.
8596

8697
```irb
87-
> s = SimpleExample.new
88-
> s.expensive_calculation_without_cache
89-
beginning expensive method
90-
=> "my_result"
91-
> s.expensive_calculation_without_cache
92-
beginning expensive method
93-
=> "my_result"
94-
```
98+
> a = GitHubApiAdapter.new
99+
> a.star_count
100+
Fetching data from GitHub
101+
=> 2
102+
> a.star_count_without_cache
103+
Fetching data from GitHub
104+
=> 2
105+
> a.star_count
106+
=> 2
107+
```
95108

96109
#### Remove the Value via `clear_#{method}_cache`
97110

98111
The cached value can be cleared at any time by calling `clear_#{your_method_name}_cache`.
99112

100113
```irb
101-
> s = SimpleExample.new
102-
> s.expensive_calculation
103-
beginning expensive method
104-
=> "my_result"
105-
> s.expensive_calculation
106-
=> "my_result"
107-
108-
> s.clear_expensive_calculation_cache
114+
> a = GitHubApiAdapter.new
115+
> a.star_count
116+
Fetching data from GitHub
117+
=> 2
118+
> a.star_count
119+
=> 2
120+
121+
> a.clear_star_count_cache
109122
=> true
110-
> s.expensive_calculation
111-
beginning expensive method
112-
=> "my_result"
123+
> a.star_count
124+
Fetching data from GitHub
125+
=> 2
113126
```
114127

115128
## Additional Configuration
116129

117-
### Cache Invalidation
130+
### Cache Keys
118131

119132
#### Default
120133

121-
One of the hardest things to do correctly is cache invalidation. Cacheable handles this in a variety of ways. By default Cacheable will construct key a key in the format `[cache_key || class_name, method_name]`.
134+
By default, Cacheable will construct key a key in the format `[cache_key || class_name, method_name]` without using method arguments.
122135

123136
If the object responds to `cache_key` its return value will be the first element in the array. `ActiveRecord` provides [`cache_key`](https://api.rubyonrails.org/classes/ActiveRecord/Integration.html#method-i-cache_key) but it can be added to any Ruby object or overwritten. If the object does not respond to it, the name of the class will be used instead. The second element will be the name of the method as a symbol.
124137

125138
It is up to the cache adapter what to do with this array. For example, Rails will turn `[SomeClass, :some_method]` into `"SomeClass/some_method"`. For more information see the documentation on [Cache Adapters](cache-adapters.md)
126139

127140
#### Set Your Own
128141

129-
If (re)defining `cache_key` does not provide enough flexibility you can pass a proc to the `key_format:` option of `cacheable`.
142+
If (re)defining `cache_key` does not provide enough flexibility, you can pass a proc to the `key_format:` option of `cacheable`.
130143

131144
```ruby
132-
class CustomKeyExample
145+
# From examples/custom_key_example.rb
146+
147+
require 'cacheable'
148+
require 'json'
149+
require 'net/http'
150+
151+
class GitHubApiAdapter
133152
include Cacheable
134153

135-
cacheable :my_method, key_format: -> (target, method_name, method_args) do
136-
args = method_args.collect { |argument| "#{argument.class}::#{argument}" }.join
137-
"#{method_name} called on #{target} with #{args}"
154+
cacheable :star_count, key_format: -> (target, method_name, method_args) do
155+
[target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/')
138156
end
139157

140-
def my_method(arg1)
141-
158+
def star_count(repo)
159+
puts "Fetching data from GitHub for #{repo}"
160+
url = "https://api.github.com/repos/splitwise/#{repo}"
161+
162+
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
142163
end
143164
end
144165
```
145166

146-
* `target` is the object the method is being called on (`#<CustomKeyExample:0x0…0>`)
147-
* `method_name` is the name of the method being cached (`:my_method`)
148-
* `method_args` is an array of arguments being passed to the method (`[arg1]`)
167+
* `target` is the object the method is being called on (`#<GitHubApiAdapter:0x0…0>`)
168+
* `method_name` is the name of the method being cached (`:star_count`)
169+
* `method_args` is an array of arguments being passed to the method (`[params]`)
149170

150-
So if we called `CustomKeyExample.new.my_method(123)` we would get the cache key
171+
Including the method argument(s) allows you to cache different calls to the same method. Without the arguments in the cache key, a call to `star_count('cacheable')` would populate the cache and `star_count('tokenautocomplete')` would return the number of stars for Cacheable instead of what you want.
151172

152-
`"my_method called on #<CustomKeyExample:0x0…0> with Integer::123"`.
173+
In addition, we're including the current date in the cache key so calling this method tomorrow will return an updated value.
153174

154-
### Conditional Caching
175+
```irb
176+
> a = GitHubApiAdapter.new
177+
> a.star_count('cacheable')
178+
Fetching data from GitHub for cacheable
179+
=> 2
180+
> a.star_count('cacheable')
181+
=> 2
182+
> a.star_count('tokenautocomplete')
183+
Fetching data from GitHub for tokenautocomplete
184+
=> 1142
185+
> a.star_count('tokenautocomplete')
186+
=> 1142
187+
188+
# In this example the follow cache keys are generated:
189+
# GitHubApiAdapter/star_count/cacheable/2018-09-21
190+
# GitHubApiAdapter/star_count/tokenautocomplete/2018-09-21
191+
```
155192

156-
You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:`. Alternatively this method can be defined on the class and a symbol of the name of the method can be passed. **Note**: When using a symbol, the first argument will not be passed but will be available in the method as `self`. The following example will not cache the value if the first argument to the method is `false`.
193+
### Conditional Caching
157194

195+
You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:`. This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. **Note**: When using a symbol, the first argument, `target`, will not be passed but will be available as `self`.
158196

159197
```ruby
160-
class ConditionalCachingExample
198+
# From examples/conditional_example.rb
199+
200+
require 'cacheable'
201+
require 'json'
202+
require 'net/http'
203+
204+
class GitHubApiAdapter
161205
include Cacheable
162206

163-
cacheable :maybe_cache, unless: :should_not_cache?
207+
cacheable :star_count, unless: :growing_fast?, key_format: -> (target, method_name, method_args) do
208+
[target.class, method_name, method_args.first].join('/')
209+
end
210+
211+
def star_count(repo)
212+
puts "Fetching data from GitHub for #{repo}"
213+
url = "https://api.github.com/repos/splitwise/#{repo}"
164214

165-
def maybe_cache(cache)
166-
215+
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
167216
end
168217

169-
def should_not_cache?(_method_name, method_args)
170-
method_args.first == false
218+
def growing_fast?(_method_name, method_args)
219+
method_args.first == 'cacheable'
171220
end
172221
end
173222
```
174223

224+
Cacheable is new so we don't want to cache the number of stars it has as we expect it to change quickly.
225+
226+
```irb
227+
> a = GitHubApiAdapter.new
228+
> a.star_count('tokenautocomplete')
229+
Fetching data from GitHub for tokenautocomplete
230+
=> 1142
231+
a.star_count('tokenautocomplete')
232+
=> 1142
233+
234+
> a.star_count('cacheable')
235+
Fetching data from GitHub for cacheable
236+
=> 2
237+
> a.star_count('cacheable')
238+
Fetching data from GitHub for cacheable
239+
=> 2
240+
```
241+
175242
### Cache Options
176243

177-
If your cache backend supports options you can pass them as the `cache_options:` option. This will be passed though untouched to the cache's `fetch` method.
244+
If your cache backend supports options, you can pass them as the `cache_options:` option. This will be passed through untouched to the cache's `fetch` method.
178245

179246
```ruby
180247
cacheable :with_options, cache_options: {expires_in: 3_600}
@@ -191,26 +258,62 @@ cacheable :this_method_has_its_own_options, unless: unless_proc2
191258

192259
### Class Method Caching
193260

194-
You can cache class methods just as easily as a Ruby class is just an instance of `Class`. You simply need to `include Cacheable` within the `class << self` block. Methods can be defined in this block or outside using the `def self.` syntax.
261+
You can cache static (class) methods as well by including Cacheable in your class' [eigenclass](https://en.wikipedia.org/wiki/Metaclass#In_Ruby). This is because all Ruby classes are instances of the `Class` class. Understanding how Ruby's class structure works is powerful and useful, however, further explanation is beyond the scope of this README and not necessary to proceed.
262+
263+
Simply put `include Cacheable` and the `cacheable` directive within a `class << self` block as in the example below. The methods you want to cache can be defined in this block or outside using the `def self.#{method_name}` syntax.
195264

196265
```ruby
197-
class StaticMethodExample
266+
# From examples/class_method_example.rb
267+
268+
require 'cacheable'
269+
require 'json'
270+
require 'net/http'
271+
272+
class GitHubApiAdapter
198273
class << self
199274
include Cacheable
200275

201-
cacheable :class_method, :self_class_method
276+
cacheable :star_count_for_cacheable, :star_count_for_tokenautocomplete
277+
278+
def star_count_for_cacheable
279+
star_count('cacheable')
280+
end
281+
282+
private
202283

203-
def class_method
204-
puts 'class_method called'
284+
def star_count(repo)
285+
puts "Fetching data from GitHub for #{repo}"
286+
url = "https://api.github.com/repos/splitwise/#{repo}"
287+
288+
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
205289
end
206290
end
207291

208-
def self.self_class_method
209-
puts 'self_class_method called'
292+
def self.star_count_for_tokenautocomplete
293+
star_count('tokenautocomplete')
210294
end
211295
end
212296
```
213297

298+
```irb
299+
> GitHubApiAdapter.star_count_for_cacheable
300+
Fetching data from GitHub for cacheable
301+
=> 2
302+
> GitHubApiAdapter.star_count_for_cacheable
303+
=> 2
304+
305+
> GitHubApiAdapter.star_count_for_tokenautocomplete
306+
Fetching data from GitHub for tokenautocomplete
307+
=> 1142
308+
> GitHubApiAdapter.star_count_for_tokenautocomplete
309+
=> 1142
310+
```
311+
312+
### Other Notes / Frequently Asked Questions
313+
314+
- Q: How does Cacheable handle cache invalidation?
315+
- A: Cacheable takes Rails' cue and sidesteps the difficult problem of cache invalidation in favor of [key-based expiration](https://signalvnoise.com/posts/3113-how-key-based-cache-expiration-works). As DHH mentions in the blog post, `ActiveRecord`'s `cache_key` uses the `updated_at` timestamp so the cache is recalculated as the object changes. This results in new cache values being calculated, and your cache implementation can be configured to expire least recently used (LRU) values. In other applications, care must be taken to include a mechanism of key-based expiration in the `cache_key` method or [`key_format` proc](#set-your-own) or you risk serving stale data. Alternatively the generated [cache clearing](#remove-the-value-via-clear_method_cache) method can be used to explicitly invalidate the cache.
316+
214317
### Contributors (alphabetical by last name)
215318

216319
* [Jess Hottenstein](https://github.com/jhottenstein)

0 commit comments

Comments
 (0)