servicy 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/lib/service.rb ADDED
@@ -0,0 +1,200 @@
1
+ require 'timeout'
2
+
3
+ module Servicy
4
+ class Service
5
+ include Comparable
6
+ attr_reader :name, :version, :host, :port, :heartbeat_port, :protocol,
7
+ :api, :heartbeat_check_rate, :latencies, :heartbeats
8
+ attr_accessor :heartbeat_last_check
9
+
10
+ NAME_REGEX = /[^\.]+\.([^\.]+\.?)+/
11
+ VERSION_REGEX = /\d+\.\d+\.\d+(-p?\d+)?/
12
+
13
+ # Create a new service.
14
+ # (see Servicy::Server#register)
15
+ def initialize(args={})
16
+ args = args.inject({}) { |h,(k,v)| h[k.to_sym] = v; h }
17
+ raise ArgumentError.new("Must provide a service name") unless args[:name]
18
+ raise ArgumentError.new("Must provide a service host") unless args[:host]
19
+ raise ArgumentError.new("Must provide a service port") unless args[:port]
20
+
21
+ unless args[:name] =~ NAME_REGEX
22
+ raise ArgumentError.new("Service name must be in inverse-domain format")
23
+ end
24
+
25
+ args[:version] ||= '1.0.0'
26
+ unless args[:version] =~ VERSION_REGEX
27
+ raise ArgumentError.new("Service version must be in formation M.m.r(-p)?")
28
+ end
29
+
30
+ if !args[:port].is_a?(Fixnum) || args[:port] < 1 || args[:port] > 65535
31
+ raise ArgumentError.new("Service port must be an integer betwee 1-65535")
32
+ end
33
+
34
+ args[:heartbeat_port] ||= args[:port]
35
+ if !args[:heartbeat_port].is_a?(Fixnum) || args[:heartbeat_port] < 1 || args[:heartbeat_port] > 65535
36
+ raise ArgumentError.new("Service heartbeat port must be an integer betwee 1-65535")
37
+ end
38
+
39
+ if args[:api]
40
+ raise ArgumentError.new("Service api not correctly defiend") unless check_api(args[:api])
41
+ end
42
+
43
+ args[:protocol] ||= 'HTTP/S'
44
+
45
+ @name = args[:name]
46
+ @version = args[:version]
47
+ @host = args[:host]
48
+ @protocol = args[:protocol]
49
+ @port = args[:port]
50
+ @heartbeat_port = args[:heartbeat_port]
51
+ @api = args[:api] || {}
52
+ @heartbeat_check_rate = args[:heartbeat_check_rate] || 1
53
+ @heartbeat_last_check = 0
54
+ @latencies = []
55
+ @heartbeats = []
56
+ end
57
+
58
+ # Get a configuration value
59
+ # This method exists mostly so that I wouldn't have to change a bunch of
60
+ # early tests, and because I know some people like to access things like a
61
+ # hash.
62
+ # @param[Symbol] thing The config value to get.
63
+ # @return [Object,nil] The value if found, nil otherwise.
64
+ def [](thing)
65
+ return self.send(thing) if self.respond_to?(thing)
66
+ nil
67
+ end
68
+
69
+ # Build a hash of the configuration values for this object.
70
+ # Used to dump json configurations.
71
+ # @return [Hash] Configuration data for the Service
72
+ def as_json
73
+ {
74
+ name: name,
75
+ version: version,
76
+ host: host,
77
+ protocol: protocol,
78
+ port: port,
79
+ heartbeat_port: heartbeat_port,
80
+ api: api
81
+ }
82
+ end
83
+
84
+ # Used by the Comparable mixin for comparing two services.
85
+ # (see Comparable)
86
+ def <=>(other)
87
+ self.as_json <=> other.as_json
88
+ end
89
+
90
+ # Returns the version of the current service as a number that we can use
91
+ # for comparisons to other version numbers.
92
+ # @return [Integer] A number representing the version
93
+ def version_as_number
94
+ self.class.version_as_number(version)
95
+ end
96
+
97
+ # (see #version_as_number)
98
+ # @param [String] A version string as per {Servicy::Server#register}
99
+ def self.version_as_number(version)
100
+ parts = version.split('.')
101
+ parts.last.gsub(/\D/, '')
102
+ parts[0].to_i * 1000 + parts[1].to_i * 100 + parts[2].to_i * 10 + (parts[3] || 0)
103
+ end
104
+
105
+ # Check weather or not a service is up, based on connecting to the hearbeat
106
+ # port, and reading a single byte.
107
+ def up?
108
+ t1 = Time.now
109
+ s = TCPSocket.new(host, heartbeat_port)
110
+ Timeout.timeout(5) do
111
+ s.recvfrom(1)
112
+ end
113
+ record_heartbeat(1)
114
+ return true
115
+ rescue
116
+ record_heartbeat(0)
117
+ return false
118
+ ensure
119
+ s.close rescue nil
120
+ t2 = Time.now
121
+ record_latency(t1, t2)
122
+ end
123
+
124
+ # Returns what the avg latency for a service is, based on the timings of
125
+ # their heartbeat connections.
126
+ # @return [Float] avg latency is ms
127
+ def avg_latency
128
+ latencies.reduce(0.0, &:+) / latencies.length.to_f
129
+ end
130
+
131
+ # Returns the uptime for a service based on heartbeat checks as a float
132
+ # between 0 and 1 -- a percentage.
133
+ # @return [Float] avg uptime as a percentage (0..1)
134
+ def uptime
135
+ heartbeats.reduce(0, &:+) / heartbeats.length.to_f
136
+ end
137
+
138
+ # Get a nice, printable name
139
+ # return [String]
140
+ def to_s
141
+ name + "#" + host
142
+ end
143
+
144
+ # Returns a hash with the configuration options needed for registration and
145
+ # remote service operation.
146
+ def to_h
147
+ {
148
+ name: name,
149
+ version: version,
150
+ host: host,
151
+ port: port,
152
+ heartbeat_port: heartbeat_port,
153
+ protocol: protocol,
154
+ api: api
155
+ }
156
+ end
157
+
158
+ # Return the api for a given method (instance, or class) or nil if not
159
+ # found.
160
+ def api_for(method_type, method)
161
+ api[method_type] && api[method_type].select { |a| a[:name] == method }.first
162
+ end
163
+
164
+ private
165
+
166
+ # These are just to keep us from filling up memory.
167
+ def record_latency(t1, t2)
168
+ @latencies << t2 - t1
169
+ @latencies = @latencies[0...10] if @latencies.length > 10
170
+ end
171
+
172
+ def record_heartbeat(h)
173
+ @heartbeats << h
174
+ @heartbeats = @heartbeats[0...10] if @heartbeats.length > 10
175
+ end
176
+
177
+ # The api is broken into two kinds of methods; instance and class. Each can
178
+ # define method name, argument number and types, and return types.
179
+ def check_api(api_def)
180
+ raise ArgumentError.new("API can only define instance and class methods") unless api_def.contains_only?(:instance, :class)
181
+
182
+ api_def.each do |(_, methods)|
183
+ # Each method is itself a hash of name, args, return type, and docs.
184
+ methods.each do |method|
185
+ raise ArgumentError.new("Methods can only contain name, args, return, and docs") unless method.contains_only?(:name, :args, :return, :docs)
186
+ raise ArgumentError.new("Methods must define a name") unless method.include? :name
187
+
188
+ method[:args].each do |arg|
189
+ # Each argument is an optional name, and optional type, and an
190
+ # option "required" flag.
191
+ raise ArgumentError.new("Arguments can only define name, type, and required") unless arg.contains_only?(:name, :type, :required)
192
+ end
193
+
194
+ return false if method[:return] && !method[:return].contains_only?(:type)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+
data/lib/servicy.rb ADDED
@@ -0,0 +1,9 @@
1
+ $: << File.expand_path(File.join(File.dirname(__FILE__)))
2
+
3
+ # NOTE: You will almost never need both of these things. In all likeliness, you
4
+ # will want to do something like `require 'servicy/client'` yourself. This is
5
+ # here mostly as a convinience to rspec.
6
+ require 'hash'
7
+ require 'server'
8
+ require 'client'
9
+ require 'api'
@@ -0,0 +1,44 @@
1
+ require 'transports'
2
+ require 'socket'
3
+ require 'tempfile'
4
+
5
+ module Servicy
6
+ # This is a simple transport implementation that is to be used primarily for
7
+ # testing, so that I don't have to have a bunch of servers running just to
8
+ # run rspec...
9
+ class InMemoryTransport < Servicy::Transport
10
+ def send(message)
11
+ socket = UNIXSocket.new socket_path
12
+ socket.puts message.body
13
+ message = Message.new(socket.gets)
14
+ socket.close
15
+ message
16
+ end
17
+
18
+ def start
19
+ @server = UNIXServer.open(socket_path)
20
+ while s = @server.accept
21
+ Thread.new do
22
+ message = Message.new s.gets
23
+ result = yield message
24
+ s.puts result.body
25
+ s.close
26
+ end
27
+ end
28
+ end
29
+
30
+ def stop
31
+ @server && @server.close
32
+ end
33
+
34
+ def socket_path
35
+ @socket_path ||= begin
36
+ file = Tempfile.new(@config[:path] || 'servicy-in-memory-transport')
37
+ file.close
38
+ path = file.path
39
+ file.delete
40
+ path
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,141 @@
1
+ module Servicy
2
+ class Transport
3
+ # The Message class contains messages passed between client and server, as
4
+ # well as utility methods for making messages to be sent.
5
+ class Message
6
+ include Comparable
7
+
8
+ attr_reader :struct
9
+
10
+ # Create a new message that will be sent over some transport.
11
+ # @param [Hash] stuff A hash that will constitute the message. You should
12
+ # likely never call this directy (i.e. Message.new), and instead use the
13
+ # class methods {#registration}, {#query}, etc.
14
+ def initialize(stuff)
15
+ if stuff.is_a?(Hash)
16
+ @struct = stuff
17
+ else
18
+ @struct = JSON.parse(stuff)
19
+ end
20
+ end
21
+
22
+ def method_missing(name, *args, &block)
23
+ return struct[name] if struct.keys.include?(name)
24
+ return struct[name.to_s] if struct.keys.include?(name.to_s)
25
+ super
26
+ end
27
+
28
+ # The body of the message for cases where you send the message over the
29
+ # wire, or through a database.
30
+ # @return [String] A JSON representation of the message
31
+ def body
32
+ struct.to_json
33
+ end
34
+
35
+ # The response code.
36
+ # @return [Integer] 200 if successful, 404 if not-found, 500 if error.
37
+ def response_code
38
+ !!struct[:success] ? 200 : struct[:response_code]
39
+ end
40
+
41
+ # Create a service registration message
42
+ # @param [{Servicy::Service}] service The service that is being registered.
43
+ # @return [{Message}] A {Message} that can be sent through a
44
+ # {Servicy::Transport}
45
+ def self.registration(service)
46
+ new({
47
+ message: "registration",
48
+ service: service.as_json
49
+ })
50
+ end
51
+
52
+ # Create a service discovery message.
53
+ # @param [Hash] args A hash that defines the query. (see Servicy::Server#find)
54
+ # @return [{Message}] A {Message} that can be sent through a
55
+ # {Servicy::Transport}
56
+ def self.query(args, only_one=false)
57
+ raise ArgumentError.new("Invalid search query") unless query_valid?(args)
58
+ new({
59
+ message: "query",
60
+ query: args,
61
+ only_one: only_one
62
+ })
63
+ end
64
+
65
+ # Create a service discover message where I only want one provider.
66
+ # (see .query)
67
+ def self.query_one(args)
68
+ query(args, true)
69
+ end
70
+
71
+ # Create a services search response message
72
+ # @param [Array<{Servicy::Service}>] services The services that were
73
+ # found based on a {Servicy::Server} query
74
+ # @return [{Message}] A {Message} that can be sent through a
75
+ # {Servicy::Transport}
76
+ def self.services(services)
77
+ new({
78
+ success: true,
79
+ message: "services",
80
+ services: services.map { |s| s.as_json }
81
+ })
82
+ end
83
+
84
+ # Create an error response message
85
+ # @param [String] error_message The error message to report
86
+ # @param [Integer] response_code The response code for the error.
87
+ # Defaults to 500
88
+ # @return [{Message}] A {Message} that can be sent through a
89
+ # {Servicy::Transport}
90
+ def self.error(error_message, response_code=500)
91
+ new({
92
+ success: false,
93
+ error: error_message
94
+ })
95
+ end
96
+
97
+ # A utility method for 404 Not-Found error message
98
+ # (see .error)
99
+ def self.not_found
100
+ error "No results found", 404
101
+ end
102
+
103
+ # A generic success message
104
+ def self.success
105
+ new({
106
+ success: true
107
+ })
108
+ end
109
+
110
+ # A message sent to a server to gather statistics about the server, also
111
+ # used by the server to send the response back to the client.
112
+ # @param [Hash,nil] stats The stats to report, if any.
113
+ def self.statistics(stats=nil)
114
+ new({
115
+ message: "stats",
116
+ stats: stats
117
+ })
118
+ end
119
+
120
+ # Allow comparing of messages. This doesn't make for useful sorting, at
121
+ # the moment, but does make it easy to know if two things are saying the
122
+ # same thing.
123
+ def <=>(b)
124
+ self.struct == b.struct ? 0 : -1
125
+ end
126
+
127
+ private
128
+
129
+ def self.query_valid?(args)
130
+ return false if !args[:name] && !args[:api]
131
+ if args[:min_version] && args[:max_version]
132
+ min = Servicy::Service.version_as_number(args[:min_version])
133
+ max = Servicy::Service.version_as_number(args[:max_version])
134
+ return false if min > max
135
+ end
136
+ return false if args[:version] && (args[:min_version] || args[:max_version])
137
+ true
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,50 @@
1
+ require 'transports'
2
+ require 'socket'
3
+
4
+ module Servicy
5
+ # This is a transport that is almost an exact duplicate of the UNIXSocket
6
+ # transport, but can actually go over the wire. That whole, "Everything is a
7
+ # file" business works out well...
8
+ class TCPTransport < Servicy::Transport
9
+ HELLO = "Servicy says HI!"
10
+ def send(message)
11
+ socket = TCPSocket.new host, port
12
+ hello = socket.gets
13
+ raise "Not a servicy server" unless hello.strip == HELLO
14
+ socket.send message.body + "\n", 0
15
+ # FIXME: For some reason, this stalls when using the command-line client.
16
+ # Wireshark isn't helpful while I'm on the vpn, so I will have to fix
17
+ # this later.
18
+ message = Message.new(socket.gets)
19
+ socket.close
20
+ message
21
+ end
22
+
23
+ def start(&block)
24
+ @server = TCPServer.open(port)
25
+ while s = @server.accept
26
+ Thread.new do
27
+ s.puts HELLO
28
+ message = Message.new s.gets
29
+ result = yield message
30
+ s.puts result.body
31
+ s.close
32
+ end
33
+ end
34
+ end
35
+
36
+ def stop
37
+ @server && @server.close
38
+ end
39
+
40
+ private
41
+
42
+ def host
43
+ @config[:host]
44
+ end
45
+
46
+ def port
47
+ @config[:port]
48
+ end
49
+ end
50
+ end
data/lib/transports.rb ADDED
@@ -0,0 +1,54 @@
1
+ require 'service'
2
+ require 'transport/messages'
3
+
4
+ module Servicy
5
+ # The Transport class is an abstract class whos implementations handle the
6
+ # actual communication over the wire or in memory between server and client.
7
+ # However, in the abstract, it doesn't matter how things are transported,
8
+ # only that they are and conform to the format described here.
9
+ class Transport
10
+ attr_reader :config
11
+
12
+ # @param [Hash] config Configuration options for the transport
13
+ def initialize(config={})
14
+ @config = config
15
+ end
16
+
17
+ # Override this method to send a message from {Client} to {Server}
18
+ def send(messge)
19
+ raise 'Not implemented'
20
+ end
21
+
22
+ # Override this method to yield a message when received on the {Server}. It
23
+ # should yield to the provided block the message received as a
24
+ # {Transport::Message} object, and send back the return value of the block
25
+ # to the client.
26
+ def start(&block)
27
+ raise 'Not implemented'
28
+ end
29
+
30
+ # Called when a transport is stopped
31
+ def stop
32
+ end
33
+
34
+ # This attempts to load a service based on the name.
35
+ def self.method_missing(name, *args, &block)
36
+ class_name = "#{name.to_s}Transport"
37
+ begin
38
+ c = Module.const_get(class_name)
39
+ return c if c && c.ancestors.include?(Servicy::Transport)
40
+ rescue NameError
41
+ c = Servicy.const_get(class_name)
42
+ return c if c && c.ancestors.include?(Servicy::Transport)
43
+ end
44
+ rescue
45
+ super
46
+ end
47
+ end
48
+ end
49
+
50
+ # Load all the transports
51
+ Dir[File.expand_path(File.join(File.dirname(__FILE__), 'transport', '*.rb'))].each do |file|
52
+ next if file.start_with?('.') || File.directory?(file)
53
+ require File.expand_path(file)
54
+ end
data/test.rb ADDED
@@ -0,0 +1,74 @@
1
+ require './lib/servicy'
2
+
3
+ class Foo
4
+ def initialize(thing)
5
+ @thing = thing
6
+ end
7
+
8
+ # Do some foobar work
9
+ # @param [String] a
10
+ # @param [Integer] b
11
+ def foobar(a,b)
12
+ p a
13
+ p b
14
+ end
15
+
16
+ # Do something on the class
17
+ # @return [Hash{Integer => Array<String>}]
18
+ def self.barbaz
19
+ puts "OH MY GOD!"
20
+ { 1 => ['foo', 'bar'] }
21
+ end
22
+ end
23
+
24
+ # Create a new class that wraps foo. It will be called FooAPI, and will behave
25
+ # just like foo, but with contract checking. It will be the place that in the
26
+ # future we extend to behave more like an API
27
+ api = Servicy::Client.create_api_for Foo
28
+ # p api
29
+
30
+ # Instance-y-stuff
31
+ foo = api.new(:something)
32
+ foo.foobar('string',1)
33
+
34
+ # Class-y stuff
35
+ api.barbaz
36
+
37
+ # Now we should get some explosions for failing to comply with the contracts
38
+ begin
39
+ foo.foobar(:not_a_string, 'or an integer')
40
+ rescue => e
41
+ puts e
42
+ end
43
+
44
+ ###############################################################################
45
+ # Making a working api with an api object. This is just some ideas to outline
46
+ # how I would like the interface to look, not really any working code just yet.
47
+ #
48
+
49
+ # Finding a registered service and using a remote service instead of a local
50
+ # object. This would take the API object, gather information about it, make the
51
+ # query to the service discovery portal, and return a new api model (that is
52
+ # the same specifications as the original) that talks with the remote service
53
+ # instead of the local object, raising an error if something goes wrong.
54
+ transport = Servicy::Transport.TCP.new(port: 1234)
55
+ api = Servicy::Client.find_service_provider_for(api, transport)
56
+
57
+ # This should also be able to work with non-API objects given to it. In the
58
+ # case of a library that you don't have installed locally.
59
+ service = Servicy::Client.find_service(name: 'foobar') # This alredy exists
60
+ api = service.api
61
+
62
+ # Starting a server with a given client/api object. Ideally, you can start
63
+ # multipl servers.
64
+ api.start_server('HTTP', 80)
65
+ api.start_server('HTTPS', 443)
66
+ api.start_server('TCP', 1235)
67
+
68
+ # Maybe it would be better to do it with Transport objects...
69
+ t = Transport.tcp.new(1235)
70
+ api.start_server t
71
+
72
+ # Combining registration and server starting
73
+ api.register_with('127.0.0.1:1234', Transport.TCP.new(1235))
74
+
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: servicy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Luce
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-08-11 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A service registration and discovery framework, as a server and client
14
+ library.
15
+ email: thomas.luce@gmail.com
16
+ executables:
17
+ - servicy
18
+ extensions: []
19
+ extra_rdoc_files:
20
+ - README.md
21
+ files:
22
+ - README.md
23
+ - Servicy.gemspec
24
+ - VERSION
25
+ - bin/servicy
26
+ - lib/api.rb
27
+ - lib/client.rb
28
+ - lib/hash.rb
29
+ - lib/load_balancer.rb
30
+ - lib/load_balancer/random.rb
31
+ - lib/load_balancer/round_robin.rb
32
+ - lib/server.rb
33
+ - lib/server/server.rb
34
+ - lib/server/service_searcher.rb
35
+ - lib/service.rb
36
+ - lib/servicy.rb
37
+ - lib/transport/in_memory_transport.rb
38
+ - lib/transport/messages.rb
39
+ - lib/transport/tcp_transport.rb
40
+ - lib/transports.rb
41
+ - test.rb
42
+ homepage: https://github.com/thomasluce/servicy
43
+ licenses: []
44
+ metadata: {}
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - '>='
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubyforge_project:
61
+ rubygems_version: 2.2.2
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: A service registration and discovery framework
65
+ test_files: []