gossiperl_client 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/gossiperl_client.gemspec +44 -0
- data/lib/gossiperl_client.rb +5 -0
- data/lib/gossiperl_client/encryption/aes256.rb +44 -0
- data/lib/gossiperl_client/headers.rb +46 -0
- data/lib/gossiperl_client/messaging.rb +120 -0
- data/lib/gossiperl_client/overlay_worker.rb +73 -0
- data/lib/gossiperl_client/requirements.rb +15 -0
- data/lib/gossiperl_client/resolution.rb +38 -0
- data/lib/gossiperl_client/serialization/serializer.rb +128 -0
- data/lib/gossiperl_client/state.rb +73 -0
- data/lib/gossiperl_client/supervisor.rb +81 -0
- data/lib/gossiperl_client/thrift/gossiperl_constants.rb +15 -0
- data/lib/gossiperl_client/thrift/gossiperl_types.rb +378 -0
- data/lib/gossiperl_client/transport/udp.rb +52 -0
- data/lib/gossiperl_client/util/validation.rb +37 -0
- data/lib/gossiperl_client/version.rb +9 -0
- data/tests/process_tests.rb +63 -0
- data/tests/thrift_tests.rb +45 -0
- metadata +129 -0
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
YzE0MjE3NzNmNjg1MWJhYzQwNTE2MDE1N2FmNzExMDc3ZTA3MDdiZQ==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
NTBjODRjMzU1YjcxNjcyZGEyMTRkYThkMDYzNTY2OTczOTc2ZTI1MQ==
|
7
|
+
SHA512:
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
Yzg0M2FhZGI4ODJmYjBiZDc2NzE0NzA3ZWYzODkzYzJlNDk2YzBiNDAzZmNm
|
10
|
+
MjI1YmFiZjQ3MTc5OTQ0NGEyZGYwY2Q0NjkxMGZiYTQzZTRiNTM1YmVkOTEw
|
11
|
+
NjljNzkzOTM1N2VlMThiZjdhYzYyZjFjM2JlMjFhZTY2Mjc4Yzg=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
YmRiY2ZhOGU1MjExMTk3MzI3MzE1ZDc4MjQ3NjA2NGZmZmNiYzZmMjBmNDRl
|
14
|
+
NWQ5YWZkNzQzMTJhOGE1MzFhODAxYmE0YzY2NzBhOWIxZjQ4NTRhZjY4OTU2
|
15
|
+
ZjYzY2ZjODdlNjY1MDJkOTMxM2FmNzU5Y2Y2ZWMyYWZiYmZmZTA=
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2014 Radoslaw Gruchalski <radek@gruchalski.com>
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
# Ruby gossiperl client
|
2
|
+
|
3
|
+
Ruby [gossiperl](https://github.com/radekg/gossiperl) client library.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
In your `Gemfile`:
|
8
|
+
|
9
|
+
gem 'gossiperl_client', :git => 'https://github.com/radekg/gossiperl-client-ruby.git'
|
10
|
+
|
11
|
+
## Running
|
12
|
+
|
13
|
+
require 'gossiperl_client'
|
14
|
+
supervisor = ::Gossiperl::Client::Supervisor.new
|
15
|
+
|
16
|
+
## Connecting to an overlay
|
17
|
+
|
18
|
+
supervisor.connect( :overlay_name => :your_overlay_name
|
19
|
+
:overlay_port => 6666,
|
20
|
+
:client_port => 54321,
|
21
|
+
:client_name => :your_client_name,
|
22
|
+
:client_secret => :your_client_secret,
|
23
|
+
:symkey => :symmetric_key )
|
24
|
+
|
25
|
+
It's also possible to connect with a block:
|
26
|
+
|
27
|
+
supervisor.connect( ... ) do |event|
|
28
|
+
if event[:event] == :connected
|
29
|
+
self.logger.info "Connected to overlay #{event[:options][:overlay_name]}..."
|
30
|
+
elsif event[:event] == :disconnected
|
31
|
+
self.logger.info "Disconnected from overlay #{event[:options][:overlay_name]}..."
|
32
|
+
elsif event[:event] == :subscribed
|
33
|
+
self.logger.info "Received subscription confirmation for #{event[:details][:types]}"
|
34
|
+
elsif event[:event] == :unsubscribed
|
35
|
+
self.logger.info "Received unsubscription confirmation for #{event[:details][:types]}"
|
36
|
+
elsif event[:event] == :event
|
37
|
+
self.logger.info "Received member related event #{event[:details][:type]} for member #{event[:details][:member]}."
|
38
|
+
elsif event[:event] == :forwarded_ack
|
39
|
+
self.logger.info "Received confirmation of forwarded message. Message ID: #{event[:details][:reply_id]}"
|
40
|
+
elsif event[:event] == :forwarded
|
41
|
+
self.logger.info "Received forwarded digest #{event[:digest]} of type #{event[:digest_type]}"
|
42
|
+
elsif event[:event] == :failed
|
43
|
+
self.logger.info "Received an error from the client. Reason: #{event[:error]}."
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
A client may be connected to multiple overlays.
|
48
|
+
|
49
|
+
## Subscribing / unsubscribing
|
50
|
+
|
51
|
+
Subscribing:
|
52
|
+
|
53
|
+
supervisor.subscribe( :overlay_name, [ :event_1, :event_2, ... ] )
|
54
|
+
|
55
|
+
Unsubscribing:
|
56
|
+
|
57
|
+
supervisor.unsubscribe( :overlay_name, [ :event_1, :event_2, ... ] )
|
58
|
+
|
59
|
+
Or in a block:
|
60
|
+
|
61
|
+
self.subscribe( [ :event_1, :event_2, ... ] )
|
62
|
+
self.unsubscribe( [ :event_1, :event_2, ... ] )
|
63
|
+
|
64
|
+
## Disconnecting from an overlay:
|
65
|
+
|
66
|
+
supervisor.disconnect( :overlay_name )
|
67
|
+
|
68
|
+
Or in a block:
|
69
|
+
|
70
|
+
self.stop
|
71
|
+
|
72
|
+
This will attempt a graceful exit from an overlay.
|
73
|
+
|
74
|
+
## Additional operations
|
75
|
+
|
76
|
+
### Checking current client state
|
77
|
+
|
78
|
+
supervisor.state( :overlay_name )
|
79
|
+
|
80
|
+
Or in a block:
|
81
|
+
|
82
|
+
self.current_state
|
83
|
+
|
84
|
+
### Get the list of current subscriptions
|
85
|
+
|
86
|
+
supervisor.subscriptions( :overlay_name )
|
87
|
+
|
88
|
+
Or in a block:
|
89
|
+
|
90
|
+
self.state.subscriptions
|
91
|
+
|
92
|
+
### Sending arbitrary digests
|
93
|
+
|
94
|
+
|
95
|
+
supervisor.send( :overlay_name, :digestType, {
|
96
|
+
:property => { :value => <value>, :type => <thrift-type-as-string>, :field_id => <field-order> }
|
97
|
+
} )
|
98
|
+
|
99
|
+
Or in a block:
|
100
|
+
|
101
|
+
self.send( :digestType, {
|
102
|
+
:property => { :value => <value>, :type => <thrift-type-as-string>, :field_id => <field-order> }
|
103
|
+
} )
|
104
|
+
|
105
|
+
Where `:type` is one of the Thrift types:
|
106
|
+
|
107
|
+
- `:stop`
|
108
|
+
- `:void`
|
109
|
+
- `:bool`
|
110
|
+
- `:byte`
|
111
|
+
- `:double`
|
112
|
+
- `:i16`
|
113
|
+
- `:i32`
|
114
|
+
- `:i64`
|
115
|
+
- `:string`
|
116
|
+
- `:struct`
|
117
|
+
- `:map`
|
118
|
+
- `:set`
|
119
|
+
- `:list`
|
120
|
+
|
121
|
+
And `:field_id` is a Thrift field ID.
|
122
|
+
|
123
|
+
## Running tests
|
124
|
+
|
125
|
+
shindont tests
|
126
|
+
|
127
|
+
Tests assume an overlay with the details specified in the `tests/process_tests.rb` running.
|
128
|
+
|
129
|
+
## License
|
130
|
+
|
131
|
+
The MIT License (MIT)
|
132
|
+
|
133
|
+
Copyright (c) 2014 Radoslaw Gruchalski <radek@gruchalski.com>
|
134
|
+
|
135
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
136
|
+
of this software and associated documentation files (the "Software"), to deal
|
137
|
+
in the Software without restriction, including without limitation the rights
|
138
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
139
|
+
copies of the Software, and to permit persons to whom the Software is
|
140
|
+
furnished to do so, subject to the following conditions:
|
141
|
+
|
142
|
+
The above copyright notice and this permission notice shall be included in
|
143
|
+
all copies or substantial portions of the Software.
|
144
|
+
|
145
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
146
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
147
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
148
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
149
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
150
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
151
|
+
THE SOFTWARE.
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "gossiperl_client/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "gossiperl_client"
|
7
|
+
s.version = Gossiperl::Client::Version::VERSION
|
8
|
+
s.has_rdoc = false
|
9
|
+
s.summary = "Gossiperl Ruby client"
|
10
|
+
s.description = "Work with gossiperl from Ruby."
|
11
|
+
s.authors = ["Rad Gruchalski"]
|
12
|
+
s.email = ["radek@gruchalski.com"]
|
13
|
+
s.homepage = "https://github.com/radekg/gossiperl-client-ruby"
|
14
|
+
s.require_paths = %w[lib]
|
15
|
+
|
16
|
+
s.add_dependency('thrift', '>=0.9.2.0')
|
17
|
+
s.add_development_dependency('shindo')
|
18
|
+
|
19
|
+
s.files = %w[
|
20
|
+
Gemfile
|
21
|
+
README.md
|
22
|
+
LICENSE
|
23
|
+
lib/gossiperl_client.rb
|
24
|
+
lib/gossiperl_client/encryption/aes256.rb
|
25
|
+
lib/gossiperl_client/serialization/serializer.rb
|
26
|
+
lib/gossiperl_client/thrift/gossiperl_constants.rb
|
27
|
+
lib/gossiperl_client/thrift/gossiperl_types.rb
|
28
|
+
lib/gossiperl_client/transport/udp.rb
|
29
|
+
lib/gossiperl_client/util/validation.rb
|
30
|
+
lib/gossiperl_client/headers.rb
|
31
|
+
lib/gossiperl_client/messaging.rb
|
32
|
+
lib/gossiperl_client/overlay_worker.rb
|
33
|
+
lib/gossiperl_client/requirements.rb
|
34
|
+
lib/gossiperl_client/resolution.rb
|
35
|
+
lib/gossiperl_client/state.rb
|
36
|
+
lib/gossiperl_client/supervisor.rb
|
37
|
+
lib/gossiperl_client/version.rb
|
38
|
+
gossiperl_client.gemspec
|
39
|
+
tests/process_tests.rb
|
40
|
+
tests/thrift_tests.rb
|
41
|
+
]
|
42
|
+
s.test_files = s.files.select { |path| path =~ /^[tests]\/.*_[tests]\.rb/ }
|
43
|
+
|
44
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
module Gossiperl
|
3
|
+
module Client
|
4
|
+
module Encryption
|
5
|
+
class Aes256 < Gossiperl::Client::Resolution
|
6
|
+
|
7
|
+
field :key, Object
|
8
|
+
|
9
|
+
def initialize key_in
|
10
|
+
# setup key:
|
11
|
+
self.key = ::Digest::SHA256.digest(key_in)
|
12
|
+
end
|
13
|
+
|
14
|
+
def algorithm
|
15
|
+
'AES-256-CBC'
|
16
|
+
end
|
17
|
+
|
18
|
+
def encrypt data
|
19
|
+
random_iv = OpenSSL::Cipher::Cipher.new(algorithm).random_iv
|
20
|
+
aes = ::OpenSSL::Cipher::Cipher.new(algorithm)
|
21
|
+
aes.encrypt
|
22
|
+
aes.key = self.key
|
23
|
+
aes.iv = random_iv
|
24
|
+
cipher = aes.update(data)
|
25
|
+
cipher << aes.final
|
26
|
+
random_iv + cipher
|
27
|
+
end
|
28
|
+
|
29
|
+
def decrypt cipher
|
30
|
+
iv = cipher[0...16]
|
31
|
+
cipher_data = cipher[16..-1]
|
32
|
+
decode_cipher = ::OpenSSL::Cipher::Cipher.new(algorithm)
|
33
|
+
decode_cipher.decrypt
|
34
|
+
decode_cipher.key = self.key
|
35
|
+
decode_cipher.padding = 0
|
36
|
+
decode_cipher.iv = iv
|
37
|
+
plain = decode_cipher.update(cipher_data)
|
38
|
+
plain << decode_cipher.final
|
39
|
+
plain
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
module Gossiperl
|
3
|
+
module Client
|
4
|
+
|
5
|
+
class Resolution; end
|
6
|
+
|
7
|
+
module Encryption
|
8
|
+
class Aes256 < Gossiperl::Client::Resolution; end
|
9
|
+
end
|
10
|
+
|
11
|
+
module Serialization
|
12
|
+
class Serializer; end
|
13
|
+
end
|
14
|
+
|
15
|
+
module Thrift
|
16
|
+
class DigestEnvelope; end
|
17
|
+
class DigestForwardedAck; end
|
18
|
+
class DigestError; end
|
19
|
+
class DigestExit; end
|
20
|
+
class DigestMember; end
|
21
|
+
class DigestSubscription; end
|
22
|
+
class Digest; end
|
23
|
+
class DigestAck; end
|
24
|
+
class DigestSubscriptions; end
|
25
|
+
class DigestSubscribe; end
|
26
|
+
class DigestSubscribeAck; end
|
27
|
+
class DigestUnsubscribe; end
|
28
|
+
class DigestUnsubscribeAck; end
|
29
|
+
class DigestEvent; end
|
30
|
+
end
|
31
|
+
|
32
|
+
module Transport
|
33
|
+
class Udp < Gossiperl::Client::Resolution; end
|
34
|
+
end
|
35
|
+
|
36
|
+
module Util
|
37
|
+
class Validation; end
|
38
|
+
end
|
39
|
+
|
40
|
+
class Messaging < Gossiperl::Client::Resolution; end
|
41
|
+
class OverlayWorker < Gossiperl::Client::Resolution; end
|
42
|
+
class State < Gossiperl::Client::Resolution; end
|
43
|
+
class Supervisor < Gossiperl::Client::Resolution; end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
module Gossiperl
|
3
|
+
module Client
|
4
|
+
class Messaging < Gossiperl::Client::Resolution
|
5
|
+
|
6
|
+
field :worker, Gossiperl::Client::OverlayWorker
|
7
|
+
field :transport, Gossiperl::Client::Transport::Udp
|
8
|
+
|
9
|
+
def initialize worker, &block
|
10
|
+
self.worker = worker
|
11
|
+
@callback_block = block
|
12
|
+
end
|
13
|
+
|
14
|
+
def get_callback_block
|
15
|
+
@callback_block
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
self.transport = Gossiperl::Client::Transport::Udp.new( self.worker )
|
20
|
+
if self.worker.options.has_key?(:thrift_window)
|
21
|
+
self.transport.recv_buf_size = self.worker.options[:thrift_window]
|
22
|
+
end
|
23
|
+
Thread.new(self) do |msg|
|
24
|
+
msg.transport.handle do |data|
|
25
|
+
if data.kind_of? Hash
|
26
|
+
if data.has_key?(:error)
|
27
|
+
msg.worker.process_event( { :event => :failed,
|
28
|
+
:error => data[:error] } )
|
29
|
+
elsif data.has_key?(:forward)
|
30
|
+
msg.worker.process_event( { :event => :forwarded,
|
31
|
+
:digest => data[:envelope],
|
32
|
+
:digest_type => data[:type] } )
|
33
|
+
msg.digest_forwarded_ack data[:envelope].id
|
34
|
+
else
|
35
|
+
msg.worker.process_event( { :event => :failed,
|
36
|
+
:error => { :unsupported_hash_response => data } } )
|
37
|
+
end
|
38
|
+
else
|
39
|
+
if data.is_a?( Gossiperl::Client::Thrift::Digest )
|
40
|
+
msg.digest_ack data
|
41
|
+
elsif data.is_a?( Gossiperl::Client::Thrift::DigestAck )
|
42
|
+
msg.worker.state.receive data
|
43
|
+
elsif data.is_a?( Gossiperl::Client::Thrift::DigestEvent )
|
44
|
+
msg.worker.process_event( { :event => :event,
|
45
|
+
:details => { :type => data.event_type,
|
46
|
+
:member => data.event_object,
|
47
|
+
:heartbeat => data.heartbeat } } )
|
48
|
+
elsif data.is_a?( Gossiperl::Client::Thrift::DigestSubscribeAck )
|
49
|
+
msg.worker.process_event( { :event => :subscribed,
|
50
|
+
:details => { :types => data.event_types.map{|item| item.to_sym},
|
51
|
+
:heartbeat => data.heartbeat } } )
|
52
|
+
elsif data.is_a?( Gossiperl::Client::Thrift::DigestUnsubscribeAck )
|
53
|
+
msg.worker.process_event( { :event => :unsubscribed,
|
54
|
+
:details => { :types => data.event_types.map{|item| item.to_sym},
|
55
|
+
:heartbeat => data.heartbeat } } )
|
56
|
+
elsif data.is_a?( Gossiperl::Client::Thrift::DigestForwardedAck )
|
57
|
+
msg.worker.process_event( { :event => :forwarded_ack,
|
58
|
+
:details => { :reply_id => data.reply_id } } )
|
59
|
+
else
|
60
|
+
msg.worker.process_event( { :event => :failed,
|
61
|
+
:error => { :unsupported_digest => data } } )
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def send digest
|
69
|
+
self.transport.send digest
|
70
|
+
end
|
71
|
+
|
72
|
+
def digest_ack digest
|
73
|
+
ack = ::Gossiperl::Client::Thrift::DigestAck.new
|
74
|
+
ack.name = self.worker.options[:client_name].to_s
|
75
|
+
ack.heartbeat = Time.now.to_i
|
76
|
+
ack.reply_id = digest.id
|
77
|
+
ack.membership = []
|
78
|
+
self.send ack
|
79
|
+
end
|
80
|
+
|
81
|
+
def digest_forwarded_ack digest_id
|
82
|
+
ack = ::Gossiperl::Client::Thrift::DigestForwardedAck.new
|
83
|
+
ack.name = self.worker.options[:client_name].to_s
|
84
|
+
ack.secret = self.worker.options[:client_secret].to_s
|
85
|
+
ack.reply_id = digest_id
|
86
|
+
self.send ack
|
87
|
+
end
|
88
|
+
|
89
|
+
def digest_subscribe event_types
|
90
|
+
digest = ::Gossiperl::Client::Thrift::DigestSubscribe.new
|
91
|
+
digest.name = self.worker.options[:client_name].to_s
|
92
|
+
digest.secret = self.worker.options[:client_secret].to_s
|
93
|
+
digest.id = SecureRandom.uuid.to_s
|
94
|
+
digest.heartbeat = Time.now.to_i
|
95
|
+
digest.event_types = event_types.map{|item| item.to_s}
|
96
|
+
self.send digest
|
97
|
+
end
|
98
|
+
|
99
|
+
def digest_unsubscribe event_types
|
100
|
+
digest = ::Gossiperl::Client::Thrift::DigestUnsubscribe.new
|
101
|
+
digest.name = self.worker.options[:client_name].to_s
|
102
|
+
digest.secret = self.worker.options[:client_secret].to_s
|
103
|
+
digest.id = SecureRandom.uuid.to_s
|
104
|
+
digest.heartbeat = Time.now.to_i
|
105
|
+
digest.event_types = event_types.map{|item| item.to_s}
|
106
|
+
self.send digest
|
107
|
+
end
|
108
|
+
|
109
|
+
def digest_exit
|
110
|
+
digest = ::Gossiperl::Client::Thrift::DigestExit.new
|
111
|
+
digest.name = self.worker.options[:client_name].to_s
|
112
|
+
digest.heartbeat = Time.now.to_i
|
113
|
+
digest.secret = self.worker.options[:client_secret].to_s
|
114
|
+
self.send digest
|
115
|
+
self.worker.working = false
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|