karafka 0.5.0.3 → 0.6.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.console_irbrc +13 -0
- data/.github/ISSUE_TEMPLATE.md +2 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +59 -1
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +67 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +46 -147
- data/README.md +51 -952
- data/Rakefile +5 -14
- data/karafka.gemspec +19 -13
- data/lib/karafka.rb +7 -4
- data/lib/karafka/app.rb +10 -6
- data/lib/karafka/attributes_map.rb +67 -0
- data/lib/karafka/base_controller.rb +42 -52
- data/lib/karafka/base_responder.rb +30 -14
- data/lib/karafka/base_worker.rb +11 -26
- data/lib/karafka/cli.rb +2 -0
- data/lib/karafka/cli/base.rb +2 -0
- data/lib/karafka/cli/console.rb +7 -1
- data/lib/karafka/cli/flow.rb +13 -13
- data/lib/karafka/cli/info.rb +7 -4
- data/lib/karafka/cli/install.rb +4 -3
- data/lib/karafka/cli/server.rb +3 -1
- data/lib/karafka/cli/worker.rb +2 -0
- data/lib/karafka/connection/config_adapter.rb +103 -0
- data/lib/karafka/connection/listener.rb +16 -12
- data/lib/karafka/connection/messages_consumer.rb +86 -0
- data/lib/karafka/connection/messages_processor.rb +74 -0
- data/lib/karafka/errors.rb +15 -29
- data/lib/karafka/fetcher.rb +10 -8
- data/lib/karafka/helpers/class_matcher.rb +2 -0
- data/lib/karafka/helpers/config_retriever.rb +46 -0
- data/lib/karafka/helpers/multi_delegator.rb +2 -0
- data/lib/karafka/loader.rb +4 -2
- data/lib/karafka/logger.rb +37 -36
- data/lib/karafka/monitor.rb +3 -1
- data/lib/karafka/params/interchanger.rb +2 -0
- data/lib/karafka/params/params.rb +34 -41
- data/lib/karafka/params/params_batch.rb +46 -0
- data/lib/karafka/parsers/json.rb +4 -2
- data/lib/karafka/patches/dry_configurable.rb +2 -0
- data/lib/karafka/process.rb +4 -2
- data/lib/karafka/responders/builder.rb +2 -0
- data/lib/karafka/responders/topic.rb +14 -6
- data/lib/karafka/routing/builder.rb +22 -59
- data/lib/karafka/routing/consumer_group.rb +54 -0
- data/lib/karafka/routing/mapper.rb +2 -0
- data/lib/karafka/routing/proxy.rb +37 -0
- data/lib/karafka/routing/router.rb +18 -16
- data/lib/karafka/routing/topic.rb +78 -0
- data/lib/karafka/schemas/config.rb +36 -0
- data/lib/karafka/schemas/consumer_group.rb +56 -0
- data/lib/karafka/schemas/responder_usage.rb +38 -0
- data/lib/karafka/server.rb +5 -3
- data/lib/karafka/setup/config.rb +79 -32
- data/lib/karafka/setup/configurators/base.rb +2 -0
- data/lib/karafka/setup/configurators/celluloid.rb +2 -0
- data/lib/karafka/setup/configurators/sidekiq.rb +2 -0
- data/lib/karafka/setup/configurators/water_drop.rb +15 -3
- data/lib/karafka/status.rb +2 -0
- data/lib/karafka/templates/app.rb.example +15 -5
- data/lib/karafka/templates/application_worker.rb.example +0 -6
- data/lib/karafka/version.rb +2 -1
- data/lib/karafka/workers/builder.rb +2 -0
- metadata +109 -60
- data/lib/karafka/cli/routes.rb +0 -36
- data/lib/karafka/connection/consumer.rb +0 -33
- data/lib/karafka/connection/message.rb +0 -17
- data/lib/karafka/connection/topic_consumer.rb +0 -94
- data/lib/karafka/responders/usage_validator.rb +0 -60
- data/lib/karafka/routing/route.rb +0 -113
- data/lib/karafka/setup/config_schema.rb +0 -44
- data/lib/karafka/setup/configurators/worker_glass.rb +0 -13
- data/lib/karafka/templates/config.ru.example +0 -13
data/lib/karafka/cli/routes.rb
DELETED
@@ -1,36 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
# Karafka framework Cli
|
3
|
-
class Cli
|
4
|
-
# Routes Karafka Cli action
|
5
|
-
class Routes < Base
|
6
|
-
desc 'Print out all defined routes in alphabetical order'
|
7
|
-
option aliases: 'r'
|
8
|
-
|
9
|
-
# Print out all defined routes in alphabetical order
|
10
|
-
def call
|
11
|
-
routes.each do |route|
|
12
|
-
puts "#{route.topic}:"
|
13
|
-
Karafka::Routing::Route::ATTRIBUTES.each do |attr|
|
14
|
-
print(attr.to_s.capitalize, route.public_send(attr))
|
15
|
-
end
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
private
|
20
|
-
|
21
|
-
# @return [Array<Karafka::Routing::Route>] all routes sorted in alphabetical order
|
22
|
-
def routes
|
23
|
-
Karafka::App.routes.sort do |route1, route2|
|
24
|
-
route1.topic <=> route2.topic
|
25
|
-
end
|
26
|
-
end
|
27
|
-
|
28
|
-
# Prints a given value with label in a nice way
|
29
|
-
# @param label [String] label describing value
|
30
|
-
# @param value [String] value that should be printed
|
31
|
-
def print(label, value)
|
32
|
-
printf "%-18s %s\n", " - #{label}:", value
|
33
|
-
end
|
34
|
-
end
|
35
|
-
end
|
36
|
-
end
|
@@ -1,33 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
module Connection
|
3
|
-
# Class that consumes messages for which we listen
|
4
|
-
class Consumer
|
5
|
-
# Consumes a message (does something with it)
|
6
|
-
# It will execute a scheduling task from a proper controller based on a message topic
|
7
|
-
# @note This should be looped to obtain a constant listening
|
8
|
-
# @note We catch all the errors here, to make sure that none failures
|
9
|
-
# for a given consumption will affect other consumed messages
|
10
|
-
# If we would't catch it, it would propagate up until killing the Celluloid actor
|
11
|
-
# @param message [Kafka::FetchedMessage] message that was fetched by kafka
|
12
|
-
def consume(message)
|
13
|
-
# We map from incoming topic name, as it might be namespaced, etc.
|
14
|
-
# @see topic_mapper internal docs
|
15
|
-
mapped_topic = Karafka::App.config.topic_mapper.incoming(message.topic)
|
16
|
-
|
17
|
-
controller = Karafka::Routing::Router.new(mapped_topic).build
|
18
|
-
# We wrap it around with our internal message format, so we don't pass around
|
19
|
-
# a raw Kafka message
|
20
|
-
controller.params = Message.new(mapped_topic, message.value)
|
21
|
-
|
22
|
-
Karafka.monitor.notice(self.class, controller.to_h)
|
23
|
-
|
24
|
-
controller.schedule
|
25
|
-
# This is on purpose - see the notes for this method
|
26
|
-
# rubocop:disable RescueException
|
27
|
-
rescue Exception => e
|
28
|
-
# rubocop:enable RescueException
|
29
|
-
Karafka.monitor.notice_error(self.class, e)
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
33
|
-
end
|
@@ -1,17 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
# Namespace that encapsulates everything related to connections
|
3
|
-
module Connection
|
4
|
-
# Single incoming Kafka message instance wrapper
|
5
|
-
class Message
|
6
|
-
attr_reader :topic, :content
|
7
|
-
|
8
|
-
# @param topic [String] topic from which this message comes
|
9
|
-
# @param content [String] raw message content (not deserialized or anything) from Kafka
|
10
|
-
# @return [Karafka::Connection::Message] incoming message instance
|
11
|
-
def initialize(topic, content)
|
12
|
-
@topic = topic
|
13
|
-
@content = content
|
14
|
-
end
|
15
|
-
end
|
16
|
-
end
|
17
|
-
end
|
@@ -1,94 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
module Connection
|
3
|
-
# Class used as a wrapper around Ruby-Kafka to simplify additional
|
4
|
-
# features that we provide/might provide in future
|
5
|
-
class TopicConsumer
|
6
|
-
# How long should we wait before trying to reconnect to Kafka cluster
|
7
|
-
# that went down (in seconds)
|
8
|
-
RECONNECT_TIMEOUT = 5
|
9
|
-
|
10
|
-
# Creates a queue consumer that will pull the data from Kafka
|
11
|
-
# @param [Karafka::Routing::Route] route details that will be used to build up a
|
12
|
-
# queue consumer instance
|
13
|
-
# @return [Karafka::Connection::QueueConsumer] queue consumer instance
|
14
|
-
def initialize(route)
|
15
|
-
@route = route
|
16
|
-
end
|
17
|
-
|
18
|
-
# Opens connection, gets messages and calls a block for each of the incoming messages
|
19
|
-
# @yieldparam [Kafka::FetchedMessage] kafka fetched message
|
20
|
-
# @note This will yield with a raw message - no preprocessing or reformatting
|
21
|
-
def fetch_loop
|
22
|
-
send(
|
23
|
-
@route.batch_mode ? :consume_each_batch : :consume_each_message
|
24
|
-
) do |message|
|
25
|
-
yield(message)
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
# Gracefuly stops topic consumption
|
30
|
-
def stop
|
31
|
-
@kafka_consumer&.stop
|
32
|
-
@kafka_consumer = nil
|
33
|
-
end
|
34
|
-
|
35
|
-
private
|
36
|
-
|
37
|
-
# Consumes messages from Kafka in batches
|
38
|
-
# @yieldparam [Kafka::FetchedMessage] kafka fetched message
|
39
|
-
def consume_each_batch
|
40
|
-
kafka_consumer.each_batch do |batch|
|
41
|
-
batch.messages.each do |message|
|
42
|
-
yield(message)
|
43
|
-
end
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
# Consumes messages from Kafka one by one
|
48
|
-
# @yieldparam [Kafka::FetchedMessage] kafka fetched message
|
49
|
-
def consume_each_message
|
50
|
-
kafka_consumer.each_message do |message|
|
51
|
-
yield(message)
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
# @return [Kafka::Consumer] returns a ready to consume Kafka consumer
|
56
|
-
# that is set up to consume a given routes topic
|
57
|
-
def kafka_consumer
|
58
|
-
@kafka_consumer ||= kafka.consumer(
|
59
|
-
group_id: @route.group,
|
60
|
-
session_timeout: ::Karafka::App.config.kafka.session_timeout,
|
61
|
-
offset_commit_interval: ::Karafka::App.config.kafka.offset_commit_interval,
|
62
|
-
offset_commit_threshold: ::Karafka::App.config.kafka.offset_commit_threshold,
|
63
|
-
heartbeat_interval: ::Karafka::App.config.kafka.heartbeat_interval
|
64
|
-
).tap do |consumer|
|
65
|
-
consumer.subscribe(
|
66
|
-
@route.topic,
|
67
|
-
start_from_beginning: @route.start_from_beginning
|
68
|
-
)
|
69
|
-
end
|
70
|
-
rescue Kafka::ConnectionError
|
71
|
-
# If we would not wait it would totally spam log file with failed
|
72
|
-
# attempts if Kafka is down
|
73
|
-
sleep(RECONNECT_TIMEOUT)
|
74
|
-
# We don't log and just reraise - this will be logged
|
75
|
-
# down the road
|
76
|
-
raise
|
77
|
-
end
|
78
|
-
|
79
|
-
# @return [Kafka] returns a Kafka
|
80
|
-
# @note We don't cache it internally because we cache kafka_consumer that uses kafka
|
81
|
-
# object instance
|
82
|
-
def kafka
|
83
|
-
Kafka.new(
|
84
|
-
seed_brokers: ::Karafka::App.config.kafka.hosts,
|
85
|
-
logger: ::Karafka.logger,
|
86
|
-
client_id: ::Karafka::App.config.name,
|
87
|
-
ssl_ca_cert: ::Karafka::App.config.kafka.ssl.ca_cert,
|
88
|
-
ssl_client_cert: ::Karafka::App.config.kafka.ssl.client_cert,
|
89
|
-
ssl_client_cert_key: ::Karafka::App.config.kafka.ssl.client_cert_key
|
90
|
-
)
|
91
|
-
end
|
92
|
-
end
|
93
|
-
end
|
94
|
-
end
|
@@ -1,60 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
module Responders
|
3
|
-
# Usage validator checks if all the requirements related to responders topics were met
|
4
|
-
class UsageValidator
|
5
|
-
# @param registered_topics [Hash] Hash with registered topics objects from
|
6
|
-
# a given responder class under it's name key
|
7
|
-
# @param used_topics [Array<String>] Array with names of topics that we used in this
|
8
|
-
# responding process
|
9
|
-
# @return [Karafka::Responders::UsageValidator] responding flow usage validator
|
10
|
-
def initialize(registered_topics, used_topics)
|
11
|
-
@registered_topics = registered_topics
|
12
|
-
@used_topics = used_topics
|
13
|
-
end
|
14
|
-
|
15
|
-
# Validates the whole flow
|
16
|
-
# @raise [Karafka::Errors::UnregisteredTopic] raised when we used a topic that we didn't
|
17
|
-
# register using #topic method
|
18
|
-
# @raise [Karafka::Errors::TopicMultipleUsage] raised when we used a non multipleusage topic
|
19
|
-
# multiple times
|
20
|
-
# @raise [Karafka::Errors::UnusedResponderRequiredTopic] raised when we didn't use a topic
|
21
|
-
# that was defined as required to be used
|
22
|
-
def validate!
|
23
|
-
@used_topics.each do |used_topic|
|
24
|
-
validate_usage_of!(used_topic)
|
25
|
-
end
|
26
|
-
|
27
|
-
@registered_topics.each do |_name, registered_topic|
|
28
|
-
validate_requirements_of!(registered_topic)
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
private
|
33
|
-
|
34
|
-
# Checks if a given used topic were used in a proper way
|
35
|
-
# @raise [Karafka::Errors::UnregisteredTopic] raised when we used a topic that we didn't
|
36
|
-
# register using #topic method
|
37
|
-
# @raise [Karafka::Errors::TopicMultipleUsage] raised when we used a non multipleusage topic
|
38
|
-
# multiple times
|
39
|
-
# @param used_topic [String] topic to which we've sent a message
|
40
|
-
def validate_usage_of!(used_topic)
|
41
|
-
raise(Errors::UnregisteredTopic, used_topic) unless @registered_topics[used_topic]
|
42
|
-
return if @registered_topics[used_topic].multiple_usage?
|
43
|
-
return unless @registered_topics[used_topic].required?
|
44
|
-
return if @used_topics.count(used_topic) < 2
|
45
|
-
raise(Errors::TopicMultipleUsage, used_topic)
|
46
|
-
end
|
47
|
-
|
48
|
-
# Checks if we met all the requirements for all the registered topics
|
49
|
-
# @raise [Karafka::Errors::UnusedResponderRequiredTopic] raised when we didn't use a topic
|
50
|
-
# that was defined as required to be used
|
51
|
-
# @param registered_topic [::Karafka::Responders::Topic] registered topic object
|
52
|
-
def validate_requirements_of!(registered_topic)
|
53
|
-
return unless registered_topic.required?
|
54
|
-
return if @used_topics.include?(registered_topic.name)
|
55
|
-
|
56
|
-
raise(Errors::UnusedResponderRequiredTopic, registered_topic.name)
|
57
|
-
end
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
@@ -1,113 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
module Routing
|
3
|
-
# Class representing a single route (from topic to worker) with all additional features
|
4
|
-
# and elements. Single route contains descriptions of:
|
5
|
-
# - topic - Kafka topic name (required)
|
6
|
-
# - controller - Class of a controller that will handle messages from a given topic (required)
|
7
|
-
# - group - Kafka group that we want to use (optional)
|
8
|
-
# - worker - Which worker should handle the backend task (optional)
|
9
|
-
# - parser - What parsed do we want to use to unparse the data (optional)
|
10
|
-
# - interchanger - What interchanger to encode/decode data do we want to use (optional)
|
11
|
-
class Route
|
12
|
-
# Only ASCII alphanumeric characters, underscore, dash and dots
|
13
|
-
# are allowed in topics and groups
|
14
|
-
NAME_FORMAT = /\A(\w|\-|\.)+\z/
|
15
|
-
|
16
|
-
# Options that we can set per each route
|
17
|
-
ATTRIBUTES = %i(
|
18
|
-
group
|
19
|
-
topic
|
20
|
-
worker
|
21
|
-
parser
|
22
|
-
interchanger
|
23
|
-
responder
|
24
|
-
inline_mode
|
25
|
-
batch_mode
|
26
|
-
start_from_beginning
|
27
|
-
).freeze
|
28
|
-
|
29
|
-
ATTRIBUTES.each { |attr| attr_writer(attr) }
|
30
|
-
|
31
|
-
# This we can get "directly" because it does not have any details, etc
|
32
|
-
attr_accessor :controller
|
33
|
-
|
34
|
-
# Initializes default values for all the options that support defaults if their values are
|
35
|
-
# not yet specified. This is need to be done (cannot be lazy loaded on first use) because
|
36
|
-
# everywhere except Karafka server command, those would not be initialized on time - for
|
37
|
-
# example for Sidekiq
|
38
|
-
def build
|
39
|
-
ATTRIBUTES.each { |attr| send(attr) }
|
40
|
-
self
|
41
|
-
end
|
42
|
-
|
43
|
-
# @return [String] Kafka group name
|
44
|
-
# @note If group is not provided in a route, will build one based on the app name
|
45
|
-
# and the route topic (that is required)
|
46
|
-
def group
|
47
|
-
(@group ||= "#{Karafka::App.config.name.underscore}_#{topic}").to_s
|
48
|
-
end
|
49
|
-
|
50
|
-
# @return [String] route topic - this is the core esence of Kafka
|
51
|
-
def topic
|
52
|
-
@topic.to_s
|
53
|
-
end
|
54
|
-
|
55
|
-
# @return [Class] Class (not an instance) of a worker that should be used to schedule the
|
56
|
-
# background job
|
57
|
-
# @note If not provided - will be built based on the provided controller
|
58
|
-
def worker
|
59
|
-
@worker ||= inline_mode ? nil : Karafka::Workers::Builder.new(controller).build
|
60
|
-
end
|
61
|
-
|
62
|
-
# @return [Class, nil] Class (not an instance) of a responder that should respond from
|
63
|
-
# controller back to Kafka (usefull for piping dataflows)
|
64
|
-
def responder
|
65
|
-
@responder ||= Karafka::Responders::Builder.new(controller).build
|
66
|
-
end
|
67
|
-
|
68
|
-
# @return [Class] Parser class (not instance) that we want to use to unparse Kafka messages
|
69
|
-
# @note If not provided - will use Json as default
|
70
|
-
def parser
|
71
|
-
@parser ||= Karafka::Parsers::Json
|
72
|
-
end
|
73
|
-
|
74
|
-
# @return [Class] Interchanger class (not an instance) that we want to use to interchange
|
75
|
-
# params between Karafka server and Karafka background job
|
76
|
-
def interchanger
|
77
|
-
@interchanger ||= Karafka::Params::Interchanger
|
78
|
-
end
|
79
|
-
|
80
|
-
# @return [Boolean] Should we perform execution in the background (default) or
|
81
|
-
# inline. This can be set globally and overwritten by a per route setting
|
82
|
-
# @note This method can be set to false, so direct assigment ||= would not work
|
83
|
-
def inline_mode
|
84
|
-
return @inline_mode unless @inline_mode.nil?
|
85
|
-
@inline_mode = Karafka::App.config.inline_mode
|
86
|
-
end
|
87
|
-
|
88
|
-
# @return [Boolean] Should the consumer handle incoming events one at a time, or in batch
|
89
|
-
def batch_mode
|
90
|
-
return @batch_mode unless @batch_mode.nil?
|
91
|
-
@batch_mode = Karafka::App.config.batch_mode
|
92
|
-
end
|
93
|
-
|
94
|
-
# For each topic subscription it's possible to decide whether to consume messages starting
|
95
|
-
# at the beginning of the topic or to just consume new messages that are produced to
|
96
|
-
# the topic.
|
97
|
-
# @return [Boolean] Should we consume from the beggining or from new incoming messages on
|
98
|
-
# the first run
|
99
|
-
def start_from_beginning
|
100
|
-
return @start_from_beginning unless @start_from_beginning.nil?
|
101
|
-
@start_from_beginning = Karafka::App.config.start_from_beginning
|
102
|
-
end
|
103
|
-
|
104
|
-
# Checks if topic and group have proper format (acceptable by Kafka)
|
105
|
-
# @raise [Karafka::Errors::InvalidTopicName] raised when topic name is invalid
|
106
|
-
# @raise [Karafka::Errors::InvalidGroupName] raised when group name is invalid
|
107
|
-
def validate!
|
108
|
-
raise Errors::InvalidTopicName, topic if NAME_FORMAT !~ topic
|
109
|
-
raise Errors::InvalidGroupName, group if NAME_FORMAT !~ group
|
110
|
-
end
|
111
|
-
end
|
112
|
-
end
|
113
|
-
end
|
@@ -1,44 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
module Setup
|
3
|
-
# Schema with validation rules for all configuration
|
4
|
-
ConfigSchema = Dry::Validation.Schema do
|
5
|
-
required(:name).filled(:str?)
|
6
|
-
required(:topic_mapper).filled
|
7
|
-
optional(:inline_mode).filled(:bool?)
|
8
|
-
|
9
|
-
required(:redis).maybe do
|
10
|
-
schema do
|
11
|
-
required(:url).filled(:str?)
|
12
|
-
end
|
13
|
-
end
|
14
|
-
|
15
|
-
# If inline_mode is true, redis should be filled
|
16
|
-
rule(redis_presence: [:redis, :inline_mode]) do |redis, inline_mode|
|
17
|
-
inline_mode.false?.then(redis.filled?)
|
18
|
-
end
|
19
|
-
|
20
|
-
optional(:batch_mode).filled(:bool?)
|
21
|
-
optional(:start_from_beginning).filled(:bool?)
|
22
|
-
|
23
|
-
optional(:connection_pool).schema do
|
24
|
-
required(:size).filled
|
25
|
-
optional(:timeout).filled(:int?)
|
26
|
-
end
|
27
|
-
|
28
|
-
required(:kafka).schema do
|
29
|
-
required(:hosts).filled(:array?)
|
30
|
-
|
31
|
-
required(:session_timeout).filled(:int?)
|
32
|
-
required(:offset_commit_interval).filled(:int?)
|
33
|
-
required(:offset_commit_threshold).filled(:int?)
|
34
|
-
required(:heartbeat_interval).filled(:int?)
|
35
|
-
|
36
|
-
optional(:ssl).schema do
|
37
|
-
required(:ca_cert).maybe(:str?)
|
38
|
-
required(:client_cert).maybe(:str?)
|
39
|
-
required(:client_cert_key).maybe(:str?)
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
@@ -1,13 +0,0 @@
|
|
1
|
-
module Karafka
|
2
|
-
module Setup
|
3
|
-
class Configurators
|
4
|
-
# Class responsible for setting up WorkerGlass settings
|
5
|
-
class WorkerGlass < Base
|
6
|
-
# Sets up a Karafka logger as celluloid logger
|
7
|
-
def setup
|
8
|
-
::WorkerGlass.logger = ::Karafka.logger
|
9
|
-
end
|
10
|
-
end
|
11
|
-
end
|
12
|
-
end
|
13
|
-
end
|