donkey 0.1.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/LICENSE +20 -0
- data/README.textile +162 -0
- data/Rakefile +57 -0
- data/VERSION.yml +4 -0
- data/lib/ass.rb +125 -0
- data/lib/ass/actor.rb +50 -0
- data/lib/ass/amqp.rb +19 -0
- data/lib/ass/callback_factory.rb +95 -0
- data/lib/ass/client.rb +19 -0
- data/lib/ass/peeper.rb +38 -0
- data/lib/ass/rpc.rb +180 -0
- data/lib/ass/serializers.rb +26 -0
- data/lib/ass/server.rb +181 -0
- data/lib/ass/topic.rb +90 -0
- data/spec/actor_spec.rb +83 -0
- data/spec/ass_spec.rb +425 -0
- data/spec/client_spec.rb +50 -0
- data/spec/rpc_spec.rb +73 -0
- data/test/ass_test.rb +7 -0
- data/test/test_helper.rb +10 -0
- metadata +85 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Howard Yeh
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.textile
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
|
2
|
+
Asynchronous Service Stages (ASS) is a way to
|
3
|
+
organize distributed services by decoupling the
|
4
|
+
what and how of computation from the when and
|
5
|
+
where. Built on top of RabbitMQ (an implementation
|
6
|
+
of AMQP), ASS helps you to build robust and
|
7
|
+
scalable distributed applications.
|
8
|
+
|
9
|
+
End of project pimping, let's get started.
|
10
|
+
|
11
|
+
h1. Install
|
12
|
+
|
13
|
+
You need
|
14
|
+
|
15
|
+
(1) Erlang
|
16
|
+
(2) RabbitMQ
|
17
|
+
(3) AMQP gem
|
18
|
+
|
19
|
+
Thread.new { EM.run }
|
20
|
+
AMQP.start
|
21
|
+
|
22
|
+
^C to exit
|
23
|
+
|
24
|
+
|
25
|
+
h1. The Basics
|
26
|
+
|
27
|
+
A service component is a ruby script that
|
28
|
+
communicates with RabbitMQ. You need to define the
|
29
|
+
AMQP server your ASS depends on. Something like this,
|
30
|
+
|
31
|
+
|
32
|
+
require 'rubygems'
|
33
|
+
require 'ass'
|
34
|
+
AMQP.start(:host => 'localhost',
|
35
|
+
#:vhost => "/ass-test",
|
36
|
+
:logging => false) do
|
37
|
+
# ASS definition
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
To start a server
|
42
|
+
|
43
|
+
server = ASS.new("echo")
|
44
|
+
# => #<ASS::Server echo>
|
45
|
+
|
46
|
+
But it doesn't do anything yet. You define the
|
47
|
+
behaviour of the server by setting its
|
48
|
+
callback. The callback can be a class, so that for
|
49
|
+
each client request an object is created from the
|
50
|
+
class to process the request. Like so,
|
51
|
+
|
52
|
+
|
53
|
+
server.react(SomeReactorClass)
|
54
|
+
|
55
|
+
|
56
|
+
However, often you just want something simple. The
|
57
|
+
react method can take a block and construct an
|
58
|
+
anonymous callback class from which the server
|
59
|
+
creates an callback object for each request. Here
|
60
|
+
we ask the server to react to @foo@ or @bar@.
|
61
|
+
|
62
|
+
|
63
|
+
server.react {
|
64
|
+
def foo(input)
|
65
|
+
[:server,:foo,input]
|
66
|
+
end
|
67
|
+
|
68
|
+
def oof(input)
|
69
|
+
[:server,:oof,input]
|
70
|
+
end
|
71
|
+
}
|
72
|
+
|
73
|
+
|
74
|
+
The react method accepts for the callback either
|
75
|
+
a Class, a Module, a block, or any object. When an
|
76
|
+
object is used, it's considered a singleton, which
|
77
|
+
is used to process all the requests.
|
78
|
+
|
79
|
+
Now that we have a server, we need to get a client
|
80
|
+
so to call the server. Because the call is
|
81
|
+
asynchronous (the client doesn't wait for the
|
82
|
+
result), to process the result when it gets back,
|
83
|
+
we need to define callback for the client (just as
|
84
|
+
we did for the server). For each call to the
|
85
|
+
remote server, the result is processed at the
|
86
|
+
client side by a method of the same name,
|
87
|
+
|
88
|
+
|
89
|
+
client = server.client.react {
|
90
|
+
def foo(output)
|
91
|
+
p [:client,:foo,output]
|
92
|
+
end
|
93
|
+
def oof(output)
|
94
|
+
p [:client,:oof,output]
|
95
|
+
end
|
96
|
+
}
|
97
|
+
|
98
|
+
c.call(:foo,42)
|
99
|
+
c.call(:oof,24)
|
100
|
+
|
101
|
+
# [:client,:foo,[:server,:foo,42]]
|
102
|
+
# [:client,:foo,[:server,:foo,24]]
|
103
|
+
|
104
|
+
|
105
|
+
> ruby server.rb
|
106
|
+
> ruby client.rb
|
107
|
+
|
108
|
+
> ruby server.rb
|
109
|
+
^C
|
110
|
+
|
111
|
+
While the server is down, the requests the client
|
112
|
+
is making is queued by the underlying message
|
113
|
+
middleware (RabbitMQ), so in some future time when
|
114
|
+
we restart the server, we wouldn't lose any
|
115
|
+
request. Let's restart the server.
|
116
|
+
|
117
|
+
> ruby server.rb
|
118
|
+
|
119
|
+
See that the server caught up with all the pending
|
120
|
+
requests. To increase service capacity, we can
|
121
|
+
just increase the number of server instances.
|
122
|
+
|
123
|
+
> ruby server.rb
|
124
|
+
|
125
|
+
Now the load is distributed between these two
|
126
|
+
instances. We can also start more clients to
|
127
|
+
handle more load.
|
128
|
+
|
129
|
+
> ruby client.rb
|
130
|
+
|
131
|
+
You can see requests coming in from two clients.
|
132
|
+
|
133
|
+
|
134
|
+
h1. Service Configuration
|
135
|
+
|
136
|
+
-how the name of a service map to AMQP entities.
|
137
|
+
-various options for different functional characteristics.
|
138
|
+
|
139
|
+
-using routing_key
|
140
|
+
-using reply_to
|
141
|
+
|
142
|
+
|
143
|
+
rabbitmqctl list_exchanges
|
144
|
+
rabbitmqctl list_queues
|
145
|
+
|
146
|
+
|
147
|
+
h1. RPC service
|
148
|
+
|
149
|
+
The RPC client provides a synchronous API to the
|
150
|
+
asynchronous services. This is so the users of an
|
151
|
+
ASS application don't have to bother with the
|
152
|
+
difficulties of asynchronous programming style
|
153
|
+
with callbacks.
|
154
|
+
|
155
|
+
The RPC client is intended to be used as the
|
156
|
+
gateway into some reliable internal
|
157
|
+
services. While the internal services coordinated
|
158
|
+
with ASS needs to be robust to component failures,
|
159
|
+
there's no such requirements for gateways. It is
|
160
|
+
ok for a gateway to fail, and fail in delivering a
|
161
|
+
response, as long as the internal services carry
|
162
|
+
out the task without a hitch.
|
data/Rakefile
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "donkey"
|
8
|
+
gem.summary = "Asynchronous Service Stages for Distributed Services"
|
9
|
+
gem.email = "hayeah@gmail.com"
|
10
|
+
gem.homepage = "http://github.com/hayeah/donkey"
|
11
|
+
gem.authors = ["Howard Yeh"]
|
12
|
+
gem.add_dependency "amqp"
|
13
|
+
gem.files = FileList["[A-Z]*", "{lib,test}/**/*"]
|
14
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
15
|
+
end
|
16
|
+
rescue LoadError
|
17
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
18
|
+
end
|
19
|
+
|
20
|
+
require 'rake/testtask'
|
21
|
+
Rake::TestTask.new(:test) do |test|
|
22
|
+
test.libs << 'lib' << 'test'
|
23
|
+
test.pattern = 'test/**/*_test.rb'
|
24
|
+
test.verbose = true
|
25
|
+
end
|
26
|
+
|
27
|
+
begin
|
28
|
+
require 'rcov/rcovtask'
|
29
|
+
Rcov::RcovTask.new do |test|
|
30
|
+
test.libs << 'test'
|
31
|
+
test.pattern = 'test/**/*_test.rb'
|
32
|
+
test.verbose = true
|
33
|
+
end
|
34
|
+
rescue LoadError
|
35
|
+
task :rcov do
|
36
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
task :default => :test
|
42
|
+
|
43
|
+
require 'rake/rdoctask'
|
44
|
+
Rake::RDocTask.new do |rdoc|
|
45
|
+
if File.exist?('VERSION.yml')
|
46
|
+
config = YAML.load(File.read('VERSION.yml'))
|
47
|
+
version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
|
48
|
+
else
|
49
|
+
version = ""
|
50
|
+
end
|
51
|
+
|
52
|
+
rdoc.rdoc_dir = 'rdoc'
|
53
|
+
rdoc.title = "ass #{version}"
|
54
|
+
rdoc.rdoc_files.include('README*')
|
55
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
56
|
+
end
|
57
|
+
|
data/VERSION.yml
ADDED
data/lib/ass.rb
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
$:.unshift File.expand_path(File.dirname(File.expand_path(__FILE__)))
|
2
|
+
require 'mq'
|
3
|
+
|
4
|
+
module ASS; end
|
5
|
+
require 'ass/amqp' # monkey patch stolen from nanite.
|
6
|
+
require 'ass/serializers'
|
7
|
+
|
8
|
+
require 'ass/server' # monkey patch stolen from nanite.
|
9
|
+
require 'ass/callback_factory'
|
10
|
+
require 'ass/actor'
|
11
|
+
require 'ass/rpc'
|
12
|
+
require 'ass/client'
|
13
|
+
|
14
|
+
require 'ass/topic'
|
15
|
+
|
16
|
+
module ASS
|
17
|
+
|
18
|
+
class << self
|
19
|
+
|
20
|
+
#MQ = nil
|
21
|
+
def start(settings={})
|
22
|
+
raise "should have one ASS per eventmachine" if EM.reactor_running? == true # allow ASS to restart if EM is not running.
|
23
|
+
EM.run {
|
24
|
+
@serializer = settings.delete(:format) || ::Marshal
|
25
|
+
raise "Object Serializer must respond to :load and :dump" unless @serializer.respond_to?(:load) && @serializer.respond_to?(:dump)
|
26
|
+
@mq = ::MQ.new(AMQP.start(settings))
|
27
|
+
# ASS and its worker threads (EM.threadpool) should share the same MQ instance.
|
28
|
+
yield if block_given?
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
def stop
|
33
|
+
AMQP.stop{ EM.stop }
|
34
|
+
true
|
35
|
+
end
|
36
|
+
|
37
|
+
def mq
|
38
|
+
@mq
|
39
|
+
end
|
40
|
+
|
41
|
+
def serializer
|
42
|
+
@serializer
|
43
|
+
end
|
44
|
+
|
45
|
+
def server(name,opts={},&block)
|
46
|
+
s = ASS::Server.new(name,opts)
|
47
|
+
if block
|
48
|
+
s.react(&block)
|
49
|
+
end
|
50
|
+
s
|
51
|
+
end
|
52
|
+
|
53
|
+
def actor(name,opts={},&block)
|
54
|
+
s = ASS::Actor.new(name,opts)
|
55
|
+
if block
|
56
|
+
s.react(&block)
|
57
|
+
end
|
58
|
+
s
|
59
|
+
end
|
60
|
+
|
61
|
+
def rpc(opts={})
|
62
|
+
ASS::RPC.new(opts)
|
63
|
+
end
|
64
|
+
|
65
|
+
# the opts is used to initiate an RPC
|
66
|
+
def client(opts={})
|
67
|
+
ASS::Client.new(opts)
|
68
|
+
end
|
69
|
+
|
70
|
+
# maybe move cast and call into ASS::Server's class methods
|
71
|
+
def cast(name,method,data,opts,meta)
|
72
|
+
call(name,method,data,opts.merge(:reply_to => nil),meta)
|
73
|
+
end
|
74
|
+
|
75
|
+
def call(name,method,data,opts,meta)
|
76
|
+
# make sure the payload hash use string
|
77
|
+
# keys. Serialization format might not
|
78
|
+
# preserve type.
|
79
|
+
payload = {
|
80
|
+
#:type => type,
|
81
|
+
"method" => method,
|
82
|
+
"data" => data,
|
83
|
+
"meta" => meta,
|
84
|
+
}
|
85
|
+
payload.merge("version" => opts[:version]) if opts.has_key?(:version)
|
86
|
+
payload.merge("meta" => opts[:meta]) if opts.has_key?(:meta)
|
87
|
+
dummy_exchange(name).publish(ASS.serializer.dump(payload),opts)
|
88
|
+
true
|
89
|
+
end
|
90
|
+
|
91
|
+
# this would create a dummy MQ exchange object
|
92
|
+
# for the sole purpose of publishing the
|
93
|
+
# message. Will not clobber existing server
|
94
|
+
# already started in the process.
|
95
|
+
def dummy_exchange(name)
|
96
|
+
@mq.direct(name,:no_declare => true)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
# def self.topic(name,opts={})
|
102
|
+
# ASS::Topic.new(name,opts)
|
103
|
+
# end
|
104
|
+
|
105
|
+
|
106
|
+
|
107
|
+
|
108
|
+
# def self.peep(server_name,callback=nil,&block)
|
109
|
+
# callback = block if callback.nil?
|
110
|
+
# callback = Module.new {
|
111
|
+
# def server(*args)
|
112
|
+
# p [:server,args]
|
113
|
+
# end
|
114
|
+
|
115
|
+
# def client(*args)
|
116
|
+
# p [:client,args]
|
117
|
+
# end
|
118
|
+
# }
|
119
|
+
# ASS::Peeper.new(server_name,callback)
|
120
|
+
# end
|
121
|
+
|
122
|
+
# assumes server initializes it with an exclusive and auto_delete queue.
|
123
|
+
|
124
|
+
|
125
|
+
end
|
data/lib/ass/actor.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
class ASS::Actor
|
2
|
+
# this class is a thin layer over ASS::Server
|
3
|
+
def initialize(name,opts={},&block)
|
4
|
+
@server = ASS.server(name,opts)
|
5
|
+
if block
|
6
|
+
react(&block)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def queue(opts={})
|
11
|
+
@server.queue(opts)
|
12
|
+
self
|
13
|
+
end
|
14
|
+
|
15
|
+
def react(callback=nil,opts=nil,&block)
|
16
|
+
if block
|
17
|
+
opts = callback
|
18
|
+
callback = block
|
19
|
+
end
|
20
|
+
opts = {} if opts.nil?
|
21
|
+
callback_factory = ASS::CallbackFactory.new(callback)
|
22
|
+
server = @server # for closure capturing, needed for callback_factory
|
23
|
+
@server.react(opts) {
|
24
|
+
define_method(:on_call) do |_|
|
25
|
+
raise "can't call an actor with method set to nil" if payload["method"].nil?
|
26
|
+
callback_object = callback_factory.callback_for(server,header,payload)
|
27
|
+
callback_object.send(payload["method"],payload["data"])
|
28
|
+
end
|
29
|
+
|
30
|
+
define_method(:on_error) do |e,_|
|
31
|
+
callback_object = callback_factory.callback_for(server,header,payload)
|
32
|
+
if callback_object.respond_to?(:on_error)
|
33
|
+
callback_object.on_error(e,payload["data"])
|
34
|
+
else
|
35
|
+
raise e
|
36
|
+
end
|
37
|
+
end
|
38
|
+
}
|
39
|
+
self
|
40
|
+
end
|
41
|
+
|
42
|
+
def call(*args)
|
43
|
+
@server.call(*args)
|
44
|
+
end
|
45
|
+
|
46
|
+
def cast(*args)
|
47
|
+
@server.cast(*args)
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
data/lib/ass/amqp.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
|
2
|
+
# monkey patch to the amqp gem that adds :no_declare => true option for new
|
3
|
+
# Exchange objects. This allows us to send messeages to exchanges that are
|
4
|
+
# declared by the mappers and that we have no configuration priviledges on.
|
5
|
+
# temporary until we get this into amqp proper
|
6
|
+
MQ::Exchange.class_eval do
|
7
|
+
def initialize mq, type, name, opts = {}
|
8
|
+
@mq = mq
|
9
|
+
@type, @name, @opts = type, name, opts
|
10
|
+
@mq.exchanges[@name = name] ||= self
|
11
|
+
@key = opts[:key]
|
12
|
+
|
13
|
+
@mq.callback{
|
14
|
+
@mq.send AMQP::Protocol::Exchange::Declare.new({ :exchange => name,
|
15
|
+
:type => type,
|
16
|
+
:nowait => true }.merge(opts))
|
17
|
+
} unless name == "amq.#{type}" or name == '' or opts[:no_declare]
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
class ASS::CallbackFactory
|
2
|
+
|
3
|
+
module ServiceMethods
|
4
|
+
def resend
|
5
|
+
throw(:__ass_resend)
|
6
|
+
end
|
7
|
+
|
8
|
+
def discard
|
9
|
+
throw(:__ass_discard)
|
10
|
+
end
|
11
|
+
|
12
|
+
def header
|
13
|
+
@__header__
|
14
|
+
end
|
15
|
+
|
16
|
+
def payload
|
17
|
+
@__payload__
|
18
|
+
end
|
19
|
+
|
20
|
+
def method
|
21
|
+
@__method__
|
22
|
+
end
|
23
|
+
|
24
|
+
def data
|
25
|
+
@__data__
|
26
|
+
end
|
27
|
+
|
28
|
+
def meta
|
29
|
+
@__meta__
|
30
|
+
end
|
31
|
+
|
32
|
+
def version
|
33
|
+
@__version__
|
34
|
+
end
|
35
|
+
|
36
|
+
def call(name,method,data=nil,opts={},meta=nil)
|
37
|
+
@__service__.call(name,method,data,opts,meta)
|
38
|
+
end
|
39
|
+
|
40
|
+
def cast(name,method,data=nil,opts={},meta=nil)
|
41
|
+
@__service__.cast(name,method,data,opts,meta)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def initialize(callback)
|
46
|
+
@factory = build_factory(callback)
|
47
|
+
end
|
48
|
+
|
49
|
+
def callback_for(server,header,payload)
|
50
|
+
# method,data
|
51
|
+
if @factory.is_a? Class
|
52
|
+
if @factory.respond_to? :version
|
53
|
+
klass = @factory.get_version(payload["version"])
|
54
|
+
else
|
55
|
+
klass = @factory
|
56
|
+
end
|
57
|
+
obj = klass.new
|
58
|
+
else
|
59
|
+
obj = @factory
|
60
|
+
end
|
61
|
+
obj.instance_variable_set("@__service__",server)
|
62
|
+
obj.instance_variable_set("@__header__",header)
|
63
|
+
obj.instance_variable_set("@__payload__",payload)
|
64
|
+
obj.instance_variable_set("@__method__",payload["method"])
|
65
|
+
obj.instance_variable_set("@__data__",payload["data"])
|
66
|
+
obj.instance_variable_set("@__meta__",payload["meta"])
|
67
|
+
obj.instance_variable_set("@__version__",payload["version"])
|
68
|
+
obj
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def build_factory(callback)
|
74
|
+
c = case callback
|
75
|
+
when Proc
|
76
|
+
Class.new &callback
|
77
|
+
when Class
|
78
|
+
callback
|
79
|
+
when Module
|
80
|
+
Class.new { include callback }
|
81
|
+
else
|
82
|
+
raise "can build factory from one of Proc, Class, Module"
|
83
|
+
end
|
84
|
+
case c
|
85
|
+
when Class
|
86
|
+
c.instance_eval { include ServiceMethods }
|
87
|
+
else
|
88
|
+
c.extend ServiceMethods
|
89
|
+
end
|
90
|
+
c
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
|
95
|
+
end
|