readthis 0.8.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ea629e7d22f58baa04359d6fabaee9116caf4e5d
4
- data.tar.gz: ff5720742c337be8b178ca840064b3469766dcda
3
+ metadata.gz: 985ab4cb70b6ff45de0b279fe4d3ca90690cb206
4
+ data.tar.gz: d3c0519b74366c12278dc2a554d0a33c2914e7ac
5
5
  SHA512:
6
- metadata.gz: b75d0eb316bb68975693ab64044cc3c30159b9511dde446b504e1075b5c6aa915bc3782290334001de3b921173714343c86110778bc41ec90f67dc84b9ef0704
7
- data.tar.gz: 765fae41b2f8d511cd99269e17ed31eacbb968239e97be78422a4b20793ae3325feefc2f19d411b6d0a17722a7b06bbba78230fe49e12c76e01edabb9fc93b43
6
+ metadata.gz: e0cec4bef654487ed5f99c46f27d05572993a823239d1993b6e0b534f0b699f170c773ef191cddf468e1b05936c588d23ac6716ddf7659c89a86710306f13c5b
7
+ data.tar.gz: 5f8ac666204946a58135953c5e4b23d64a12f412019d578b28c8b8b1a7c351c1004775c0470a812038b12d2577b87eec5df8a4e09f628e5fbb1bed3fc008517a
data/README.md CHANGED
@@ -52,7 +52,11 @@ cache = Readthis::Cache.new(
52
52
  )
53
53
  ```
54
54
 
55
+ You can also specify `host`, `port`, `db` or any other valid Redis options. For
56
+ more details about connection options see in [redis gem documentation][redisrb]
57
+
55
58
  [store]: http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html
59
+ [redisrb]: https://github.com/redis/redis-rb#getting-started
56
60
 
57
61
  ### Instances & Databases
58
62
 
@@ -96,25 +100,43 @@ config.cache_store = :readthis_store, {
96
100
  }
97
101
  ```
98
102
 
99
- ### Marshalling
103
+ ### Serializing
100
104
 
101
- Readthis uses Ruby's `Marshal` module for dumping and loading all values by
102
- default. This isn't always the fastest option, and depending on your use case it
103
- may be desirable to use a faster but less flexible marshaller.
105
+ Readthis uses Ruby's `Marshal` module for serializing all values by default.
106
+ This isn't always the fastest option, and depending on your use case it may be
107
+ desirable to use a faster but less flexible serializer.
104
108
 
105
- Use Oj for JSON marshalling, extremely fast, but supports limited types:
109
+ By default Readthis knows about 3 different serializers:
106
110
 
107
- ```ruby
108
- Readthis::Cache.new(marshal: Oj)
109
- ```
111
+ * Marshal
112
+ * JSON
113
+ * Passthrough
110
114
 
111
115
  If all cached data can safely be represented as a string then use the
112
- pass-through marshaller:
116
+ pass-through serializer:
113
117
 
114
118
  ```ruby
115
119
  Readthis::Cache.new(marshal: Readthis::Passthrough)
116
120
  ```
117
121
 
122
+ You can introduce up to four additional serializers by configuring `serializers`
123
+ on the Readthis module. For example, if you wanted to use the extremely fast Oj
124
+ library for JSON serialization:
125
+
126
+ ```ruby
127
+ Readthis.serializers << Oj
128
+
129
+ # Freeze the serializers to ensure they aren't changed at runtime.
130
+ Readthis.serializers.freeze!
131
+
132
+ Readthis::Cache.new(marshal: Oj)
133
+ ```
134
+
135
+ Be aware that the order in which you add serializers matters. Serializers are
136
+ sticky and a flag is stored with each cached value. If you subsequently go to
137
+ deserialize values and haven't configured the same serializers in the same order
138
+ your application will raise errors.
139
+
118
140
  ## Differences From ActiveSupport::Cache
119
141
 
120
142
  Readthis supports all of standard cache methods except for the following:
@@ -2,6 +2,6 @@ require 'readthis'
2
2
 
3
3
  module ActiveSupport
4
4
  module Cache
5
- ReadthisStore ||= Readthis::Cache
5
+ ReadthisStore ||= Readthis::Cache # rubocop:disable Style/ConstantName
6
6
  end
7
7
  end
@@ -1,24 +1,19 @@
1
1
  require 'readthis/entity'
2
2
  require 'readthis/expanders'
3
- require 'readthis/notifications'
4
3
  require 'readthis/passthrough'
5
4
  require 'redis'
6
5
  require 'connection_pool'
7
6
 
8
7
  module Readthis
9
8
  class Cache
10
- attr_reader :entity, :expires_in, :namespace, :options, :pool
9
+ attr_reader :entity, :notifications, :options, :pool
11
10
 
12
11
  # Provide a class level lookup of the proper notifications module.
13
12
  # Instrumention is expected to occur within applications that have
14
13
  # ActiveSupport::Notifications available, but needs to work even when it
15
14
  # isn't.
16
15
  def self.notifications
17
- if defined?(ActiveSupport::Notifications)
18
- ActiveSupport::Notifications
19
- else
20
- Readthis::Notifications
21
- end
16
+ ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
22
17
  end
23
18
 
24
19
  # Creates a new Readthis::Cache object with the given options.
@@ -39,9 +34,7 @@ module Readthis
39
34
  # Readthis::Cache.new(compress: true, compression_threshold: 2048)
40
35
  #
41
36
  def initialize(options = {})
42
- @options = options
43
- @expires_in = options.fetch(:expires_in, nil)
44
- @namespace = options.fetch(:namespace, nil)
37
+ @options = options
45
38
 
46
39
  @entity = Readthis::Entity.new(
47
40
  marshal: options.fetch(:marshal, Marshal),
@@ -171,7 +164,7 @@ module Readthis
171
164
  # cache.increment('counter', 2) # => 3
172
165
  #
173
166
  def increment(key, amount = 1, options = {})
174
- invoke(:incremenet, key) do |store|
167
+ invoke(:increment, key) do |_store|
175
168
  alter(key, amount, options)
176
169
  end
177
170
  end
@@ -192,7 +185,7 @@ module Readthis
192
185
  # cache.decrement('counter', 2) # => 17
193
186
  #
194
187
  def decrement(key, amount = 1, options = {})
195
- invoke(:decrement, key) do |store|
188
+ invoke(:decrement, key) do |_store|
196
189
  alter(key, amount * -1, options)
197
190
  end
198
191
  end
@@ -271,13 +264,13 @@ module Readthis
271
264
  extracted = extract_options!(keys)
272
265
  missing = {}
273
266
 
274
- invoke(:fetch_multi, keys) do |store|
267
+ invoke(:fetch_multi, keys) do |_store|
275
268
  results.each do |key, value|
276
- if value.nil?
277
- value = yield(key)
278
- missing[key] = value
279
- results[key] = value
280
- end
269
+ next unless value.nil?
270
+
271
+ value = yield(key)
272
+ missing[key] = value
273
+ results[key] = value
281
274
  end
282
275
  end
283
276
 
@@ -310,7 +303,7 @@ module Readthis
310
303
  # @example
311
304
  #
312
305
  # cache.clear #=> 'OK'
313
- def clear(options = {})
306
+ def clear(_options = nil)
314
307
  invoke(:clear, '*', &:flushdb)
315
308
  end
316
309
 
@@ -318,11 +311,12 @@ module Readthis
318
311
 
319
312
  def write_entity(key, value, store, options)
320
313
  namespaced = namespaced_key(key, options)
314
+ dumped = entity.dump(value, options)
321
315
 
322
316
  if expiration = options[:expires_in]
323
- store.setex(namespaced, expiration.to_i, entity.dump(value))
317
+ store.setex(namespaced, expiration.to_i, dumped)
324
318
  else
325
- store.set(namespaced, entity.dump(value))
319
+ store.set(namespaced, dumped)
326
320
  end
327
321
  end
328
322
 
@@ -335,11 +329,15 @@ module Readthis
335
329
  delta
336
330
  end
337
331
 
338
- def instrument(operation, key)
339
- name = "cache_#{operation}.active_support"
340
- payload = { key: key }
332
+ def instrument(name, key)
333
+ if self.class.notifications
334
+ name = "cache_#{name}.active_support"
335
+ payload = { key: key, name: name }
341
336
 
342
- self.class.notifications.instrument(name, key) { yield(payload) }
337
+ self.class.notifications.instrument(name, payload) { yield(payload) }
338
+ else
339
+ yield
340
+ end
343
341
  end
344
342
 
345
343
  def invoke(operation, key, &block)
@@ -353,10 +351,7 @@ module Readthis
353
351
  end
354
352
 
355
353
  def merged_options(options)
356
- options = options || {}
357
- options[:namespace] ||= namespace
358
- options[:expires_in] ||= expires_in
359
- options
354
+ (options || {}).merge!(@options)
360
355
  end
361
356
 
362
357
  def pool_options(options)
@@ -2,51 +2,143 @@ require 'zlib'
2
2
 
3
3
  module Readthis
4
4
  class Entity
5
- DEFAULT_THRESHOLD = 8 * 1024
6
- MAGIC_BYTES = [120, 156].freeze
5
+ DEFAULT_OPTIONS = {
6
+ compress: false,
7
+ marshal: Marshal,
8
+ threshold: 8 * 1024
9
+ }.freeze
7
10
 
8
- attr_reader :marshal, :compression, :threshold
11
+ COMPRESSED_FLAG = 0x8
9
12
 
13
+ # Creates a Readthis::Entity with default options. Each option can be
14
+ # overridden later when entities are being dumped.
15
+ #
16
+ # Options are sticky, meaning that whatever is used when dumping will
17
+ # automatically be used again when loading, regardless of how current
18
+ # options are set.
19
+ #
20
+ # @option [Boolean] :compress (false) Enable or disable automatic compression
21
+ # @option [Module] :marshal (Marshal) Any module that responds to `dump` and `load`
22
+ # @option [Number] :threshold (8k) The size a string must be for compression
23
+ #
10
24
  def initialize(options = {})
11
- @marshal = options.fetch(:marshal, Marshal)
12
- @compression = options.fetch(:compress, false)
13
- @threshold = options.fetch(:threshold, DEFAULT_THRESHOLD)
25
+ @options = DEFAULT_OPTIONS.merge(options)
14
26
  end
15
27
 
16
- def dump(value)
17
- if compress?(value)
18
- compress(value)
19
- else
20
- marshal.dump(value)
21
- end
28
+ # Output a value prepared for cache storage. Passed options will override
29
+ # whatever has been specified for the instance.
30
+ #
31
+ # @param [String] String to dump
32
+ # @option [Boolean] :compress Enable or disable automatic compression
33
+ # @option [Module] :marshal Any module that responds to `dump` and `load`
34
+ # @option [Number] :threshold The size a string must be for compression
35
+ # @return [String] The prepared, possibly compressed, string
36
+ #
37
+ # @example Dumping a value using defaults
38
+ #
39
+ # entity.dump(string)
40
+ #
41
+ # @example Dumping a value with overrides
42
+ #
43
+ # entity.dump(string, compress: false)
44
+ #
45
+ def dump(value, options = {})
46
+ compress = with_fallback(options, :compress)
47
+ marshal = with_fallback(options, :marshal)
48
+ threshold = with_fallback(options, :threshold)
49
+
50
+ dumped = deflate(marshal.dump(value), compress, threshold)
51
+
52
+ compose(dumped, marshal, compress)
22
53
  end
23
54
 
24
- def load(value)
25
- if compressed?(value)
26
- decompress(value)
27
- else
28
- marshal.load(value)
29
- end
30
- rescue TypeError, Zlib::Error
31
- value
55
+ # Parse a dumped value using the embedded options.
56
+ #
57
+ # @param [String] Option embedded string to load
58
+ # @return [String] The original dumped string, restored
59
+ #
60
+ # @example
61
+ #
62
+ # entity.load(dumped)
63
+ #
64
+ def load(string)
65
+ marshal, compress, value = decompose(string)
66
+
67
+ marshal.load(inflate(value, compress))
68
+ rescue TypeError, NoMethodError
69
+ string
32
70
  end
33
71
 
34
- def compress(value)
35
- Zlib::Deflate.deflate(marshal.dump(value))
72
+ # Composes a single byte comprised of the chosen serializer and compression
73
+ # options. The byte is formatted as:
74
+ #
75
+ # | 0000 | 0 | 000 |
76
+ #
77
+ # Where there are four unused bits, 1 compression bit, and 3 bits for the
78
+ # serializer. This allows up to 8 different serializers for marshaling.
79
+ #
80
+ # @param [String] String to prefix with flags
81
+ # @param [Module] The marshal module to be used
82
+ # @param [Boolean] Flag determining whether the value is compressed
83
+ # @return [String] The original string with a single byte prefixed
84
+ #
85
+ # @example Compose an option embedded string
86
+ #
87
+ # entity.compose(string, Marshal, false) => 0x1 + string
88
+ # entity.compose(string, JSON, true) => 0x10 + string
89
+ #
90
+ def compose(value, marshal, compress)
91
+ flags = serializers.assoc(marshal)
92
+ flags |= COMPRESSED_FLAG if compress
93
+
94
+ value.prepend([flags].pack('C'))
36
95
  end
37
96
 
38
- def decompress(value)
39
- marshal.load(Zlib::Inflate.inflate(value))
97
+ # Decompose an option embedded string into marshal, compression and value.
98
+ #
99
+ # @param [String] Option embedded string to
100
+ # @return [Array<Module, Boolean, String>] An array comprised of the
101
+ # marshal, compression flag, and the base string.
102
+ #
103
+ def decompose(string)
104
+ flags = string[0].unpack('C').first
105
+
106
+ if flags < 16
107
+ marshal = serializers.rassoc(flags)
108
+ compress = (flags & COMPRESSED_FLAG) != 0
109
+
110
+ [marshal, compress, string[1..-1]]
111
+ else
112
+ [@options[:marshal], @options[:compress], string]
113
+ end
40
114
  end
41
115
 
42
116
  private
43
117
 
44
- def compress?(value)
45
- compression && value.bytesize >= threshold
118
+ def deflate(value, compress, threshold)
119
+ if compress && value.bytesize >= threshold
120
+ Zlib::Deflate.deflate(value)
121
+ else
122
+ value
123
+ end
124
+ end
125
+
126
+ def inflate(value, decompress)
127
+ if decompress
128
+ Zlib::Inflate.inflate(value)
129
+ else
130
+ value
131
+ end
132
+ rescue Zlib::Error
133
+ value
134
+ end
135
+
136
+ def serializers
137
+ Readthis.serializers
46
138
  end
47
139
 
48
- def compressed?(value)
49
- compression && value[0, 2].unpack('CC') == MAGIC_BYTES
140
+ def with_fallback(options, key)
141
+ options.key?(key) ? options[key] : @options[key]
50
142
  end
51
143
  end
52
144
  end
@@ -7,7 +7,7 @@ module Readthis
7
7
  when key.is_a?(Array)
8
8
  key.flat_map { |elem| expand_key(elem) }.join('/')
9
9
  when key.is_a?(Hash)
10
- key.sort_by { |key, _| key.to_s }.map { |key, val| "#{key}=#{val}" }.join('/')
10
+ key.sort_by { |hkey, _| hkey.to_s }.map { |hkey, val| "#{hkey}=#{val}" }.join('/')
11
11
  when key.respond_to?(:to_param)
12
12
  key.to_param
13
13
  else
@@ -0,0 +1,111 @@
1
+ require 'json'
2
+ require 'readthis/passthrough'
3
+
4
+ module Readthis
5
+ SerializersFrozenError = Class.new(StandardError)
6
+ SerializersLimitError = Class.new(StandardError)
7
+ UnknownSerializerError = Class.new(StandardError)
8
+
9
+ class Serializers
10
+ BASE_SERIALIZERS = {
11
+ Marshal => 0x1,
12
+ Passthrough => 0x2,
13
+ JSON => 0x3
14
+ }.freeze
15
+
16
+ SERIALIZER_LIMIT = 7
17
+
18
+ attr_reader :serializers, :inverted
19
+
20
+ # Creates a new Readthis::Serializers entity. No configuration is expected
21
+ # during initialization.
22
+ #
23
+ def initialize
24
+ reset!
25
+ end
26
+
27
+ # Append a new serializer. Up to 7 total serializers may be configured for
28
+ # any single application be configured for any single application. This
29
+ # limit is based on the number of bytes available in the option flag.
30
+ #
31
+ # @param [Module] Any object that responds to `dump` and `load`
32
+ # @return [self] Returns itself for possible chaining
33
+ #
34
+ # @example
35
+ #
36
+ # serializers = Readthis::Serializers.new
37
+ # serializers << Oj
38
+ #
39
+ def <<(serializer)
40
+ case
41
+ when serializers.frozen?
42
+ fail SerializersFrozenError
43
+ when serializers.length > SERIALIZER_LIMIT
44
+ fail SerializersLimitError
45
+ else
46
+ @serializers[serializer] = flags.max.succ
47
+ @inverted = @serializers.invert
48
+ end
49
+
50
+ self
51
+ end
52
+
53
+ # Freeze the serializers hash, preventing modification.
54
+ #
55
+ def freeze!
56
+ serializers.freeze
57
+ end
58
+
59
+ # Reset the instance back to the default state. Useful for cleanup during
60
+ # testing.
61
+ #
62
+ def reset!
63
+ @serializers = BASE_SERIALIZERS.dup
64
+ @inverted = @serializers.invert
65
+ end
66
+
67
+ # Find a flag for a serializer object.
68
+ #
69
+ # @param [Object] Look up a flag by object
70
+ # @return [Number] Corresponding flag for the serializer object
71
+ # @raise [UnknownSerializerError] Indicates that a serializer was
72
+ # specified, but hasn't been configured for usage.
73
+ #
74
+ # @example
75
+ #
76
+ # serializers.assoc(JSON) #=> 1
77
+ #
78
+ def assoc(serializer)
79
+ flag = serializers[serializer]
80
+
81
+ unless flag
82
+ fail UnknownSerializerError, "'#{serializer}' hasn't been configured"
83
+ end
84
+
85
+ flag
86
+ end
87
+
88
+ # Find a serializer object by flag value.
89
+ #
90
+ # @param [Number] Flag to look up the serializer object by
91
+ # @return [Module] The serializer object
92
+ #
93
+ # @example
94
+ #
95
+ # serializers.rassoc(1) #=> Marshal
96
+ #
97
+ def rassoc(flag)
98
+ inverted[flag & inverted.length]
99
+ end
100
+
101
+ # @private
102
+ def marshals
103
+ serializers.keys
104
+ end
105
+
106
+ # @private
107
+ def flags
108
+ serializers.values
109
+ end
110
+ end
111
+ end
@@ -1,3 +1,3 @@
1
1
  module Readthis
2
- VERSION = '0.8.1'
2
+ VERSION = '1.0.0'
3
3
  end
data/lib/readthis.rb CHANGED
@@ -1,5 +1,11 @@
1
1
  require 'readthis/cache'
2
+ require 'readthis/serializers'
2
3
  require 'readthis/version'
3
4
 
4
5
  module Readthis
6
+ extend self
7
+
8
+ def serializers
9
+ @serializers ||= Readthis::Serializers.new
10
+ end
5
11
  end
@@ -1,4 +1,4 @@
1
- require 'readthis/cache'
1
+ require 'readthis'
2
2
 
3
3
  RSpec.describe Readthis::Cache do
4
4
  let(:cache) { Readthis::Cache.new }
@@ -8,18 +8,6 @@ RSpec.describe Readthis::Cache do
8
8
  end
9
9
 
10
10
  describe '#initialize' do
11
- it 'accepts and persists a namespace' do
12
- cache = Readthis::Cache.new(namespace: 'kash')
13
-
14
- expect(cache.namespace).to eq('kash')
15
- end
16
-
17
- it 'accepts and persists an expiration' do
18
- cache = Readthis::Cache.new(expires_in: 10)
19
-
20
- expect(cache.expires_in).to eq(10)
21
- end
22
-
23
11
  it 'makes options available' do
24
12
  cache = Readthis::Cache.new(namespace: 'cache', expires_in: 1)
25
13
 
@@ -82,19 +70,52 @@ RSpec.describe Readthis::Cache do
82
70
  end
83
71
  end
84
72
 
73
+ describe 'serializers' do
74
+ after do
75
+ Readthis.serializers.reset!
76
+ end
77
+
78
+ it 'uses globally configured serializers' do
79
+ custom = Class.new do
80
+ def self.dump(value)
81
+ value
82
+ end
83
+
84
+ def self.load(value)
85
+ value
86
+ end
87
+ end
88
+
89
+ Readthis.serializers << custom
90
+
91
+ cache.write('customized', 'some value', marshal: custom)
92
+
93
+ expect(cache.read('customized')).to eq('some value')
94
+ end
95
+ end
96
+
85
97
  describe 'compression' do
86
- it 'round trips entries when compression is enabled' do
98
+ it 'roundtrips entries when compression is enabled' do
87
99
  com_cache = Readthis::Cache.new(compress: true, compression_threshold: 8)
88
100
  raw_cache = Readthis::Cache.new
89
101
  value = 'enough text that it should be compressed'
90
102
 
91
103
  com_cache.write('compressed', value)
92
104
 
93
- expect(raw_cache.read('compressed')).not_to eq(value)
94
105
  expect(com_cache.read('compressed')).to eq(value)
106
+ expect(raw_cache.read('compressed')).to eq(value)
107
+ end
108
+
109
+ it 'roundtrips entries with option overrides' do
110
+ cache = Readthis::Cache.new(compress: false)
111
+ value = 'enough text that it should be compressed'
112
+
113
+ cache.write('comp-round', value, marshal: JSON, compress: true, threshold: 8)
114
+
115
+ expect(cache.read('comp-round')).to eq(value)
95
116
  end
96
117
 
97
- it 'round trips bulk entries when compression is enabled' do
118
+ it 'roundtrips bulk entries when compression is enabled' do
98
119
  cache = Readthis::Cache.new(compress: true, compression_threshold: 8)
99
120
  value = 'also enough text to compress'
100
121
 
@@ -146,7 +167,7 @@ RSpec.describe Readthis::Cache do
146
167
  expect(cache.read_multi('a', 'b', 'c')).to eq(
147
168
  'a' => 1,
148
169
  'b' => 2,
149
- 'c' => '3',
170
+ 'c' => '3'
150
171
  )
151
172
  end
152
173
 
@@ -156,7 +177,7 @@ RSpec.describe Readthis::Cache do
156
177
 
157
178
  expect(cache.read_multi('d', 'e', namespace: 'cache')).to eq(
158
179
  'd' => 1,
159
- 'e' => 2,
180
+ 'e' => 2
160
181
  )
161
182
  end
162
183
 
@@ -175,7 +196,11 @@ RSpec.describe Readthis::Cache do
175
196
  end
176
197
 
177
198
  it 'respects passed options' do
178
- cache.write_multi({ 'a' => 1, 'b' => 2 }, namespace: 'multi', expires_in: 1)
199
+ cache.write_multi(
200
+ { 'a' => 1, 'b' => 2 },
201
+ namespace: 'multi',
202
+ expires_in: 1
203
+ )
179
204
 
180
205
  expect(cache.read('a')).to be_nil
181
206
  expect(cache.read('a', namespace: 'multi')).to eq(1)
@@ -194,7 +219,7 @@ RSpec.describe Readthis::Cache do
194
219
  expect(results).to eq(
195
220
  'a' => 1,
196
221
  'b' => 'bb',
197
- 'c' => 3,
222
+ 'c' => 3
198
223
  )
199
224
 
200
225
  expect(cache.read('b')).to eq('bb')
@@ -266,4 +291,27 @@ RSpec.describe Readthis::Cache do
266
291
  expect(cache.decrement('unknown')).to eq(-1)
267
292
  end
268
293
  end
294
+
295
+ describe 'instrumentation' do
296
+ it 'instruments cache invokations' do
297
+ require 'active_support/notifications'
298
+
299
+ notes = ActiveSupport::Notifications
300
+ cache = Readthis::Cache.new
301
+ events = []
302
+
303
+ notes.subscribe(/cache_*/) do |*args|
304
+ events << ActiveSupport::Notifications::Event.new(*args)
305
+ end
306
+
307
+ cache.write('a', 'a')
308
+ cache.read('a')
309
+
310
+ expect(events.length).to eq(2)
311
+ expect(events.map(&:name)).to eq(%w[
312
+ cache_write.active_support
313
+ cache_read.active_support
314
+ ])
315
+ end
316
+ end
269
317
  end