ntl-orchestra 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +22 -0
- data/README.md +539 -0
- data/Rakefile +21 -0
- data/bin/rake +16 -0
- data/lib/orchestra/conductor.rb +119 -0
- data/lib/orchestra/configuration.rb +12 -0
- data/lib/orchestra/dsl/nodes.rb +72 -0
- data/lib/orchestra/dsl/object_adapter.rb +134 -0
- data/lib/orchestra/dsl/operations.rb +108 -0
- data/lib/orchestra/errors.rb +44 -0
- data/lib/orchestra/node/output.rb +61 -0
- data/lib/orchestra/node.rb +130 -0
- data/lib/orchestra/operation.rb +49 -0
- data/lib/orchestra/performance.rb +137 -0
- data/lib/orchestra/recording.rb +83 -0
- data/lib/orchestra/run_list.rb +171 -0
- data/lib/orchestra/thread_pool.rb +163 -0
- data/lib/orchestra/util.rb +98 -0
- data/lib/orchestra/version.rb +3 -0
- data/lib/orchestra.rb +35 -0
- data/orchestra.gemspec +26 -0
- data/test/examples/fizz_buzz.rb +32 -0
- data/test/examples/invitation_service.rb +118 -0
- data/test/integration/multithreading_test.rb +38 -0
- data/test/integration/recording_telemetry_test.rb +86 -0
- data/test/integration/replayable_operation_test.rb +53 -0
- data/test/lib/console.rb +103 -0
- data/test/lib/test_runner.rb +19 -0
- data/test/support/telemetry_recorder.rb +49 -0
- data/test/test_helper.rb +16 -0
- data/test/unit/conductor_test.rb +25 -0
- data/test/unit/dsl_test.rb +122 -0
- data/test/unit/node_test.rb +122 -0
- data/test/unit/object_adapter_test.rb +100 -0
- data/test/unit/operation_test.rb +224 -0
- data/test/unit/run_list_test.rb +131 -0
- data/test/unit/thread_pool_test.rb +105 -0
- data/test/unit/util_test.rb +20 -0
- data/tmp/.keep +0 -0
- metadata +159 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e510c2671333a04041a77dc8c6a745fd4e6f2e58
|
4
|
+
data.tar.gz: 0c874a17df26a2f97e942feb60713b68c5648cd2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e4beddf07b80930847e43228307e3065170a0fbb041e3215c28f89ef3ec9e50ce7d2a1d190f238e7c524caaec760a3dfdb6e085fb51d4bb118a97ba95bb55f8e
|
7
|
+
data.tar.gz: 2c60866f23f0722cb01b96d6aa71ac717267ada8d4fb5dd17ab897749ddc9dc59b23a306dd2599f9015682e9721a59076c5e60ff161d0475df76d06de24e3ddc
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 ntl
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,539 @@
|
|
1
|
+
# Orchestra
|
2
|
+
|
3
|
+
Seamlessly chain multiple command or query objects together with a simple, lightweight framework.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'ntl-orchestra'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install ntl-orchestra
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
Here's a simple example without a lot of context:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
operation = Orchestra.define do
|
27
|
+
node :make_array do
|
28
|
+
depends_on :up_to
|
29
|
+
provides :array
|
30
|
+
perform do
|
31
|
+
limit.times.to_a
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
node :apply_fizzbuzz do
|
36
|
+
iterates_over :array
|
37
|
+
provides :fizzbuzz
|
38
|
+
perform do |num|
|
39
|
+
next if num == 0 # filter 0 from the output
|
40
|
+
str = ''
|
41
|
+
str << "Fizz" if num.mod 3 == 0
|
42
|
+
str << "Buzz" if num.mod 5 == 0
|
43
|
+
str << num.to_s if str.empty?
|
44
|
+
str
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
finally do
|
49
|
+
iterates_over :fizzbuzz
|
50
|
+
perform do |str|
|
51
|
+
puts str
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
Orchestra.perform operation, :up_to => 31
|
57
|
+
```
|
58
|
+
|
59
|
+
There is an easy way to take this gem for a test drive. Clone the repo, and at the project root:
|
60
|
+
|
61
|
+
```sh
|
62
|
+
bin/rake console
|
63
|
+
```
|
64
|
+
|
65
|
+
You can run the gem's tests from within the console with `rake`:
|
66
|
+
|
67
|
+
```sh
|
68
|
+
[1] pry(Orchestra)> rake
|
69
|
+
Run options: --seed 59938
|
70
|
+
|
71
|
+
# Running:
|
72
|
+
|
73
|
+
................................................
|
74
|
+
|
75
|
+
Finished in 0.379958s, 126.3298 runs/s, 1526.4845 assertions/s.
|
76
|
+
|
77
|
+
48 runs, 580 assertions, 0 failures, 0 errors, 0 skips
|
78
|
+
=> true
|
79
|
+
```
|
80
|
+
|
81
|
+
Also, you can access the examples:
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
[1] pry(Orchestra)> Orchestra.perform FizzBuzz, :up_to => 31
|
85
|
+
[1] pry(Orchestra)> Orchestra.perform InvitationService, :account_name => 'realntl`
|
86
|
+
```
|
87
|
+
|
88
|
+
## Why?
|
89
|
+
|
90
|
+
Suppose your application, MyApp, allows users to email an invitation to share the app with all the users' followers on a popular social microblogging network. However, the application also maintains an internal database of known blacklist users who never wish to be emailed. In addition, the application uses a simple heuristic algorithm to filter out bots. From the users' perspective, this is all one feature, but it would be difficult to pack into a single class. A straightforward implementation might look something like this:
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
class InvitationService
|
94
|
+
DEFAULT_MESSAGE = "I would really love for you to try out MyApp."
|
95
|
+
ROBOT_FOLLOWER_THRESHHOLD = 500
|
96
|
+
|
97
|
+
attr :user, :message
|
98
|
+
|
99
|
+
def initialize user, message = DEFAULT_MESSAGE
|
100
|
+
@user = user
|
101
|
+
@message = message
|
102
|
+
end
|
103
|
+
|
104
|
+
def call
|
105
|
+
target_emails.each do |follower|
|
106
|
+
EmailDelivery.send message, :to => follower
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def target_emails
|
113
|
+
filtered_followers.map do |account_name|
|
114
|
+
account = FlutterAPI.get_account account_name
|
115
|
+
account['email']
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def filtered_followers
|
120
|
+
@filtered_followers ||= filter_robots(raw_followers - blacklisted_followers)
|
121
|
+
end
|
122
|
+
|
123
|
+
def raw_followers
|
124
|
+
@raw_followers ||= FlutterAPI.get_followers account_name
|
125
|
+
end
|
126
|
+
|
127
|
+
def blacklisted_followers
|
128
|
+
@blacklisted_followers ||= Blacklist.pluck :flutter_account_name
|
129
|
+
end
|
130
|
+
|
131
|
+
def filter_robots list
|
132
|
+
list.reject! do |account_name|
|
133
|
+
account = FlutterAPI.get_account account_name
|
134
|
+
next unless account['following'] > ROBOT_FOLLOWER_THRESHHOLD
|
135
|
+
account['following'] > (account['followers'] / 2)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
```
|
140
|
+
|
141
|
+
Despite appearing to conform to many popular conventions, so-called "service objects" such as `InvitationService` often prove extremely painful to work with and maintain, for a litany of reasons. The primary problem is that visibility into the flow of logic through the object has been sacrificed in order to reduce the surface area of the public API. Initially, during development, this appears to be a win, since smaller public APIs mean both less coupling *and* an easier interface for the next programmer to learn. However, suppose `FlutterAPI.get_account` starts returning hashes without a `'following'` key; this will cause `#filter_robots` to begin failing without any obvious reason why. To make matters worse, in order to discover where in the process the exception occurred, you have to reverse engineer the order of the operations by walking through all the memoization in your mind.
|
142
|
+
|
143
|
+
Another problem is that because `InvitationService` directly calls out to external services through `FlutterAPI` (an HTTP gatewary) and `Blacklist` (an `ActiveRecord` class), the only way to determine exactly what happened during an invokation of `InvitationService#call` is to know exactly what those API calls returned. This often means having to pull down production databases and API credentials in order to debug specific failure cases. And that doesn't even begin to scratch the surface of how painful an object like `InvitationService` is to test. You have to shove a bunch of fabricated records into a database and mock all of the API calls to `FlutterAPI`.
|
144
|
+
|
145
|
+
As your application matures, you'll find that your test environment begins diverging significantly from your production environment. Confidence in your test suite drops, and you have to resort to either pulling down production state or logging into a rails console on a production system in order to debug problems. The difficulty of write automated tests for this "service object" and the need to constantly invoke the code within a production context are actually two facets of the same problem -- your code is coupled to your environment.
|
146
|
+
|
147
|
+
Objects like `InvitationService` are not fun to work with.
|
148
|
+
|
149
|
+
## Wiring up an orchestration
|
150
|
+
|
151
|
+
Here is a simple translation of the above `InvitationService` into an orchestration:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
InvitationService = Orchestra.define do
|
155
|
+
DEFAULT_MESSAGE = "I would really love for you to try out MyApp."
|
156
|
+
ROBOT_FOLLOWER_THRESHHOLD = 500
|
157
|
+
|
158
|
+
node :fetch_followers do
|
159
|
+
depends_on :account_name
|
160
|
+
provides :followers
|
161
|
+
perform do
|
162
|
+
FlutterAPI.get_account account_name
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
node :fetch_blacklist do
|
167
|
+
provides :blacklist
|
168
|
+
perform do
|
169
|
+
Blacklist.pluck :flutter_account_name
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
node :remove_blacklisted_followers do
|
174
|
+
depends_on :blacklist
|
175
|
+
modifies :followers
|
176
|
+
perform do
|
177
|
+
followers.reject! do |follower|
|
178
|
+
account_name = follower.fetch 'username'
|
179
|
+
blacklist.include? account_name
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
node :filter_robots do
|
185
|
+
modifies :followers, :collection => true
|
186
|
+
perform do |follower|
|
187
|
+
account_name = follower.fetch 'username'
|
188
|
+
account = FlutterAPI.get_account account_name
|
189
|
+
next unless account['following'] > ROBOT_FOLLOWER_THRESHHOLD
|
190
|
+
next unless account['following'] > (account['followers'] / 2)
|
191
|
+
follower
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
finally :deliver_emails do
|
196
|
+
depends_on :message => DEFAULT_MESSAGE
|
197
|
+
iterates_over :followers
|
198
|
+
perform do |follower|
|
199
|
+
EmailDelivery.send message, :to => follower
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
```
|
204
|
+
|
205
|
+
At first sight, that very likely appears to be a giant pile of ruby DSL goop. And you would be correct. I'll show you how to plug in POROs in a bit, and there are some further improvements that will make the indirection worth while. For now, here is how you would perform this command:
|
206
|
+
|
207
|
+
```ruby
|
208
|
+
# Use the default message
|
209
|
+
Orchestra.perform InvitationService, :account_name => 'realntl'
|
210
|
+
|
211
|
+
# Override the default message
|
212
|
+
Orchestra.perform InvitationService, :account_name => 'realntl', :message => 'Say cheese!'
|
213
|
+
```
|
214
|
+
|
215
|
+
There's a lot more to learn, but let's start by building an understanding of the DSL.
|
216
|
+
|
217
|
+
## Breaking down the DSL: Nodes
|
218
|
+
|
219
|
+
An *operation* is a collection of nodes that each individually take part in producing some larger behavior. Each node represents one step, or stage, in the whole process. A node, essentially, accepts input, processes, and provides output. Let's look at the first node, called `:fetch_followers`.
|
220
|
+
|
221
|
+
```ruby
|
222
|
+
node :fetch_followers do
|
223
|
+
depends_on :account_name
|
224
|
+
provides :followers
|
225
|
+
perform do
|
226
|
+
FlutterAPI.get_account account_name
|
227
|
+
end
|
228
|
+
end
|
229
|
+
```
|
230
|
+
|
231
|
+
This node depends on something called an `account_name`. This means you must supply `:account_name` when you perform the operation, otherwise, `:fetch_followers` won't work. `orchestra` ensures that your performance can't commence without the required input:
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
# Raises Orchestra::MissingInputError: Missing input :account_name
|
235
|
+
operation.perform
|
236
|
+
# Works correctly
|
237
|
+
operation.perform :account_name => 'realntl'
|
238
|
+
```
|
239
|
+
|
240
|
+
Dependencies can be optional. In the above example, `deliver_emails` defaults the `:message` input to `DEFAULT_MESSAGE`.
|
241
|
+
|
242
|
+
Often, you will need the output of one operation to feed into the input of another. The annotations `depends_on`, `iterates_over`, and `modifies` all describe the inputs, and `provides` describes the output. When `provides` is omitted, the name of the output is set to match the name of the node. Orchestra actually uses the annotations to sort the ordering of the nodes at runtime to ensure that all dependencies are satisfied. In the above example, `remove_blacklisted_followers` would not execute before `fetch_blacklist`, no matter where their definitions were placed within the operation. Orchestra detects that `remove_blacklisted_followers` *depends on* `blacklist`, and `fetch_blacklist` actually provides a `blacklist`, so it knows to run `fetch_blacklist` before `remove_blacklisted_followers`. Similarly, if you had your own list of blacklisted account names lying around, you could bypass the `fetch_blacklist` node altogether, since there is no sense fetching a `blacklist` when you've already got one:
|
243
|
+
|
244
|
+
```ruby
|
245
|
+
# Never invokes fetch_blacklist
|
246
|
+
operation.perform :account_name => 'realntl', :blacklist => %w(dhh unclebobmartin)
|
247
|
+
```
|
248
|
+
|
249
|
+
This allows your operations to be reused in cases where some of the dependencies can already be satisfied.
|
250
|
+
|
251
|
+
Finally, the `modifies` annotiation deserves some explaining. When a node merely mutates an input, you are certainly welcome to declare distinct `depends_on` and `modifies` annotations:
|
252
|
+
|
253
|
+
```ruby
|
254
|
+
node :remove_blacklisted_followers do
|
255
|
+
depends_on :blacklist, :followers
|
256
|
+
provides :followers
|
257
|
+
end
|
258
|
+
```
|
259
|
+
|
260
|
+
However, `modifies` simply condenses the two into one. The following example is identical to the previous:
|
261
|
+
|
262
|
+
```ruby
|
263
|
+
node :remove_blacklisted_followers do
|
264
|
+
depends_on :blacklist
|
265
|
+
modifies :followers
|
266
|
+
end
|
267
|
+
```
|
268
|
+
|
269
|
+
## Breaking down the DSL: the operation itself
|
270
|
+
|
271
|
+
Configuring the operation is rather simple. You define the various nodes, and then specify the result. There are three ways to specify the result.
|
272
|
+
|
273
|
+
The first is very straightforward:
|
274
|
+
|
275
|
+
```ruby
|
276
|
+
Orchestra.define do
|
277
|
+
node :foo do
|
278
|
+
depends_on :bar
|
279
|
+
provides :foo # optional, since the node is called :foo
|
280
|
+
perform do … end
|
281
|
+
end
|
282
|
+
|
283
|
+
self.result = :foo
|
284
|
+
end
|
285
|
+
```
|
286
|
+
|
287
|
+
The second is just a shortened form of the first:
|
288
|
+
|
289
|
+
```ruby
|
290
|
+
Orchestra.define do
|
291
|
+
# Define a node called :foo and make it the result
|
292
|
+
result :foo do
|
293
|
+
depends_on :bar
|
294
|
+
perform do … end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
```
|
298
|
+
|
299
|
+
The third is a minor variation of the second. The only difference is that the operation will always return `true`. `finally` makes sense for operations that perform side effects (e.g. Command objects), wherease `result` will make sense for queries.
|
300
|
+
|
301
|
+
```ruby
|
302
|
+
Orchestra.define do
|
303
|
+
finally :foo do
|
304
|
+
depends_on :bar
|
305
|
+
perform do … end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
```
|
309
|
+
|
310
|
+
## Hooking in POROs
|
311
|
+
|
312
|
+
You can also hook up POROs to operations as nodes. This is important both to manage complex nodes as well as leveraging existing objects in the system. The `filter_robots` node could be expressed as a PORO rather easily:
|
313
|
+
|
314
|
+
```ruby
|
315
|
+
node FilterRobots, :iterates_over => :followers, :collection => true
|
316
|
+
|
317
|
+
class FilterRobots
|
318
|
+
def initialize followers
|
319
|
+
@followers = followers
|
320
|
+
end
|
321
|
+
|
322
|
+
def perform follower
|
323
|
+
account_name = follower.fetch 'account_name'
|
324
|
+
account = FlutterAPI.get_account account_name
|
325
|
+
next unless account['following'] > ROBOT_FOLLOWER_THRESHHOLD
|
326
|
+
next unless account['following'] > (account['followers'] / 2)
|
327
|
+
account_name
|
328
|
+
end
|
329
|
+
end
|
330
|
+
```
|
331
|
+
|
332
|
+
Orchestra infers the dependencies from `FilterRobots#initialize`, and automatically instantiates the object for you during the performance. You can alter the name of the method:
|
333
|
+
|
334
|
+
```ruby
|
335
|
+
node MyPoro, :method => :call
|
336
|
+
```
|
337
|
+
|
338
|
+
You can also hook into singletons like `Module` (or a `Class` that implements `self.perform`):
|
339
|
+
|
340
|
+
```ruby
|
341
|
+
node MySingleton, :method => :invoke
|
342
|
+
|
343
|
+
module MySingleton
|
344
|
+
def self.invoke … end
|
345
|
+
end
|
346
|
+
```
|
347
|
+
|
348
|
+
By default, the name of the provision will be inferred from the object name.
|
349
|
+
|
350
|
+
## Multithreading
|
351
|
+
|
352
|
+
Two of the nodes in the `InvitationService` orchestration -- `filter_robots` and `deliver_emails` -- actually operate on *collections*. `deliver_emails` indicates that `:followers` is a collection by using the `iterates_over` annotation instead of `depends_on`. In fact, the two annotations are identical *except* that `iterates_over` indicates that the dependency is in fact going to be a list. Collections can be defined on a `modifies` annotation, as well, by supplying `:collection => true` as in the case of `filter_robots`.
|
353
|
+
|
354
|
+
When nodes iterate over collections, Orchestra invokes `perform do … end` block once for each item in the collection passed in. It also spreads out each invokation across a thread pool. By default, there is only one thread in the thread pool. You can reconfigure that globally in an initializer of some kind:
|
355
|
+
|
356
|
+
```ruby
|
357
|
+
Orchestra.configure do
|
358
|
+
# Thread pools will spin up five threads
|
359
|
+
self.thread_count = 5
|
360
|
+
end
|
361
|
+
```
|
362
|
+
|
363
|
+
These collections can operate as filters; the output list is a mapping of the input list *transformed* by the `perform` block. When the `perform` block returns `nil`, the output shrinks by one element. Consider the FizzBuzz example at the top of this document. Notice that `0` doesn't get printed out. This is because the `perform` block in `apply_fizzbuzz` returned nil when the `num` was zero. `nil` values get `compact`'ed.
|
364
|
+
|
365
|
+
## Invoking an operation through a conductor
|
366
|
+
|
367
|
+
Now that you understand how to define operations, we can do some cool things with them. First, though, we need to change the way we invoke operations. Let's instantiate a `Conductor`, and have *that* perform our operations for us:
|
368
|
+
|
369
|
+
```ruby
|
370
|
+
conductor = Orchestra::Conductor.new
|
371
|
+
conductor.perform InvitationService, :account_name => 'realntl'
|
372
|
+
```
|
373
|
+
|
374
|
+
What did that buy us? First, we can configure the size of the thread pool specifically for this conductor:
|
375
|
+
|
376
|
+
```ruby
|
377
|
+
conductor.thread_count = 5
|
378
|
+
```
|
379
|
+
|
380
|
+
Second, we can inject *services* into our operation. Our operation needs to be modified such that our database connections and API access are passed in as dependencies:
|
381
|
+
|
382
|
+
```ruby
|
383
|
+
node :fetch_followers do
|
384
|
+
depends_on :account_name, :flutter_api
|
385
|
+
provides :followers
|
386
|
+
perform do
|
387
|
+
flutter_api.get_account account_name
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
# and
|
392
|
+
|
393
|
+
node :fetch_blacklist do
|
394
|
+
depends_on :blacklist_table
|
395
|
+
provides :blacklist
|
396
|
+
perform do
|
397
|
+
blacklist_table.pluck :flutter_account_name
|
398
|
+
end
|
399
|
+
end
|
400
|
+
```
|
401
|
+
|
402
|
+
Now we can teach the conductor how to supply the services.
|
403
|
+
|
404
|
+
```ruby
|
405
|
+
conductor = Orchestra::Conductor.new(
|
406
|
+
:flutter_api => FlutterAPI,
|
407
|
+
:blacklist_table => Blacklist,
|
408
|
+
)
|
409
|
+
```
|
410
|
+
|
411
|
+
We can also override the conductor's service registry by supplying them into the performance itself, as we do any other dependency like `account_name`:
|
412
|
+
|
413
|
+
```ruby
|
414
|
+
conductor.perform InvitationService, :account_name => 'realntl', :blacklist_table => mock
|
415
|
+
```
|
416
|
+
|
417
|
+
What did this buy us? Two big things. We can now attach *observers* to the performance, and we can actually record all calls in and out of the `flutter_api` and `blacklist_table` services. The former allows us to share the internal operation of the performance with the rest of the system without breaking encapsulation, and the latter allows us to actually replay the operation against recorded snapshots of live performances.
|
418
|
+
|
419
|
+
Additionally, you can pass the `conductor` into nodes. In this way you can embed one orchestration into another:
|
420
|
+
|
421
|
+
```ruby
|
422
|
+
inner_operation = Orchestra.define do
|
423
|
+
result :foo do
|
424
|
+
provides :bar
|
425
|
+
perform do
|
426
|
+
bar * 2
|
427
|
+
end
|
428
|
+
end
|
429
|
+
end
|
430
|
+
|
431
|
+
outer_operation = Orchestra.define do
|
432
|
+
result :baz do
|
433
|
+
depends_on :conductor
|
434
|
+
provides :qux
|
435
|
+
perform do
|
436
|
+
conductor.perform inner_operation
|
437
|
+
end
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
conductor = Conductor.new
|
442
|
+
conductor.perform outer_operation
|
443
|
+
```
|
444
|
+
|
445
|
+
To shorten this, the inner operation can be "mounted" inside the outer operation:
|
446
|
+
|
447
|
+
```ruby
|
448
|
+
inner_operation = Orchestra.define do
|
449
|
+
result :foo do
|
450
|
+
provides :bar
|
451
|
+
perform do
|
452
|
+
bar * 2
|
453
|
+
end
|
454
|
+
end
|
455
|
+
end
|
456
|
+
|
457
|
+
outer_operation = Orchestra.define do
|
458
|
+
result inner_operation
|
459
|
+
end
|
460
|
+
```
|
461
|
+
|
462
|
+
## Observing a performance
|
463
|
+
|
464
|
+
You can attach observers to any `Conductor`:
|
465
|
+
|
466
|
+
```ruby
|
467
|
+
conductor.add_observer MyObserver
|
468
|
+
|
469
|
+
class MyObserver
|
470
|
+
def update event_name, *args
|
471
|
+
case event_name
|
472
|
+
when :operation_entered then "Hello"
|
473
|
+
when :operation_exited then "World!"
|
474
|
+
when :node_entered then "Hello from within a node"
|
475
|
+
when :node_exited then "Goodbye from within a node"
|
476
|
+
when :error_raised then "Ruh roh!"
|
477
|
+
end
|
478
|
+
end
|
479
|
+
end
|
480
|
+
```
|
481
|
+
|
482
|
+
The arguments passed to `update` will vary based on the event:
|
483
|
+
|
484
|
+
| Event | First argument | Second argument |
|
485
|
+
| ------------------------ | ------------------------------------ | --------------------------------- |
|
486
|
+
| `:operation_entered` | The name of the operation starting | Input going into the operation |
|
487
|
+
| `:operation_exited` | The name of the operation finishing | Output of the operation |
|
488
|
+
| `:node_entered` | The name of the node | Input going into the node |
|
489
|
+
| `:node_exited` | The name of the node | Output of the node |
|
490
|
+
| `:error_raised` | The error itself | `nil` |
|
491
|
+
|
492
|
+
Embedded performances will inherit the observers of the outer operation.
|
493
|
+
|
494
|
+
## Recording and playing back services
|
495
|
+
|
496
|
+
The final main feature of Orchestra is the ability to record the service calls throughout an operation. These recordings can then be used to replay operations. This could be helpful, for instance, to attach to exceptions in your exception logging service so that programmers can replay failed performances on their development environments. In addition, these recordings could be used to drive integration testing. Thus, instead of using separate tools such as like ActiveRecord fixtures, FactoryGirl, and VCR for every service dependency, you can test your operations with one single setup artifact.
|
497
|
+
|
498
|
+
You can record a performance on any `Conductor` by calling `#record` instead of `#perform`:
|
499
|
+
|
500
|
+
```ruby
|
501
|
+
recording = conductor.record InvitationService, :account_name => 'realntl'
|
502
|
+
recording.output # <-- the usual output is attached to the recording itself
|
503
|
+
```
|
504
|
+
|
505
|
+
And a recording can be replayed:
|
506
|
+
|
507
|
+
```ruby
|
508
|
+
Orchestra.replay_recording InvitationService, recording
|
509
|
+
```
|
510
|
+
|
511
|
+
You can override the inputs passed in when replaying:
|
512
|
+
|
513
|
+
```ruby
|
514
|
+
Orchestra.replay_recording InvitationService, recording, :account_name => "dhh"
|
515
|
+
```
|
516
|
+
|
517
|
+
If you want to serialize/persist the recording, just use `JSON.dump`:
|
518
|
+
|
519
|
+
```ruby
|
520
|
+
json = JSON.dump recording
|
521
|
+
File.write "/tmp/recording.json", json
|
522
|
+
```
|
523
|
+
|
524
|
+
You can replay the recording using `JSON.load`:
|
525
|
+
|
526
|
+
```ruby
|
527
|
+
json = File.read "tmp/recording.json"
|
528
|
+
recording = JSON.load json
|
529
|
+
Orchestra.replay_recording InvitationService, recording
|
530
|
+
```
|
531
|
+
|
532
|
+
|
533
|
+
## Contributing
|
534
|
+
|
535
|
+
1. Fork it ( https://github.com/[my-github-username]/orchestra/fork )
|
536
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
537
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
538
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
539
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
require "rake/testtask"
|
2
|
+
require "bundler/gem_tasks"
|
3
|
+
|
4
|
+
Dir["test/lib/**/*.rb"].each &method(:load)
|
5
|
+
Dir["test/lib/tasks/**/*.rake"].each &method(:load)
|
6
|
+
|
7
|
+
task :env do
|
8
|
+
require 'bundler'
|
9
|
+
Bundler.setup
|
10
|
+
Bundler.require :default, :development
|
11
|
+
end
|
12
|
+
|
13
|
+
desc "Open a development console"
|
14
|
+
task :console => :env do
|
15
|
+
Console.load
|
16
|
+
Orchestra.pry
|
17
|
+
end
|
18
|
+
|
19
|
+
task :test => :env do TestRunner.run end
|
20
|
+
|
21
|
+
task :default => :test
|
data/bin/rake
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# This file was generated by Bundler.
|
4
|
+
#
|
5
|
+
# The application 'rake' is installed as part of a gem, and
|
6
|
+
# this file is here to facilitate running it.
|
7
|
+
#
|
8
|
+
|
9
|
+
require 'pathname'
|
10
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
|
11
|
+
Pathname.new(__FILE__).realpath)
|
12
|
+
|
13
|
+
require 'rubygems'
|
14
|
+
require 'bundler/setup'
|
15
|
+
|
16
|
+
load Gem.bin_path('rake', 'rake')
|