em-proxy 0.1.1 → 0.1.2

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.rdoc CHANGED
@@ -34,7 +34,7 @@ EventMachine Proxy DSL for writing high-performance transparent / intercepting p
34
34
  end
35
35
 
36
36
  # termination logic
37
- conn.on_finish do |backend|
37
+ conn.on_finish do |backend, name|
38
38
  p [:on_finish, name]
39
39
 
40
40
  # terminate connection (in duplex mode, you can terminate when prod is done)
@@ -47,4 +47,33 @@ For more examples see the /examples directory.
47
47
  - Duplicating traffic
48
48
  - Selective forwarding
49
49
  - Beanstalkd interceptor
50
- - etc.
50
+ - etc.
51
+
52
+ A schema-free MySQL proof of concept, via an EM-Proxy server:
53
+ - http://www.igvita.com/2010/03/01/schema-free-mysql-vs-nosql/
54
+ - Code in examples/schemaless-mysql
55
+
56
+ == License
57
+
58
+ (The MIT License)
59
+
60
+ Copyright (c) 2010 Ilya Grigorik
61
+
62
+ Permission is hereby granted, free of charge, to any person obtaining
63
+ a copy of this software and associated documentation files (the
64
+ 'Software'), to deal in the Software without restriction, including
65
+ without limitation the rights to use, copy, modify, merge, publish,
66
+ distribute, sublicense, and/or sell copies of the Software, and to
67
+ permit persons to whom the Software is furnished to do so, subject to
68
+ the following conditions:
69
+
70
+ The above copyright notice and this permission notice shall be
71
+ included in all copies or substantial portions of the Software.
72
+
73
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
74
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
75
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
76
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
77
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
78
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
79
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require 'rake'
2
+
3
+ begin
4
+ require 'jeweler'
5
+ Jeweler::Tasks.new do |gemspec|
6
+ gemspec.name = "em-proxy"
7
+ gemspec.summary = "EventMachine Proxy DSL"
8
+ gemspec.description = gemspec.summary
9
+ gemspec.email = "ilya@igvita.com"
10
+ gemspec.homepage = "http://github.com/igrigorik/em-proxy"
11
+ gemspec.authors = ["Ilya Grigorik"]
12
+ gemspec.add_dependency('eventmachine', '>= 0.12.9')
13
+ gemspec.rubyforge_project = "em-proxy"
14
+ end
15
+
16
+ Jeweler::GemcutterTasks.new
17
+ rescue LoadError
18
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
19
+ end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.1
1
+ 0.1.2
@@ -0,0 +1,26 @@
1
+ require 'lib/em-proxy'
2
+
3
+ Proxy.start(:host => "0.0.0.0", :port => 80, :debug => false) do |conn|
4
+ # Specifying :relay_server or :relay_client is useful if only requests or responses
5
+ # need to be processed. The proxy throughput will roughly double.
6
+ conn.server :srv, :host => "127.0.0.1", :port => 81, :relay_client => true, :relay_server => true
7
+
8
+ conn.on_connect do
9
+ p [:on_connect, "#{conn.peer.join(':')} connected"]
10
+ end
11
+
12
+ # modify / process request stream
13
+ # on_data will not be called when :relay_server => true is passed as server option
14
+ conn.on_data do |data|
15
+ p [:on_data, data]
16
+ data
17
+ end
18
+
19
+ # modify / process response stream
20
+ # on_response will not be called when :relay_client => true is passed as server option
21
+ conn.on_response do |backend, resp|
22
+ p [:on_response, backend, resp]
23
+ # resp = "HTTP/1.1 200 OK\r\nConnection: close\r\nDate: Thu, 30 Apr 2009 03:53:28 GMT\r\nContent-Type: text/plain\r\n\r\nHar!"
24
+ resp
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ require "rubygems"
2
+ require "mysql" # gem install ruby-mysql -v 2.9.2
3
+
4
+ my = Mysql.connect('127.0.0.1', 'root', '', 'noschema', 3307)
5
+
6
+ p my.list_tables
7
+ __END__
8
+
9
+ # look ma, no schema! ooh yeah.
10
+ my.query("create table posts")
11
+
12
+ # insert a few records into our schemaless MySQL store... :-)
13
+ my.query("insert into posts values('first post'('author', 'Ilya'),('nick', 'igrigorik'))")
14
+ my.query("insert into posts values('author:Ilya','location:Waterloo')")
15
+
16
+ my.query("select author from posts") { |r| puts r }
17
+ my.query("select nick from posts") { |r| puts r }
@@ -0,0 +1,181 @@
1
+ require "lib/em-proxy"
2
+ require "em-mysql"
3
+ require "stringio"
4
+ require "fiber"
5
+
6
+ Proxy.start(:host => "0.0.0.0", :port => 3307) do |conn|
7
+ conn.server :mysql, :host => "127.0.0.1", :port => 3306, :relay_server => true
8
+
9
+ QUERY_CMD = 3
10
+ MAX_PACKET_LENGTH = 2**24-1
11
+
12
+ # open a direct connection to MySQL for the schema-free coordination logic
13
+ @mysql = EventMachine::MySQL.new(:host => 'localhost', :database => 'noschema')
14
+
15
+ conn.on_data do |data|
16
+ fiber = Fiber.new {
17
+ p [:original_request, data]
18
+
19
+ overhead, chunks, seq = data[0,4].unpack("CvC")
20
+ type, sql = data[4, data.size].unpack("Ca*")
21
+
22
+ p [:request, [overhead, chunks, seq], [type, sql]]
23
+
24
+ if type == QUERY_CMD
25
+ query = sql.downcase.split
26
+ p [:query, query]
27
+
28
+ # TODO: can probably switch to http://github.com/omghax/sql
29
+ # for AST query parsing & mods.
30
+
31
+ case query.first
32
+ when "create" then
33
+ # Allow schemaless table creation, ex: 'create table posts'
34
+ # By creating a table with a single id for key storage, aka
35
+ # rewrite to: 'create table posts (id varchar(255))'. All
36
+ # future attribute tables will be created on demand at
37
+ # insert time of a new record
38
+ overload = "(id varchar(255), UNIQUE(id));"
39
+ query += [overload]
40
+ overhead += overload.size + 1
41
+
42
+ p [:create_new_schema_free_table, query, data]
43
+
44
+ when "insert" then
45
+ # Overload the INSERT syntax to allow for nested parameters
46
+ # inside the statement. ex:
47
+ # INSERT INTO posts (id, author, nickname, ...) VALUES (
48
+ # 'ilya', 'Ilya Grigorik', 'igrigorik'
49
+ # )
50
+ #
51
+ # The following query will be mapped into 3 distinct tables:
52
+ # => 'posts' table will store the key
53
+ # => 'posts_author' will store key, value
54
+ # => 'posts_nickname' will store key, value
55
+ #
56
+ # or, in SQL..
57
+ #
58
+ # => insert into posts values("ilya");
59
+ # => create table posts_author (id varchar(40), value varchar(255), UNIQUE(id));
60
+ # => insert into posts_author values("ilya", "Ilya Grigorik");
61
+ # => ... repeat for every attribute
62
+ #
63
+ # If the table post_value has not been seen before, it will
64
+ # be created on the fly. Hence allowing us to add and remove
65
+ # keys and values at will. :-)
66
+ #
67
+ # P.S. There is probably cleaner syntax for this, but hey...
68
+
69
+
70
+ if insert = sql.match(/\((.*?)\).*?\((.*?)\)/)
71
+ data = {}
72
+ table = query[2]
73
+ keys = insert[1].split(',').map!{|s| s.strip}
74
+ values = insert[2].scan(/([^\'|\"]+)/).flatten.reject {|s| s.strip == ','}
75
+ keys.each_with_index {|k,i| data[k] = values[i]}
76
+
77
+ data.each do |key, value|
78
+ next if key == 'id'
79
+ attr_sql = "insert into #{table}_#{key} values('#{data['id']}', '#{value}')"
80
+
81
+ q = @mysql.query(attr_sql)
82
+ q.errback { |res|
83
+ # if the attribute table for this model does not yet exist then create it!
84
+ # - yes, there is a race condition here, add fiber logic later
85
+ if res.is_a?(Mysql::Error) and res.message =~ /Table.*doesn\'t exist/
86
+
87
+ table_sql = "create table #{table}_#{key} (id varchar(255), value varchar(255), UNIQUE(id))"
88
+ tc = @mysql.query(table_sql)
89
+ tc.callback { @mysql.query(attr_sql) }
90
+ end
91
+ }
92
+
93
+ p [:inserted_attr, table, key, value]
94
+ end
95
+
96
+ # override the query to insert the key into posts table
97
+ query = query[0,3] + ["VALUES('#{data['id']}')"]
98
+ overhead = query.join(" ").size + 1
99
+
100
+ p [:insert, query]
101
+ end
102
+
103
+ when "select" then
104
+ # Overload the select call to perform a multi-join in the background
105
+ # and rewrite the attribute names to fool the client into thinking it
106
+ # all came from the same table.
107
+ #
108
+ # To figure out which tables we need to join on, do the simple / dumb
109
+ # approach and issue a 'show tables like key_%' to do 'runtime
110
+ # introspection'. Could easily cache this, but that's for later.
111
+ #
112
+ # Ex, a 'select * from posts' query with one value (author) would be
113
+ # rewritten into the following query:
114
+ #
115
+ # SELECT posts.id as id, posts_author.value as author FROM posts
116
+ # LEFT OUTER JOIN posts_author ON posts_author.id = posts.id
117
+ # WHERE posts.id = "ilya";
118
+
119
+ select = sql.match(/select(.*?)from\s([^\s]+)/)
120
+ where = sql.match(/where\s([^=]+)\s?=\s?'?"?([^\s'"]+)'?"?/)
121
+ attrs, table = select[1].strip.split(','), select[2] if select
122
+ key = where[2] if where
123
+
124
+ if select
125
+ p [:select, select, attrs, where]
126
+
127
+ tables = @mysql.query("show tables like '#{table}_%'")
128
+ tables.callback { |res|
129
+ fiber.resume(res.all_hashes.collect(&:values).flatten.collect{ |c|
130
+ c.split('_').last
131
+ })
132
+ }
133
+ tables = Fiber.yield
134
+
135
+ p [:select_tables, tables]
136
+
137
+ # build the select statements, hide the tables behind each attribute
138
+ join = "select #{table}.id as id "
139
+ tables.each do |column|
140
+ join += " , #{table}_#{column}.value as #{column} "
141
+ end
142
+
143
+ # add the joins to stich it all together
144
+ join += " FROM #{table} "
145
+ tables.each do |column|
146
+ join += " LEFT OUTER JOIN #{table}_#{column} ON #{table}_#{column}.id = #{table}.id "
147
+ end
148
+
149
+ join += " WHERE #{table}.id = '#{key}' " if key
150
+
151
+ query = [join]
152
+ overhead = join.size + 1
153
+
154
+ p [:join_query, join]
155
+ end
156
+ end
157
+
158
+ # repack the query data and forward to server
159
+ # - have to split message on packet boundaries
160
+
161
+ seq, data = 0, []
162
+ query = StringIO.new([type, query.join(" ")].pack("Ca*"))
163
+ while q = query.read(MAX_PACKET_LENGTH)
164
+ data.push [q.length % 256, q.length / 256, seq].pack("CvC") + q
165
+ seq = (seq + 1) % 256
166
+ end
167
+
168
+ p [:final_query, data, chunks, overhead]
169
+ puts "-" * 100
170
+ end
171
+
172
+ [data].flatten.each do |chunk|
173
+ conn.relay_to_servers(chunk)
174
+ end
175
+
176
+ :async # we will render results later
177
+ }
178
+
179
+ fiber.resume
180
+ end
181
+ end
data/lib/em-proxy.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  $:.unshift(File.dirname(__FILE__) + '/../lib')
2
2
 
3
+
3
4
  require "rubygems"
4
5
  require "eventmachine"
6
+ require "socket"
5
7
 
6
8
  %w[ backend proxy connection ].each do |file|
7
9
  require "em-proxy/#{file}"
@@ -7,6 +7,7 @@ module EventMachine
7
7
  def on_data(&blk); @on_data = blk; end
8
8
  def on_response(&blk); @on_response = blk; end
9
9
  def on_finish(&blk); @on_finish = blk; end
10
+ def on_connect(&blk); blk.call; end
10
11
 
11
12
  ##### EventMachine
12
13
  def initialize(options)
@@ -18,6 +19,11 @@ module EventMachine
18
19
  debug [:connection, data]
19
20
  processed = @on_data.call(data)
20
21
 
22
+ return if processed == :async or processed.nil?
23
+ relay_to_servers(processed)
24
+ end
25
+
26
+ def relay_to_servers(processed)
21
27
  if processed.is_a? Array
22
28
  data, servers = *processed
23
29
 
@@ -32,7 +38,7 @@ module EventMachine
32
38
  s.send_data data unless data.nil?
33
39
  end
34
40
  end
35
-
41
+
36
42
  #
37
43
  # initialize connections to backend servers
38
44
  #
@@ -40,11 +46,20 @@ module EventMachine
40
46
  srv = EventMachine::connect(opts[:host], opts[:port], EventMachine::ProxyServer::Backend, @debug) do |c|
41
47
  c.name = name
42
48
  c.plexer = self
49
+ c.proxy_incoming_to(self, 10240) if opts[:relay_server]
43
50
  end
51
+ self.proxy_incoming_to(srv, 10240) if opts[:relay_client]
44
52
 
45
53
  @servers[name] = srv
46
54
  end
47
55
 
56
+ #
57
+ # [ip, port] of the connected client
58
+ #
59
+ def peer
60
+ @peer ||= Socket.unpack_sockaddr_in(get_peername).reverse
61
+ end
62
+
48
63
  #
49
64
  # relay data from backend server to client
50
65
  #
@@ -69,21 +84,24 @@ module EventMachine
69
84
  @servers[name] = nil
70
85
 
71
86
  # if all connections are terminated downstream, then notify client
72
- close_connection if @servers.values.compact.size.zero?
87
+ close_connection_after_writing if @servers.values.compact.size.zero?
73
88
 
74
89
  if @on_finish
75
90
  @on_finish.call(name)
76
- @on_finish.call(:done) if @servers.values.compact.size.zero?
91
+
92
+ # not sure if this is required
93
+ # @on_finish.call(:done) if @servers.values.compact.size.zero?
77
94
  end
78
95
  end
79
96
 
80
97
  private
81
98
 
82
99
  def debug(*data)
83
- return unless @debug
84
- require 'pp'
85
- pp data
86
- puts
100
+ if @debug
101
+ require 'pp'
102
+ pp data
103
+ puts
104
+ end
87
105
  end
88
106
  end
89
107
  end
@@ -9,7 +9,7 @@ class Proxy
9
9
 
10
10
  EventMachine::start_server(options[:host], options[:port],
11
11
  EventMachine::ProxyServer::Connection, options) do |c|
12
- c.instance_eval(&blk)
12
+ blk.call(c)
13
13
  end
14
14
  end
15
15
  end
data/spec/proxy_spec.rb CHANGED
@@ -9,7 +9,7 @@ describe Proxy do
9
9
 
10
10
  it "should recieve data on port 8080" do
11
11
  EM.run do
12
- EventMachine.add_timer(2) do
12
+ EventMachine.add_timer(0.1) do
13
13
  EventMachine::HttpRequest.new('http://127.0.0.1:8080/test').get({:timeout => 1})
14
14
  end
15
15
 
@@ -22,9 +22,27 @@ describe Proxy do
22
22
  end
23
23
  end
24
24
 
25
+ it "should call the on_connect callback" do
26
+ connected = false
27
+ EM.run do
28
+ EventMachine.add_timer(0.1) do
29
+ EventMachine::HttpRequest.new('http://127.0.0.1:8080/test').get({:timeout => 1})
30
+ end
31
+
32
+ Proxy.start(:host => "0.0.0.0", :port => 8080) do |conn|
33
+ conn.on_connect do
34
+ connected = true
35
+ EventMachine.stop
36
+ end
37
+ end
38
+ end
39
+ connected.should == true
40
+ end
41
+
42
+
25
43
  it "should transparently redirect TCP traffic to google" do
26
44
  EM.run do
27
- EventMachine.add_timer(2) do
45
+ EventMachine.add_timer(0.1) do
28
46
  EventMachine::HttpRequest.new('http://127.0.0.1:8080/').get({:timeout => 1})
29
47
  end
30
48
 
@@ -43,7 +61,7 @@ describe Proxy do
43
61
 
44
62
  it "should duplex TCP traffic to two backends google & yahoo" do
45
63
  EM.run do
46
- EventMachine.add_timer(2) do
64
+ EventMachine.add_timer(0.1) do
47
65
  EventMachine::HttpRequest.new('http://127.0.0.1:8080/').get({:timeout => 1})
48
66
  end
49
67
 
@@ -72,7 +90,7 @@ describe Proxy do
72
90
 
73
91
  it "should intercept & alter response from Google" do
74
92
  EM.run do
75
- EventMachine.add_timer(2) do
93
+ EventMachine.add_timer(0.1) do
76
94
  http = EventMachine::HttpRequest.new('http://127.0.0.1:8080/').get({:timeout => 1})
77
95
  http.errback { failed }
78
96
  http.callback {
@@ -93,7 +111,7 @@ describe Proxy do
93
111
 
94
112
  it "should invoke on_finish callback when connection is terminated" do
95
113
  EM.run do
96
- EventMachine.add_timer(2) do
114
+ EventMachine.add_timer(0.1) do
97
115
  EventMachine::HttpRequest.new('http://127.0.0.1:8080/').get({:timeout => 1})
98
116
  end
99
117
 
@@ -108,4 +126,40 @@ describe Proxy do
108
126
  end
109
127
  end
110
128
  end
129
+
130
+ it "should not invoke on_data when :relay_client is passed as server option" do
131
+ lambda {
132
+ EM.run do
133
+ EventMachine.add_timer(0.1) do
134
+ http =EventMachine::HttpRequest.new('http://127.0.0.1:8080/').get({:timeout => 1})
135
+ http.callback { EventMachine.stop }
136
+ end
137
+
138
+ Proxy.start(:host => "0.0.0.0", :port => 8080) do |conn|
139
+ conn.server :goog, :host => "google.com", :port => 80, :relay_client => true
140
+ conn.on_data { |data| raise "Should not be here"; data }
141
+ conn.on_response { |backend, resp| resp }
142
+
143
+ end
144
+ end
145
+ }.should_not raise_error
146
+ end
147
+
148
+ it "should not invoke on_response when :relay_server is passed as server option" do
149
+ lambda {
150
+ EM.run do
151
+ EventMachine.add_timer(0.1) do
152
+ http =EventMachine::HttpRequest.new('http://127.0.0.1:8080/').get({:timeout => 1})
153
+ http.callback { EventMachine.stop }
154
+ end
155
+
156
+ Proxy.start(:host => "0.0.0.0", :port => 8080) do |conn|
157
+ conn.server :goog, :host => "google.com", :port => 80, :relay_server => true
158
+ conn.on_data { |data| data }
159
+ conn.on_response { |backend, resp| raise "Should not be here"; }
160
+
161
+ end
162
+ end
163
+ }.should_not raise_error
164
+ end
111
165
  end
metadata CHANGED
@@ -1,7 +1,12 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: em-proxy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 1
8
+ - 2
9
+ version: 0.1.2
5
10
  platform: ruby
6
11
  authors:
7
12
  - Ilya Grigorik
@@ -9,19 +14,23 @@ autorequire:
9
14
  bindir: bin
10
15
  cert_chain: []
11
16
 
12
- date: 2009-10-25 00:00:00 -04:00
17
+ date: 2010-03-26 00:00:00 -04:00
13
18
  default_executable:
14
19
  dependencies:
15
20
  - !ruby/object:Gem::Dependency
16
21
  name: eventmachine
17
- type: :runtime
18
- version_requirement:
19
- version_requirements: !ruby/object:Gem::Requirement
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
20
24
  requirements:
21
25
  - - ">="
22
26
  - !ruby/object:Gem::Version
27
+ segments:
28
+ - 0
29
+ - 12
30
+ - 9
23
31
  version: 0.12.9
24
- version:
32
+ type: :runtime
33
+ version_requirements: *id001
25
34
  description: EventMachine Proxy DSL
26
35
  email: ilya@igvita.com
27
36
  executables: []
@@ -32,12 +41,15 @@ extra_rdoc_files:
32
41
  - README.rdoc
33
42
  files:
34
43
  - README.rdoc
44
+ - Rakefile
35
45
  - VERSION
36
46
  - examples/appserver.rb
37
47
  - examples/beanstalkd_interceptor.rb
38
48
  - examples/duplex.rb
39
49
  - examples/line_interceptor.rb
40
50
  - examples/port_forward.rb
51
+ - examples/relay_port_forward.rb
52
+ - examples/schemaless-mysql/mysql_interceptor.rb
41
53
  - examples/selective_forward.rb
42
54
  - examples/smtp_spam_filter.rb
43
55
  - examples/smtp_whitelist.rb
@@ -60,18 +72,20 @@ required_ruby_version: !ruby/object:Gem::Requirement
60
72
  requirements:
61
73
  - - ">="
62
74
  - !ruby/object:Gem::Version
75
+ segments:
76
+ - 0
63
77
  version: "0"
64
- version:
65
78
  required_rubygems_version: !ruby/object:Gem::Requirement
66
79
  requirements:
67
80
  - - ">="
68
81
  - !ruby/object:Gem::Version
82
+ segments:
83
+ - 0
69
84
  version: "0"
70
- version:
71
85
  requirements: []
72
86
 
73
87
  rubyforge_project: em-proxy
74
- rubygems_version: 1.3.5
88
+ rubygems_version: 1.3.6
75
89
  signing_key:
76
90
  specification_version: 3
77
91
  summary: EventMachine Proxy DSL
@@ -83,6 +97,9 @@ test_files:
83
97
  - examples/duplex.rb
84
98
  - examples/line_interceptor.rb
85
99
  - examples/port_forward.rb
100
+ - examples/relay_port_forward.rb
101
+ - examples/schemaless-mysql/client.rb
102
+ - examples/schemaless-mysql/mysql_interceptor.rb
86
103
  - examples/selective_forward.rb
87
104
  - examples/smtp_spam_filter.rb
88
105
  - examples/smtp_whitelist.rb