arturo 2.2.0 → 3.0.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 +5 -5
- data/CHANGELOG.md +46 -0
- data/README.md +141 -91
- data/app/controllers/arturo/features_controller.rb +3 -3
- data/app/helpers/arturo/features_helper.rb +1 -0
- data/app/views/arturo/features/_feature.html.erb +1 -0
- data/app/views/arturo/features/_form.html.erb +1 -0
- data/app/views/arturo/features/edit.html.erb +1 -0
- data/app/views/arturo/features/forbidden.html.erb +1 -0
- data/app/views/arturo/features/index.html.erb +1 -0
- data/app/views/arturo/features/new.html.erb +1 -0
- data/app/views/arturo/features/show.html.erb +1 -0
- data/config/routes.rb +1 -0
- data/lib/arturo/controller_filters.rb +2 -1
- data/lib/arturo/engine.rb +6 -2
- data/lib/arturo/feature.rb +41 -38
- data/lib/arturo/feature_availability.rb +2 -1
- data/lib/arturo/feature_caching.rb +103 -10
- data/lib/arturo/feature_factories.rb +3 -2
- data/lib/arturo/feature_management.rb +1 -0
- data/lib/arturo/feature_params_support.rb +1 -0
- data/lib/arturo/middleware.rb +1 -0
- data/lib/arturo/no_such_feature.rb +1 -0
- data/lib/arturo/null_logger.rb +8 -0
- data/lib/arturo/special_handling.rb +6 -6
- data/lib/arturo/test_support.rb +3 -2
- data/lib/arturo/version.rb +4 -0
- data/lib/arturo.rb +12 -2
- data/lib/generators/arturo/assets_generator.rb +1 -0
- data/lib/generators/arturo/feature_model_generator.rb +14 -0
- data/lib/generators/arturo/initializer_generator.rb +1 -0
- data/lib/generators/arturo/migration_generator.rb +10 -10
- data/lib/generators/arturo/routes_generator.rb +1 -0
- data/lib/generators/arturo/templates/feature.rb +9 -0
- data/lib/generators/arturo/templates/initializer.rb +1 -0
- data/lib/generators/arturo/templates/migration.erb +17 -0
- metadata +50 -118
- data/lib/generators/arturo/templates/migration.rb +0 -17
data/lib/arturo/feature.rb
CHANGED
|
@@ -1,41 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
require 'active_record'
|
|
3
|
+
require 'active_support'
|
|
2
4
|
|
|
3
|
-
# a stub
|
|
4
|
-
# possible TODO: remove and and refactor into an acts_as_feature mixin
|
|
5
5
|
module Arturo
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
module FeatureMethods
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
8
|
include Arturo::SpecialHandling
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
SYMBOL_REGEX = /^[a-zA-z][a-zA-Z0-9_]*$/
|
|
11
11
|
DEFAULT_ATTRIBUTES = { :deployment_percentage => 0 }.with_indifferent_access
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# Looks up a feature by symbol. Also accepts a Feature as input.
|
|
25
|
-
# @param [Symbol, Arturo::Feature] feature_or_name a Feature or the Symbol of a Feature
|
|
26
|
-
# @return [Arturo::Feature, nil] the Feature if found, else Arturo::NoSuchFeature
|
|
27
|
-
def self.to_feature(feature_or_symbol)
|
|
28
|
-
return feature_or_symbol if feature_or_symbol.kind_of?(self)
|
|
29
|
-
symbol = feature_or_symbol.to_sym.to_s
|
|
30
|
-
self.where(:symbol => symbol).first || Arturo::NoSuchFeature.new(symbol)
|
|
13
|
+
included do
|
|
14
|
+
attr_readonly :symbol
|
|
15
|
+
|
|
16
|
+
validates_presence_of :symbol, :deployment_percentage
|
|
17
|
+
validates_uniqueness_of :symbol, :allow_blank => true, :case_sensitive => false
|
|
18
|
+
validates_numericality_of :deployment_percentage,
|
|
19
|
+
:only_integer => true,
|
|
20
|
+
:allow_blank => true,
|
|
21
|
+
:greater_than_or_equal_to => 0,
|
|
22
|
+
:less_than_or_equal_to => 100
|
|
31
23
|
end
|
|
32
24
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
25
|
+
class_methods do
|
|
26
|
+
# Looks up a feature by symbol. Also accepts a Feature as input.
|
|
27
|
+
# @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
|
|
28
|
+
# @return [Arturo::Feature, Arturo::NoSuchFeature] the Feature if found, else Arturo::NoSuchFeature
|
|
29
|
+
def to_feature(feature_or_symbol)
|
|
30
|
+
return feature_or_symbol if feature_or_symbol.kind_of?(self)
|
|
31
|
+
|
|
32
|
+
symbol = feature_or_symbol.to_sym.to_s
|
|
33
|
+
self.where(:symbol => symbol).first || Arturo::NoSuchFeature.new(symbol)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Looks up a feature by symbol. Also accepts a Feature as input.
|
|
37
|
+
# @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
|
|
38
|
+
# @return [Arturo::Feature, nil] the Feature if found, else nil
|
|
39
|
+
def find_feature(feature_or_symbol)
|
|
40
|
+
feature = to_feature(feature_or_symbol)
|
|
41
|
+
feature.is_a?(Arturo::NoSuchFeature) ? nil : feature
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def last_updated_at
|
|
45
|
+
maximum(:updated_at)
|
|
46
|
+
end
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
# Create a new Feature
|
|
@@ -54,11 +62,12 @@ module Arturo
|
|
|
54
62
|
return false if feature_recipient.nil?
|
|
55
63
|
return false if blacklisted?(feature_recipient)
|
|
56
64
|
return true if whitelisted?(feature_recipient)
|
|
57
|
-
passes_threshold?(feature_recipient)
|
|
65
|
+
passes_threshold?(feature_recipient, deployment_percentage || 0)
|
|
58
66
|
end
|
|
59
67
|
|
|
60
68
|
def name
|
|
61
69
|
return I18n.translate("arturo.feature.nameless") if symbol.blank?
|
|
70
|
+
|
|
62
71
|
I18n.translate("arturo.feature.#{symbol}", :default => symbol.to_s.titleize)
|
|
63
72
|
end
|
|
64
73
|
|
|
@@ -74,14 +83,8 @@ module Arturo
|
|
|
74
83
|
"<Arturo::Feature #{name}, deployed to #{deployment_percentage}%>"
|
|
75
84
|
end
|
|
76
85
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
protected
|
|
82
|
-
|
|
83
|
-
def passes_threshold?(feature_recipient)
|
|
84
|
-
threshold = self.deployment_percentage || 0
|
|
86
|
+
# made public so as to allow for thresholds stored outside of the model
|
|
87
|
+
def passes_threshold?(feature_recipient, threshold)
|
|
85
88
|
return true if threshold == 100
|
|
86
89
|
return false if threshold == 0 || !feature_recipient.id
|
|
87
90
|
(((feature_recipient.id + (self.id || 1) + 17) * 13) % 100) < threshold
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Arturo
|
|
2
3
|
|
|
3
4
|
# A mixin that provides #feature_enabled? and #if_feature_enabled
|
|
@@ -24,7 +25,7 @@ module Arturo
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# By default, returns current_user.
|
|
27
|
-
#
|
|
28
|
+
#
|
|
28
29
|
# If you would like to change this implementation, it is recommended
|
|
29
30
|
# you do so in config/initializers/arturo_initializer.rb
|
|
30
31
|
# @return [Object] the recipient of features.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
require 'arturo/no_such_feature'
|
|
2
3
|
|
|
3
4
|
module Arturo
|
|
@@ -36,32 +37,45 @@ module Arturo
|
|
|
36
37
|
class << base
|
|
37
38
|
prepend PrependMethods
|
|
38
39
|
attr_accessor :cache_ttl, :feature_cache, :feature_caching_strategy
|
|
40
|
+
attr_writer :extend_cache_on_failure
|
|
39
41
|
end
|
|
40
42
|
base.send(:after_save) do |f|
|
|
41
43
|
f.class.feature_caching_strategy.expire(f.class.feature_cache, f.symbol.to_sym) if f.class.caches_features?
|
|
42
44
|
end
|
|
43
45
|
base.cache_ttl = 0
|
|
46
|
+
base.extend_cache_on_failure = false
|
|
44
47
|
base.feature_cache = Arturo::FeatureCaching::Cache.new
|
|
45
48
|
base.feature_caching_strategy = AllStrategy
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
def extend_cache_on_failure?
|
|
52
|
+
!!@extend_cache_on_failure
|
|
53
|
+
end
|
|
54
|
+
|
|
48
55
|
def caches_features?
|
|
49
56
|
self.cache_ttl.to_i > 0
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
def warm_cache!
|
|
53
|
-
|
|
60
|
+
to_feature(:fake_feature_to_force_cache_warming)
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
class AllStrategy
|
|
57
64
|
class << self
|
|
65
|
+
##
|
|
66
|
+
# @param cache [Arturo::Cache] cache backend
|
|
67
|
+
# @param symbol [Symbol] arturo identifier
|
|
68
|
+
# @return [Arturo::Feature, Arturo::NoSuchFeature]
|
|
69
|
+
#
|
|
58
70
|
def fetch(cache, symbol, &block)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
existing_features = cache.read("arturo.all")
|
|
72
|
+
|
|
73
|
+
features = if cache_is_current?(cache, existing_features)
|
|
74
|
+
existing_features
|
|
75
|
+
else
|
|
76
|
+
arturos_from_origin(fallback: existing_features).tap do |updated_features|
|
|
77
|
+
update_and_extend_cache!(cache, updated_features)
|
|
78
|
+
end
|
|
65
79
|
end
|
|
66
80
|
|
|
67
81
|
features[symbol] || Arturo::NoSuchFeature.new(symbol)
|
|
@@ -71,17 +85,96 @@ module Arturo
|
|
|
71
85
|
cache.delete("arturo.all")
|
|
72
86
|
end
|
|
73
87
|
|
|
88
|
+
def register_cache_update_listener(&block)
|
|
89
|
+
cache_update_listeners << block
|
|
90
|
+
end
|
|
91
|
+
|
|
74
92
|
private
|
|
75
93
|
|
|
94
|
+
def cache_update_listeners
|
|
95
|
+
@cache_update_listeners ||= []
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
##
|
|
99
|
+
# @param fallback [Hash] features to use on database failure
|
|
100
|
+
# @return [Hash] updated features from origin or fallback
|
|
101
|
+
# @raise [ActiveRecord::ActiveRecordError] on database failure
|
|
102
|
+
# without cache extension option
|
|
103
|
+
#
|
|
104
|
+
def arturos_from_origin(fallback:)
|
|
105
|
+
Arturo::Feature.all.to_h { |f| [f.symbol.to_sym, f] }
|
|
106
|
+
rescue ActiveRecord::ActiveRecordError
|
|
107
|
+
raise unless Arturo::Feature.extend_cache_on_failure?
|
|
108
|
+
|
|
109
|
+
if fallback.blank?
|
|
110
|
+
log_empty_cache
|
|
111
|
+
raise
|
|
112
|
+
else
|
|
113
|
+
log_stale_cache
|
|
114
|
+
fallback
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
##
|
|
119
|
+
# @return [Boolean] whether the current cache has to be updated from origin
|
|
120
|
+
# @raise [ActiveRecord::ActiveRecordError] on database failure
|
|
121
|
+
# without cache extension option
|
|
122
|
+
#
|
|
76
123
|
def cache_is_current?(cache, features)
|
|
77
124
|
return unless features
|
|
78
125
|
return true if cache.read("arturo.current")
|
|
79
|
-
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
return false if origin_changed?(features)
|
|
129
|
+
rescue ActiveRecord::ActiveRecordError
|
|
130
|
+
raise unless Arturo::Feature.extend_cache_on_failure?
|
|
131
|
+
|
|
132
|
+
if features.blank?
|
|
133
|
+
log_empty_cache
|
|
134
|
+
raise
|
|
135
|
+
else
|
|
136
|
+
log_stale_cache
|
|
137
|
+
update_and_extend_cache!(cache, features)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
return true
|
|
141
|
+
end
|
|
80
142
|
mark_as_current!(cache)
|
|
81
143
|
end
|
|
82
144
|
|
|
145
|
+
def formatted_log(namespace, msg)
|
|
146
|
+
"[Arturo][#{namespace}] #{msg}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def log_empty_cache
|
|
150
|
+
Arturo.logger.error(formatted_log('extend_cache_on_failure', 'Fallback cache is empty'))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def log_stale_cache
|
|
154
|
+
Arturo.logger.warn(formatted_log('extend_cache_on_failure', 'Falling back to stale cache'))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
##
|
|
158
|
+
# @return [True]
|
|
159
|
+
#
|
|
83
160
|
def mark_as_current!(cache)
|
|
84
|
-
cache.write("arturo.current", true, :
|
|
161
|
+
cache.write("arturo.current", true, expires_in: Arturo::Feature.cache_ttl)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
##
|
|
165
|
+
# The Arturo origin might return a big payload, so checking for the latest
|
|
166
|
+
# update is a cheaper operation.
|
|
167
|
+
#
|
|
168
|
+
# @return [Boolean] if origin has been updated since the last cache update.
|
|
169
|
+
#
|
|
170
|
+
def origin_changed?(features)
|
|
171
|
+
features.values.map(&:updated_at).compact.max != Arturo::Feature.maximum(:updated_at)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def update_and_extend_cache!(cache, features)
|
|
175
|
+
mark_as_current!(cache)
|
|
176
|
+
cache.write("arturo.all", features, expires_in: Arturo::Feature.cache_ttl * 10)
|
|
177
|
+
cache_update_listeners.each(&:call)
|
|
85
178
|
end
|
|
86
179
|
end
|
|
87
180
|
end
|
|
@@ -91,7 +184,7 @@ module Arturo
|
|
|
91
184
|
if feature = cache.read("arturo.#{symbol}")
|
|
92
185
|
feature
|
|
93
186
|
else
|
|
94
|
-
cache.write("arturo.#{symbol}", yield || Arturo::NoSuchFeature.new(symbol), :
|
|
187
|
+
cache.write("arturo.#{symbol}", yield || Arturo::NoSuchFeature.new(symbol), expires_in: Arturo::Feature.cache_ttl)
|
|
95
188
|
end
|
|
96
189
|
end
|
|
97
190
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
FactoryBot.define do
|
|
2
3
|
sequence(:feature_symbol) { |n| "feature_#{n}".to_sym }
|
|
3
4
|
|
|
4
5
|
factory :feature, :class => Arturo::Feature do
|
|
5
6
|
symbol { generate(:feature_symbol) }
|
|
6
7
|
deployment_percentage { rand(101) }
|
|
7
8
|
end
|
|
8
|
-
end
|
|
9
|
+
end
|
data/lib/arturo/middleware.rb
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
|
|
1
5
|
module Arturo
|
|
2
6
|
|
|
3
7
|
# Adds whitelist and blacklist support to individual features by name
|
|
@@ -24,13 +28,9 @@ module Arturo
|
|
|
24
28
|
# This is particularly important if your application runs in several
|
|
25
29
|
# different processes or on several servers.
|
|
26
30
|
module SpecialHandling
|
|
31
|
+
extend ActiveSupport::Concern
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
base.extend Arturo::SpecialHandling::ClassMethods
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
module ClassMethods
|
|
33
|
-
|
|
33
|
+
class_methods do
|
|
34
34
|
def whitelists
|
|
35
35
|
@whitelists ||= []
|
|
36
36
|
end
|
data/lib/arturo/test_support.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
Arturo.instance_eval do
|
|
2
3
|
|
|
3
4
|
# Enable a feature; create it if necessary.
|
|
@@ -9,7 +10,7 @@ Arturo.instance_eval do
|
|
|
9
10
|
def enable_feature!(name)
|
|
10
11
|
if feature = Arturo::Feature.find_feature(name)
|
|
11
12
|
feature = feature.class.find(feature.id) if feature.frozen?
|
|
12
|
-
feature.
|
|
13
|
+
feature.update(:deployment_percentage => 100)
|
|
13
14
|
else
|
|
14
15
|
Arturo::Feature.create!(:symbol => name, :deployment_percentage => 100)
|
|
15
16
|
end
|
|
@@ -24,7 +25,7 @@ Arturo.instance_eval do
|
|
|
24
25
|
def disable_feature!(name)
|
|
25
26
|
if feature = Arturo::Feature.find_feature(name)
|
|
26
27
|
feature = feature.class.find(feature.id) if feature.frozen?
|
|
27
|
-
feature.
|
|
28
|
+
feature.update(:deployment_percentage => 0)
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
data/lib/arturo.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Arturo
|
|
2
|
-
|
|
3
|
+
require 'arturo/null_logger'
|
|
3
4
|
require 'arturo/special_handling'
|
|
5
|
+
require 'arturo/feature'
|
|
4
6
|
require 'arturo/feature_availability'
|
|
5
7
|
require 'arturo/feature_management'
|
|
6
8
|
require 'arturo/feature_caching'
|
|
7
|
-
require 'arturo/engine' if defined?(Rails
|
|
9
|
+
require 'arturo/engine' if defined?(Rails)
|
|
8
10
|
|
|
9
11
|
class << self
|
|
10
12
|
# Quick check for whether a feature is enabled for a recipient.
|
|
@@ -15,5 +17,13 @@ module Arturo
|
|
|
15
17
|
f = self::Feature.to_feature(feature_name)
|
|
16
18
|
f && f.enabled_for?(recipient)
|
|
17
19
|
end
|
|
20
|
+
|
|
21
|
+
def logger=(logger)
|
|
22
|
+
@logger = logger
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def logger
|
|
26
|
+
@logger || NullLogger.new
|
|
27
|
+
end
|
|
18
28
|
end
|
|
19
29
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'rails/generators'
|
|
3
|
+
|
|
4
|
+
module Arturo
|
|
5
|
+
class FeatureModelGenerator < Rails::Generators::Base
|
|
6
|
+
def self.source_root
|
|
7
|
+
File.join(File.dirname(__FILE__), 'templates')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def copy_feature_model_file
|
|
11
|
+
copy_file "feature.rb", "app/models/arturo/feature.rb"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'rails/generators'
|
|
2
4
|
require 'rails/generators/migration'
|
|
5
|
+
require 'rails/generators/active_record'
|
|
3
6
|
|
|
4
7
|
module Arturo
|
|
5
8
|
class MigrationGenerator < Rails::Generators::Base
|
|
@@ -9,19 +12,16 @@ module Arturo
|
|
|
9
12
|
File.join(File.dirname(__FILE__), 'templates')
|
|
10
13
|
end
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# http://github.com/rails/rails/blob/master/activerecord/lib/generators/active_record.rb
|
|
15
|
-
def self.next_migration_number(dirname) #:nodoc:
|
|
16
|
-
if ActiveRecord::Base.timestamped_migrations
|
|
17
|
-
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
18
|
-
else
|
|
19
|
-
"%.3d" % (current_migration_number(dirname) + 1)
|
|
20
|
-
end
|
|
15
|
+
def self.next_migration_number(dirname)
|
|
16
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
21
17
|
end
|
|
22
18
|
|
|
23
19
|
def create_migration_file
|
|
24
|
-
migration_template 'migration.
|
|
20
|
+
migration_template 'migration.erb', 'db/migrate/create_features.rb', { migration_version: migration_version }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def migration_version
|
|
24
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateFeatures < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def self.up
|
|
5
|
+
create_table :features do |t|
|
|
6
|
+
t.string :symbol, null: false
|
|
7
|
+
t.integer :deployment_percentage, null: false
|
|
8
|
+
# Any additional fields here
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.down
|
|
15
|
+
drop_table :features
|
|
16
|
+
end
|
|
17
|
+
end
|