raptor 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.
- checksums.yaml +7 -0
- data/.buildkite/pipeline.yml +36 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +86 -0
- data/Rakefile +28 -0
- data/exe/raptor +8 -0
- data/ext/raptor_http/extconf.rb +7 -0
- data/ext/raptor_http/raptor_http.c +1248 -0
- data/ext/raptor_http2/extconf.rb +7 -0
- data/ext/raptor_http2/huffman_table.h +4888 -0
- data/ext/raptor_http2/raptor_http2.c +772 -0
- data/lib/raptor/binder.rb +249 -0
- data/lib/raptor/cli.rb +171 -0
- data/lib/raptor/cluster.rb +357 -0
- data/lib/raptor/http2.rb +416 -0
- data/lib/raptor/reactor.rb +411 -0
- data/lib/raptor/request.rb +992 -0
- data/lib/raptor/server.rb +167 -0
- data/lib/raptor/stats.rb +94 -0
- data/lib/raptor/version.rb +6 -0
- data/lib/raptor.rb +13 -0
- data/sig/generated/raptor/binder.rbs +162 -0
- data/sig/generated/raptor/cli.rbs +71 -0
- data/sig/generated/raptor/cluster.rbs +171 -0
- data/sig/generated/raptor/http2.rbs +145 -0
- data/sig/generated/raptor/reactor.rbs +251 -0
- data/sig/generated/raptor/request.rbs +477 -0
- data/sig/generated/raptor/server.rbs +88 -0
- data/sig/generated/raptor/stats.rbs +78 -0
- data/sig/generated/raptor/version.rbs +5 -0
- data/sig/generated/raptor.rbs +9 -0
- metadata +160 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "socket"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Raptor
|
|
8
|
+
# Manages binding to network addresses and creating listening sockets.
|
|
9
|
+
#
|
|
10
|
+
# Binder handles parsing URI bind specifications, creating TCP, Unix, and SSL
|
|
11
|
+
# server sockets, and managing socket options for optimal performance. It
|
|
12
|
+
# supports binding to multiple addresses simultaneously.
|
|
13
|
+
#
|
|
14
|
+
# @example TCP binding
|
|
15
|
+
# binder = Binder.new(["tcp://0.0.0.0:3000", "tcp://[::1]:3000"])
|
|
16
|
+
# puts binder.addresses #=> ["0.0.0.0:3000", "[::1]:3000"]
|
|
17
|
+
# binder.close
|
|
18
|
+
#
|
|
19
|
+
# @example Unix socket binding
|
|
20
|
+
# binder = Binder.new(["unix:///tmp/raptor.sock"])
|
|
21
|
+
# puts binder.addresses #=> ["/tmp/raptor.sock"]
|
|
22
|
+
# binder.close
|
|
23
|
+
#
|
|
24
|
+
# @example SSL binding
|
|
25
|
+
# binder = Binder.new(["ssl://0.0.0.0:443?cert=/path/to.crt&key=/path/to.key"])
|
|
26
|
+
# puts binder.addresses #=> ["ssl://0.0.0.0:443"]
|
|
27
|
+
# binder.close
|
|
28
|
+
#
|
|
29
|
+
# @example Localhost binding
|
|
30
|
+
# binder = Binder.new(["tcp://localhost:8080"])
|
|
31
|
+
# # Binds to both IPv4 and IPv6 loopback addresses
|
|
32
|
+
#
|
|
33
|
+
class Binder
|
|
34
|
+
SOCKET_BACKLOG = 1024
|
|
35
|
+
|
|
36
|
+
class UnknownBindSchemeError < TypeError
|
|
37
|
+
# @rbs (String scheme) -> void
|
|
38
|
+
def initialize(scheme) = super("unknown scheme: #{scheme.inspect}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Wraps a TCPServer with an SSL context for accepting SSL connections.
|
|
42
|
+
#
|
|
43
|
+
# Holds both the underlying TCP server and the SSL context together so
|
|
44
|
+
# the server thread can accept a TCP connection and then perform the SSL
|
|
45
|
+
# handshake in a single coordinated step.
|
|
46
|
+
#
|
|
47
|
+
SslListener = Data.define(:tcp_server, :ssl_context) do
|
|
48
|
+
# @rbs () -> TCPServer
|
|
49
|
+
def to_io = tcp_server
|
|
50
|
+
|
|
51
|
+
# @rbs () -> Addrinfo
|
|
52
|
+
def local_address = tcp_server.local_address
|
|
53
|
+
|
|
54
|
+
# @rbs () -> void
|
|
55
|
+
def close = tcp_server.close
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @rbs @bind_uris: Array[String]
|
|
59
|
+
# @rbs @listeners: Array[TCPServer | UNIXServer | SslListener]
|
|
60
|
+
|
|
61
|
+
# Array of listening sockets.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<TCPServer, UNIXServer, SslListener>] the server sockets
|
|
64
|
+
attr_reader :listeners
|
|
65
|
+
|
|
66
|
+
# Creates a new Binder with the specified bind URIs.
|
|
67
|
+
#
|
|
68
|
+
# Parses the provided bind URIs and creates listening sockets for each one.
|
|
69
|
+
# Supports tcp://, unix://, and ssl:// schemes. Localhost is expanded to
|
|
70
|
+
# all available loopback addresses (both IPv4 and IPv6).
|
|
71
|
+
#
|
|
72
|
+
# @param bind_uris [Array<String>] array of URI strings to bind to
|
|
73
|
+
# @return [void]
|
|
74
|
+
# @raise [UnknownBindSchemeError] if a URI has an unsupported scheme
|
|
75
|
+
#
|
|
76
|
+
# @example
|
|
77
|
+
# binder = Binder.new(["tcp://0.0.0.0:3000", "unix:///tmp/raptor.sock"])
|
|
78
|
+
#
|
|
79
|
+
# @rbs (Array[String] bind_uris) -> void
|
|
80
|
+
def initialize(bind_uris)
|
|
81
|
+
@bind_uris = bind_uris
|
|
82
|
+
@listeners = nil
|
|
83
|
+
parse
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the bound addresses as strings.
|
|
87
|
+
#
|
|
88
|
+
# TCP listeners are returned as "host:port", Unix listeners as the socket
|
|
89
|
+
# path, and SSL listeners as "ssl://host:port".
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<String>] address strings for each bound listener
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# binder.addresses #=> ["127.0.0.1:3000", "/tmp/raptor.sock", "ssl://0.0.0.0:443"]
|
|
95
|
+
#
|
|
96
|
+
# @rbs () -> Array[String]
|
|
97
|
+
def addresses
|
|
98
|
+
@listeners.map do |listener|
|
|
99
|
+
case listener
|
|
100
|
+
when UNIXServer
|
|
101
|
+
listener.path
|
|
102
|
+
when SslListener
|
|
103
|
+
address = listener.local_address
|
|
104
|
+
"ssl://#{address.ip_address}:#{address.ip_port}"
|
|
105
|
+
else
|
|
106
|
+
address = listener.local_address
|
|
107
|
+
"#{address.ip_address}:#{address.ip_port}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the port number of the first TCP or SSL listener.
|
|
113
|
+
#
|
|
114
|
+
# Used to populate SERVER_PORT in the Rack environment. Returns 0
|
|
115
|
+
# if no TCP or SSL listener is configured (e.g., Unix socket only).
|
|
116
|
+
#
|
|
117
|
+
# @return [Integer] the port number, or 0 if no TCP listener exists
|
|
118
|
+
#
|
|
119
|
+
# @rbs () -> Integer
|
|
120
|
+
def server_port
|
|
121
|
+
tcp_listener = @listeners.find { |listener| listener.is_a?(TCPServer) || listener.is_a?(SslListener) }
|
|
122
|
+
return 0 unless tcp_listener
|
|
123
|
+
|
|
124
|
+
tcp_listener.local_address.ip_port
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Closes all listening sockets.
|
|
128
|
+
#
|
|
129
|
+
# @return [void]
|
|
130
|
+
#
|
|
131
|
+
# @rbs () -> void
|
|
132
|
+
def close
|
|
133
|
+
@listeners.each(&:close)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Parses bind URIs and creates listening sockets.
|
|
139
|
+
#
|
|
140
|
+
# @return [void]
|
|
141
|
+
# @raise [UnknownBindSchemeError] if a URI scheme is not supported
|
|
142
|
+
#
|
|
143
|
+
# @rbs () -> void
|
|
144
|
+
def parse
|
|
145
|
+
@listeners = @bind_uris.map do |bind_uri|
|
|
146
|
+
uri = URI.parse(bind_uri)
|
|
147
|
+
case uri.scheme
|
|
148
|
+
when "tcp"
|
|
149
|
+
create_tcp_listeners(uri.host, uri.port)
|
|
150
|
+
when "unix"
|
|
151
|
+
create_unix_listeners(uri.path)
|
|
152
|
+
when "ssl"
|
|
153
|
+
create_ssl_listeners(uri.host, uri.port, URI.decode_www_form(uri.query || "").to_h)
|
|
154
|
+
else
|
|
155
|
+
raise UnknownBindSchemeError.new(uri.scheme)
|
|
156
|
+
end
|
|
157
|
+
end.tap(&:flatten!)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Creates TCP server sockets for the given host and port.
|
|
161
|
+
#
|
|
162
|
+
# @param host [String, nil] hostname or IP address to bind to
|
|
163
|
+
# @param port [Integer, nil] port number to bind to
|
|
164
|
+
# @return [Array<TCPServer>] array containing the created TCP server socket(s)
|
|
165
|
+
#
|
|
166
|
+
# @rbs (String? host, Integer? port) -> Array[TCPServer]
|
|
167
|
+
def create_tcp_listeners(host, port)
|
|
168
|
+
if host == "localhost"
|
|
169
|
+
return loopback_addresses.map { |address| create_tcp_listeners(address, port) }.flatten
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
host = host[1..-2] if host&.start_with?("[")
|
|
173
|
+
|
|
174
|
+
tcp_server = TCPServer.new(host, port)
|
|
175
|
+
tcp_server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
|
176
|
+
tcp_server.listen SOCKET_BACKLOG
|
|
177
|
+
|
|
178
|
+
[tcp_server]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Creates a Unix domain server socket at the given path.
|
|
182
|
+
#
|
|
183
|
+
# Removes stale socket files left by crashed processes (when the socket
|
|
184
|
+
# is not currently in use). Registers an at_exit hook to clean up the
|
|
185
|
+
# socket file on normal process exit.
|
|
186
|
+
#
|
|
187
|
+
# @param path [String] filesystem path for the Unix socket
|
|
188
|
+
# @return [Array<UNIXServer>] array containing the created Unix server socket
|
|
189
|
+
# @raise [RuntimeError] if the socket path is already in active use
|
|
190
|
+
#
|
|
191
|
+
# @rbs (String path) -> Array[UNIXServer]
|
|
192
|
+
def create_unix_listeners(path)
|
|
193
|
+
if File.exist?(path)
|
|
194
|
+
begin
|
|
195
|
+
UNIXSocket.new(path).close
|
|
196
|
+
raise "Socket #{path.inspect} is already in use"
|
|
197
|
+
rescue Errno::ECONNREFUSED
|
|
198
|
+
File.delete(path)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
server = UNIXServer.new(path)
|
|
203
|
+
master_pid = Process.pid
|
|
204
|
+
at_exit { File.delete(path) rescue nil if Process.pid == master_pid }
|
|
205
|
+
|
|
206
|
+
[server]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Creates SSL server sockets for the given host, port, and SSL parameters.
|
|
210
|
+
#
|
|
211
|
+
# Wraps each TCP listener with an SSL context to produce SslListener objects.
|
|
212
|
+
# The ssl_params hash must include "cert" and "key" entries pointing to the
|
|
213
|
+
# certificate and private key files respectively.
|
|
214
|
+
#
|
|
215
|
+
# @param host [String, nil] hostname or IP address to bind to
|
|
216
|
+
# @param port [Integer, nil] port number to bind to
|
|
217
|
+
# @param ssl_params [Hash<String, String>] SSL options ("cert" and "key" paths)
|
|
218
|
+
# @return [Array<SslListener>] array containing the created SSL listener(s)
|
|
219
|
+
#
|
|
220
|
+
# @rbs (String? host, Integer? port, Hash[String, String] ssl_params) -> Array[SslListener]
|
|
221
|
+
def create_ssl_listeners(host, port, ssl_params)
|
|
222
|
+
require "openssl"
|
|
223
|
+
|
|
224
|
+
tcp_servers = create_tcp_listeners(host, port)
|
|
225
|
+
|
|
226
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
227
|
+
ssl_context.cert = OpenSSL::X509::Certificate.new(File.read(ssl_params["cert"]))
|
|
228
|
+
ssl_context.key = OpenSSL::PKey.read(File.read(ssl_params["key"]))
|
|
229
|
+
ssl_context.alpn_protocols = ["h2", "http/1.1"]
|
|
230
|
+
ssl_context.alpn_select_cb = ->(protocols) { protocols.include?("h2") ? "h2" : "http/1.1" }
|
|
231
|
+
ssl_context.freeze
|
|
232
|
+
|
|
233
|
+
tcp_servers.map { |tcp_server| SslListener.new(tcp_server: tcp_server, ssl_context: ssl_context) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Returns all available loopback IP addresses.
|
|
237
|
+
#
|
|
238
|
+
# @return [Array<String>] unique loopback addresses (IPv4 and IPv6)
|
|
239
|
+
#
|
|
240
|
+
# @rbs () -> Array[String]
|
|
241
|
+
def loopback_addresses
|
|
242
|
+
Socket.ip_address_list.filter_map do |addrinfo|
|
|
243
|
+
next unless addrinfo.ipv4_loopback? || addrinfo.ipv6_loopback?
|
|
244
|
+
|
|
245
|
+
addrinfo.ip_address
|
|
246
|
+
end.tap(&:uniq!)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
data/lib/raptor/cli.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "etc"
|
|
5
|
+
require "json"
|
|
6
|
+
require "optparse"
|
|
7
|
+
|
|
8
|
+
require_relative "cluster"
|
|
9
|
+
|
|
10
|
+
module Raptor
|
|
11
|
+
# Command-line interface for the Raptor web server.
|
|
12
|
+
#
|
|
13
|
+
# CLI parses command-line arguments and starts the server cluster with the
|
|
14
|
+
# specified configuration options. It supports configuring the number of
|
|
15
|
+
# workers, threads, ractors, bind addresses, and various client timeout
|
|
16
|
+
# settings.
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage
|
|
19
|
+
# cli = Raptor::CLI.new(["config.ru", "-t", "8", "-w", "4"])
|
|
20
|
+
# cli.run
|
|
21
|
+
#
|
|
22
|
+
# @example With custom timeouts
|
|
23
|
+
# cli = Raptor::CLI.new(["--first-data-timeout", "60", "--threads", "8"])
|
|
24
|
+
# cli.run
|
|
25
|
+
#
|
|
26
|
+
class CLI
|
|
27
|
+
DEFAULT_WORKER_COUNT = Etc.nprocessors
|
|
28
|
+
|
|
29
|
+
DEFAULT_OPTIONS = {
|
|
30
|
+
binds: ["tcp://0.0.0.0:9292"].freeze,
|
|
31
|
+
threads: 3,
|
|
32
|
+
ractors: 1,
|
|
33
|
+
workers: DEFAULT_WORKER_COUNT,
|
|
34
|
+
rackup: "config.ru",
|
|
35
|
+
stats_file: "tmp/raptor.json",
|
|
36
|
+
client: {
|
|
37
|
+
first_data_timeout: 30,
|
|
38
|
+
chunk_data_timeout: 10,
|
|
39
|
+
persistent_data_timeout: 65,
|
|
40
|
+
},
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# @rbs @command: Symbol
|
|
44
|
+
# @rbs @options: Hash[Symbol, untyped]
|
|
45
|
+
# @rbs @parser: OptionParser
|
|
46
|
+
|
|
47
|
+
# Creates a new CLI instance and parses command-line arguments.
|
|
48
|
+
#
|
|
49
|
+
# Parses the provided command-line arguments and configures the server
|
|
50
|
+
# options accordingly. A rackup file can be provided as the first
|
|
51
|
+
# positional argument (defaults to config.ru).
|
|
52
|
+
#
|
|
53
|
+
# @param argv [Array<String>] command-line arguments to parse
|
|
54
|
+
# @return [void]
|
|
55
|
+
# @raise [OptionParser::ParseError] if invalid options are provided
|
|
56
|
+
#
|
|
57
|
+
# @example With rackup file
|
|
58
|
+
# cli = CLI.new(["my_app.ru", "-w", "4"])
|
|
59
|
+
#
|
|
60
|
+
# @example With options only
|
|
61
|
+
# cli = CLI.new(["-t", "8", "-r", "2"])
|
|
62
|
+
#
|
|
63
|
+
# @rbs (Array[String] argv) -> void
|
|
64
|
+
def initialize(argv)
|
|
65
|
+
if argv.first == "stats"
|
|
66
|
+
argv.shift
|
|
67
|
+
@command = :stats
|
|
68
|
+
else
|
|
69
|
+
@command = :server
|
|
70
|
+
end
|
|
71
|
+
@options = DEFAULT_OPTIONS.dup
|
|
72
|
+
@options[:client] = @options[:client].dup
|
|
73
|
+
@parser = create_parser
|
|
74
|
+
@parser.parse!(argv)
|
|
75
|
+
|
|
76
|
+
@options[:rackup] = argv.first if @command == :server && argv.first
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Runs the requested command.
|
|
80
|
+
#
|
|
81
|
+
# @return [void]
|
|
82
|
+
#
|
|
83
|
+
# @rbs () -> void
|
|
84
|
+
def run
|
|
85
|
+
@command == :stats ? run_stats : Cluster.run(@options)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Reads and prints the stats file.
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
93
|
+
#
|
|
94
|
+
# @rbs () -> void
|
|
95
|
+
def run_stats
|
|
96
|
+
stats_file = @options[:stats_file]
|
|
97
|
+
|
|
98
|
+
unless File.exist?(stats_file)
|
|
99
|
+
warn "No stats file at #{stats_file.inspect}. Is Raptor running?"
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
data = JSON.parse(File.read(stats_file), symbolize_names: true)
|
|
104
|
+
|
|
105
|
+
puts "Master PID: #{data[:master_pid]}"
|
|
106
|
+
data[:workers].each_with_index do |worker, index|
|
|
107
|
+
status = worker[:booted] ? "booted" : "starting"
|
|
108
|
+
last_checkin = Time.at(worker[:last_checkin]).strftime("%H:%M:%S")
|
|
109
|
+
puts "Worker #{index}: pid=#{worker[:pid]}, requests=#{worker[:requests]}, " \
|
|
110
|
+
"backlog=#{worker[:backlog]}, #{status}, last_checkin=#{last_checkin}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Creates the OptionParser instance with all supported command-line options.
|
|
115
|
+
#
|
|
116
|
+
# @return [OptionParser] configured option parser
|
|
117
|
+
#
|
|
118
|
+
# @rbs () -> OptionParser
|
|
119
|
+
def create_parser
|
|
120
|
+
OptionParser.new do |opts|
|
|
121
|
+
opts.banner = "Usage: raptor [options] [rackup file]"
|
|
122
|
+
|
|
123
|
+
opts.on("-b", "--bind URI", String, "Bind address (default: tcp://0.0.0.0:9292)") do |bind|
|
|
124
|
+
if @options[:binds] == DEFAULT_OPTIONS[:binds]
|
|
125
|
+
@options[:binds] = [bind]
|
|
126
|
+
else
|
|
127
|
+
@options[:binds] << bind
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
opts.on("-t", "--threads NUM", Integer, "Number of threads (default: 3)") do |num|
|
|
132
|
+
@options[:threads] = num
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
opts.on("-r", "--ractors NUM", Integer, "Number of ractors (default: 1)") do |num|
|
|
136
|
+
@options[:ractors] = num
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
opts.on("-w", "--workers NUM", Integer, "Number of worker processes (default: #{DEFAULT_WORKER_COUNT})") do |num|
|
|
140
|
+
@options[:workers] = num
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
opts.on("--first-data-timeout SECONDS", Integer, "First data timeout in seconds (default: 30)") do |timeout|
|
|
144
|
+
@options[:client][:first_data_timeout] = timeout
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
opts.on("--chunk-data-timeout SECONDS", Integer, "Chunk data timeout in seconds (default: 10)") do |timeout|
|
|
148
|
+
@options[:client][:chunk_data_timeout] = timeout
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
opts.on("--persistent-data-timeout SECONDS", Integer, "Persistent data timeout in seconds (default: 65)") do |timeout|
|
|
152
|
+
@options[:client][:persistent_data_timeout] = timeout
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
opts.on("--stats-file PATH", String, "Stats file path (default: tmp/raptor.json)") do |path|
|
|
156
|
+
@options[:stats_file] = path
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
opts.on("--help", "Show this help") do
|
|
160
|
+
puts opts
|
|
161
|
+
exit
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
opts.on("-v", "--version", "Show version") do
|
|
165
|
+
puts Raptor::VERSION
|
|
166
|
+
exit
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|