omnes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,617 @@
1
+ # Omnes
2
+
3
+ Pub/sub for Ruby.
4
+
5
+ Omnes is a Ruby library implementing the publish-subscribe pattern. This
6
+ pattern allows senders of messages to be decoupled from their receivers. An
7
+ Event Bus acts as a middleman where events are published while interested
8
+ parties can subscribe to them.
9
+
10
+ ## Installation
11
+
12
+ `bundle add omnes`
13
+
14
+ ## Usage
15
+
16
+ There're two ways to make use of the pub/sub features Omnes provides:
17
+
18
+ - Standalone, through an [`Omnes::Bus`](lib/omnes/bus.rb) instance:
19
+
20
+ ```ruby
21
+ require "omnes"
22
+
23
+ bus = Omnes::Bus.new
24
+ ```
25
+
26
+ - Mixing in the behavior in another class by including the [`Omnes`](lib/omnes.rb) module.
27
+
28
+ ```ruby
29
+ require "omnes"
30
+
31
+ class Notifier
32
+ include Omnes
33
+ end
34
+ ```
35
+
36
+ The following examples will use the direct `Omnes::Bus` instance. The only
37
+ difference for the mixing use case is that the methods are directly called in
38
+ the including instance.
39
+
40
+ ### Registering events
41
+
42
+ Before being able to work with a given event, its name (which must be a
43
+ `Symbol`) must be registered:
44
+
45
+ ```ruby
46
+ bus.register(:order_created)
47
+ ```
48
+
49
+ ### Publishing events
50
+
51
+ An event can be anything responding to a method `:name`, which must match with a
52
+ registered name.
53
+
54
+ Typically, there're two main ways to generate events.
55
+
56
+ 1. Unstructured events
57
+
58
+ An event can be generated at publication time, where you provide its name and a
59
+ payload to be consumed by its subscribers:
60
+
61
+ ```ruby
62
+ bus.publish(:order_created, number: order.number, user_email: user.email)
63
+ ```
64
+
65
+ In that case, an instance of [`Omnes::UnstructuredEvent`](lib/omnes/unstructured_event.rb) is generated
66
+ under the hood.
67
+
68
+ Unstructured events are straightforward to create and use, but they're harder
69
+ to debug as they're defined at publication time. On top of that, other
70
+ features, such as event persistence, can't be reliably built on top of them.
71
+
72
+ 2. Instance-backed events
73
+
74
+ You can also publish an instance of a class including
75
+ [`Omnes::Event`](lib/omnes/event.rb). The only fancy thing it provides is an
76
+ OOTB event name generated based on the class name. In fact, you can use
77
+ anything responding to `#omnes_event_name`.
78
+
79
+ ```ruby
80
+ class OrderCreatedEvent
81
+ include Omnes::Event
82
+
83
+ attr_reader :number, :user_email
84
+
85
+ def initialize(number:, user_email:)
86
+ @number = number
87
+ @user_email = user_email
88
+ end
89
+ end
90
+
91
+ event = OrderCreatedEvent.new(number: order.number, user_email: user.email)
92
+ bus.publish(event)
93
+ ```
94
+
95
+ By default, an event name instance equals the event class name downcased,
96
+ underscored and with the `Event` suffix removed if present (`:order_created` in
97
+ the previous example). However, you can configure your own name generator based
98
+ on the event instance:
99
+
100
+ ```ruby
101
+ event_name_as_class = ->(event) { event.class.name.to_sym } # :OrderCreatedEvent in the example
102
+ Omnes.config.event.name_builder = event_name_as_class
103
+ ```
104
+
105
+ Instance-backed events provide a well-defined structure, and other features,
106
+ like event persistence, can be added on top of them.
107
+
108
+ ### Subscribing to events
109
+
110
+ You can subscribe to a specific event to run some code whenever it's published.
111
+ The event is yielded to the subscription block:
112
+
113
+ ```ruby
114
+ bus.subscribe(:order_created) do |event|
115
+ # ...
116
+ end
117
+ ```
118
+
119
+ For unstructured events, the published data is made available through the
120
+ `payload` method, although `#[]` can be used as a shortcut:
121
+
122
+ ```ruby
123
+ bus.subscribe(:order_created) do |event|
124
+ OrderCreationEmail.new.send(number: event[:number], email: event[:user_email])
125
+ # OrderCreationEmail.new.send(number: event.payload[:number], email: event.payload[:user_email])
126
+ end
127
+ ```
128
+
129
+ Otherwise, use the event instance according to its structure:
130
+
131
+ ```ruby
132
+ bus.subscribe(:order_created) do |event|
133
+ OrderCreationEmail.new.send(number: event.number, email: event.user_email)
134
+ end
135
+ ```
136
+
137
+ The subscription code can also be given as anything responding to a method
138
+ `#call`.
139
+
140
+ ```ruby
141
+ class OrderCreationEmailSubscription
142
+ def call(event)
143
+ OrderCreationEmail.new.send(number: event.number, email: event.user_email)
144
+ end
145
+ end
146
+
147
+ bus.subscribe(:order_created, OrderCreationEmailSubscription.new)
148
+ ```
149
+
150
+ However, see [Event subscribers](#event-subscribers) section bellow for a more powerful way
151
+ to define standalone event handlers.
152
+
153
+ #### Global subscriptions
154
+
155
+ You can also create a subscription that will run for all events:
156
+
157
+ ```ruby
158
+ class LogEventsSubscription
159
+ attr_reader :logger
160
+
161
+ def initialize(logger: Logger.new(STDOUT))
162
+ @logger = logger
163
+ end
164
+
165
+ def call(event)
166
+ logger.info("Event #{event.name} published")
167
+ end
168
+ end
169
+
170
+ bus.subscribe_to_all(LogEventsSubscription.new)
171
+ ```
172
+
173
+ #### Custom matcher subscriptions
174
+
175
+ Custom event matchers can be defined. A matcher is something responding to
176
+ `#call` and taking the event as an argument. It must return `true` or `false`
177
+ to match or ignore the event.
178
+
179
+ ```ruby
180
+ ORDER_EVENTS_MATCHER = ->(event) { event.name.start_with?(:order) }
181
+
182
+ bus.subscribe_with_matcher(ORDER_EVENTS_MATCHER) do |event|
183
+ # ...
184
+ end
185
+ ```
186
+
187
+ ## Event subscribers
188
+
189
+ Events subscribers offer a way to define event subscriptions from a custom
190
+ class.
191
+
192
+ In its simplest form, you can match an event to a method in the class.
193
+
194
+ ```ruby
195
+ class OrderCreationEmailSubscriber
196
+ include Omnes::Subscriber
197
+
198
+ handle :order_created, with: :send_confirmation_email
199
+
200
+ attr_reader :service
201
+
202
+ def initialize(service: OrderCreationEmail.new)
203
+ @service = service
204
+ end
205
+
206
+ def send_confirmation_email(event)
207
+ service.send(number: event.number, email: event.user_email)
208
+ end
209
+ end
210
+
211
+ OrderCreationEmailSubscriber.new.subscribe_to(bus)
212
+ ```
213
+
214
+ Equivalent to the subscribe methods we've seen above, you can also subscribe to
215
+ all events:
216
+
217
+ ```ruby
218
+ class LogEventsSubscriber
219
+ include Omnes::Subscriber
220
+
221
+ handle_all with: :log_event
222
+
223
+ attr_reader :logger
224
+
225
+ def initialize(logger: Logger.new(STDOUT))
226
+ @logger = logger
227
+ end
228
+
229
+ def log_event(event)
230
+ logger.info("Event #{event.name} published")
231
+ end
232
+ end
233
+ ```
234
+
235
+ You can also handle the event with your own custom matcher:
236
+
237
+ ```ruby
238
+ class OrderSubscriber
239
+ include Omnes::Subscriber
240
+
241
+ handle_with_matcher ORDER_EVENTS_MATCHER, with: :register_order_event
242
+
243
+ def register_order_event(event)
244
+ # ...
245
+ end
246
+ end
247
+ ```
248
+
249
+ ### Autodiscovering event handlers
250
+
251
+ You can let the event handlers to be automatically discovered.You need to
252
+ enable the `autodiscover` feature and prefix the event name with `on_` for your
253
+ handler name.
254
+
255
+ ```ruby
256
+ class OrderCreationEmailSubscriber
257
+ include Omnes::Subscriber[
258
+ autodiscover: true
259
+ ]
260
+
261
+ # ...
262
+
263
+ def on_order_created(event)
264
+ # ...
265
+ end
266
+ end
267
+ ```
268
+
269
+ If you prefer, you can make `autodiscover` on by default:
270
+
271
+ ```ruby
272
+ Omnes.config.subscriber.autodiscover = true
273
+ ```
274
+
275
+ You can also specify your own autodiscover strategy. It must be something
276
+ callable, transforming the event name into the handler name.
277
+
278
+ ```ruby
279
+ AUTODISCOVER_STRATEGY = ->(event_name) { event_name }
280
+
281
+ class OrderCreationEmailSubscriber
282
+ include Omnes::Subscriber[
283
+ autodiscover: true,
284
+ autodiscover_strategy: AUTODISCOVER_STRATEGY
285
+ ]
286
+
287
+ # ...
288
+
289
+ def order_created(event)
290
+ # ...
291
+ end
292
+ end
293
+ ```
294
+
295
+ ### Adapters
296
+
297
+ Subscribers are not limited to use a method as event handler. They can interact
298
+ with the whole instance context and leverage it to build adapters.
299
+
300
+ Omnes ships with a few of them.
301
+
302
+ #### Sidekiq adapter
303
+
304
+ The Sidekiq adapter allows creating a subscription to be processed as a
305
+ [Sidekiq](https://sidekiq.org) background job.
306
+
307
+ Sidekiq requires that the argument passed to `#perform` is serializable. By
308
+ default, the result of calling `#payload` in the event is taken.
309
+
310
+ ```ruby
311
+ class OrderCreationEmailSubscriber
312
+ include Omnes::Subscriber
313
+ include Sidekiq::Job
314
+
315
+ handle :order_created, with: Adapter::Sidekiq
316
+
317
+ def perform(payload)
318
+ OrderCreationEmail.send(number: payload["number"], email: payload["user_email"])
319
+ end
320
+ end
321
+
322
+ bus = Omnes::Bus.new
323
+ bus.register(:order_created)
324
+ OrderCreationEmailSubscriber.new.subscribe_to(bus)
325
+ bus.publish(:order_created, "number" => order.number, "user_email" => user.email)
326
+ ```
327
+
328
+ However, you can configure how the event is serialized thanks to the
329
+ `serializer:` option. It needs to be something callable taking the event as
330
+ argument:
331
+
332
+ ```ruby
333
+ handle :order_created, with: Adapter::Sidekiq[serializer: :serialized_payload.to_proc]
334
+ ```
335
+
336
+ You can also globally configure the default serializer:
337
+
338
+ ```ruby
339
+ Omnes.config.subscriber.adapter.sidekiq.serializer = :serialized_payload.to_proc
340
+ ```
341
+
342
+ You can delay the callback execution from the publication time with the `.in`
343
+ method (analogous to `Sidekiq::Job.perform_in`):
344
+
345
+ ```ruby
346
+ handle :order_created, with: Adapter::Sidekiq.in(60)
347
+ ```
348
+
349
+ #### ActiveJob adapter
350
+
351
+ The ActiveJob adapter allows creating a subscription to be processed as an
352
+ [ActiveJob](https://edgeguides.rubyonrails.org/active_job_basics.html)
353
+ background job.
354
+
355
+ ActiveJob requires that the argument passed to `#perform` is serializable. By
356
+ default, the result of calling `#payload` in the event is taken.
357
+
358
+ ```ruby
359
+ class OrderCreationEmailSubscriber < ActiveJob
360
+ include Omnes::Subscriber
361
+
362
+ handle :order_created, with: Adapter::ActiveJob
363
+
364
+ def perform(payload)
365
+ OrderCreationEmail.send(number: payload["number"], email: payload["user_email"])
366
+ end
367
+ end
368
+
369
+ bus = Omnes::Bus.new
370
+ bus.register(:order_created)
371
+ OrderCreationEmailSubscriber.new.subscribe_to(bus)
372
+ bus.publish(:order_created, "number" => order.number, "user_email" => user.email)
373
+ ```
374
+
375
+ However, you can configure how the event is serialized thanks to the
376
+ `serializer:` option. It needs to be something callable taking the event as
377
+ argument:
378
+
379
+ ```ruby
380
+ handle :order_created, with: Adapter::ActiveJob[serializer: :serialized_payload.to_proc]
381
+ ```
382
+
383
+ You can also globally configure the default serializer:
384
+
385
+ ```ruby
386
+ Omnes.config.subscriber.adapter.active_job.serializer = :serialized_payload.to_proc
387
+ ```
388
+
389
+ #### Custom adapters
390
+
391
+ Custom adapters can be built. They need to implement a method `#call` taking
392
+ the instance of `Omnes::Subscriber` and the event.
393
+
394
+ Here's a custom adapter executing a subscriber method in a different
395
+ thread (we add an extra argument for the method name, and we partially apply it
396
+ at the definition time to obey the adapter requirements).
397
+
398
+ ```ruby
399
+ THREAD_ADAPTER = lambda do |method_name, instance, event|
400
+ Thread.new { instance.method(method_name).call(event) }
401
+ end
402
+
403
+ class OrderCreationEmailSubscriber
404
+ include Omnes::Subscriber
405
+ include Sidekiq::Job
406
+
407
+ handle :order_created, with: THREAD_ADAPTER.curry[:order_created]
408
+
409
+ def order_created(event)
410
+ # ...
411
+ end
412
+ end
413
+ ```
414
+
415
+ Alternatively, adapters can be curried and only take the instance as an
416
+ argument, returning a one-argument callable taking the event. For instance, we
417
+ could also have defined the thread adapter like this:
418
+
419
+ ```ruby
420
+ class ThreadAdapter
421
+ attr_reader :method_name
422
+
423
+ def initialize(method_name)
424
+ @method_name = method_name
425
+ end
426
+
427
+ def call(instance)
428
+ raise unless instance.respond_to?(method_name)
429
+
430
+ ->(event) { instance.method(:call).(event) }
431
+ end
432
+ end
433
+
434
+ # ...
435
+ handle :order_created, with: ThreadAdapter.new(:order_created)
436
+ # ...
437
+ ```
438
+
439
+ ## Debugging
440
+
441
+ ### Unsubscribing
442
+
443
+ When you create a subscription, an instance of
444
+ [`Omnes::Subscription`](lib/omnes/subscription.rb) is returned (or an array for
445
+ `Omnes::Subscriber`). It can be used to unsubscribe it in case you need to
446
+ debug some behavior.
447
+
448
+ ```ruby
449
+ subscription = bus.subscribe(:order_created, OrderCreationEmailSubscription.new)
450
+ bus.unsubscribe(subscription)
451
+ ```
452
+
453
+ ### Registration
454
+
455
+ Whenever you register an event, you get back an [`Omnes::Registry::Registration`](lib/omnes/registry.rb)
456
+ instance. It gives access to both the registered `#event_name` and the
457
+ `#caller_location` of the registration.
458
+
459
+ An `Omnes::Bus` contains a reference to its registry, which can be used to
460
+ retrieve a registration later on.
461
+
462
+ ```ruby
463
+ bus.registry.registration(:order_created)
464
+ ```
465
+
466
+ You can also use the registry to retrieve all registered event names:
467
+
468
+ ```ruby
469
+ bus.registry.event_names
470
+ ```
471
+
472
+ See [`Omnes::Registry`](lib/omnes/registry.rb) for other available methods.
473
+
474
+ ### Publication
475
+
476
+ When you publish an event, you get back an
477
+ [`Omnes::Publication`](lib/omnes/publication.rb) instance. It contains some
478
+ attributes that allow observing what happened:
479
+
480
+ - `#event` contains the event instance that has been published.
481
+ - `#caller_location` refers to the publication caller.
482
+ - `#time` is the time stamp for the publication.
483
+ - `#executions` contains an array of
484
+ `Omnes::Execution`(lib/omnes/execution.rb). Read more below.
485
+
486
+ `Omnes::Execution` represents a subscription individual execution. It contains
487
+ the following attributes:
488
+
489
+ - `#subscription` is an instance of [`Omnes::Subscription`](lib/omnes/subscripiton.rb).
490
+ - `#result` contains the result of the execution.
491
+ - `#benchmark` of the operation.
492
+ - `#time` is the time where the execution started.
493
+
494
+ ## Testing
495
+
496
+ Ideally, you wouldn't need big setups to test your event-driven behavior. You
497
+ could design your subscribers to use lightweight mocks for any external or
498
+ operation at the integration level. Example:
499
+
500
+ ```ruby
501
+ if # test environment
502
+ bus.subscribe(:order_created, OrderCreationEmailSubscriber.new(service: MockService.new)
503
+ else
504
+ bus.subscribe(:order_created, OrderCreationEmailSubscriber.new)
505
+ end
506
+ ```
507
+
508
+ Then, at the unit level, you can test your subscribers as any other class.
509
+
510
+ However, there's also a handy `Omnes::Bus#performing_only` method that allows
511
+ running a code block with only a selection of subscriptions as potential
512
+ callbacks for published events.
513
+
514
+ ```ruby
515
+ creation_subscription = bus.subscribe(:order_created, OrderCreationEmailSubscriber.new)
516
+ deletion_subscription = bus.subscribe(:order_deleted, OrderDeletionSubscriber.new)
517
+ bus.performing_only(creation_subscription) do
518
+ bus.publish(:order_created, number: order.number, user_email: user.email) # `creation_subscription` will run
519
+ bus.publish(:order_deleted, number: order.number) # `deletion_subscription` won't run
520
+ end
521
+ bus.publish(:order_deleted, number: order.number) # `deletion_subscription` will run
522
+ ```
523
+
524
+ Remember that the array of created subscriptions is returned on `Omnes::Subscriber#subscribe_to`.
525
+
526
+ There's also a specialized `Omnes::Bus#performing_nothing` method that runs no
527
+ subscriptions for the duration of the block.
528
+
529
+ ## Recipes
530
+
531
+ ### Rails
532
+
533
+ Create an initializer in `config/initializers/omnes.rb`:
534
+
535
+ ```ruby
536
+ require "omnes"
537
+
538
+ Omnes.config.subscriber.autodiscover = true
539
+
540
+ Bus = Omnes::Bus.new
541
+ Bus.register(:order_created)
542
+
543
+ OrderCreationEmailSubscriber.new.subscribe_to(Bus)
544
+ ```
545
+
546
+ We can define `OrderCreationEmailSubscriber` in
547
+ `app/subscribers/order_creation_email_subscriber.rb`:
548
+
549
+ ```ruby
550
+ # frozen_string_literal: true
551
+
552
+ class OrderCreationEmailSubscriber
553
+ include Omnes::Subscriber
554
+
555
+ def on_order_created(event)
556
+ # ...
557
+ end
558
+ end
559
+ ```
560
+
561
+ Ideally, you'll publish your event in a [custom service
562
+ layer](https://www.toptal.com/ruby-on-rails/rails-service-objects-tutorial). If
563
+ that's not possible, you can publish it in the controller.
564
+
565
+ We strongly discourage publishing events as part of an `ActiveRecord` callback.
566
+ Subscribers should run code that is independent of the main business
567
+ transaction. As such, they shouldn't run within the same database transaction,
568
+ and they should be decoupled of persistence responsibilities altogether.
569
+
570
+ ## Why is it called Omnes?
571
+
572
+ Why an Event Bus is called an _Event Bus_? It's a long story:
573
+
574
+ - The first leap leaves us with the hardware computer buses. They move data from one hardware component to another.
575
+ - The name leaked to the software to describe architectures that communicate parts by sending messages, like an Event Bus.
576
+ - That was given as an analogy of buses as vehicles, where not data but people are transported.
577
+ - _Bus_ is a clipped version of the Latin _omnibus_. That's what buses used to be called (and they're still called like that in some places, like Argentina).
578
+ - _Bus_ stands for the preposition _for_, while _Omni_ means _all_. That's _for
579
+ all_, but, for some reason, we decided to keep the part void of meaning.
580
+ - Why were they called _omnibus_? Let's move back to 1823 and talk about a man named Stanislas Baudry.
581
+ - Stanislas lived in a suburb of Nantes, France. There, he ran a corn mill.
582
+ - Hot water was a by-product of the mill, so Stanislas decided to build a spa business.
583
+ - As the mill was on the city's outskirts, he arranged some horse-drawn
584
+ transportation to bring people to his spa.
585
+ - It turned out that people weren't interested in it, but they did use the carriage to go to and fro.
586
+ - The first stop of the service was in front of the shop of a hatter called
587
+ __Omnes__.
588
+ - Omnes was a witty man. He'd named his shop with a pun on his Latin-sounding
589
+ name: _Omnes Omnibus_. That means something like _everything for everyone_.
590
+ - Therefore, people in Nantes started to call _Omnibus_ to the new service.
591
+
592
+ So, it turns out we call it the "Event Bus" because presumably, the parents of
593
+ Omnes gave him that name. So, the name of this library, it's a tribute to
594
+ Omnes, the hatter.
595
+
596
+ By the way, in case you're wondering, Stanislas, the guy of the mill, closed
597
+ both it and the spa to run his service.
598
+ Eventually, he moved to Paris to earn more money in a bigger city.
599
+
600
+ ## Development
601
+
602
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
603
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
604
+ prompt that will allow you to experiment.
605
+
606
+ ## Contributing
607
+
608
+ Bug reports and pull requests are welcome on GitHub at https://github.com/nebulab/omnes. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/nebulab/omnes/blob/master/CODE_OF_CONDUCT.md).
609
+
610
+
611
+ ## License
612
+
613
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
614
+
615
+ ## Code of Conduct
616
+
617
+ Everyone interacting in the Omnes project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/nebulab/omnes/blob/master/CODE_OF_CONDUCT.md).
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,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "omnes"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+
11
+ # (If you use this, don't forget to add pry to your Gemfile!)
12
+ # require "pry"
13
+ # Pry.start
14
+
15
+ require "irb"
16
+ 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