sqs_simplify 0.2.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/README.pt-br.md ADDED
@@ -0,0 +1,384 @@
1
+ # SqsSimplify
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/sqs_simplify.svg)](https://badge.fury.io/rb/sqs_simplify)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-red.svg)](https://www.ruby-lang.org)
6
+
7
+ Uma DSL Ruby de alto nível sobre o `aws-sdk-sqs` para produzir e consumir mensagens do AWS SQS com o mínimo de boilerplate.
8
+
9
+ Possui 3 papéis principais:
10
+ * **SqsSimplify::Scheduler**: Envia mensagens para uma fila.
11
+ * **SqsSimplify::Consumer**: Consome mensagens de uma fila.
12
+ * **SqsSimplify::Job**: Envia e consome mensagens de uma fila.
13
+
14
+ > 🇺🇸 An English version of this document is available at [README.md](README.md).
15
+
16
+ ## Sumário
17
+
18
+ * [Requisitos](#requisitos)
19
+ * [Instalação](#instalação)
20
+ * [Como usar](#como-usar)
21
+ * [Configuração Inicial](#1-configuração-inicial)
22
+ * [Scheduler](#2-scheduler)
23
+ * [Consumer](#3-consumer)
24
+ * [Job](#4-job)
25
+ * [Configurações](#configurações)
26
+ * [Configuração Global](#1-configuração-global)
27
+ * [Hooks](#2-hooks)
28
+ * [Processo Background](#processo-background)
29
+ * [Recursos avançados](#recursos-avançados)
30
+ * [Desenvolvimento](#desenvolvimento)
31
+ * [Contribuição](#contribuição)
32
+ * [Licença](#licença)
33
+
34
+ ## Requisitos
35
+
36
+ * Ruby `>= 3.0.0`
37
+ * Dependências de runtime (instaladas automaticamente junto com a gem):
38
+ * [`aws-sdk-sqs`](https://rubygems.org/gems/aws-sdk-sqs) `~> 1.116`
39
+ * [`parallel`](https://rubygems.org/gems/parallel) `~> 2.1`
40
+
41
+ Você também precisa de credenciais AWS válidas com acesso ao SQS.
42
+
43
+ ## Instalação
44
+
45
+ Adicione esta linha na Gemfile da aplicação:
46
+
47
+ ```ruby
48
+ gem 'sqs_simplify'
49
+ ```
50
+
51
+ E execute:
52
+
53
+ $ bundle install
54
+
55
+ Ou instale você mesmo com:
56
+
57
+ $ gem install sqs_simplify
58
+ ___
59
+
60
+ ## Como usar
61
+ ### 1. Configuração Inicial
62
+
63
+ Crie um arquivo de configuração para ser carregado na inicialização da aplicação.
64
+
65
+ Exemplo: *sqs_simplify.rb*
66
+
67
+ Se for uma aplicação **Rails**, crie em *config/initializers/sqs_simplify.rb*.
68
+
69
+ Neste arquivo você pode configurar as credenciais da AWS e outras customizações.
70
+
71
+ ```ruby
72
+ # sqs_simplify.rb
73
+
74
+ SqsSimplify.configure do |config|
75
+ config.access_key_id = ENV['AWS_ACCESS_KEY_ID']
76
+ config.secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']
77
+ config.region = 'us-east-1'
78
+
79
+ config.queue_prefix = Rails.env # optional
80
+ config.queue_suffix = 'my_application_name' # optional
81
+ end
82
+ ```
83
+ ___
84
+
85
+
86
+ ### 2. Scheduler
87
+
88
+ O componente Scheduler é responsável por enviar mensagens para a fila SQS.
89
+
90
+ Seu foco principal é na utilização de uma fila como barramento entre duas aplicações distintas.
91
+
92
+ Por exemplo: a aplicação **A** tem um Scheduler que envia mensagem para a fila SQS, mas quem vai consumir esta mensagem será a aplicação **B**.
93
+
94
+ ```ruby
95
+ # app/jobs/my_scheduler
96
+
97
+ class MyScheduler < SqsSimplify::Scheduler
98
+ end
99
+
100
+
101
+ # app/model/wheel_factory.rb
102
+
103
+ class WheelFactory
104
+ def send_now
105
+ message = { wheels: ['back_wheel', 'front_wheel'], type: 'motorcycle' }
106
+ MyScheduler.send_message(message: message)
107
+ end
108
+
109
+ def send_later(delay)
110
+ message = { wheels: ['back_wheel', 'front_wheel'], type: 'motorcycle' }
111
+ MyScheduler.send_message(message: message, after: delay)
112
+ end
113
+ end
114
+
115
+
116
+ wheel_factory = WheelFactory.new
117
+
118
+ # run now
119
+ wheel_factory.send_now # "8685d169-f4a0-476b-b970-39ee055f957b"
120
+
121
+ # run after 2 minutes
122
+ wheel_factory.send_later(120) # "5ebd6a74-8571-43e2-a9c8-7866b7598765"
123
+
124
+ ```
125
+
126
+ #### `group_id`
127
+
128
+ O parâmetro `group_id` é enviado ao SQS como `message_group_id`. Informe-o em
129
+ `send_message`:
130
+
131
+ ```ruby
132
+ MyScheduler.send_message(message: message, group_id: 'tenant-123')
133
+ ```
134
+
135
+ Seu significado depende do tipo de fila:
136
+
137
+ * **Filas FIFO**: o SQS exige `message_group_id`. Mensagens com o mesmo
138
+ `group_id` são processadas em ordem (FIFO).
139
+ * **Filas standard**: o `message_group_id` é usado como identificador de
140
+ *tenant* para o recurso de [fair queues](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-fair-queues.html),
141
+ que mitiga o impacto de *noisy neighbor* em filas multi-tenant. Aqui ele
142
+ **não** garante ordenação — serve apenas para agrupar mensagens por tenant.
143
+
144
+ Se omitido (`nil`), nenhum `message_group_id` é enviado.
145
+
146
+ ### 3. Consumer
147
+
148
+ O componente Consumer é responsável por consumir mensagens da fila SQS.
149
+
150
+ Seu foco principal também é na utilização de uma fila como barramento entre duas aplicações distintas.
151
+
152
+ Por exemplo: a aplicação **B** tem um Consumer que solicita mensagens da fila SQS que foram enviadas pela aplicação **A**.
153
+
154
+ ```ruby
155
+ # app/jobs/motorcycle_assembler.rb
156
+
157
+ class MotorcycleAssembler < SqsSimplify::Consumer
158
+
159
+ def perform
160
+ # your logic here
161
+ # your object has "message" method with data of sqs_message
162
+ p message # {:wheels=>["back_wheel", "front_wheel"], :type=>"motorcycle"}
163
+ end
164
+
165
+ end
166
+
167
+ ```
168
+
169
+
170
+ ### 4. Job
171
+
172
+ O componente Job é responsável por enviar e consumir mensagens da fila SQS dentro da mesma aplicação.
173
+
174
+ Diferente dos outros componentes, seu foco **não** é na utilização de uma fila como barramento.
175
+
176
+ Por exemplo: sua aplicação tem uma classe **Report** que gera um relatório.
177
+ Este processamento demora muito para ser executado.
178
+ Então você pode agendar a execução para mais tarde.
179
+
180
+ O método de negócio **deve** se chamar `perform`.
181
+
182
+ ```ruby
183
+ # app/jobs/report.rb
184
+
185
+ class Report < SqsSimplify::Job
186
+
187
+ def perform(list)
188
+ # your logic here
189
+ PersistReport.save(list)
190
+ end
191
+
192
+ end
193
+
194
+ list = # many data
195
+
196
+ # enfileira para execução posterior
197
+ Report.perform_later(list) # "76107a55-43d9-4f2e-b449-02c329a51692"
198
+
199
+ # enfileira com atraso de 180 segundos
200
+ Report.new_job(after: 180).perform_later(list) # "be6837d5-c11f-495c-a03e-cb093011f1d0"
201
+
202
+ # executa inline, sem enfileirar
203
+ # atenção, não será agendado
204
+ Report.perform(list) # :executed
205
+ ```
206
+
207
+ ___
208
+
209
+ ## Configurações
210
+
211
+ ### 1. Configuração Global
212
+
213
+ #### SqsSimplify
214
+ Toda configuração feita na classe SqsSimplify será aplicada em todos os componentes.
215
+
216
+ Exemplo:
217
+
218
+ ```ruby
219
+ # sqs_simplify.rb
220
+
221
+ SqsSimplify.configure do |config|
222
+ config.access_key_id = ENV['AWS_ACCESS_KEY_ID']
223
+ config.secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']
224
+ config.region = 'us-east-2'
225
+
226
+ config.queue_prefix = 'production'
227
+ end
228
+ ```
229
+
230
+ Com esta configuração todos os componentes — Scheduler, Consumer e Job — terão a mesma configuração.
231
+
232
+ Todos terão acesso às filas SQS de *us-east-2*
233
+
234
+ Todos terão o prefixo de *production*
235
+
236
+
237
+ ```ruby
238
+
239
+ class MyScheduler < SqsSimplify::Scheduler
240
+
241
+ end
242
+
243
+ class MyConsumer < SqsSimplify::Consumer
244
+
245
+ end
246
+
247
+ class MyJob < SqsSimplify::Job
248
+
249
+ end
250
+
251
+ # same prefix
252
+ MyScheduler.queue_name # "production_my_scheduler"
253
+ MyConsumer.queue_name # "production_my_consumer"
254
+ MyJob.queue_name # "production_my_job"
255
+
256
+ ```
257
+
258
+ ### 2. Hooks
259
+
260
+ **resolver_exception:** É invocado sempre que ocorrer uma **Exception** na sua aplicação.
261
+ Disponibiliza dois parâmetros:
262
+ * o **primeiro parâmetro** é uma **Exception**.
263
+ * o **segundo parâmetro** pode variar de acordo com o componente e onde ocorreu a **Exception**.
264
+
265
+ **message_not_deleted:** É invocado quando um Job ou Consumer pega uma mensagem da fila SQS e não consegue apagá-la.
266
+ Há dois principais motivos para isso ocorrer:
267
+ * **Exception**: quando ocorre uma **Exception**. Obs: também será invocado o hook **resolver_exception**.
268
+ * **Default visibility timeout**: a mensagem não foi processada no tempo definido.
269
+
270
+ ```ruby
271
+ # sqs_simplify.rb
272
+
273
+ SqsSimplify.configure do |config|
274
+ config.hooks.resolver_exception do |exception, args|
275
+ logger.info "Exception => #{exception.message}, args => #{args}"
276
+ end
277
+
278
+ config.hooks.message_not_deleted do |consumer|
279
+ logger.info "Consumer => #{consumer}"
280
+ end
281
+ end
282
+ ```
283
+
284
+ ___
285
+
286
+ ## Processo Background
287
+ ### 1. Configuração
288
+ Para rodar o processo de consumo deve criar um arquivo de script.
289
+ O método `run` roda um loop em foreground que consome as filas até receber
290
+ `SIGINT` (Ctrl+C) — não daemoniza o processo.
291
+ Exemplo de um arquivo de script nomeado de *sqs_simplify*
292
+
293
+ ```ruby
294
+ #!/usr/bin/env ruby
295
+
296
+ require 'sqs_simplify/command'
297
+ SqsSimplify::Command.new(ARGV).run
298
+ ```
299
+
300
+ Para projeto Rails você pode carregar a aplicação antes da invocação da GEM.
301
+ Exemplo de um arquivo de script nomeado de *bin/sqs_simplify*
302
+ ```ruby
303
+ #!/usr/bin/env ruby
304
+
305
+ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment'))
306
+ require 'sqs_simplify/command'
307
+ SqsSimplify::Command.new(ARGV).run
308
+ ```
309
+
310
+ E deve dar permissão de execução.
311
+ ````bash
312
+ $ chmod +x sqs_simplify
313
+ ````
314
+
315
+ ### 2. Comandos
316
+ Para ver as opções do comando, execute no diretório do arquivo:
317
+
318
+ ````bash
319
+ $ sqs_simplify -h
320
+ Usage: sqs_simplify [options]
321
+ -h, --help Show help
322
+ -n, --number_of_workers=workers Number of unique workers to spawn
323
+ -e, --environment=environment Environment
324
+ --queues=queues queues that will be consumed
325
+ --priority with priority in the queues
326
+ -f, --fork parallel in processes
327
+ -t, --thread parallel in threads
328
+
329
+
330
+ ````
331
+
332
+ ___
333
+
334
+ ## Recursos avançados
335
+
336
+ Além do básico apresentado acima, a gem também oferece:
337
+
338
+ * **`map_queue(nickname, &block)`** — roteia um único Scheduler para filas
339
+ alternativas dinamicamente (`SqsSimplify::Scheduler`).
340
+ * **Sobrescrita de destino por chamada** — passe `queue_url:` para `send_message`
341
+ para enviar uma mensagem a uma fila específica no momento da chamada.
342
+ * **DSL `set` por classe** — sobrescreve configurações por classe, como o nome da
343
+ fila, o visibility timeout, a serialização (`dump_message`/`load_message`) e mais,
344
+ por exemplo `set :queue_name, 'custom_name'`.
345
+ * **Dead-letter queues automáticas** — cada fila ganha uma `<nome>_dead`
346
+ pareada como dead-letter queue.
347
+ * **Hooks adicionais** — além de `resolver_exception` e `message_not_deleted`, o
348
+ pipeline também suporta os hooks `before`/`after` (`:each` / `:all`) e `around`.
349
+ * **Testes sem AWS** — defina `config.faker = true` para usar o `FakerClient` em
350
+ memória, ou `config.stub_responses = true` para stubar o cliente `aws-sdk-sqs`.
351
+
352
+ ## Desenvolvimento
353
+
354
+ Após clonar o repositório, instale as dependências e rode a suíte de testes:
355
+
356
+ ```bash
357
+ bin/setup # instala as dependências
358
+ bundle exec rake spec # roda a suíte de testes completa
359
+ bundle exec rubocop # lint
360
+ bin/console # prompt interativo para experimentar
361
+ ```
362
+
363
+ Os testes rodam contra um cliente SQS falso em memória, então nenhum acesso real à
364
+ AWS é necessário.
365
+
366
+ Para instalar esta gem na sua máquina local, rode `bundle exec rake install`. Para
367
+ publicar uma nova versão, atualize o número da versão em `lib/sqs_simplify/version.rb`
368
+ e então rode `bundle exec rake release`.
369
+
370
+ ## Contribuição
371
+
372
+ Relatos de bugs e pull requests são bem-vindos no GitHub em
373
+ https://github.com/ralphsbaesso/sqs_simplify. Para contribuir:
374
+
375
+ 1. Faça um fork do repositório.
376
+ 2. Crie um branch de feature (`git checkout -b minha-feature`).
377
+ 3. Faça commit das suas alterações e garanta que os testes (`bundle exec rake spec`)
378
+ e o linter (`bundle exec rubocop`) passem.
379
+ 4. Abra um pull request.
380
+
381
+
382
+ ## Licença
383
+
384
+ A gem está disponível como código aberto sob os termos da [Licença MIT](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'sqs_simplify'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module SqsSimplify
6
+ class Base
7
+ include SqsSimplify::Client
8
+
9
+ VISIBILITY_TIMEOUT = 10 * 60
10
+ MESSAGE_RETENTION_PERIOD = 14 * 24 * 60 * 60
11
+ DELAY_SECONDS = 0
12
+
13
+ protected
14
+
15
+ def dump_message(value)
16
+ self.class.dump_message value
17
+ end
18
+
19
+ def load_message(value)
20
+ self.class.load_message value
21
+ end
22
+
23
+ class << self
24
+ def set(key, value)
25
+ settings[key.to_sym] = value
26
+ end
27
+
28
+ def queue_name
29
+ (settings[:queue_name] || to_underscore(name)).to_s
30
+ end
31
+
32
+ def queue_full_name
33
+ build_queue_full_name
34
+ end
35
+
36
+ def queue_url
37
+ @queue_url ||= client.get_queue_url(queue_name: queue_full_name).queue_url
38
+ rescue Aws::SQS::Errors::NonExistentQueue
39
+ raise SqsSimplify::Errors::NonExistentQueue, queue_full_name
40
+ end
41
+
42
+ def dead_queue
43
+ @dead_queue ||= build_dead_queue_class
44
+ end
45
+
46
+ def count_messages
47
+ approximate_number_of_messages(load: true)
48
+ end
49
+
50
+ def approximate_number_of_messages(load: false)
51
+ queue_info(load: load)['ApproximateNumberOfMessages'].to_i
52
+ end
53
+
54
+ def queue_info(load: false)
55
+ @queue_info = nil if load
56
+ @queue_info ||= client.get_queue_attributes(queue_url: queue_url, attribute_names: ['All']).attributes
57
+ end
58
+
59
+ def queue_arn(load: false)
60
+ queue_info(load: load)['QueueArn']
61
+ end
62
+
63
+ def approximate_number_of_messages_not_visible(load: false)
64
+ queue_info(load: load)['ApproximateNumberOfMessagesNotVisible'].to_i
65
+ end
66
+
67
+ def created_at(load: false)
68
+ DateTime.strptime(queue_info(load: load)['CreatedTimestamp'], '%s')
69
+ end
70
+
71
+ def updated_at(load: false)
72
+ DateTime.strptime(queue_info(load: load)['LastModifiedTimestamp'], '%s')
73
+ end
74
+
75
+ def visibility_timeout(load: false)
76
+ queue_info(load: load)['VisibilityTimeout'].to_i
77
+ end
78
+
79
+ def message_retention_period(load: false)
80
+ queue_info(load: load)['MessageRetentionPeriod'].to_i
81
+ end
82
+
83
+ def max_receive_count
84
+ @max_receive_count ||= build_max_receive_count
85
+ end
86
+
87
+ def dump_message(value)
88
+ dump_message = settings[:dump_message] || :to_json
89
+
90
+ case dump_message
91
+ when Proc
92
+ dump_message.call value
93
+ when :to_json
94
+ value.to_json
95
+ else
96
+ value.to_s
97
+ end
98
+ end
99
+
100
+ def load_message(message)
101
+ body = message.body
102
+ load_message = settings[:load_message] || :to_json
103
+
104
+ case load_message
105
+ when Proc
106
+ load_message.call body
107
+ when :to_json
108
+ JSON.parse(body)
109
+ else
110
+ body
111
+ end
112
+ end
113
+
114
+ def settings
115
+ @settings ||= {}
116
+ end
117
+
118
+ def create_queue_and_dead_queue
119
+ create_queue
120
+ dead_queue.create_queue
121
+ true
122
+ end
123
+
124
+ def create_queue
125
+ queue = find_queue_by_name(queue_full_name)
126
+ return queue if queue
127
+
128
+ client.create_queue(
129
+ queue_name: queue_full_name,
130
+ attributes: default_attributes
131
+ )
132
+ end
133
+
134
+ def find_queue_by_name(name)
135
+ client.get_queue_url(queue_name: name)
136
+ rescue Aws::SQS::Errors::NonExistentQueue
137
+ nil
138
+ end
139
+
140
+ def update_attributes(**attributes)
141
+ attributes = attributes.map { |att| att.map(&:to_s) }.to_h
142
+ client.set_queue_attributes(
143
+ queue_url: queue_url,
144
+ attributes: attributes
145
+ )
146
+ true
147
+ end
148
+
149
+ private
150
+
151
+ def build_queue_full_name
152
+ [
153
+ settings[:queue_prefix] || SqsSimplify.settings.queue_prefix,
154
+ queue_name,
155
+ settings[:queue_suffix] || SqsSimplify.settings.queue_suffix
156
+ ].reject { |value| value.nil? || value.empty? }
157
+ .join('_')
158
+ end
159
+
160
+ def build_dead_queue_class
161
+ class_name = "::#{name}::DeadQueue"
162
+
163
+ class_eval <<~M, __FILE__, __LINE__ + 1
164
+ class #{class_name} < SqsSimplify::DeadQueue; end
165
+ M
166
+
167
+ dead_queue_class = const_get class_name
168
+ dead_queue_class.set :queue_name, "#{queue_name}_dead"
169
+ dead_queue_class
170
+ end
171
+
172
+ def build_max_receive_count
173
+ value = settings[:max_receive_count].to_i
174
+ value.positive? ? value : 1
175
+ rescue StandardError
176
+ 1
177
+ end
178
+
179
+ def default_attributes
180
+ visibility_timeout = settings[:visibility_timeout] || self::VISIBILITY_TIMEOUT # Should be between 0 seconds and 12 hours.
181
+ message_retention_period = settings[:message_retention_period] || self::MESSAGE_RETENTION_PERIOD # retention, Should be between 1 minute and 14 days.
182
+ delay_seconds = settings[:delay_seconds] || self::DELAY_SECONDS
183
+
184
+ {
185
+ VisibilityTimeout: visibility_timeout.to_s,
186
+ MessageRetentionPeriod: message_retention_period.to_s,
187
+ DelaySeconds: delay_seconds.to_s
188
+ }
189
+ end
190
+
191
+ def to_underscore(value)
192
+ value.gsub(/::/, '_')
193
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
194
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
195
+ .tr('-', '_')
196
+ .downcase
197
+ end
198
+
199
+ def inherited(sub)
200
+ super
201
+ return if sub.name.nil?
202
+ return if %w[SqsSimplify::Job SqsSimplify::Consumer SqsSimplify::Scheduler].include? sub.name
203
+
204
+ if sub < SqsSimplify::Consumer || sub < SqsSimplify::Job
205
+ SqsSimplify.consumers << sub
206
+ elsif sub < SqsSimplify::Scheduler
207
+ SqsSimplify.schedulers << sub
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ module Client
5
+ protected
6
+
7
+ def client
8
+ self.class.client
9
+ end
10
+
11
+ module ClassMethods
12
+ def client
13
+ @client ||= build_client
14
+ end
15
+
16
+ private
17
+
18
+ def build_client
19
+ args = {
20
+ access_key_id: settings[:access_key_id] || SqsSimplify.settings.access_key_id,
21
+ secret_access_key: settings[:secret_access_key] || SqsSimplify.settings.secret_access_key,
22
+ region: settings[:region] || SqsSimplify.settings.region,
23
+ stub_responses: SqsSimplify.settings.stub_responses
24
+ }.select { |_k, v| v }
25
+
26
+ SqsSimplify.settings.faker ? SqsSimplify::FakerClient.new : Aws::SQS::Client.new(args)
27
+ end
28
+ end
29
+
30
+ def self.included(base)
31
+ base.extend ClassMethods
32
+ end
33
+ end
34
+ end