david 0.3.0.pre → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.travis.yml +24 -0
- data/Gemfile +11 -7
- data/Gemfile.lock +155 -19
- data/README.md +21 -3
- data/Rakefile +5 -0
- data/TODO.md +73 -0
- data/benchmarks/Gemfile +4 -0
- data/benchmarks/Gemfile.lock +31 -0
- data/benchmarks/rps.rb +20 -0
- data/bin/david +9 -4
- data/config.ru +4 -0
- data/david.gemspec +10 -4
- data/experiments/mcast.rb +37 -0
- data/experiments/structs.rb +45 -0
- data/{test.rb → experiments/test.rb} +0 -0
- data/lib/david.rb +15 -4
- data/lib/david/actor.rb +18 -0
- data/lib/david/garbage_collector.rb +35 -0
- data/lib/david/observe.rb +102 -0
- data/lib/david/rails/action_controller/base.rb +11 -0
- data/lib/david/railties/config.rb +20 -1
- data/lib/david/railties/middleware.rb +18 -6
- data/lib/david/request.rb +80 -0
- data/lib/david/resource_discovery.rb +92 -0
- data/lib/david/resource_discovery_proxy.rb +13 -0
- data/lib/david/server.rb +72 -27
- data/lib/david/server/constants.rb +48 -0
- data/lib/david/server/deduplication.rb +21 -0
- data/lib/david/server/mapping.rb +64 -12
- data/lib/david/server/multicast.rb +54 -0
- data/lib/david/server/options.rb +32 -0
- data/lib/david/server/respond.rb +146 -0
- data/lib/david/server/utility.rb +1 -6
- data/lib/david/show_exceptions.rb +52 -0
- data/lib/david/version.rb +2 -1
- data/lib/rack/handler/david.rb +16 -6
- data/lib/rack/hello_world.rb +23 -0
- data/spec/dummy/Rakefile +6 -0
- data/spec/dummy/app/assets/images/.keep +0 -0
- data/spec/dummy/app/assets/javascripts/application.js +13 -0
- data/spec/dummy/app/assets/stylesheets/application.css +15 -0
- data/spec/dummy/app/controllers/application_controller.rb +5 -0
- data/spec/dummy/app/controllers/concerns/.keep +0 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/mailers/.keep +0 -0
- data/spec/dummy/app/models/.keep +0 -0
- data/spec/dummy/app/models/concerns/.keep +0 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/bin/bundle +3 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/config/application.rb +29 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/database.yml +25 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +37 -0
- data/spec/dummy/config/environments/production.rb +78 -0
- data/spec/dummy/config/environments/test.rb +39 -0
- data/spec/dummy/config/initializers/assets.rb +8 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/spec/dummy/config/initializers/inflections.rb +16 -0
- data/spec/dummy/config/initializers/mime_types.rb +4 -0
- data/spec/dummy/config/initializers/session_store.rb +3 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy/config/locales/en.yml +23 -0
- data/spec/dummy/config/routes.rb +58 -0
- data/spec/dummy/config/secrets.yml +22 -0
- data/spec/dummy/db/test.sqlite3 +0 -0
- data/spec/dummy/lib/assets/.keep +0 -0
- data/spec/dummy/log/.keep +0 -0
- data/spec/dummy/public/404.html +67 -0
- data/spec/dummy/public/422.html +67 -0
- data/spec/dummy/public/500.html +66 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/guerilla_rack_handler_spec.rb +16 -0
- data/spec/mapping_spec.rb +56 -0
- data/spec/observe_spec.rb +111 -0
- data/spec/perf/server_perf_spec.rb +15 -9
- data/spec/resource_discovery_spec.rb +65 -0
- data/spec/server_spec.rb +306 -0
- data/spec/spec_helper.rb +43 -1
- data/spec/utility_spec.rb +8 -0
- metadata +195 -38
- data/lib/david/server/response.rb +0 -124
- data/lib/david/well_known.rb +0 -59
@@ -0,0 +1,92 @@
|
|
1
|
+
module David
|
2
|
+
class ResourceDiscovery
|
3
|
+
include Celluloid
|
4
|
+
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(env)
|
10
|
+
dup._call(env)
|
11
|
+
end
|
12
|
+
|
13
|
+
def _call(env)
|
14
|
+
return @app.call(env) if env['PATH_INFO'] != '/.well-known/core'
|
15
|
+
return [405, {}, []] if env['REQUEST_METHOD'] != 'GET'
|
16
|
+
|
17
|
+
@env = env
|
18
|
+
|
19
|
+
filtered = routes_hash.select { |link| filter(link) }
|
20
|
+
body = filtered.keys.map(&:to_s).join(',')
|
21
|
+
|
22
|
+
# TODO On multicast, do not respond if result set empty.
|
23
|
+
|
24
|
+
[
|
25
|
+
200,
|
26
|
+
{
|
27
|
+
'Content-Type' => 'application/link-format',
|
28
|
+
'Content-Length' => body.bytesize.to_s
|
29
|
+
},
|
30
|
+
[body]
|
31
|
+
]
|
32
|
+
end
|
33
|
+
|
34
|
+
def register(controller, options)
|
35
|
+
name = controller.controller_name
|
36
|
+
default = options.delete(:default)
|
37
|
+
|
38
|
+
routes_hash.each do |link, route|
|
39
|
+
next unless route[:controller] == name
|
40
|
+
|
41
|
+
link.merge!(default) unless default.nil?
|
42
|
+
|
43
|
+
attrs = options[route[:action].to_sym]
|
44
|
+
link.merge!(attrs) unless attrs.nil?
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def clean_routes
|
51
|
+
@clean_routes ||= routes
|
52
|
+
.uniq { |r| r[0] }
|
53
|
+
.select { |r| r if include_route?(r) }
|
54
|
+
.each { |r| delete_format!(r) }
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete_format!(route)
|
58
|
+
route[0].gsub!(/\(\.:format\)\z/, '')
|
59
|
+
end
|
60
|
+
|
61
|
+
def filter(link)
|
62
|
+
href = @env['QUERY_STRING'].split('href=').last
|
63
|
+
|
64
|
+
return true if href.blank?
|
65
|
+
|
66
|
+
# TODO If query end in '*', match on prefix.
|
67
|
+
# Otherwise match on whole string.
|
68
|
+
# https://tools.ietf.org/html/rfc6690#section-4.1
|
69
|
+
link.uri =~ Regexp.new(href)
|
70
|
+
end
|
71
|
+
|
72
|
+
def include_route?(route)
|
73
|
+
!(route[0] =~ /\A\/(assets|rails)/)
|
74
|
+
end
|
75
|
+
|
76
|
+
def routes
|
77
|
+
Rails.application.routes.routes.map do |route|
|
78
|
+
[
|
79
|
+
route.path.spec.to_s,
|
80
|
+
route.defaults[:controller],
|
81
|
+
route.defaults[:action]
|
82
|
+
]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def routes_hash
|
87
|
+
@routes_hash ||= Hash[clean_routes.collect { |r|
|
88
|
+
[CoRE::Link.new(r[0]), { controller: r[1], action: r[2] }]
|
89
|
+
}]
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/lib/david/server.rb
CHANGED
@@ -1,35 +1,46 @@
|
|
1
|
+
require 'david/server/deduplication'
|
2
|
+
require 'david/server/multicast'
|
1
3
|
require 'david/server/options'
|
2
|
-
require 'david/server/
|
4
|
+
require 'david/server/respond'
|
3
5
|
|
4
6
|
module David
|
5
7
|
class Server
|
6
8
|
include Celluloid::IO
|
7
|
-
include CoAP::
|
9
|
+
include CoAP::Coding
|
8
10
|
|
11
|
+
include Deduplication
|
12
|
+
include Multicast
|
9
13
|
include Options
|
10
|
-
include
|
14
|
+
include Respond
|
11
15
|
|
12
|
-
attr_reader :logger
|
16
|
+
attr_reader :logger, :socket
|
13
17
|
|
14
18
|
finalizer :shutdown
|
15
19
|
|
16
20
|
def initialize(app, options)
|
17
|
-
@
|
18
|
-
@
|
19
|
-
@
|
20
|
-
@
|
21
|
+
@block = choose(:block, options[:Block])
|
22
|
+
@cbor = choose(:cbor, options[:CBOR])
|
23
|
+
@host = choose(:host, options[:Host])
|
24
|
+
@logger = choose(:logger, options[:Log])
|
25
|
+
@mcast = choose(:mcast, options[:Multicast])
|
26
|
+
@observe = choose(:observe, options[:Observe])
|
27
|
+
@port = options[:Port].to_i
|
21
28
|
|
22
|
-
@app
|
29
|
+
@app = app.respond_to?(:new) ? app.new : app
|
30
|
+
|
31
|
+
@default_format = choose(:default_format, options[:DefaultFormat])
|
32
|
+
|
33
|
+
@dedup_cache = {}
|
23
34
|
|
24
35
|
logger.info "David #{David::VERSION} on #{RUBY_DESCRIPTION}"
|
25
36
|
logger.info "Starting on [#{@host}]:#{@port}"
|
26
37
|
|
27
|
-
ipv6 = IPAddr.new(@host).ipv6?
|
28
|
-
af = ipv6 ? ::Socket::AF_INET6 : ::Socket::AF_INET
|
38
|
+
@ipv6 = IPAddr.new(@host).ipv6?
|
39
|
+
af = @ipv6 ? ::Socket::AF_INET6 : ::Socket::AF_INET
|
29
40
|
|
30
|
-
# Actually Celluloid::IO::
|
31
|
-
# (Use celluloid-io from git, 0.15.0 does not support AF_INET6).
|
41
|
+
# Actually Celluloid::IO::UDPSocket.
|
32
42
|
@socket = UDPSocket.new(af)
|
43
|
+
multicast_initialize if @mcast
|
33
44
|
@socket.bind(@host, @port)
|
34
45
|
|
35
46
|
async.run
|
@@ -37,26 +48,60 @@ module David
|
|
37
48
|
|
38
49
|
private
|
39
50
|
|
40
|
-
def
|
41
|
-
|
42
|
-
end
|
51
|
+
def handle_input(*args)
|
52
|
+
data, sender, _, anc = args
|
43
53
|
|
44
|
-
|
45
|
-
|
46
|
-
|
54
|
+
if defined?(JRuby)
|
55
|
+
port, _, host = sender[1..3]
|
56
|
+
else
|
57
|
+
host, port = sender.ip_address, sender.ip_port
|
58
|
+
end
|
59
|
+
|
60
|
+
message = CoAP::Message.parse(data)
|
61
|
+
request = Request.new(host, port, message, anc)
|
47
62
|
|
48
|
-
|
49
|
-
|
50
|
-
request
|
63
|
+
return unless request.con? || request.non?
|
64
|
+
return unless request.valid_method?
|
65
|
+
return if !request.non? && request.multicast?
|
51
66
|
|
52
|
-
logger.info "[#{host}]:#{port}: #{request}"
|
53
|
-
logger.debug
|
67
|
+
logger.info "[#{host}]:#{port}: #{message} (block #{request.block.num})"
|
68
|
+
logger.debug message.inspect
|
54
69
|
|
55
|
-
|
70
|
+
if request.con? && duplicate?(request) #&& !request.idempotent?
|
71
|
+
response, options = cached_response(request)
|
72
|
+
logger.debug "(mid:#{request.mid} duplicate, response cached)"
|
73
|
+
else
|
74
|
+
response, options = respond(request)
|
75
|
+
end
|
56
76
|
|
57
|
-
|
77
|
+
unless response.nil?
|
78
|
+
logger.debug response.inspect
|
58
79
|
|
59
|
-
|
80
|
+
CoAP::Transmission.send(response, host, port,
|
81
|
+
options.merge(socket: @socket))
|
82
|
+
|
83
|
+
request.options = options
|
84
|
+
cache_response(request, response)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def run
|
89
|
+
loop do
|
90
|
+
if defined?(JRuby)
|
91
|
+
async.handle_input(*@socket.recvfrom(1152))
|
92
|
+
else
|
93
|
+
begin
|
94
|
+
async.handle_input(*@socket.to_io.recvmsg_nonblock)
|
95
|
+
rescue ::IO::WaitReadable
|
96
|
+
Celluloid::IO.wait_readable(@socket)
|
97
|
+
retry
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def shutdown
|
104
|
+
@socket.close unless @socket.nil?
|
60
105
|
end
|
61
106
|
end
|
62
107
|
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module David
|
2
|
+
class Server
|
3
|
+
module Constants
|
4
|
+
# Freeze some WSGI env keys.
|
5
|
+
REMOTE_ADDR = 'REMOTE_ADDR'.freeze
|
6
|
+
REMOTE_PORT = 'REMOTE_PORT'.freeze
|
7
|
+
REQUEST_METHOD = 'REQUEST_METHOD'.freeze
|
8
|
+
SCRIPT_NAME = 'SCRIPT_NAME'.freeze
|
9
|
+
PATH_INFO = 'PATH_INFO'.freeze
|
10
|
+
QUERY_STRING = 'QUERY_STRING'.freeze
|
11
|
+
SERVER_NAME = 'SERVER_NAME'.freeze
|
12
|
+
SERVER_PORT = 'SERVER_PORT'.freeze
|
13
|
+
CONTENT_LENGTH = 'CONTENT_LENGTH'.freeze
|
14
|
+
CONTENT_TYPE = 'CONTENT_TYPE'.freeze
|
15
|
+
HTTP_ACCEPT = 'HTTP_ACCEPT'.freeze
|
16
|
+
|
17
|
+
# Freeze some Rack env keys.
|
18
|
+
RACK_VERSION = 'rack.version'.freeze
|
19
|
+
RACK_URL_SCHEME = 'rack.url_scheme'.freeze
|
20
|
+
RACK_INPUT = 'rack.input'.freeze
|
21
|
+
RACK_ERRORS = 'rack.errors'.freeze
|
22
|
+
RACK_MULTITHREAD = 'rack.multithread'.freeze
|
23
|
+
RACK_MULTIPROCESS = 'rack.multiprocess'.freeze
|
24
|
+
RACK_RUN_ONCE = 'rack.run_once'.freeze
|
25
|
+
RACK_LOGGER = 'rack.logger'.freeze
|
26
|
+
|
27
|
+
# Freeze CoAP specific env keys.
|
28
|
+
COAP_VERSION = 'coap.version'.freeze
|
29
|
+
COAP_MULTICAST = 'coap.multicast'.freeze
|
30
|
+
COAP_DTLS = 'coap.dtls'.freeze
|
31
|
+
COAP_DTLS_ID = 'coap.dtls.id'.freeze
|
32
|
+
COAP_DTLS_NOSEC = 'NoSec'.freeze
|
33
|
+
|
34
|
+
# Freeze some Rack env values.
|
35
|
+
EMPTY_STRING = ''.freeze
|
36
|
+
CONTENT_TYPE_JSON = 'application/json'.freeze
|
37
|
+
CONTENT_TYPE_CBOR = 'application/cbor'.freeze
|
38
|
+
RACK_URL_SCHEME_HTTP = 'http'.freeze
|
39
|
+
|
40
|
+
# Freeze HTTP header strings.
|
41
|
+
HTTP_CACHE_CONTROL = 'Cache-Control'.freeze
|
42
|
+
HTTP_CONTENT_LENGTH = 'Content-Length'.freeze
|
43
|
+
HTTP_CONTENT_TYPE = 'Content-Type'.freeze
|
44
|
+
HTTP_ETAG = 'ETag'.freeze
|
45
|
+
HTTP_LOCATION = 'Location'.freeze
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module David
|
2
|
+
module Deduplication
|
3
|
+
def self.included(base)
|
4
|
+
attr_reader :dedup_cache
|
5
|
+
end
|
6
|
+
|
7
|
+
def cache_response(request, response)
|
8
|
+
return if duplicate?(request)
|
9
|
+
@dedup_cache[[request.host, request.mid]] = [response, Time.now.to_i]
|
10
|
+
end
|
11
|
+
|
12
|
+
def cached_response(request)
|
13
|
+
response = @dedup_cache[[request.host, request.mid]]
|
14
|
+
[response[0], response[0].options] if response
|
15
|
+
end
|
16
|
+
|
17
|
+
def duplicate?(request)
|
18
|
+
return !!cached_response(request)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/david/server/mapping.rb
CHANGED
@@ -1,32 +1,84 @@
|
|
1
1
|
module David
|
2
2
|
class Server
|
3
3
|
module Mapping
|
4
|
+
include Constants
|
5
|
+
|
6
|
+
HTTP_TO_COAP_CODES = {
|
7
|
+
200 => 205,
|
8
|
+
202 => 201,
|
9
|
+
203 => 205,
|
10
|
+
204 => 205,
|
11
|
+
304 => 203,
|
12
|
+
407 => 401,
|
13
|
+
408 => 400,
|
14
|
+
409 => 412,
|
15
|
+
410 => 404,
|
16
|
+
411 => 402,
|
17
|
+
414 => 402,
|
18
|
+
505 => 500,
|
19
|
+
506 => 500,
|
20
|
+
511 => 500,
|
21
|
+
}.freeze
|
22
|
+
|
4
23
|
protected
|
24
|
+
|
25
|
+
def accept_to_http(request)
|
26
|
+
if request.accept.nil?
|
27
|
+
@default_format
|
28
|
+
else
|
29
|
+
CoAP::Registry.convert_content_format(request.accept)
|
30
|
+
end
|
31
|
+
end
|
5
32
|
|
6
33
|
def body_to_cbor(body)
|
7
34
|
JSON.parse(body).to_cbor
|
8
35
|
end
|
9
36
|
|
10
|
-
def
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
def etag(options, bytes = 8)
|
15
|
-
etag = options['ETag']
|
16
|
-
etag.delete('"').bytes.first(bytes * 2).pack('C*').hex if etag
|
17
|
-
end
|
37
|
+
def code_to_coap(code)
|
38
|
+
if code.is_a?(Float)
|
39
|
+
return [code.to_i, (code * 100 % 100).round]
|
40
|
+
end
|
18
41
|
|
19
|
-
def http_to_coap_code(code)
|
20
42
|
code = code.to_i
|
21
|
-
|
22
|
-
h = {200 => 205}
|
23
|
-
code = h[code] if h[code]
|
43
|
+
code = HTTP_TO_COAP_CODES[code] if HTTP_TO_COAP_CODES[code]
|
24
44
|
|
25
45
|
a = code / 100
|
26
46
|
b = code - (a * 100)
|
27
47
|
|
28
48
|
[a, b]
|
29
49
|
end
|
50
|
+
|
51
|
+
def etag_to_coap(headers, bytes = 8)
|
52
|
+
etag = headers[HTTP_ETAG]
|
53
|
+
|
54
|
+
if etag
|
55
|
+
etag = etag.split('"')
|
56
|
+
etag = etag[1] || etag[0]
|
57
|
+
|
58
|
+
etag
|
59
|
+
.bytes
|
60
|
+
.first(bytes * 2)
|
61
|
+
.pack('C*')
|
62
|
+
.hex
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def location_to_coap(headers)
|
67
|
+
l = headers[HTTP_LOCATION].split('/').reject(&:empty?)
|
68
|
+
return l.empty? ? nil : l
|
69
|
+
rescue NoMethodError
|
70
|
+
nil
|
71
|
+
end
|
72
|
+
|
73
|
+
def max_age_to_coap(headers)
|
74
|
+
headers[HTTP_CACHE_CONTROL][/max-age=([0-9]*)/, 1]
|
75
|
+
rescue NoMethodError
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
|
79
|
+
def method_to_http(method)
|
80
|
+
method.to_s.upcase
|
81
|
+
end
|
30
82
|
end
|
31
83
|
end
|
32
84
|
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module David
|
2
|
+
class Server
|
3
|
+
# See https://tools.ietf.org/html/rfc7252#section-12.8
|
4
|
+
module Multicast
|
5
|
+
def multicast_initialize
|
6
|
+
@socket.to_io.setsockopt(:SOL_SOCKET, :SO_REUSEADDR, 1)
|
7
|
+
|
8
|
+
if @ipv6
|
9
|
+
maddrs = ['ff02::fd', 'ff05::fd']
|
10
|
+
maddrs << 'ff02::1' if OS.osx? # OSX needs ff02::1 explicitly joined.
|
11
|
+
maddrs.each { |maddr| multicast_listen_ipv6(maddr) }
|
12
|
+
|
13
|
+
setsockopts_ipv6
|
14
|
+
else
|
15
|
+
maddrs = ['224.0.1.187']
|
16
|
+
multicast_listen_ipv4(maddrs.first)
|
17
|
+
|
18
|
+
setsockopts_ipv4
|
19
|
+
end
|
20
|
+
|
21
|
+
logger.debug "Joined multicast groups: #{maddrs.join(', ')}"
|
22
|
+
rescue Errno::ENODEV, Errno::EADDRNOTAVAIL
|
23
|
+
logger.warn 'Multicast initialization failure: Device not found.'
|
24
|
+
@mcast = false
|
25
|
+
end
|
26
|
+
|
27
|
+
def multicast_listen_ipv4(address)
|
28
|
+
mreq = IPAddr.new(address).hton + IPAddr.new('0.0.0.0').hton
|
29
|
+
@socket.to_io.setsockopt(:IPPROTO_IP, :IP_ADD_MEMBERSHIP, mreq)
|
30
|
+
end
|
31
|
+
|
32
|
+
def multicast_listen_ipv6(address)
|
33
|
+
ifindex = 0
|
34
|
+
|
35
|
+
# http://lists.apple.com/archives/darwin-kernel/2014/Mar/msg00012.html
|
36
|
+
if OS.osx?
|
37
|
+
ifname = Socket.if_up?('en1') ? 'en1' : 'en0'
|
38
|
+
ifindex = Socket.if_nametoindex(ifname)
|
39
|
+
end
|
40
|
+
|
41
|
+
mreq = IPAddr.new(address).hton + [ifindex].pack('i_')
|
42
|
+
@socket.to_io.setsockopt(:IPPROTO_IPV6, :IPV6_JOIN_GROUP, mreq)
|
43
|
+
end
|
44
|
+
|
45
|
+
def setsockopts_ipv4
|
46
|
+
@socket.to_io.setsockopt(:IPPROTO_IP, :IP_PKTINFO, 1)
|
47
|
+
end
|
48
|
+
|
49
|
+
def setsockopts_ipv6
|
50
|
+
@socket.to_io.setsockopt(:IPPROTO_IPV6, :IPV6_RECVPKTINFO, 1)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|