atr 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.rspec +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +347 -0
- data/Rakefile +2 -0
- data/atr.gemspec +36 -0
- data/bin/atr_server +24 -0
- data/lib/atr.rb +43 -0
- data/lib/atr/config.rb +25 -0
- data/lib/atr/errors.rb +4 -0
- data/lib/atr/event.rb +24 -0
- data/lib/atr/publishable.rb +95 -0
- data/lib/atr/publisher.rb +11 -0
- data/lib/atr/railtie.rb +29 -0
- data/lib/atr/reactor.rb +119 -0
- data/lib/atr/redis.rb +18 -0
- data/lib/atr/registry.rb +15 -0
- data/lib/atr/request_authenticator.rb +17 -0
- data/lib/atr/request_scope.rb +17 -0
- data/lib/atr/server.rb +49 -0
- data/lib/atr/version.rb +3 -0
- data/spec/atr/event_spec.rb +23 -0
- data/spec/atr/publishable_spec.rb +62 -0
- data/spec/atr/publisher_spec.rb +43 -0
- data/spec/atr/redis_spec.rb +13 -0
- data/spec/atr/registry_spec.rb +27 -0
- data/spec/atr/request_authenticator_spec.rb +20 -0
- data/spec/atr/request_scope_spec.rb +21 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/support/db/setup.rb +21 -0
- data/spec/support/models.rb +1 -0
- data/spec/support/models/post.rb +8 -0
- data/spec/test.db +0 -0
- metadata +287 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 9067fa1049b19201c58fea01f1d223530981a3bf
|
4
|
+
data.tar.gz: 74926a242a7203a02df577c59e69a59e930fbc80
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4e78aaa088307f96fdf2991934f08820aaac8255cfaa7595aa415deab5ed32bce4c032368247fefe8c9d12a50b26180251ca2f23b0a6684d056c9d8dabeff47c
|
7
|
+
data.tar.gz: add7f386011c77ae85486850d5d4656644f610d52de0afb099581ca96c3720aebb774e9832e20e10e74772515178e7fb83a48ec4ba4e1ab4522ebf36d447a699
|
data/.gitignore
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
*.bundle
|
19
|
+
*.so
|
20
|
+
*.o
|
21
|
+
*.a
|
22
|
+
mkmf.log
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Jason Ayre
|
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,347 @@
|
|
1
|
+
## What a stupid name
|
2
|
+
You likely think that this library has something to do with attributes, but it does not. So yes, it probably is a stupid name. Its named after
|
3
|
+
|
4
|
+
http://en.wikipedia.org/wiki/Ampex_ATR-100
|
5
|
+
|
6
|
+
in the spirit of following the celluloid/reel analog tape metaphor naming convention. But mostly because its short to type and Im lazy.
|
7
|
+
|
8
|
+
## What it does
|
9
|
+
|
10
|
+
Websockets/publishing events to connected clients, using the following as its backbone:
|
11
|
+
|
12
|
+
Reel, Celluloid&Celluloid::IO, and Redis
|
13
|
+
|
14
|
+
## Why use this over other websocket libraries for ruby?
|
15
|
+
|
16
|
+
Well dont use this yet because its incomplete. But the goal is mainly:
|
17
|
+
|
18
|
+
1. Make it easy to publish changes to the connected client
|
19
|
+
2. Event machine is a pile of *your preferred synonym for garbage*, so, didnt want to use any library that uses event machine. Use CelluloidIO and Reel as a foundation, because Reel is pretty cool.
|
20
|
+
3. Be able to publish events from anywhere in your application eventually, but for now just focus on making it easy to publish when resources themselves are created/updated/destroyed.
|
21
|
+
4. Single thread per websocket request that comes in. That lets you scope the websockets to the lowest level in your application that you choose to. I.E.
|
22
|
+
|
23
|
+
Lets say you have a subscriber which has many users. You can scope the publishing of events to the subscriber, and each connected user has its own websocket thread open to prevent chaotic behavior Ive seen happen in many other websocket implementations.
|
24
|
+
|
25
|
+
**So its strongly focused on listening for events, not triggering, maybe that will change in future maybe not IDK.**
|
26
|
+
|
27
|
+
### Usage
|
28
|
+
|
29
|
+
To generate events upon actions taking place within your application, include
|
30
|
+
``` ruby
|
31
|
+
include ::Atr::Publishable
|
32
|
+
```
|
33
|
+
|
34
|
+
Into your model. Example
|
35
|
+
|
36
|
+
### How it works
|
37
|
+
|
38
|
+
``` ruby
|
39
|
+
class Post < ::ActiveRecord::Base
|
40
|
+
include ::Atr::Publishable
|
41
|
+
end
|
42
|
+
```
|
43
|
+
|
44
|
+
This will set up 3 publishing queues, and 3 after_action callbacks for the respective actions.
|
45
|
+
|
46
|
+
``` ruby
|
47
|
+
post.created
|
48
|
+
post.destroyed
|
49
|
+
post.updated
|
50
|
+
```
|
51
|
+
|
52
|
+
which will basically do
|
53
|
+
|
54
|
+
``` ruby
|
55
|
+
|
56
|
+
def publish_created_event
|
57
|
+
routing_key = self.class.build_routing_key_for_record_action(self, "created")
|
58
|
+
// post.created
|
59
|
+
event_name = self.class.resource_action_routing_key("created")
|
60
|
+
// post.created
|
61
|
+
|
62
|
+
record_created_event = ::Atr::Event.new(routing_key, event_name, self)
|
63
|
+
|
64
|
+
::Atr.publish_event(record_created_event)
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
Etc Etc for updated/destroyed.
|
69
|
+
|
70
|
+
**So to walkthrough publish_created_event above**
|
71
|
+
|
72
|
+
1. First, we create routing key based on the name of the class, + the action. Additionally if you scope the event, this will be reflected in the routing key (i.e. you can scope it to a particular subscriber or user or whatever, so you can share state and or sync events between multiple users belonging to the same organization). (more on that later)
|
73
|
+
2. we generate an event name based on name of the class + the action (scope doesent matter we just want to describe what happened)
|
74
|
+
3. wrap the record in an ::Atr::Event object
|
75
|
+
4. Publish the event, this will Marshal.dump the record through redis, and if there is a subscriber listening on the routing key of the event, the websocket connection (Atr::Reactor) instance, will receive that message, unmarshall it back into the original event object, and write it to the websocket.
|
76
|
+
|
77
|
+
This allows us to publish events with pretty fine grained precision, and broadcast them to specific subscribers. If you're unfamiliar with redis pub/sub, rundown is, if you are listening to the channel at the moment the message is published, the subscriber will get it, otherwise it removes it and pays no regard to the msssage being published. No durability, but thats what we want for websocket events.
|
78
|
+
|
79
|
+
### How the websocket server works
|
80
|
+
|
81
|
+
The websocket server works differently than many other implementations, in that its by design a standalone process, which acts mainly as a router for websocket connections that come in. When a valid websocket request comes in, it will launch a brand new thread, close the original request and detatch the websocket, and pass it into the object which controls the websocket (::Atr::Reactor, for lack of a better name ATM). This once again is mainly about scope, and has arisen out of the past frustrations of using websocket libraries which were built on event machine which I spent countless hours debugging, issues like duplicate events.
|
82
|
+
|
83
|
+
Its also IMO the ideal way to model a socket server, 1 thread belonging to each client which connects to it. Close the thread when they disconnect. Only use resources for whats currently relevant.
|
84
|
+
|
85
|
+
### Starting the socket server
|
86
|
+
``` ruby
|
87
|
+
bx atr_server start --server_host="127.0.0.1" --server_port="7777"
|
88
|
+
```
|
89
|
+
(the defaults, the above is the same as)
|
90
|
+
|
91
|
+
``` ruby
|
92
|
+
bx atr_server start
|
93
|
+
```
|
94
|
+
|
95
|
+
### Connecting to socket server via JS
|
96
|
+
|
97
|
+
``` javascript
|
98
|
+
var ws = new WebSocket("ws://127.0.0.1:7777");
|
99
|
+
```
|
100
|
+
|
101
|
+
### Listen for events
|
102
|
+
``` javascript
|
103
|
+
ws.onmessage = function(e) {
|
104
|
+
var event, parsed_event;
|
105
|
+
|
106
|
+
event = e.data;
|
107
|
+
parsed_event = JSON.parse(event);
|
108
|
+
console.log(parsed_event);
|
109
|
+
}
|
110
|
+
```
|
111
|
+
|
112
|
+
### Full Example (including client side)
|
113
|
+
|
114
|
+
Here is a snippet of code from an inprogress sideproject, using a base angular controller (using angular-ui-router). This is enough to listen to any event in the application, and display growl notifications for all connected members of the "organization", notifying them of what action took place.
|
115
|
+
|
116
|
+
``` javascript
|
117
|
+
$stateProvider.state('base', {
|
118
|
+
abstract: false,
|
119
|
+
url: "",
|
120
|
+
templateUrl: '/templates/base.html',
|
121
|
+
resolve: {
|
122
|
+
current_organization: function(CurrentOrganization) {
|
123
|
+
return CurrentOrganization.get();
|
124
|
+
},
|
125
|
+
current_user: function(CurrentUser) {
|
126
|
+
return CurrentUser.get();
|
127
|
+
}
|
128
|
+
},
|
129
|
+
controller: function($scope, current_organization, current_user, $state, growl) {
|
130
|
+
$scope.current_organization = current_organization;
|
131
|
+
$scope.current_user = current_user;
|
132
|
+
|
133
|
+
$scope.websocket_params = {
|
134
|
+
organization: current_organization.id
|
135
|
+
};
|
136
|
+
|
137
|
+
$scope.websocket_base_url = "ws://127.0.0.1:7777";
|
138
|
+
|
139
|
+
$scope.websocketUrl = function() {
|
140
|
+
return [ $scope.websocket_base_url, _.flatten(_.pairs($scope.websocket_params)).join("/") ].join("/");
|
141
|
+
};
|
142
|
+
|
143
|
+
$scope.ws = new WebSocket($scope.websocketUrl());
|
144
|
+
|
145
|
+
$scope.ws.onopen = function() {
|
146
|
+
console.log('opening ws con');
|
147
|
+
$scope.ws.send(JSON.stringify({action: "do.something." + current_user.id}));
|
148
|
+
};
|
149
|
+
|
150
|
+
$scope.ws.onmessage = function(e) {
|
151
|
+
$scope.dispatchMessage(e.data);
|
152
|
+
};
|
153
|
+
|
154
|
+
$scope.ws.onclose = function() {
|
155
|
+
alert("websocket connection closed");
|
156
|
+
};
|
157
|
+
|
158
|
+
$scope.do_something = function() {
|
159
|
+
$scope.ws.send('do_something');
|
160
|
+
};
|
161
|
+
|
162
|
+
$scope.dispatchMessage = function (message) {
|
163
|
+
var event = JSON.parse(message);
|
164
|
+
$scope.$root.$broadcast(event.name, event);
|
165
|
+
};
|
166
|
+
|
167
|
+
_.each(current_organization.websocket_channels, function(channel){
|
168
|
+
$scope.$on(channel, function(e, websocket_event){
|
169
|
+
console.log(websocket_event);
|
170
|
+
growl.addInfoMessage(websocket_event.name);
|
171
|
+
$scope.$root.$digest();
|
172
|
+
});
|
173
|
+
});
|
174
|
+
}
|
175
|
+
});
|
176
|
+
```
|
177
|
+
|
178
|
+
**Initializer**
|
179
|
+
``` ruby
|
180
|
+
::Atr.configure do |config|
|
181
|
+
config.authenticate_with = ::WebsocketAuthenticator
|
182
|
+
config.scope_with = ::WebsocketScope
|
183
|
+
config.event_serializer = ::WebsocketEventSerializer
|
184
|
+
end
|
185
|
+
```
|
186
|
+
|
187
|
+
**NOTE:** the following are bad examples. I.e. Im not really authenticating anything im just checking that the websocket request has a valid organization id in the path, really youd want to use auth token or some way to validate the request. But it's so low level that it should be easy to do whatever you need to w/this middlewarish pattern for scoping/validating.
|
188
|
+
|
189
|
+
**Websocket Authenticator**
|
190
|
+
|
191
|
+
``` ruby
|
192
|
+
class WebsocketAuthenticator < ::Atr::RequestAuthenticator
|
193
|
+
def matches?
|
194
|
+
current_organization.present?
|
195
|
+
end
|
196
|
+
|
197
|
+
def current_organization
|
198
|
+
@current_organization ||= ::Client::Organization.find(segs[1])
|
199
|
+
end
|
200
|
+
|
201
|
+
def segs
|
202
|
+
@segs ||= request.url.split("/").reject(&:empty?)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
```
|
206
|
+
**Websocket Scope**
|
207
|
+
``` ruby
|
208
|
+
class WebsocketScope < ::Atr::RequestScope
|
209
|
+
VALID_SCOPE_KEYS = ["organization"]
|
210
|
+
|
211
|
+
def segs
|
212
|
+
@segs ||= request.url.split("/").reject(&:empty?)
|
213
|
+
end
|
214
|
+
|
215
|
+
def routing_key
|
216
|
+
[segs[0], segs[1]].join(".")
|
217
|
+
end
|
218
|
+
|
219
|
+
def valid?
|
220
|
+
VALID_SCOPE_KEYS.include?(segs[0]) && segs.size == 2
|
221
|
+
end
|
222
|
+
end
|
223
|
+
```
|
224
|
+
|
225
|
+
You also have access to query string params as a hash with params method in either class.
|
226
|
+
|
227
|
+
|
228
|
+
**Event Serializer**
|
229
|
+
|
230
|
+
Im using ActiveModel Serializers, but any serializer that is instantiated as new, passes in the record, and is serialized via the .to_json method should work (so if you want to use decorators or a custom class or something).
|
231
|
+
|
232
|
+
``` ruby
|
233
|
+
class WebsocketEventSerializer < BaseSerializer
|
234
|
+
self.root = false
|
235
|
+
|
236
|
+
attributes :id, :name, :record, :routing_key, :record, :occured_at, :time_ago
|
237
|
+
|
238
|
+
def time_ago
|
239
|
+
distance_of_time_in_words(object.occured_at, ::DateTime.now)
|
240
|
+
end
|
241
|
+
|
242
|
+
def action
|
243
|
+
object.name.split(".").pop
|
244
|
+
end
|
245
|
+
end
|
246
|
+
```
|
247
|
+
|
248
|
+
**Model, and scoping the publication of the event**
|
249
|
+
|
250
|
+
``` ruby
|
251
|
+
class Post < ::ActiveRecord::Base
|
252
|
+
include ::Atr::Publishable
|
253
|
+
publication_scope :organization_id
|
254
|
+
end
|
255
|
+
```
|
256
|
+
|
257
|
+
Kind of ghetto, but works for now, basically, this will using the organization_id attribute, and prepend the key (without the _id), i.e.
|
258
|
+
|
259
|
+
organization.#{organization_id}.post.created
|
260
|
+
|
261
|
+
Whenever creating routing keys when publishing events. (only for that specific resource though, so you probably want to add that same publication scope, and define a method that gets to that scope for each of your models requiring publication).
|
262
|
+
|
263
|
+
Last but not least, for a programatic way of knowing which channels to listen to I.E., in the javascript above
|
264
|
+
|
265
|
+
``` javascript
|
266
|
+
_.each(current_organization.websocket_channels, function(channel){
|
267
|
+
$scope.$on(channel, function(e, websocket_event){
|
268
|
+
console.log(websocket_event);
|
269
|
+
});
|
270
|
+
});
|
271
|
+
```
|
272
|
+
|
273
|
+
You can get the channels via the registry
|
274
|
+
|
275
|
+
``` ruby
|
276
|
+
def websocket_channels
|
277
|
+
::Atr::Registry.channels
|
278
|
+
end
|
279
|
+
```
|
280
|
+
|
281
|
+
### If any of how it works is confusing, pay attention to the following as it may clear things up:
|
282
|
+
|
283
|
+
**NOTE:** the redis pubsub mechanism is only concerned about the publication scope, i.e.
|
284
|
+
|
285
|
+
organization.1234.post.created
|
286
|
+
|
287
|
+
But since each connection launches a new thread, listenting to that specific channel, we can then broadcast the event itself to the websocket as
|
288
|
+
|
289
|
+
post.created
|
290
|
+
|
291
|
+
and it will be scoped appropriately to correct client, as its only actually written out to the websocket threads belonging to that organization.
|
292
|
+
|
293
|
+
(we actually just write the entire event object to the socket and the client side JS is responsible for figuring out how to route it and what not)
|
294
|
+
|
295
|
+
### Scoping and authentication
|
296
|
+
|
297
|
+
Quick explanation is, it works much like Rack middleware. Configure a class and it will be passed the request object on initialize.
|
298
|
+
|
299
|
+
Class must respond to matches? which will determine whether the request is valid, and in the case of scope_with, will scope the publishing of the record, i.e.
|
300
|
+
|
301
|
+
organization.1234.post.created
|
302
|
+
rather than post.created
|
303
|
+
|
304
|
+
### Advanced Configuration
|
305
|
+
Todo: explain scoping and authenticating the websocket requests and provide better example.
|
306
|
+
|
307
|
+
### Important To Do
|
308
|
+
Allow target redis instance to actually be configurable. Right now just running locally so connects to redis defaults.
|
309
|
+
|
310
|
+
### Configuration / Initializer
|
311
|
+
|
312
|
+
``` ruby
|
313
|
+
::Atr.configure do |config|
|
314
|
+
config.authenticate_with = ::WebsocketAuthenticator
|
315
|
+
config.scope_with = ::WebsocketScope
|
316
|
+
config.event_serializer = ::WebsocketEventSerializer
|
317
|
+
end
|
318
|
+
```
|
319
|
+
|
320
|
+
### FYIs / Gotchas / Notes to self
|
321
|
+
|
322
|
+
ActiveRecord opens new connection each time request comes in, Im manually closing it as we do need to have AR loaded for the purposes of reading the schema, but after that we dont need a connection at all since all the marshaling/unmarshaling the event does not require it. (As far as I can see at least). The main application w/ the publisher, does the serialization, so the server doesen't need the connections. So no used up connections per the websocket threads that are created. Winning.
|
323
|
+
|
324
|
+
If I decide to go the route of websocket rails to allow cruding beyond just listening as it stands right now, the websocket connections will need to use connection pool.
|
325
|
+
|
326
|
+
## Installation
|
327
|
+
|
328
|
+
Add this line to your application's Gemfile:
|
329
|
+
|
330
|
+
gem 'atr'
|
331
|
+
|
332
|
+
And then execute:
|
333
|
+
|
334
|
+
$ bundle
|
335
|
+
|
336
|
+
Or install it yourself as:
|
337
|
+
|
338
|
+
$ gem install atr
|
339
|
+
|
340
|
+
|
341
|
+
## Contributing
|
342
|
+
|
343
|
+
1. Fork it ( https://github.com/[my-github-username]/atr/fork )
|
344
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
345
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
346
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
347
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
data/atr.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'atr/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "atr"
|
8
|
+
spec.version = Atr::VERSION
|
9
|
+
spec.authors = ["Jason Ayre"]
|
10
|
+
spec.email = ["jasonayre@gmail.com"]
|
11
|
+
spec.summary = %q{Pub, sub and websocket server}
|
12
|
+
spec.description = %q{Pub sub and websockets}
|
13
|
+
spec.homepage = "http://github.com/jasonayre/atr"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "active_attr"
|
22
|
+
spec.add_dependency "reel", "> 0.4.0"
|
23
|
+
spec.add_dependency "redis"
|
24
|
+
spec.add_dependency "celluloid-io"
|
25
|
+
spec.add_dependency "celluloid-redis"
|
26
|
+
spec.add_dependency "json"
|
27
|
+
|
28
|
+
spec.add_development_dependency 'activerecord'
|
29
|
+
spec.add_development_dependency 'sqlite3'
|
30
|
+
spec.add_development_dependency "bundler"
|
31
|
+
spec.add_development_dependency "rake"
|
32
|
+
spec.add_development_dependency "rspec"
|
33
|
+
spec.add_development_dependency "rspec-pride"
|
34
|
+
spec.add_development_dependency "pry-nav"
|
35
|
+
spec.add_development_dependency "simplecov"
|
36
|
+
end
|