secure_carrot 0.1.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.
- data/.gitignore +4 -0
- data/LICENSE +20 -0
- data/README.markdown +34 -0
- data/Rakefile +42 -0
- data/VERSION.yml +5 -0
- data/lib/amqp/buffer.rb +401 -0
- data/lib/amqp/exchange.rb +51 -0
- data/lib/amqp/frame.rb +121 -0
- data/lib/amqp/header.rb +27 -0
- data/lib/amqp/protocol.rb +209 -0
- data/lib/amqp/queue.rb +144 -0
- data/lib/amqp/server.rb +187 -0
- data/lib/amqp/spec.rb +820 -0
- data/lib/carrot.rb +92 -0
- data/lib/examples/simple_pop.rb +13 -0
- data/protocol/amqp-0.8.json +617 -0
- data/protocol/amqp-0.8.xml +3908 -0
- data/protocol/codegen.rb +173 -0
- data/protocol/doc.txt +281 -0
- data/test/carrot_test.rb +25 -0
- data/test/test_helper.rb +18 -0
- metadata +102 -0
@@ -0,0 +1,209 @@
|
|
1
|
+
module Carrot::AMQP
|
2
|
+
module Protocol
|
3
|
+
#:stopdoc:
|
4
|
+
class Class::Method
|
5
|
+
def initialize *args
|
6
|
+
opts = args.pop if args.last.is_a? Hash
|
7
|
+
opts ||= {}
|
8
|
+
|
9
|
+
@debug = 1 # XXX hack, p(obj) == '' if no instance vars are set
|
10
|
+
|
11
|
+
if args.size == 1 and args.first.is_a? Buffer
|
12
|
+
buf = args.shift
|
13
|
+
else
|
14
|
+
buf = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
self.class.arguments.each do |type, name|
|
18
|
+
val = buf ? buf.read(type) :
|
19
|
+
args.shift || opts[name] || opts[name.to_s]
|
20
|
+
instance_variable_set("@#{name}", val)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def arguments
|
25
|
+
self.class.arguments.inject({}) do |hash, (type, name)|
|
26
|
+
hash.update name => instance_variable_get("@#{name}")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_binary
|
31
|
+
buf = Buffer.new
|
32
|
+
buf.write :short, self.class.parent.id
|
33
|
+
buf.write :short, self.class.id
|
34
|
+
|
35
|
+
bits = []
|
36
|
+
|
37
|
+
self.class.arguments.each do |type, name|
|
38
|
+
val = instance_variable_get("@#{name}")
|
39
|
+
if type == :bit
|
40
|
+
bits << (val || false)
|
41
|
+
else
|
42
|
+
unless bits.empty?
|
43
|
+
buf.write :bit, bits
|
44
|
+
bits = []
|
45
|
+
end
|
46
|
+
buf.write type, val
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
buf.write :bit, bits unless bits.empty?
|
51
|
+
buf.rewind
|
52
|
+
|
53
|
+
buf
|
54
|
+
end
|
55
|
+
|
56
|
+
def to_s
|
57
|
+
to_binary.to_s
|
58
|
+
end
|
59
|
+
|
60
|
+
def to_frame channel = 0
|
61
|
+
Frame::Method.new(self, channel)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
#:startdoc:
|
66
|
+
#
|
67
|
+
# Contains a properties hash that holds some potentially interesting
|
68
|
+
# information.
|
69
|
+
# * :delivery_mode
|
70
|
+
# 1 equals transient.
|
71
|
+
# 2 equals persistent. Unconsumed persistent messages will survive
|
72
|
+
# a server restart when they are stored in a durable queue.
|
73
|
+
# * :redelivered
|
74
|
+
# True or False
|
75
|
+
# * :routing_key
|
76
|
+
# The routing string used for matching this message to this queue.
|
77
|
+
# * :priority
|
78
|
+
# An integer in the range of 0 to 9 inclusive.
|
79
|
+
# * :content_type
|
80
|
+
# Always "application/octet-stream" (byte stream)
|
81
|
+
# * :exchange
|
82
|
+
# The source exchange which published this message.
|
83
|
+
# * :message_count
|
84
|
+
# The number of unconsumed messages contained in the queue.
|
85
|
+
# * :delivery_tag
|
86
|
+
# A monotonically increasing integer. This number should not be trusted
|
87
|
+
# as a sequence number. There is no guarantee it won't get reset.
|
88
|
+
class Header
|
89
|
+
def initialize *args
|
90
|
+
opts = args.pop if args.last.is_a? Hash
|
91
|
+
opts ||= {}
|
92
|
+
|
93
|
+
first = args.shift
|
94
|
+
|
95
|
+
if first.is_a? ::Class and first.ancestors.include? Protocol::Class
|
96
|
+
@klass = first
|
97
|
+
@size = args.shift || 0
|
98
|
+
@weight = args.shift || 0
|
99
|
+
@properties = opts
|
100
|
+
|
101
|
+
elsif first.is_a? Buffer or first.is_a? String
|
102
|
+
buf = first
|
103
|
+
buf = Buffer.new(buf) unless buf.is_a? Buffer
|
104
|
+
|
105
|
+
@klass = Protocol.classes[buf.read(:short)]
|
106
|
+
@weight = buf.read(:short)
|
107
|
+
@size = buf.read(:longlong)
|
108
|
+
|
109
|
+
props = buf.read(:properties, *klass.properties.map{|type,_| type })
|
110
|
+
@properties = Hash[*klass.properties.map{|_,name| name }.zip(props).reject{|k,v| v.nil? }.flatten]
|
111
|
+
|
112
|
+
else
|
113
|
+
raise ArgumentError, 'Invalid argument'
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
attr_accessor :klass, :size, :weight, :properties
|
118
|
+
|
119
|
+
def to_binary
|
120
|
+
buf = Buffer.new
|
121
|
+
buf.write :short, klass.id
|
122
|
+
buf.write :short, weight # XXX rabbitmq only supports weight == 0
|
123
|
+
buf.write :longlong, size
|
124
|
+
buf.write :properties, (klass.properties.map do |type, name|
|
125
|
+
[ type, properties[name] || properties[name.to_s] ]
|
126
|
+
end)
|
127
|
+
buf.rewind
|
128
|
+
buf
|
129
|
+
end
|
130
|
+
|
131
|
+
def to_s
|
132
|
+
to_binary.to_s
|
133
|
+
end
|
134
|
+
|
135
|
+
def to_frame channel = 0
|
136
|
+
Frame::Header.new(self, channel)
|
137
|
+
end
|
138
|
+
|
139
|
+
def == header
|
140
|
+
[ :klass, :size, :weight, :properties ].inject(true) do |eql, field|
|
141
|
+
eql and __send__(field) == header.__send__(field)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def method_missing meth, *args, &blk
|
146
|
+
@properties.has_key?(meth) || @klass.properties.find{|_,name| name == meth } ? @properties[meth] :
|
147
|
+
super
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.parse buf
|
152
|
+
buf = Buffer.new(buf) unless buf.is_a? Buffer
|
153
|
+
class_id, method_id = buf.read(:short, :short)
|
154
|
+
classes[class_id].methods[method_id].new(buf)
|
155
|
+
end
|
156
|
+
#:stopdoc:
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
if $0 =~ /bacon/ or $0 == __FILE__
|
161
|
+
require 'bacon'
|
162
|
+
include AMQP
|
163
|
+
|
164
|
+
describe Protocol do
|
165
|
+
should 'instantiate methods with arguments' do
|
166
|
+
meth = Protocol::Connection::StartOk.new nil, 'PLAIN', nil, 'en_US'
|
167
|
+
meth.locale.should == 'en_US'
|
168
|
+
end
|
169
|
+
|
170
|
+
should 'instantiate methods with named parameters' do
|
171
|
+
meth = Protocol::Connection::StartOk.new :locale => 'en_US',
|
172
|
+
:mechanism => 'PLAIN'
|
173
|
+
meth.locale.should == 'en_US'
|
174
|
+
end
|
175
|
+
|
176
|
+
should 'convert methods to binary' do
|
177
|
+
meth = Protocol::Connection::Secure.new :challenge => 'secret'
|
178
|
+
meth.to_binary.should.be.kind_of? Buffer
|
179
|
+
|
180
|
+
meth.to_s.should == [ 10, 20, 6, 'secret' ].pack('nnNa*')
|
181
|
+
end
|
182
|
+
|
183
|
+
should 'convert binary to method' do
|
184
|
+
orig = Protocol::Connection::Secure.new :challenge => 'secret'
|
185
|
+
copy = Protocol.parse orig.to_binary
|
186
|
+
orig.should == copy
|
187
|
+
end
|
188
|
+
|
189
|
+
should 'convert headers to binary' do
|
190
|
+
head = Protocol::Header.new Protocol::Basic,
|
191
|
+
size = 5,
|
192
|
+
weight = 0,
|
193
|
+
:content_type => 'text/json',
|
194
|
+
:delivery_mode => 1,
|
195
|
+
:priority => 1
|
196
|
+
head.to_s.should == [ 60, weight, 0, size, 0b1001_1000_0000_0000, 9, 'text/json', 1, 1 ].pack('nnNNnCa*CC')
|
197
|
+
end
|
198
|
+
|
199
|
+
should 'convert binary to header' do
|
200
|
+
orig = Protocol::Header.new Protocol::Basic,
|
201
|
+
size = 5,
|
202
|
+
weight = 0,
|
203
|
+
:content_type => 'text/json',
|
204
|
+
:delivery_mode => 1,
|
205
|
+
:priority => 1
|
206
|
+
Protocol::Header.new(orig.to_binary).should == orig
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
data/lib/amqp/queue.rb
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
module Carrot::AMQP
|
2
|
+
class Queue
|
3
|
+
attr_reader :name, :carrot
|
4
|
+
attr_accessor :delivery_tag
|
5
|
+
|
6
|
+
def initialize(carrot, name, opts = {})
|
7
|
+
@opts = opts
|
8
|
+
@name = name
|
9
|
+
@carrot = carrot
|
10
|
+
server.send_frame(
|
11
|
+
Protocol::Queue::Declare.new({ :queue => name, :nowait => true }.merge(opts))
|
12
|
+
)
|
13
|
+
end
|
14
|
+
|
15
|
+
def pop(opts = {})
|
16
|
+
self.delivery_tag = nil
|
17
|
+
server.send_frame(
|
18
|
+
Protocol::Basic::Get.new({ :queue => name, :consumer_tag => name, :no_ack => !opts.delete(:ack), :nowait => true }.merge(opts))
|
19
|
+
)
|
20
|
+
method = server.next_method
|
21
|
+
return unless method.kind_of?(Protocol::Basic::GetOk)
|
22
|
+
|
23
|
+
self.delivery_tag = method.delivery_tag
|
24
|
+
|
25
|
+
header = server.next_payload
|
26
|
+
|
27
|
+
msg = ''
|
28
|
+
while msg.length < header.size
|
29
|
+
msg << server.next_payload
|
30
|
+
end
|
31
|
+
|
32
|
+
msg
|
33
|
+
end
|
34
|
+
|
35
|
+
def ack
|
36
|
+
server.send_frame(
|
37
|
+
Protocol::Basic::Ack.new(:delivery_tag => delivery_tag)
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
def publish(data, opts = {})
|
42
|
+
exchange.publish(data, opts)
|
43
|
+
end
|
44
|
+
|
45
|
+
def message_count
|
46
|
+
status.first
|
47
|
+
end
|
48
|
+
|
49
|
+
def consumer_count
|
50
|
+
status.last
|
51
|
+
end
|
52
|
+
|
53
|
+
def status(opts = {}, &blk)
|
54
|
+
server.send_frame(
|
55
|
+
Protocol::Queue::Declare.new({ :queue => name, :passive => true }.merge(opts))
|
56
|
+
)
|
57
|
+
method = server.next_method
|
58
|
+
return [nil, nil] if method.kind_of?(Protocol::Connection::Close)
|
59
|
+
|
60
|
+
[method.message_count, method.consumer_count]
|
61
|
+
end
|
62
|
+
|
63
|
+
def bind(exchange, opts = {})
|
64
|
+
exchange = exchange.respond_to?(:name) ? exchange.name : exchange
|
65
|
+
bindings[exchange] = opts
|
66
|
+
server.send_frame(
|
67
|
+
Protocol::Queue::Bind.new({ :queue => name, :exchange => exchange, :routing_key => opts.delete(:key), :nowait => true }.merge(opts))
|
68
|
+
)
|
69
|
+
end
|
70
|
+
|
71
|
+
def unbind(exchange, opts = {})
|
72
|
+
exchange = exchange.respond_to?(:name) ? exchange.name : exchange
|
73
|
+
bindings.delete(exchange)
|
74
|
+
|
75
|
+
server.send_frame(
|
76
|
+
Protocol::Queue::Unbind.new({
|
77
|
+
:queue => name, :exchange => exchange, :routing_key => opts.delete(:key), :nowait => true }.merge(opts)
|
78
|
+
)
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
def delete(opts = {})
|
83
|
+
server.send_frame(
|
84
|
+
Protocol::Queue::Delete.new({ :queue => name, :nowait => true }.merge(opts))
|
85
|
+
)
|
86
|
+
carrot.queues.delete(name)
|
87
|
+
end
|
88
|
+
|
89
|
+
def purge(opts = {})
|
90
|
+
server.send_frame(
|
91
|
+
Protocol::Queue::Purge.new({ :queue => name, :nowait => true }.merge(opts))
|
92
|
+
)
|
93
|
+
end
|
94
|
+
|
95
|
+
def server
|
96
|
+
carrot.server
|
97
|
+
end
|
98
|
+
|
99
|
+
##
|
100
|
+
# Is a wrapper around publish to send persistent messages.
|
101
|
+
|
102
|
+
def send_message(data,opts={})
|
103
|
+
opts.merge!(:persistent => true)
|
104
|
+
exchange.publish(data,opts)
|
105
|
+
end
|
106
|
+
|
107
|
+
def encrypt_message(message, password)
|
108
|
+
encrypted_message = message.encrypt(:symmetric, :password => password)
|
109
|
+
encrypted_message
|
110
|
+
end
|
111
|
+
|
112
|
+
def decrypt_message(message, password)
|
113
|
+
decrypted_message = message.decrypt(:symmetric, :password => password)
|
114
|
+
decrypted_message
|
115
|
+
end
|
116
|
+
|
117
|
+
##
|
118
|
+
# Is a wrapper around publish to send persistent and encrypted messages using symmetric key.
|
119
|
+
|
120
|
+
def encrypt_and_send_message(message, password, opts={})
|
121
|
+
opts.merge!(:persistent => true)
|
122
|
+
encrypted_message = encrypt_message(message, password)
|
123
|
+
exchange.publish(encrypted_message,opts)
|
124
|
+
end
|
125
|
+
|
126
|
+
##
|
127
|
+
# This method will receive and decrypt messages using symmetric key.
|
128
|
+
|
129
|
+
def receive_and_decrypt_message(password, opts={})
|
130
|
+
msg = pop(opts)
|
131
|
+
decrypted_message = decrypt_message(msg, password)
|
132
|
+
decrypted_message
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
def exchange
|
137
|
+
@exchange ||= Exchange.new(carrot, :direct, '', :key => name)
|
138
|
+
end
|
139
|
+
|
140
|
+
def bindings
|
141
|
+
@bindings ||= {}
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
data/lib/amqp/server.rb
ADDED
@@ -0,0 +1,187 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'thread'
|
3
|
+
require 'timeout'
|
4
|
+
|
5
|
+
module Carrot::AMQP
|
6
|
+
class Server
|
7
|
+
CONNECT_TIMEOUT = 1.0
|
8
|
+
RETRY_DELAY = 10.0
|
9
|
+
DEFAULT_PORT = 5672
|
10
|
+
|
11
|
+
attr_reader :host, :port, :status
|
12
|
+
attr_accessor :channel, :ticket
|
13
|
+
|
14
|
+
class ServerDown < StandardError; end
|
15
|
+
class ProtocolError < StandardError; end
|
16
|
+
|
17
|
+
def initialize(opts = {})
|
18
|
+
@host = opts[:host] || 'localhost'
|
19
|
+
@port = opts[:port] || DEFAULT_PORT
|
20
|
+
@user = opts[:user] || 'guest'
|
21
|
+
@pass = opts[:pass] || 'guest'
|
22
|
+
@vhost = opts[:vhost] || '/'
|
23
|
+
@insist = opts[:insist]
|
24
|
+
@status = 'NOT CONNECTED'
|
25
|
+
|
26
|
+
@multithread = opts[:multithread]
|
27
|
+
start_session
|
28
|
+
end
|
29
|
+
|
30
|
+
def send_frame(*args)
|
31
|
+
args.each do |data|
|
32
|
+
data.ticket = ticket if ticket and data.respond_to?(:ticket=)
|
33
|
+
data = data.to_frame(channel) unless data.is_a?(Frame)
|
34
|
+
data.channel = channel
|
35
|
+
|
36
|
+
log :send, data
|
37
|
+
write(data.to_s)
|
38
|
+
end
|
39
|
+
nil
|
40
|
+
end
|
41
|
+
|
42
|
+
def next_frame
|
43
|
+
frame = Frame.parse(buffer)
|
44
|
+
log :received, frame
|
45
|
+
frame
|
46
|
+
end
|
47
|
+
|
48
|
+
def next_method
|
49
|
+
next_payload
|
50
|
+
end
|
51
|
+
|
52
|
+
def next_payload
|
53
|
+
frame = next_frame
|
54
|
+
frame and frame.payload
|
55
|
+
end
|
56
|
+
|
57
|
+
def close
|
58
|
+
send_frame(
|
59
|
+
Protocol::Channel::Close.new(:reply_code => 200, :reply_text => 'bye', :method_id => 0, :class_id => 0)
|
60
|
+
)
|
61
|
+
puts "Error closing channel #{channel}" unless next_method.is_a?(Protocol::Channel::CloseOk)
|
62
|
+
|
63
|
+
self.channel = 0
|
64
|
+
send_frame(
|
65
|
+
Protocol::Connection::Close.new(:reply_code => 200, :reply_text => 'Goodbye', :class_id => 0, :method_id => 0)
|
66
|
+
)
|
67
|
+
puts "Error closing connection" unless next_method.is_a?(Protocol::Connection::CloseOk)
|
68
|
+
|
69
|
+
rescue ServerDown => e
|
70
|
+
ensure
|
71
|
+
close_socket
|
72
|
+
end
|
73
|
+
|
74
|
+
def read(*args)
|
75
|
+
send_command(:read, *args)
|
76
|
+
end
|
77
|
+
|
78
|
+
def write(*args)
|
79
|
+
send_command(:write, *args)
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def buffer
|
85
|
+
@buffer ||= Buffer.new(self)
|
86
|
+
end
|
87
|
+
|
88
|
+
def send_command(cmd, *args)
|
89
|
+
begin
|
90
|
+
socket.__send__(cmd, *args)
|
91
|
+
rescue Errno::EPIPE, IOError, Errno::ECONNRESET, Errno::EINVAL => e
|
92
|
+
raise ServerDown, e.message
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def socket
|
97
|
+
return @socket if @socket and not @socket.closed?
|
98
|
+
|
99
|
+
begin
|
100
|
+
# Attempt to connect.
|
101
|
+
mutex.lock if multithread?
|
102
|
+
@socket = timeout(CONNECT_TIMEOUT) do
|
103
|
+
TCPSocket.new(host, port)
|
104
|
+
end
|
105
|
+
|
106
|
+
if Socket.constants.include? 'TCP_NODELAY'
|
107
|
+
@socket.setsockopt Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1
|
108
|
+
end
|
109
|
+
@status = 'CONNECTED'
|
110
|
+
rescue SocketError, SystemCallError, IOError, Timeout::Error => e
|
111
|
+
msg = e.message << " - #{@host}:#{@port}"
|
112
|
+
raise ServerDown, e.message
|
113
|
+
ensure
|
114
|
+
mutex.unlock if multithread?
|
115
|
+
end
|
116
|
+
|
117
|
+
@socket
|
118
|
+
end
|
119
|
+
|
120
|
+
def start_session
|
121
|
+
@channel = 0
|
122
|
+
write(HEADER)
|
123
|
+
write([1, 1, VERSION_MAJOR, VERSION_MINOR].pack('C4'))
|
124
|
+
raise ProtocolError, 'bad start connection' unless next_method.is_a?(Protocol::Connection::Start)
|
125
|
+
|
126
|
+
send_frame(
|
127
|
+
Protocol::Connection::StartOk.new(
|
128
|
+
{:platform => 'Ruby', :product => 'Carrot', :information => 'http://github.com/famosagle/carrot', :version => VERSION},
|
129
|
+
'AMQPLAIN',
|
130
|
+
{:LOGIN => @user, :PASSWORD => @pass},
|
131
|
+
'en_US'
|
132
|
+
)
|
133
|
+
)
|
134
|
+
|
135
|
+
method = next_method
|
136
|
+
raise ProtocolError, "Bad AMQP Credentials. user: #{@user}, pass: #{@pass}" if method.nil?
|
137
|
+
|
138
|
+
if method.is_a?(Protocol::Connection::Tune)
|
139
|
+
send_frame(
|
140
|
+
Protocol::Connection::TuneOk.new( :channel_max => 0, :frame_max => 131072, :heartbeat => 0)
|
141
|
+
)
|
142
|
+
end
|
143
|
+
|
144
|
+
send_frame(
|
145
|
+
Protocol::Connection::Open.new(:virtual_host => @vhost, :capabilities => '', :insist => @insist)
|
146
|
+
)
|
147
|
+
raise ProtocolError, 'bad open connection' unless next_method.is_a?(Protocol::Connection::OpenOk)
|
148
|
+
|
149
|
+
@channel = 1
|
150
|
+
send_frame(Protocol::Channel::Open.new)
|
151
|
+
raise ProtocolError, "cannot open channel #{channel}" unless next_method.is_a?(Protocol::Channel::OpenOk)
|
152
|
+
|
153
|
+
send_frame(
|
154
|
+
Protocol::Access::Request.new(:realm => '/data', :read => true, :write => true, :active => true, :passive => true)
|
155
|
+
)
|
156
|
+
method = next_method
|
157
|
+
raise ProtocolError, 'access denied' unless method.is_a?(Protocol::Access::RequestOk)
|
158
|
+
self.ticket = method.ticket
|
159
|
+
end
|
160
|
+
|
161
|
+
def multithread?
|
162
|
+
@multithread
|
163
|
+
end
|
164
|
+
|
165
|
+
def close_socket(reason=nil)
|
166
|
+
# Close the socket. The server is not considered dead.
|
167
|
+
mutex.lock if multithread?
|
168
|
+
@socket.close if @socket and not @socket.closed?
|
169
|
+
@socket = nil
|
170
|
+
@status = "NOT CONNECTED"
|
171
|
+
ensure
|
172
|
+
mutex.unlock if multithread?
|
173
|
+
end
|
174
|
+
|
175
|
+
def mutex
|
176
|
+
@mutex ||= Mutex.new
|
177
|
+
end
|
178
|
+
|
179
|
+
def log(*args)
|
180
|
+
return unless Carrot.logging?
|
181
|
+
require 'pp'
|
182
|
+
pp args
|
183
|
+
puts
|
184
|
+
end
|
185
|
+
|
186
|
+
end
|
187
|
+
end
|