jashmenn-dalli 1.0.3
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +7 -0
- data/History.md +169 -0
- data/LICENSE +20 -0
- data/Performance.md +77 -0
- data/README.md +177 -0
- data/Rakefile +34 -0
- data/Upgrade.md +45 -0
- data/dalli.gemspec +33 -0
- data/lib/action_controller/session/dalli_store.rb +62 -0
- data/lib/action_dispatch/middleware/session/dalli_store.rb +67 -0
- data/lib/active_support/cache/dalli_store.rb +185 -0
- data/lib/active_support/cache/dalli_store23.rb +172 -0
- data/lib/dalli.rb +43 -0
- data/lib/dalli/client.rb +264 -0
- data/lib/dalli/compatibility.rb +52 -0
- data/lib/dalli/memcache-client.rb +1 -0
- data/lib/dalli/options.rb +44 -0
- data/lib/dalli/ring.rb +105 -0
- data/lib/dalli/server.rb +516 -0
- data/lib/dalli/socket.rb +37 -0
- data/lib/dalli/version.rb +3 -0
- data/test/abstract_unit.rb +284 -0
- data/test/benchmark_test.rb +170 -0
- data/test/helper.rb +54 -0
- data/test/memcached_mock.rb +106 -0
- data/test/test_active_support.rb +177 -0
- data/test/test_compatibility.rb +33 -0
- data/test/test_dalli.rb +398 -0
- data/test/test_encoding.rb +34 -0
- data/test/test_failover.rb +89 -0
- data/test/test_network.rb +54 -0
- data/test/test_ring.rb +89 -0
- data/test/test_sasl.rb +79 -0
- data/test/test_session_store.rb +230 -0
- metadata +145 -0
data/lib/dalli.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'dalli/client'
|
2
|
+
require 'dalli/ring'
|
3
|
+
require 'dalli/server'
|
4
|
+
require 'dalli/socket'
|
5
|
+
require 'dalli/version'
|
6
|
+
require 'dalli/options'
|
7
|
+
|
8
|
+
unless ''.respond_to?(:bytesize)
|
9
|
+
class String
|
10
|
+
alias_method :bytesize, :size
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module Dalli
|
15
|
+
# generic error
|
16
|
+
class DalliError < RuntimeError; end
|
17
|
+
# socket/server communication error
|
18
|
+
class NetworkError < DalliError; end
|
19
|
+
# no server available/alive error
|
20
|
+
class RingError < DalliError; end
|
21
|
+
# application error in marshalling
|
22
|
+
class MarshalError < DalliError; end
|
23
|
+
|
24
|
+
def self.logger
|
25
|
+
@logger ||= (rails_logger || default_logger)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.rails_logger
|
29
|
+
(defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
|
30
|
+
(defined?(RAILS_DEFAULT_LOGGER) && RAILS_DEFAULT_LOGGER.respond_to?(:debug) && RAILS_DEFAULT_LOGGER)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.default_logger
|
34
|
+
require 'logger'
|
35
|
+
l = Logger.new(STDOUT)
|
36
|
+
l.level = Logger::INFO
|
37
|
+
l
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.logger=(logger)
|
41
|
+
@logger = logger
|
42
|
+
end
|
43
|
+
end
|
data/lib/dalli/client.rb
ADDED
@@ -0,0 +1,264 @@
|
|
1
|
+
# encoding: ascii
|
2
|
+
module Dalli
|
3
|
+
class Client
|
4
|
+
|
5
|
+
##
|
6
|
+
# Dalli::Client is the main class which developers will use to interact with
|
7
|
+
# the memcached server. Usage:
|
8
|
+
#
|
9
|
+
# Dalli::Client.new(['localhost:11211:10', 'cache-2.example.com:11211:5', '192.168.0.1:22122:5'],
|
10
|
+
# :threadsafe => true, :failover => true, :expires_in => 300)
|
11
|
+
#
|
12
|
+
# servers is an Array of "host:port:weight" where weight allows you to distribute cache unevenly.
|
13
|
+
# Both weight and port are optional. If you pass in nil, Dalli will default to 'localhost:11211'.
|
14
|
+
# Note that the <tt>MEMCACHE_SERVERS</tt> environment variable will override the servers parameter for use
|
15
|
+
# in managed environments like Heroku.
|
16
|
+
#
|
17
|
+
# Options:
|
18
|
+
# - :failover - if a server is down, look for and store values on another server in the ring. Default: true.
|
19
|
+
# - :threadsafe - ensure that only one thread is actively using a socket at a time. Default: true.
|
20
|
+
# - :expires_in - default TTL in seconds if you do not pass TTL as a parameter to an individual operation, defaults to 0 or forever
|
21
|
+
# - :compression - defaults to false, if true Dalli will compress values larger than 100 bytes before
|
22
|
+
# sending them to memcached.
|
23
|
+
#
|
24
|
+
def initialize(servers=nil, options={})
|
25
|
+
@servers = env_servers || servers || 'localhost:11211'
|
26
|
+
@options = { :expires_in => 0 }.merge(options)
|
27
|
+
self.extend(Dalli::Client::MemcacheClientCompatibility) if Dalli::Client.compatibility_mode
|
28
|
+
@ring = nil
|
29
|
+
end
|
30
|
+
|
31
|
+
##
|
32
|
+
# Turn on compatibility mode, which mixes in methods in memcache_client_compatibility.rb
|
33
|
+
# This value is set to true in memcache-client.rb.
|
34
|
+
def self.compatibility_mode
|
35
|
+
@compatibility_mode ||= false
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.compatibility_mode=(compatibility_mode)
|
39
|
+
require 'dalli/compatibility'
|
40
|
+
@compatibility_mode = compatibility_mode
|
41
|
+
end
|
42
|
+
|
43
|
+
#
|
44
|
+
# The standard memcached instruction set
|
45
|
+
#
|
46
|
+
|
47
|
+
##
|
48
|
+
# Turn on quiet aka noreply support.
|
49
|
+
# All relevant operations within this block with be effectively
|
50
|
+
# pipelined as Dalli will use 'quiet' operations where possible.
|
51
|
+
# Currently supports the set, add, replace and delete operations.
|
52
|
+
def multi
|
53
|
+
old, Thread.current[:dalli_multi] = Thread.current[:dalli_multi], true
|
54
|
+
yield
|
55
|
+
ensure
|
56
|
+
Thread.current[:dalli_multi] = old
|
57
|
+
end
|
58
|
+
|
59
|
+
def get(key, options=nil)
|
60
|
+
resp = perform(:get, key)
|
61
|
+
(!resp || resp == 'Not found') ? nil : resp
|
62
|
+
end
|
63
|
+
|
64
|
+
##
|
65
|
+
# Fetch multiple keys efficiently.
|
66
|
+
# Returns a hash of { 'key' => 'value', 'key2' => 'value1' }
|
67
|
+
def get_multi(*keys)
|
68
|
+
return {} if keys.empty?
|
69
|
+
options = nil
|
70
|
+
options = keys.pop if keys.last.is_a?(Hash) || keys.last.nil?
|
71
|
+
ring.lock do
|
72
|
+
keys.flatten.each do |key|
|
73
|
+
perform(:getkq, key)
|
74
|
+
end
|
75
|
+
|
76
|
+
values = {}
|
77
|
+
ring.servers.each do |server|
|
78
|
+
next unless server.alive?
|
79
|
+
begin
|
80
|
+
server.request(:noop).each_pair do |key, value|
|
81
|
+
values[key_without_namespace(key)] = value
|
82
|
+
end
|
83
|
+
rescue NetworkError => e
|
84
|
+
Dalli.logger.debug { e.message }
|
85
|
+
Dalli.logger.debug { "results from this server will be missing" }
|
86
|
+
end
|
87
|
+
end
|
88
|
+
values
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def fetch(key, ttl=nil, options=nil)
|
93
|
+
ttl ||= @options[:expires_in]
|
94
|
+
val = get(key, options)
|
95
|
+
if val.nil? && block_given?
|
96
|
+
val = yield
|
97
|
+
add(key, val, ttl, options)
|
98
|
+
end
|
99
|
+
val
|
100
|
+
end
|
101
|
+
|
102
|
+
##
|
103
|
+
# compare and swap values using optimistic locking.
|
104
|
+
# Fetch the existing value for key.
|
105
|
+
# If it exists, yield the value to the block.
|
106
|
+
# Add the block's return value as the new value for the key.
|
107
|
+
# Add will fail if someone else changed the value.
|
108
|
+
#
|
109
|
+
# Returns:
|
110
|
+
# - nil if the key did not exist.
|
111
|
+
# - false if the value was changed by someone else.
|
112
|
+
# - true if the value was successfully updated.
|
113
|
+
def cas(key, ttl=nil, options=nil, &block)
|
114
|
+
ttl ||= @options[:expires_in]
|
115
|
+
(value, cas) = perform(:cas, key)
|
116
|
+
value = (!value || value == 'Not found') ? nil : value
|
117
|
+
if value
|
118
|
+
newvalue = block.call(value)
|
119
|
+
perform(:add, key, newvalue, ttl, cas, options)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def set(key, value, ttl=nil, options=nil)
|
124
|
+
raise "Invalid API usage, please require 'dalli/memcache-client' for compatibility, see Upgrade.md" if options == true
|
125
|
+
ttl ||= @options[:expires_in]
|
126
|
+
perform(:set, key, value, ttl, options)
|
127
|
+
end
|
128
|
+
|
129
|
+
##
|
130
|
+
# Conditionally add a key/value pair, if the key does not already exist
|
131
|
+
# on the server. Returns true if the operation succeeded.
|
132
|
+
def add(key, value, ttl=nil, options=nil)
|
133
|
+
ttl ||= @options[:expires_in]
|
134
|
+
perform(:add, key, value, ttl, 0, options)
|
135
|
+
end
|
136
|
+
|
137
|
+
##
|
138
|
+
# Conditionally add a key/value pair, only if the key already exists
|
139
|
+
# on the server. Returns true if the operation succeeded.
|
140
|
+
def replace(key, value, ttl=nil, options=nil)
|
141
|
+
ttl ||= @options[:expires_in]
|
142
|
+
perform(:replace, key, value, ttl, options)
|
143
|
+
end
|
144
|
+
|
145
|
+
def delete(key)
|
146
|
+
perform(:delete, key)
|
147
|
+
end
|
148
|
+
|
149
|
+
def append(key, value)
|
150
|
+
perform(:append, key, value.to_s)
|
151
|
+
end
|
152
|
+
|
153
|
+
def prepend(key, value)
|
154
|
+
perform(:prepend, key, value.to_s)
|
155
|
+
end
|
156
|
+
|
157
|
+
def flush(delay=0)
|
158
|
+
time = -delay
|
159
|
+
ring.servers.map { |s| s.request(:flush, time += delay) }
|
160
|
+
end
|
161
|
+
|
162
|
+
# deprecated, please use #flush.
|
163
|
+
alias_method :flush_all, :flush
|
164
|
+
|
165
|
+
##
|
166
|
+
# Incr adds the given amount to the counter on the memcached server.
|
167
|
+
# Amt must be a positive value.
|
168
|
+
#
|
169
|
+
# memcached counters are unsigned and cannot hold negative values. Calling
|
170
|
+
# decr on a counter which is 0 will just return 0.
|
171
|
+
#
|
172
|
+
# If default is nil, the counter must already exist or the operation
|
173
|
+
# will fail and will return nil. Otherwise this method will return
|
174
|
+
# the new value for the counter.
|
175
|
+
def incr(key, amt=1, ttl=nil, default=nil)
|
176
|
+
raise ArgumentError, "Positive values only: #{amt}" if amt < 0
|
177
|
+
ttl ||= @options[:expires_in]
|
178
|
+
perform(:incr, key, amt, ttl, default)
|
179
|
+
end
|
180
|
+
|
181
|
+
##
|
182
|
+
# Decr subtracts the given amount from the counter on the memcached server.
|
183
|
+
# Amt must be a positive value.
|
184
|
+
#
|
185
|
+
# memcached counters are unsigned and cannot hold negative values. Calling
|
186
|
+
# decr on a counter which is 0 will just return 0.
|
187
|
+
#
|
188
|
+
# If default is nil, the counter must already exist or the operation
|
189
|
+
# will fail and will return nil. Otherwise this method will return
|
190
|
+
# the new value for the counter.
|
191
|
+
def decr(key, amt=1, ttl=nil, default=nil)
|
192
|
+
raise ArgumentError, "Positive values only: #{amt}" if amt < 0
|
193
|
+
ttl ||= @options[:expires_in]
|
194
|
+
perform(:decr, key, amt, ttl, default)
|
195
|
+
end
|
196
|
+
|
197
|
+
##
|
198
|
+
# Collect the stats for each server.
|
199
|
+
# Returns a hash like { 'hostname:port' => { 'stat1' => 'value1', ... }, 'hostname2:port' => { ... } }
|
200
|
+
def stats
|
201
|
+
values = {}
|
202
|
+
ring.servers.each do |server|
|
203
|
+
values["#{server.hostname}:#{server.port}"] = server.alive? ? server.request(:stats) : nil
|
204
|
+
end
|
205
|
+
values
|
206
|
+
end
|
207
|
+
|
208
|
+
##
|
209
|
+
# Close our connection to each server.
|
210
|
+
# If you perform another operation after this, the connections will be re-established.
|
211
|
+
def close
|
212
|
+
if @ring
|
213
|
+
@ring.servers.map { |s| s.close }
|
214
|
+
@ring = nil
|
215
|
+
end
|
216
|
+
end
|
217
|
+
alias_method :reset, :close
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
def ring
|
222
|
+
@ring ||= Dalli::Ring.new(
|
223
|
+
Array(@servers).map do |s|
|
224
|
+
Dalli::Server.new(s, @options)
|
225
|
+
end, @options
|
226
|
+
)
|
227
|
+
end
|
228
|
+
|
229
|
+
def env_servers
|
230
|
+
ENV['MEMCACHE_SERVERS'] ? ENV['MEMCACHE_SERVERS'].split(',') : nil
|
231
|
+
end
|
232
|
+
|
233
|
+
# Chokepoint method for instrumentation
|
234
|
+
def perform(op, key, *args)
|
235
|
+
key = key.to_s
|
236
|
+
validate_key(key)
|
237
|
+
key = key_with_namespace(key)
|
238
|
+
begin
|
239
|
+
server = ring.server_for_key(key)
|
240
|
+
server.request(op, key, *args)
|
241
|
+
rescue NetworkError => e
|
242
|
+
Dalli.logger.debug { e.message }
|
243
|
+
Dalli.logger.debug { "retrying request with new server" }
|
244
|
+
retry
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
def validate_key(key)
|
249
|
+
raise ArgumentError, "illegal character in key #{key}" if key.respond_to?(:ascii_only?) && !key.ascii_only?
|
250
|
+
raise ArgumentError, "illegal character in key #{key}" if key =~ /\s/
|
251
|
+
raise ArgumentError, "illegal character in key #{key}" if key =~ /[\x00-\x20\x80-\xFF]/
|
252
|
+
raise ArgumentError, "key cannot be blank" if key.nil? || key.strip.size == 0
|
253
|
+
raise ArgumentError, "key too long #{key.inspect}" if key.length > 250
|
254
|
+
end
|
255
|
+
|
256
|
+
def key_with_namespace(key)
|
257
|
+
@options[:namespace] ? "#{@options[:namespace]}:#{key}" : key
|
258
|
+
end
|
259
|
+
|
260
|
+
def key_without_namespace(key)
|
261
|
+
@options[:namespace] ? key.gsub(%r(\A#{@options[:namespace]}:), '') : key
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
class Dalli::Client
|
2
|
+
|
3
|
+
module MemcacheClientCompatibility
|
4
|
+
|
5
|
+
def initialize(*args)
|
6
|
+
Dalli.logger.error("Starting Dalli in memcache-client compatibility mode")
|
7
|
+
super(*args)
|
8
|
+
end
|
9
|
+
|
10
|
+
def set(key, value, ttl = nil, options = nil)
|
11
|
+
if options == true || options == false
|
12
|
+
Dalli.logger.error("Dalli: please use set(key, value, ttl, :raw => boolean): #{caller[0]}")
|
13
|
+
options = { :raw => options }
|
14
|
+
end
|
15
|
+
super(key, value, ttl, options) ? "STORED\r\n" : "NOT_STORED\r\n"
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
def add(key, value, ttl = nil, options = nil)
|
20
|
+
if options == true || options == false
|
21
|
+
Dalli.logger.error("Dalli: please use add(key, value, ttl, :raw => boolean): #{caller[0]}")
|
22
|
+
options = { :raw => options }
|
23
|
+
end
|
24
|
+
super(key, value, ttl, options) ? "STORED\r\n" : "NOT_STORED\r\n"
|
25
|
+
end
|
26
|
+
|
27
|
+
def replace(key, value, ttl = nil, options = nil)
|
28
|
+
if options == true || options == false
|
29
|
+
Dalli.logger.error("Dalli: please use replace(key, value, ttl, :raw => boolean): #{caller[0]}")
|
30
|
+
options = { :raw => options }
|
31
|
+
end
|
32
|
+
super(key, value, ttl, options) ? "STORED\r\n" : "NOT_STORED\r\n"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Dalli does not unmarshall data that does not have the marshalled flag set so we need
|
36
|
+
# to unmarshall manually any marshalled data originally put in memcached by memcache-client.
|
37
|
+
# Peek at the data and see if it looks marshalled.
|
38
|
+
def get(key, options = nil)
|
39
|
+
value = super(key, options)
|
40
|
+
if value && value.is_a?(String) && !options && value.size > 2 &&
|
41
|
+
bytes = value.unpack('cc') && bytes[0] == 4 && bytes[1] == 8
|
42
|
+
return Marshal.load(value) rescue value
|
43
|
+
end
|
44
|
+
value
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete(key)
|
48
|
+
super(key) ? "DELETED\r\n" : "NOT_DELETED\r\n"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
Dalli::Client.compatibility_mode = true
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'monitor'
|
3
|
+
|
4
|
+
module Dalli
|
5
|
+
|
6
|
+
# Make Dalli threadsafe by using a lock around all
|
7
|
+
# public server methods.
|
8
|
+
#
|
9
|
+
# Dalli::Server.extend(Dalli::Threadsafe)
|
10
|
+
#
|
11
|
+
module Threadsafe
|
12
|
+
def request(op, *args)
|
13
|
+
lock.synchronize do
|
14
|
+
super
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def alive?
|
19
|
+
lock.synchronize do
|
20
|
+
super
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def close
|
25
|
+
lock.synchronize do
|
26
|
+
super
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def lock!
|
31
|
+
lock.mon_enter
|
32
|
+
end
|
33
|
+
|
34
|
+
def unlock!
|
35
|
+
lock.mon_exit
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
def lock
|
40
|
+
@lock ||= Monitor.new
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
data/lib/dalli/ring.rb
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'digest/sha1'
|
2
|
+
require 'zlib'
|
3
|
+
|
4
|
+
module Dalli
|
5
|
+
class Ring
|
6
|
+
POINTS_PER_SERVER = 160 # this is the default in libmemcached
|
7
|
+
|
8
|
+
attr_accessor :servers, :continuum
|
9
|
+
|
10
|
+
def initialize(servers, options)
|
11
|
+
@servers = servers
|
12
|
+
@continuum = nil
|
13
|
+
if servers.size > 1
|
14
|
+
total_weight = servers.inject(0) { |memo, srv| memo + srv.weight }
|
15
|
+
continuum = []
|
16
|
+
servers.each do |server|
|
17
|
+
entry_count_for(server, servers.size, total_weight).times do |idx|
|
18
|
+
hash = Digest::SHA1.hexdigest("#{server.hostname}:#{server.port}:#{idx}")
|
19
|
+
value = Integer("0x#{hash[0..7]}")
|
20
|
+
continuum << Dalli::Ring::Entry.new(value, server)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
@continuum = continuum.sort { |a, b| a.value <=> b.value }
|
24
|
+
end
|
25
|
+
|
26
|
+
threadsafe! unless options[:threadsafe] == false
|
27
|
+
@failover = options[:failover] != false
|
28
|
+
end
|
29
|
+
|
30
|
+
def server_for_key(key)
|
31
|
+
if @continuum
|
32
|
+
hkey = hash_for(key)
|
33
|
+
20.times do |try|
|
34
|
+
entryidx = self.class.binary_search(@continuum, hkey)
|
35
|
+
server = @continuum[entryidx].server
|
36
|
+
return server if server.alive?
|
37
|
+
break unless @failover
|
38
|
+
hkey = hash_for("#{try}#{key}")
|
39
|
+
end
|
40
|
+
else
|
41
|
+
server = @servers.first
|
42
|
+
return server if server && server.alive?
|
43
|
+
end
|
44
|
+
|
45
|
+
raise Dalli::RingError, "No server available"
|
46
|
+
end
|
47
|
+
|
48
|
+
def lock
|
49
|
+
@servers.each { |s| s.lock! }
|
50
|
+
begin
|
51
|
+
return yield
|
52
|
+
ensure
|
53
|
+
@servers.each { |s| s.unlock! }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def threadsafe!
|
60
|
+
@servers.each do |s|
|
61
|
+
s.extend(Dalli::Threadsafe)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def hash_for(key)
|
66
|
+
Zlib.crc32(key)
|
67
|
+
end
|
68
|
+
|
69
|
+
def entry_count_for(server, total_servers, total_weight)
|
70
|
+
((total_servers * POINTS_PER_SERVER * server.weight) / Float(total_weight)).floor
|
71
|
+
end
|
72
|
+
|
73
|
+
# Find the closest index in the Ring with value <= the given value
|
74
|
+
def self.binary_search(ary, value)
|
75
|
+
upper = ary.size - 1
|
76
|
+
lower = 0
|
77
|
+
idx = 0
|
78
|
+
|
79
|
+
while (lower <= upper) do
|
80
|
+
idx = (lower + upper) / 2
|
81
|
+
comp = ary[idx].value <=> value
|
82
|
+
|
83
|
+
if comp == 0
|
84
|
+
return idx
|
85
|
+
elsif comp > 0
|
86
|
+
upper = idx - 1
|
87
|
+
else
|
88
|
+
lower = idx + 1
|
89
|
+
end
|
90
|
+
end
|
91
|
+
return upper
|
92
|
+
end
|
93
|
+
|
94
|
+
class Entry
|
95
|
+
attr_reader :value
|
96
|
+
attr_reader :server
|
97
|
+
|
98
|
+
def initialize(val, srv)
|
99
|
+
@value = val
|
100
|
+
@server = srv
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
end
|