shoryuken 0.0.5 → 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 +4 -4
- data/.gitignore +1 -0
- data/.hound.yml +2 -0
- data/.travis.yml +2 -1
- data/Gemfile +2 -0
- data/README.md +20 -8
- data/Rakefile +2 -2
- data/examples/bootstrap_queues.rb +17 -9
- data/lib/shoryuken.rb +27 -3
- data/lib/shoryuken/cli.rb +18 -126
- data/lib/shoryuken/client.rb +26 -12
- data/lib/shoryuken/environment_loader.rb +167 -0
- data/lib/shoryuken/extensions/active_job_adapter.rb +12 -4
- data/lib/shoryuken/fetcher.rb +6 -7
- data/lib/shoryuken/manager.rb +6 -2
- data/lib/shoryuken/middleware/server/auto_delete.rb +7 -1
- data/lib/shoryuken/middleware/server/timing.rb +2 -2
- data/lib/shoryuken/processor.rb +6 -4
- data/lib/shoryuken/sns_arn.rb +27 -0
- data/lib/shoryuken/topic.rb +17 -0
- data/lib/shoryuken/version.rb +1 -1
- data/lib/shoryuken/worker.rb +4 -9
- data/shoryuken.gemspec +4 -1
- data/spec/integration/launcher_spec.rb +53 -62
- data/spec/shoryuken/client_spec.rb +9 -50
- data/spec/shoryuken/default_worker_registry_spec.rb +1 -1
- data/spec/shoryuken/fetcher_spec.rb +27 -21
- data/spec/shoryuken/middleware/server/auto_delete_spec.rb +20 -8
- data/spec/shoryuken/middleware/server/timing_spec.rb +14 -4
- data/spec/shoryuken/processor_spec.rb +74 -24
- data/spec/shoryuken/sns_arn_spec.rb +42 -0
- data/spec/shoryuken/topic_spec.rb +32 -0
- data/spec/shoryuken/util_spec.rb +3 -1
- data/spec/shoryuken/worker_spec.rb +25 -12
- data/spec/spec_helper.rb +22 -11
- metadata +57 -5
@@ -1,62 +1,21 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Shoryuken::Client do
|
4
|
-
let(:
|
5
|
-
let(:
|
6
|
-
let(:
|
7
|
-
let(:
|
4
|
+
let(:credentials) { Aws::Credentials.new('access_key_id', 'secret_access_key') }
|
5
|
+
let(:sqs) { Aws::SQS::Client.new(stub_responses: true, credentials: credentials) }
|
6
|
+
let(:queue_name) { 'shoryuken' }
|
7
|
+
let(:queue_url) { 'https://eu-west-1.amazonaws.com:6059/123456789012/shoryuken' }
|
8
8
|
|
9
9
|
before do
|
10
|
-
|
11
|
-
allow(sqs).to receive(:queues).and_return(queue_collection)
|
12
|
-
allow(queue_collection).to receive(:named).and_return(sqs_queue)
|
10
|
+
described_class.sqs = sqs
|
13
11
|
end
|
14
12
|
|
15
|
-
describe '.
|
13
|
+
describe '.queue' do
|
16
14
|
it 'memoizes queues' do
|
17
|
-
|
15
|
+
sqs.stub_responses(:get_queue_url, { queue_url: queue_url }, { queue_url: 'xyz' })
|
18
16
|
|
19
|
-
expect(Shoryuken::Client.queues(
|
20
|
-
expect(Shoryuken::Client.queues(
|
21
|
-
end
|
22
|
-
end
|
23
|
-
|
24
|
-
describe '.send_message' do
|
25
|
-
it 'enqueues a message' do
|
26
|
-
expect(sqs_queue).to receive(:send_message).with('test', {})
|
27
|
-
|
28
|
-
described_class.send_message(queue, 'test')
|
29
|
-
end
|
30
|
-
|
31
|
-
it 'enqueues a message with options' do
|
32
|
-
expect(sqs_queue).to receive(:send_message).with('test2', delay_seconds: 60)
|
33
|
-
|
34
|
-
described_class.send_message(queue, 'test2', delay_seconds: 60)
|
35
|
-
end
|
36
|
-
|
37
|
-
it 'parsers as JSON by default' do
|
38
|
-
msg = { field: 'test', other_field: 'other' }
|
39
|
-
|
40
|
-
expect(sqs_queue).to receive(:send_message).with(JSON.dump(msg), {})
|
41
|
-
|
42
|
-
described_class.send_message(queue, msg)
|
43
|
-
end
|
44
|
-
|
45
|
-
it 'parsers as JSON by default and keep the options' do
|
46
|
-
msg = { field: 'test', other_field: 'other' }
|
47
|
-
|
48
|
-
expect(sqs_queue).to receive(:send_message).with(JSON.dump(msg), { delay_seconds: 60 })
|
49
|
-
|
50
|
-
described_class.send_message(queue, msg, delay_seconds: 60)
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
describe '.visibility_timeout' do
|
55
|
-
it 'memoizes visibility_timeout' do
|
56
|
-
expect(sqs_queue).to receive(:visibility_timeout).once.and_return(30)
|
57
|
-
|
58
|
-
expect(Shoryuken::Client.visibility_timeout(queue)).to eq 30
|
59
|
-
expect(Shoryuken::Client.visibility_timeout(queue)).to eq 30
|
17
|
+
expect(Shoryuken::Client.queues(queue_name).url).to eq queue_url
|
18
|
+
expect(Shoryuken::Client.queues(queue_name).url).to eq queue_url
|
60
19
|
end
|
61
20
|
end
|
62
21
|
end
|
@@ -48,7 +48,7 @@ describe Shoryuken::DefaultWorkerRegistry do
|
|
48
48
|
string_value: explicit_worker.to_s,
|
49
49
|
data_type: 'String' } if explicit_worker
|
50
50
|
|
51
|
-
double
|
51
|
+
double Aws::SQS::Message,
|
52
52
|
body: 'test',
|
53
53
|
message_attributes: attributes,
|
54
54
|
message_id: SecureRandom.uuid
|
@@ -3,62 +3,68 @@ require 'shoryuken/manager'
|
|
3
3
|
require 'shoryuken/fetcher'
|
4
4
|
|
5
5
|
describe Shoryuken::Fetcher do
|
6
|
-
let(:manager)
|
7
|
-
let(:
|
8
|
-
let(:
|
9
|
-
|
6
|
+
let(:manager) { double Shoryuken::Manager }
|
7
|
+
let(:queue) { double Aws::SQS::Queue }
|
8
|
+
let(:queue_name) { 'default' }
|
9
|
+
|
10
|
+
let(:sqs_msg) do
|
11
|
+
double Aws::SQS::Message,
|
12
|
+
queue_url: queue_name,
|
13
|
+
body: 'test',
|
14
|
+
message_id: 'fc754df7-9cc2-4c41-96ca-5996a44b771e'
|
15
|
+
end
|
10
16
|
|
11
17
|
subject { described_class.new(manager) }
|
12
18
|
|
13
19
|
before do
|
14
20
|
allow(manager).to receive(:async).and_return(manager)
|
15
|
-
allow(Shoryuken::Client).to receive(:queues).with(
|
21
|
+
allow(Shoryuken::Client).to receive(:queues).with(queue_name).and_return(queue)
|
16
22
|
end
|
17
23
|
|
18
24
|
|
19
25
|
describe '#fetch' do
|
20
26
|
it 'calls pause when no message' do
|
21
|
-
allow(
|
27
|
+
allow(queue).to receive(:receive_messages).with(max_number_of_messages: 1, message_attribute_names: ['All']).and_return([])
|
22
28
|
|
23
|
-
expect(manager).to receive(:pause_queue!).with(
|
29
|
+
expect(manager).to receive(:pause_queue!).with(queue_name)
|
24
30
|
expect(manager).to receive(:dispatch)
|
25
31
|
|
26
|
-
subject.fetch(
|
32
|
+
subject.fetch(queue_name, 1)
|
27
33
|
end
|
28
34
|
|
29
35
|
it 'assigns messages' do
|
30
|
-
allow(
|
36
|
+
allow(queue).to receive(:receive_messages).with(max_number_of_messages: 5, message_attribute_names: ['All']).and_return(sqs_msg)
|
31
37
|
|
32
|
-
expect(manager).to receive(:rebalance_queue_weight!).with(
|
33
|
-
expect(manager).to receive(:assign).with(
|
38
|
+
expect(manager).to receive(:rebalance_queue_weight!).with(queue_name)
|
39
|
+
expect(manager).to receive(:assign).with(queue_name, sqs_msg)
|
34
40
|
expect(manager).to receive(:dispatch)
|
35
41
|
|
36
|
-
subject.fetch(
|
42
|
+
subject.fetch(queue_name, 5)
|
37
43
|
end
|
38
44
|
|
39
45
|
it 'assigns messages in batch' do
|
40
46
|
TestWorker.get_shoryuken_options['batch'] = true
|
41
47
|
|
42
|
-
allow(
|
48
|
+
allow(queue).to receive(:receive_messages).with(max_number_of_messages: described_class::FETCH_LIMIT, message_attribute_names: ['All']).and_return(sqs_msg)
|
43
49
|
|
44
|
-
expect(manager).to receive(:rebalance_queue_weight!).with(
|
45
|
-
expect(manager).to receive(:assign).with(
|
50
|
+
expect(manager).to receive(:rebalance_queue_weight!).with(queue_name)
|
51
|
+
expect(manager).to receive(:assign).with(queue_name, [sqs_msg])
|
46
52
|
expect(manager).to receive(:dispatch)
|
47
53
|
|
48
|
-
subject.fetch(
|
54
|
+
subject.fetch(queue_name, 5)
|
49
55
|
end
|
50
56
|
|
51
57
|
context 'when worker not found' do
|
52
|
-
let(:
|
58
|
+
let(:queue_name) { 'notfound' }
|
53
59
|
|
54
60
|
it 'ignores batch' do
|
55
|
-
allow(
|
61
|
+
allow(queue).to receive(:receive_messages).with(max_number_of_messages: 5, message_attribute_names: ['All']).and_return(sqs_msg)
|
56
62
|
|
57
|
-
expect(manager).to receive(:rebalance_queue_weight!).with(
|
58
|
-
expect(manager).to receive(:assign).with(
|
63
|
+
expect(manager).to receive(:rebalance_queue_weight!).with(queue_name)
|
64
|
+
expect(manager).to receive(:assign).with(queue_name, sqs_msg)
|
59
65
|
expect(manager).to receive(:dispatch)
|
60
66
|
|
61
|
-
subject.fetch(
|
67
|
+
subject.fetch(queue_name, 5)
|
62
68
|
end
|
63
69
|
end
|
64
70
|
end
|
@@ -1,9 +1,17 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Shoryuken::Middleware::Server::AutoDelete do
|
4
|
-
let(:sqs_msg) { double AWS::SQS::ReceivedMessage, id: 'fc754df7-9cc2-4c41-96ca-5996a44b771e', body: 'test' }
|
5
4
|
let(:queue) { 'default' }
|
6
|
-
let(:sqs_queue) { double
|
5
|
+
let(:sqs_queue) { double Aws::SQS::Queue }
|
6
|
+
|
7
|
+
def build_message
|
8
|
+
double Aws::SQS::Message,
|
9
|
+
queue_url: queue,
|
10
|
+
body: 'test',
|
11
|
+
receipt_handle: SecureRandom.uuid
|
12
|
+
end
|
13
|
+
|
14
|
+
let(:sqs_msg) { build_message }
|
7
15
|
|
8
16
|
before do
|
9
17
|
allow(Shoryuken::Client).to receive(:queues).with(queue).and_return(sqs_queue)
|
@@ -12,7 +20,8 @@ describe Shoryuken::Middleware::Server::AutoDelete do
|
|
12
20
|
it 'deletes a message' do
|
13
21
|
TestWorker.get_shoryuken_options['auto_delete'] = true
|
14
22
|
|
15
|
-
expect(sqs_queue).to receive(:
|
23
|
+
expect(sqs_queue).to receive(:delete_messages).with(entries: [
|
24
|
+
{ id: '0', receipt_handle: sqs_msg.receipt_handle }])
|
16
25
|
|
17
26
|
subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) {}
|
18
27
|
end
|
@@ -20,12 +29,15 @@ describe Shoryuken::Middleware::Server::AutoDelete do
|
|
20
29
|
it 'deletes a batch' do
|
21
30
|
TestWorker.get_shoryuken_options['auto_delete'] = true
|
22
31
|
|
23
|
-
sqs_msg2 =
|
24
|
-
sqs_msg3 =
|
32
|
+
sqs_msg2 = build_message
|
33
|
+
sqs_msg3 = build_message
|
25
34
|
|
26
35
|
sqs_msgs = [sqs_msg, sqs_msg2, sqs_msg3]
|
27
36
|
|
28
|
-
expect(sqs_queue).to receive(:
|
37
|
+
expect(sqs_queue).to receive(:delete_messages).with(entries: [
|
38
|
+
{ id: '0', receipt_handle: sqs_msg.receipt_handle },
|
39
|
+
{ id: '1', receipt_handle: sqs_msg2.receipt_handle },
|
40
|
+
{ id: '2', receipt_handle: sqs_msg3.receipt_handle }])
|
29
41
|
|
30
42
|
subject.call(TestWorker.new, queue, sqs_msgs, [sqs_msg.body, sqs_msg2.body, sqs_msg3.body]) {}
|
31
43
|
end
|
@@ -33,14 +45,14 @@ describe Shoryuken::Middleware::Server::AutoDelete do
|
|
33
45
|
it 'does not delete a message' do
|
34
46
|
TestWorker.get_shoryuken_options['auto_delete'] = false
|
35
47
|
|
36
|
-
expect(sqs_queue).to_not receive(:
|
48
|
+
expect(sqs_queue).to_not receive(:delete_messages)
|
37
49
|
|
38
50
|
subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) {}
|
39
51
|
end
|
40
52
|
|
41
53
|
context 'when exception' do
|
42
54
|
it 'does not delete a message' do
|
43
|
-
expect(sqs_queue).to_not receive(:
|
55
|
+
expect(sqs_queue).to_not receive(:delete_messages)
|
44
56
|
|
45
57
|
expect {
|
46
58
|
subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise }
|
@@ -1,10 +1,21 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Shoryuken::Middleware::Server::Timing do
|
4
|
-
let(:
|
5
|
-
let(:
|
4
|
+
let(:queue) { 'default' }
|
5
|
+
let(:sqs_queue) { double Aws::SQS::Queue, visibility_timeout: 60 }
|
6
|
+
|
7
|
+
let(:sqs_msg) do
|
8
|
+
double Aws::SQS::Message,
|
9
|
+
queue_url: queue,
|
10
|
+
body: 'test',
|
11
|
+
message_id: 'fc754df7-9cc2-4c41-96ca-5996a44b771e'
|
12
|
+
end
|
13
|
+
|
14
|
+
before do
|
15
|
+
allow(Shoryuken::Client).to receive(:queues).with(queue).and_return(sqs_queue)
|
16
|
+
end
|
6
17
|
|
7
|
-
|
18
|
+
it 'logs timing' do
|
8
19
|
expect(Shoryuken.logger).to receive(:info).with(/started at/)
|
9
20
|
expect(Shoryuken.logger).to receive(:info).with(/completed in/)
|
10
21
|
|
@@ -13,7 +24,6 @@ describe Shoryuken::Middleware::Server::Timing do
|
|
13
24
|
|
14
25
|
context 'when exceeded the `visibility_timeout`' do
|
15
26
|
it 'logs exceeded' do
|
16
|
-
allow(Shoryuken::Client).to receive(:visibility_timeout).and_return(60)
|
17
27
|
allow(subject).to receive(:elapsed).and_return(120000)
|
18
28
|
|
19
29
|
expect(Shoryuken.logger).to receive(:info).with(/started at/)
|
@@ -4,9 +4,17 @@ require 'shoryuken/manager'
|
|
4
4
|
|
5
5
|
describe Shoryuken::Processor do
|
6
6
|
let(:manager) { double Shoryuken::Manager, processor_done: nil }
|
7
|
-
let(:sqs_queue) { double
|
7
|
+
let(:sqs_queue) { double Aws::SQS::Queue, visibility_timeout: 30 }
|
8
8
|
let(:queue) { 'default' }
|
9
|
-
|
9
|
+
|
10
|
+
let(:sqs_msg) do
|
11
|
+
double Aws::SQS::Message,
|
12
|
+
queue_url: queue,
|
13
|
+
body: 'test',
|
14
|
+
message_attributes: {},
|
15
|
+
message_id: SecureRandom.uuid,
|
16
|
+
receipt_handle: SecureRandom.uuid
|
17
|
+
end
|
10
18
|
|
11
19
|
subject { described_class.new(manager) }
|
12
20
|
|
@@ -110,29 +118,66 @@ describe Shoryuken::Processor do
|
|
110
118
|
|
111
119
|
def perform(sqs_msg, body); end
|
112
120
|
end
|
121
|
+
end
|
113
122
|
|
114
|
-
|
115
|
-
|
116
|
-
|
123
|
+
context 'server' do
|
124
|
+
before do
|
125
|
+
allow(Shoryuken).to receive(:server?).and_return(true)
|
126
|
+
WorkerCalledMiddlewareWorker.instance_variable_set(:@server_chain, nil) # un-memoize middleware
|
127
|
+
|
128
|
+
Shoryuken.configure_server do |config|
|
129
|
+
config.server_middleware do |chain|
|
130
|
+
chain.add WorkerCalledMiddleware
|
131
|
+
end
|
117
132
|
end
|
118
133
|
end
|
119
|
-
end
|
120
134
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
135
|
+
after do
|
136
|
+
Shoryuken.configure_server do |config|
|
137
|
+
config.server_middleware do |chain|
|
138
|
+
chain.remove WorkerCalledMiddleware
|
139
|
+
end
|
125
140
|
end
|
126
141
|
end
|
142
|
+
|
143
|
+
it 'invokes middleware' do
|
144
|
+
expect(manager).to receive(:processor_done).with(queue, subject)
|
145
|
+
|
146
|
+
expect_any_instance_of(WorkerCalledMiddlewareWorker).to receive(:perform).with(sqs_msg, sqs_msg.body)
|
147
|
+
expect_any_instance_of(WorkerCalledMiddlewareWorker).to receive(:called).with(sqs_msg, queue)
|
148
|
+
|
149
|
+
subject.process(queue, sqs_msg)
|
150
|
+
end
|
127
151
|
end
|
128
152
|
|
129
|
-
|
130
|
-
|
153
|
+
context 'client' do
|
154
|
+
before do
|
155
|
+
allow(Shoryuken).to receive(:server?).and_return(false)
|
156
|
+
WorkerCalledMiddlewareWorker.instance_variable_set(:@server_chain, nil) # un-memoize middleware
|
131
157
|
|
132
|
-
|
133
|
-
|
158
|
+
Shoryuken.configure_server do |config|
|
159
|
+
config.server_middleware do |chain|
|
160
|
+
chain.add WorkerCalledMiddleware
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
134
164
|
|
135
|
-
|
165
|
+
after do
|
166
|
+
Shoryuken.configure_server do |config|
|
167
|
+
config.server_middleware do |chain|
|
168
|
+
chain.remove WorkerCalledMiddleware
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
it "doesn't invoke middleware" do
|
174
|
+
expect(manager).to receive(:processor_done).with(queue, subject)
|
175
|
+
|
176
|
+
expect_any_instance_of(WorkerCalledMiddlewareWorker).to receive(:perform).with(sqs_msg, sqs_msg.body)
|
177
|
+
expect_any_instance_of(WorkerCalledMiddlewareWorker).to_not receive(:called).with(sqs_msg, queue)
|
178
|
+
|
179
|
+
subject.process(queue, sqs_msg)
|
180
|
+
end
|
136
181
|
end
|
137
182
|
end
|
138
183
|
|
@@ -143,7 +188,7 @@ describe Shoryuken::Processor do
|
|
143
188
|
|
144
189
|
expect_any_instance_of(TestWorker).to receive(:perform).with(sqs_msg, sqs_msg.body)
|
145
190
|
|
146
|
-
expect(sqs_queue).to receive(:
|
191
|
+
expect(sqs_queue).to receive(:delete_messages).with(entries: [{ id: '0', receipt_handle: sqs_msg.receipt_handle }])
|
147
192
|
|
148
193
|
subject.process(queue, sqs_msg)
|
149
194
|
end
|
@@ -155,18 +200,23 @@ describe Shoryuken::Processor do
|
|
155
200
|
|
156
201
|
expect_any_instance_of(TestWorker).to receive(:perform).with(sqs_msg, sqs_msg.body)
|
157
202
|
|
158
|
-
expect(sqs_queue).to_not receive(:
|
203
|
+
expect(sqs_queue).to_not receive(:delete_messages)
|
159
204
|
|
160
205
|
subject.process(queue, sqs_msg)
|
161
206
|
end
|
162
207
|
|
163
208
|
context 'when shoryuken_class header' do
|
164
|
-
let(:sqs_msg)
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
209
|
+
let(:sqs_msg) do
|
210
|
+
double Aws::SQS::Message,
|
211
|
+
queue_url: queue,
|
212
|
+
body: 'test',
|
213
|
+
message_attributes: {
|
214
|
+
'shoryuken_class' => {
|
215
|
+
string_value: TestWorker.to_s,
|
216
|
+
data_type: 'String' }},
|
217
|
+
message_id: SecureRandom.uuid,
|
218
|
+
receipt_handle: SecureRandom.uuid
|
219
|
+
end
|
170
220
|
|
171
221
|
it 'performs without delete' do
|
172
222
|
Shoryuken.worker_registry.clear # unregister TestWorker
|
@@ -175,7 +225,7 @@ describe Shoryuken::Processor do
|
|
175
225
|
|
176
226
|
expect_any_instance_of(TestWorker).to receive(:perform).with(sqs_msg, sqs_msg.body)
|
177
227
|
|
178
|
-
expect(sqs_queue).to_not receive(:
|
228
|
+
expect(sqs_queue).to_not receive(:delete_messages)
|
179
229
|
|
180
230
|
subject.process(queue, sqs_msg)
|
181
231
|
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Shoryuken::SnsArn do
|
4
|
+
let(:account_id) { '1234567890' }
|
5
|
+
let(:region) { 'eu-west-1' }
|
6
|
+
let(:topic) { 'topic-x' }
|
7
|
+
|
8
|
+
before do
|
9
|
+
Shoryuken::Client.account_id = account_id
|
10
|
+
Aws.config = { region: region }
|
11
|
+
end
|
12
|
+
|
13
|
+
subject { described_class.new(topic).to_s }
|
14
|
+
|
15
|
+
describe '#to_s' do
|
16
|
+
context 'when the Aws config includes all the information necessary' do
|
17
|
+
it 'generates an SNS arn' do
|
18
|
+
expect(subject).to eq('arn:aws:sns:eu-west-1:1234567890:topic-x')
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
context 'when the Aws config does not include the account id' do
|
23
|
+
before do
|
24
|
+
Shoryuken::Client.account_id = nil
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'fails' do
|
28
|
+
expect { subject }.to raise_error(/an :account_id/)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
context 'when the Aws config does not include the region' do
|
33
|
+
before do
|
34
|
+
Aws.config.delete :region
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'fails' do
|
38
|
+
expect { subject }.to raise_error(/a :region/)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|