servicy 0.0.3 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: caf0064f89f13aec57352cff7a92d144db60444c
4
- data.tar.gz: 7b95d789be54127997ed6ea51d709fd18910efe8
3
+ metadata.gz: d9048f3bb1f1521a29ceeb800dd7b3eb5149c9b7
4
+ data.tar.gz: 1c474e01e90d51469893fe19be368004cc4654d8
5
5
  SHA512:
6
- metadata.gz: 0b3af8c73e8b8a5d2a61eef42c89dbef44b528a199e38436ff4ce1a54613d776a4f95fab796c73a736d9321b20917949d891b313ab66ab2cfcf86c011e3a729f
7
- data.tar.gz: b4d288ad7756cd24814b5d57de1a15b8f50b1497fde40dc662ec1811d42127746337875afc3a2d6bb3da5417853729c4ff933710c81a84ce0cd1cc070188b1a1
6
+ metadata.gz: 7eb75dfafdc14c501aeb551579d839991aa312b3d554cadcc1efb410f1c9636651f9d6a8516b88415fe0739b867f242c95fd5e0adec9c067941d95c094e54d9b
7
+ data.tar.gz: 4986b4d40c49b4bb5b78f427fb5ccb25da068cb22bb225c09ca4adc245a8805af3f6ce62acb1f5b44153599a5cb740064a01700a172e9c6796ac23a5a964b126
data/README.md CHANGED
@@ -245,6 +245,27 @@ with the provided query. In the last two queries, additional error information
245
245
  may be provided, but should be considered for human-use only; ie, showing an
246
246
  error message to a user.
247
247
 
248
+ Notes on design of your objects
249
+ ===============================
250
+
251
+ See [here](http://martinfowler.com/articles/distributed-objects-microservices.html).
252
+ It's a good article. The upshot is that, while hiding weather or not an object
253
+ is remote is good for programmer burden (ie, it reduces it), it's bad for API
254
+ design.
255
+
256
+ Servicy's goal is to make it simple for you to build microservices in Ruby that
257
+ can either run locally or remotely without the developer having to worry about
258
+ or care which. That said, as the developer of a service, it is _your_
259
+ responsibility to make sure that you don't make services that suck (tm).
260
+
261
+ Don't build your objects to have iterators that require multiple-traversals
262
+ over the wire. Don't build objects that lazy-evaluate things. Don't build
263
+ objects that themselves rely on other services that are poorly built. Etc.
264
+
265
+ Do eat your own dogfood, and use your own toilet paper. If it hurts for you to
266
+ use it, with all your insider knowledge and perspective, it is downright agony
267
+ for other people.
268
+
248
269
  TODO
249
270
  ====
250
271
 
@@ -253,5 +274,8 @@ Things left to do:
253
274
  1. In the command-line tool, there is some kind of bug where the registration
254
275
  message never returns back to the client. This causes registration to hang
255
276
  forever...
256
- 1. There are a number of just kind of ugly-code things around that I should sort out.
257
- 1. Probably some other things that I'm forgetting.
277
+ 1. I should sit down and really think out code structure and refactor where
278
+ needed. Things are getting a little difficult to follow if you are jumping
279
+ around between files a bunch.
280
+ 1. I need to cut down on the number of latency and heartbeat entries that are
281
+ saved. It gets a bit out of hand.
data/Servicy.gemspec CHANGED
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: servicy 0.0.3 ruby lib
5
+ # stub: servicy 0.0.5 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "servicy"
9
- s.version = "0.0.3"
9
+ s.version = "0.0.5"
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib"]
13
13
  s.authors = ["Thomas Luce"]
14
- s.date = "2014-08-11"
14
+ s.date = "2014-08-15"
15
15
  s.description = "A service registration and discovery framework, as a server and client library."
16
16
  s.email = "thomas.luce@gmail.com"
17
17
  s.executables = ["servicy"]
@@ -25,6 +25,9 @@ Gem::Specification.new do |s|
25
25
  "bin/servicy",
26
26
  "lib/api.rb",
27
27
  "lib/client.rb",
28
+ "lib/configurable.rb",
29
+ "lib/formats.rb",
30
+ "lib/formats/json.rb",
28
31
  "lib/hash.rb",
29
32
  "lib/load_balancer.rb",
30
33
  "lib/load_balancer/random.rb",
@@ -36,6 +39,7 @@ Gem::Specification.new do |s|
36
39
  "lib/servicy.rb",
37
40
  "lib/transport/in_memory_transport.rb",
38
41
  "lib/transport/messages.rb",
42
+ "lib/transport/null_transport.rb",
39
43
  "lib/transport/tcp_transport.rb",
40
44
  "lib/transports.rb",
41
45
  "test.rb"
@@ -43,5 +47,17 @@ Gem::Specification.new do |s|
43
47
  s.homepage = "https://github.com/thomasluce/servicy"
44
48
  s.rubygems_version = "2.2.2"
45
49
  s.summary = "A service registration and discovery framework"
50
+
51
+ if s.respond_to? :specification_version then
52
+ s.specification_version = 4
53
+
54
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
55
+ s.add_runtime_dependency(%q<contraction>, [">= 0"])
56
+ else
57
+ s.add_dependency(%q<contraction>, [">= 0"])
58
+ end
59
+ else
60
+ s.add_dependency(%q<contraction>, [">= 0"])
61
+ end
46
62
  end
47
63
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.3
1
+ 0.0.5
data/lib/api.rb CHANGED
@@ -1,14 +1,8 @@
1
- # TODO: Investigate this kind of thing using Delegator to see if I can get
2
- # a speed boost. Each exported method would have to be dynamically defined
3
- # in the API class to do contract checking and then pass that on to the
4
- # delegated object. I don't, however, know if that's actually faster than
5
- # using method_missing. Investigate
6
1
  module Servicy
7
2
  class API
8
- def self.create(klass, method_descriptions)
9
- @klass = klass
10
- method_descriptions = { class: method_descriptions[:class].inject({}) { |h, info| h[info[:method].to_sym] = info; h },
11
- instance: method_descriptions[:instance].inject({}) { |h, info| h[info[:method].to_sym] = info; h }
3
+ def self.create(klass, mds)
4
+ method_descriptions = { class: mds[:class].inject({}) { |h, info| h[info[:method].to_sym] = info; h },
5
+ instance: mds[:instance].inject({}) { |h, info| h[info[:method].to_sym] = info; h }
12
6
  }
13
7
 
14
8
  # We inherit from API here so that in the future we can add functionality
@@ -16,27 +10,42 @@ module Servicy
16
10
  # This may change to Client at some point... Who
17
11
  # knows.
18
12
  magic_class = Class.new(Servicy::API) do
19
- @@base_class = klass
20
- @@method_descriptions = method_descriptions
21
-
22
13
  def initialize(*args)
23
- @instance = @@base_class.new(*args)
14
+ @instance = self.class.const_get('BASE_CLASS').new(*args)
24
15
  end
25
16
 
26
- def method_descriptions
27
- @@method_descriptions
17
+ def self.set_remote(client)
18
+ @remote = client
19
+ end
20
+ def self.remote?
21
+ @remote
22
+ end
23
+ def self.client
24
+ @remote
28
25
  end
29
26
 
27
+ # TODO: I'm thinking that I might be able to do this with
28
+ # #define_method's instead. That will spead things up aroud 2-5x.
29
+
30
30
  # The magic happens in the two method_missing methods.
31
31
  # Meta-programming-averse; gaze upon my works, ye haughty, and despair!
32
32
  def method_missing(method, *args, &block)
33
33
  # Verify the contract
34
- data = method_descriptions[:instance][method]
35
- raise NoMethodError.new("#{@klass.to_s} does not expose an API endpoint called, #{method.to_s}") unless data
34
+ data = self.class.const_get('METHOD_DESCRIPTIONS')[:instance][method]
35
+ raise NoMethodError.new("#{self.class.const_get('BASE_CLASS')} does not expose an API endpoint called, #{method.to_s}") unless data
36
36
  data[:contract].valid_args?(*args)
37
37
 
38
38
  # Dispatch the result
39
- result = @instance.send(method, *args, &block)
39
+ result = nil
40
+ if self.class.remote?
41
+ params = data[:contract].params.each_with_index.inject({}) do |h, (param, index)|
42
+ h[param.to_s] = args[index]
43
+ h
44
+ end
45
+ result = self.class.client.transport.remote_request(method, params)
46
+ else
47
+ result = @instance.send(method, *args, &block)
48
+ end
40
49
 
41
50
  # Check the result
42
51
  data[:contract].valid_return?(*args, result)
@@ -47,17 +56,26 @@ module Servicy
47
56
 
48
57
  # Make sure that we can respond to the things we actually do respond to.
49
58
  def respond_to?(method)
50
- method_descriptions[:instance].include?(method) || super
59
+ self.class.const_get('METHOD_DESCRIPTIONS')[:instance].include?(method) || super
51
60
  end
52
61
 
53
62
  def self.method_missing(method, *args, &block)
54
63
  # Verify the contract
55
- data = @@method_descriptions[:class][method]
56
- raise NoMethodError.new("#{@klass.to_s} does not expose an API endpoint called, #{method.to_s}") unless data
64
+ data = self.const_get('METHOD_DESCRIPTIONS')[:class][method]
65
+ raise NoMethodError.new("#{self.const_get('BASE_CLASS')} does not expose an API endpoint called, #{method.to_s}") unless data
57
66
  data[:contract].valid_args?(*args)
58
67
 
59
68
  # Dispatch the result
60
- result = @@base_class.send(method, *args, &block)
69
+ result = nil
70
+ if remote?
71
+ params = data[:contract].params.each_with_index.inject({}) do |h, (param, index)|
72
+ h[param.to_s] = args[index]
73
+ h
74
+ end
75
+ result = client.transport.remote_request(method, params)
76
+ else
77
+ result = self.const_get('BASE_CLASS').send(method, *args, &block)
78
+ end
61
79
 
62
80
  # Check the result
63
81
  data[:contract].valid_return?(*args, result)
@@ -67,14 +85,14 @@ module Servicy
67
85
  end
68
86
 
69
87
  def self.respond_to?(method)
70
- @@method_descriptions[:class].include?(method) || super
88
+ const_get('METHOD_DESCRIPTIONS')[:class].include?(method) || super
71
89
  end
72
90
 
73
91
  # Send back either all the documentation, or just for a particular
74
92
  # method.
75
93
  def self.docs(method=nil)
76
94
  if method.nil?
77
- return @@method_descriptions.map do |(type, methods)|
95
+ return const_get('METHOD_DESCRIPTIONS').map do |(type, methods)|
78
96
  methods.values.map { |v| v[:docs] }
79
97
  end.flatten
80
98
  else
@@ -83,7 +101,7 @@ module Servicy
83
101
  search_in = method[0] == '.' ? [:class] : (method[0] == '#' ? [:instance] : search_in)
84
102
  end
85
103
  search_in.each do |type|
86
- @@method_descriptions[type].each do |(name,stuff)|
104
+ const_get('METHOD_DESCRIPTIONS')[type].each do |(name,stuff)|
87
105
  return stuff[:docs] if method.to_s =~ /(\.|#)?#{name.to_s}$/
88
106
  end
89
107
  end
@@ -95,6 +113,8 @@ module Servicy
95
113
  klass.constants(false).each do |const|
96
114
  magic_class.const_set(const, klass.const_get(const))
97
115
  end
116
+ magic_class.const_set('BASE_CLASS', klass)
117
+ magic_class.const_set('METHOD_DESCRIPTIONS', method_descriptions)
98
118
 
99
119
  # Give it a good name
100
120
  class_name = "#{klass.to_s}API"
@@ -117,10 +137,5 @@ module Servicy
117
137
 
118
138
  { name: name, host: 'localhost', port: port, version: version, heartbeat_port: heartbeat_port }
119
139
  end
120
-
121
- # Sets the remote configuration options
122
- def set_remote_configuration(config)
123
- @remote_config = config
124
- end
125
140
  end
126
141
  end
data/lib/client.rb CHANGED
@@ -12,23 +12,35 @@ module Servicy
12
12
 
13
13
  # Create a new Client instance
14
14
  def initialize(transport=nil)
15
- @transport = transport || Transport.InMemory.new
15
+ @transport = transport || Servicy.client.transport
16
+ end
17
+
18
+ # Return weather or not you are connected to a remote servicy server
19
+ def connected?
20
+ @transport.send(Servicy::Transport::Message.query(name: 'com.servicy.not.a.real.service')) rescue return false
21
+ true
16
22
  end
17
23
 
18
24
  # Register a service with the service discovery portal found at the other
19
25
  # end of @transport
20
26
  def register_service(args)
27
+ if !args.is_a?(Hash) && args.ancestors.include?(Servicy::API)
28
+ args = args.search_query
29
+ end
30
+
21
31
  if args.is_a?(Hash)
22
32
  @transport.send(Servicy::Transport::Message.registration(Servicy::Service.new(args)))
23
33
  else
24
- # TODO: Check if it's an API, and if so register with that.
34
+ raise ArgumentError.new("Don't know how to register type, #{args.inspect}")
25
35
  end
26
36
  end
27
37
 
28
38
  # Find a single service matching a query. This will cause the server to use
29
39
  # whatever configured load-balancing mechanism is configured for it.
30
40
  def find_service(args)
31
- service = @transport.send(Servicy::Transport::Message.query_one(args)).services.first
41
+ result = @transport.send(Servicy::Transport::Message.query_one(args))
42
+ services = result && result.services
43
+ service = services && services.first
32
44
  service && Servicy::Service.new(service)
33
45
  end
34
46
 
@@ -79,18 +91,6 @@ module Servicy
79
91
  API.create klass, data
80
92
  end
81
93
 
82
- # Ask the server at the other end of transport for a provider for the
83
- # provided API object, and configure the API object to use the remote
84
- # service.
85
- def self.find_service_provider_for(api, transport)
86
- client = Client.new(transport)
87
- service = client.find_service(api.search_query)
88
- raise ServiceNotFoundError.new(api.search_query) unless service
89
-
90
- api.set_remote_configuration(service.to_h)
91
- api
92
- end
93
-
94
94
  private
95
95
 
96
96
  # Get the contents of a file that contains a method, along with the line
@@ -0,0 +1,19 @@
1
+ class Configurable
2
+ def initialize
3
+ @stuff = {}
4
+ end
5
+
6
+ def method_missing(name, *args, &block)
7
+ if name.to_s =~ /=$/
8
+ @stuff[name.to_s.sub(/=$/, '')] = args.first
9
+ return
10
+ end
11
+
12
+ return @stuff[name] if @stuff[name]
13
+ @stuff[name] = Configurable.new
14
+ end
15
+
16
+ def nil?
17
+ true
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ require 'json'
2
+
3
+ module Servicy
4
+ module Formats
5
+ class JSON < Servicy::Format
6
+ class << self
7
+ def format(things)
8
+ things.to_json
9
+ end
10
+
11
+ def unformat(things)
12
+ return nil if things == "null"
13
+ return ::JSON.parse(things) rescue ::JSON.parse("[#{things.inspect}]").first
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/formats.rb ADDED
@@ -0,0 +1,36 @@
1
+ # Formats decide how a thing is encoded over the wire, regardless of what
2
+ # transport protocol is used. So, if you want to send protobufs over HTTP go
3
+ # nuts, dude.
4
+ module Servicy
5
+ # This represents an abstract class that should be overwritten for individual
6
+ # formats.
7
+ class Format
8
+ class << self
9
+ # @param [Hash] things is a key-value pairing where the values are the
10
+ # things to be encoded. You can call this recursively for nested objects.
11
+ def format(things)
12
+ raise NotImplementedError.new
13
+ end
14
+
15
+ # Turn a formatted thing back into a native representation
16
+ def unformat(thing)
17
+ raise NotImplementedError.new
18
+ end
19
+ end
20
+ end
21
+
22
+ module Formats
23
+ def self.method_missing(name, *args, &block)
24
+ c = Servicy::Formats.const_get(name)
25
+ return c if c && c.ancestors.include?(Servicy::Format)
26
+ rescue
27
+ super
28
+ end
29
+ end
30
+ end
31
+
32
+ # Load all the formatters
33
+ Dir[File.expand_path(File.join(File.dirname(__FILE__), 'formats', '*.rb'))].each do |file|
34
+ next if file.start_with?('.') || File.directory?(file)
35
+ require File.expand_path(file)
36
+ end
data/lib/server/server.rb CHANGED
@@ -9,52 +9,47 @@ module Servicy
9
9
  # TODO: I should really make this take an options hash, but that will
10
10
  # require changing a lot of shit...
11
11
  def initialize(transport=nil, load_balancer=nil, logger_stream=nil, config_file=nil)
12
- @transport = transport || InMemoryTransport.new
12
+ @transport = transport || Servicy.config.server.transport
13
13
  @services = [] # All services
14
14
  @deactivated_services = [] # Dead services
15
- @load_balancer = load_balancer || RoundRobinLoadBalancer.new
16
- logger_stream ||= File.open(File.join('/var', 'log', 'servicy_server.log'), 'a')
17
- @logger = Logger.new(logger_stream)
18
- @config_file = config_file || File.expand_path("~/.servicy.conf")
15
+ @load_balancer = load_balancer || Servicy.config.server.load_balancer
16
+ @config_file = config_file || Servicy.config.server.config_file
19
17
  start_heartbeat_process
20
18
  end
21
19
 
22
20
  # Starts the {Servicy::Transport} listening (whatever that means for the
23
21
  # implementation), gets messages, handles them, and sends replies.
24
- # TODO: I think I might need to invert this so that it tells the transport
25
- # to start, and passes a block to the start method that handles the
26
- # messages... That way I can have the transport fork when a connection
27
- # comes in, handle it, and clean up the fork when it's done, allowing
28
- # everything to keep chugging along.
29
22
  def go!
30
23
  transport.start do |message|
31
- @logger.info "Received message, #{message.inspect}"
24
+ Servicy.logger.info "Received message, #{message.inspect}"
32
25
  last_service_count = @services.length
33
26
 
34
- result = nil
35
- if message.message == 'registration'
27
+ result = case message.message
28
+ when 'registration'
36
29
  self.register message.service
37
- result = Transport::Message.success
38
- elsif message.message == 'query'
30
+ Transport::Message.success
31
+ when 'query'
39
32
  if message.only_one
40
33
  temp = [self.find_one(message.query)].compact
41
34
  else
42
35
  temp = self.find(message.query)
43
36
  end
44
- result = Transport::Message.services temp
45
- elsif message.message == 'stats'
37
+ Transport::Message.services temp
38
+ when 'stats'
46
39
  # Gather stats about the running system, and return it
47
40
  # TODO: Other stats?
48
- result = Transport::Message.statistics(num_services: last_service_count,
41
+ Transport::Message.statistics(num_services: last_service_count,
49
42
  uptimes: uptimes,
50
43
  latencies: latencies
51
44
  )
52
45
  else
53
- result = Transport::Message.error("Bad message type, #{message.inspect}")
46
+ Transport::Message.error("Bad message type, #{message.inspect}")
54
47
  end
55
48
 
56
49
  if @services.length > last_service_count
50
+ Servicy.logger.debug("Saving to #{@config_file}")
57
51
  self.save!(@config_file)
52
+ Servicy.logger.debug("Saved")
58
53
  last_service_count = @services.length
59
54
  end
60
55
  result
@@ -107,13 +102,17 @@ module Servicy
107
102
  # tests and README.md for examples.
108
103
  def register(args)
109
104
  service = Service.new(args)
110
- @logger.info "Registering service, #{service.inspect}..."
105
+ Servicy.logger.info "Registering service, #{service}..."
111
106
  if service.up?
112
- @logger.info "Service, #{service}, up; registered."
113
- @services << service
107
+ if @services.include? service
108
+ Servicy.logger.info "Service, #{service}, already exists. Not duplicating"
109
+ else
110
+ @services << service
111
+ Servicy.logger.info "Service, #{service}, up; registered."
112
+ end
114
113
  return service
115
114
  else
116
- @logger.info "Service, #{service}, down; not registered."
115
+ Servicy.logger.info "Service, #{service}, down; not registered."
117
116
  return false
118
117
  end
119
118
  end
@@ -122,7 +121,8 @@ module Servicy
122
121
  # active list of services that are available to searches, etc.
123
122
  # @param [{Service}] service the service to de-register
124
123
  def unregister(service)
125
- @logger.info "De-registering service, #{service}"
124
+ return if @deactivated_services.include?(service)
125
+ Servicy.logger.info "De-registering service, #{service}"
126
126
  @deactivated_services << service
127
127
  end
128
128
 
@@ -131,7 +131,7 @@ module Servicy
131
131
  # @param [{Service}] service The service to remove from deactivated list
132
132
  def reregister(service)
133
133
  if @deactivated_services.delete(service)
134
- @logger.info "Re-registering service, #{service}"
134
+ Servicy.logger.info "Re-registering service, #{service}"
135
135
  end
136
136
  end
137
137
 
@@ -160,16 +160,53 @@ module Servicy
160
160
  end
161
161
 
162
162
  # Find one or more services based on some search criteria.
163
- # TODO: Give more documentation about this here.
163
+ # When finding things, you can search by any single or combination of name,
164
+ # version, and api definition. When searching for name:
165
+ #
166
+ # find(name: 'com.foobar.baz')
167
+ #
168
+ # ... or for wild-cards if you don't care who provides a thing
169
+ #
170
+ # find(name: '.baz')
171
+ #
172
+ # For searching by version, you can search by absolute version number,
173
+ # minimum, maximum, or a range (by passing min and max together):
174
+ #
175
+ # # Absolute version
176
+ # find(version: '1.2.4-p123')
177
+ #
178
+ # # Min and max as range
179
+ # find(min_version: '1.2.0', max_version: '2.0.0')
180
+ #
181
+ # All version numbers must follow the format:
182
+ #
183
+ # (major).(minor).(revision)(-patch)?
184
+ #
185
+ # And are compared to one another by converting the version to numbers and
186
+ # comparing those numbers. The version happens by:
187
+ #
188
+ # (major * 1000) + (minor * 100) + (revision * 10) + (1/patch)
189
+ #
190
+ # Searching by api can be used to search for a service that provides a
191
+ # certain interface, even if you don't know what that interface is called.
192
+ # For example, if you don't know what your authorization service is called,
193
+ # but you know that it takes 2 strings (username and password) and returns
194
+ # either false or a User object, you can search by:
195
+ #
196
+ # find(api: '.login/2[String,String] -> User|nil')
197
+ #
198
+ # NOTE The query syntax listed here isn't working currently. See
199
+ # {Servicy::ServiceSearcher} for information on how to actually search by
200
+ # api in the current version.
164
201
  def find(args={})
165
- @logger.info "Finding #{args.inspect}..."
202
+ Servicy.logger.info "Finding #{args.inspect}..."
166
203
 
167
204
  searcher = Servicy::ServiceSearcher.new(services - deactivated_services)
168
205
  searcher.filter_by_name args[:name]
169
206
  searcher.filter_by_versions args[:min_version], args[:max_version], args[:version]
170
207
  searcher.filter_by_api args[:api]
171
208
 
172
- @logger.info "Found #{searcher.services.inspect}"
209
+ Servicy.logger.info "Found #{searcher.services.length} providers for #{args.inspect}"
173
210
  searcher.services
174
211
  end
175
212
 
data/lib/server.rb CHANGED
@@ -3,6 +3,5 @@ require 'json'
3
3
  require 'transports'
4
4
  require 'transport/in_memory_transport'
5
5
  require 'load_balancer'
6
- require 'logger'
7
6
  require 'server/service_searcher'
8
7
  require 'server/server'
data/lib/service.rb CHANGED
@@ -4,7 +4,7 @@ module Servicy
4
4
  class Service
5
5
  include Comparable
6
6
  attr_reader :name, :version, :host, :port, :heartbeat_port, :protocol,
7
- :api, :heartbeat_check_rate, :latencies, :heartbeats
7
+ :api, :heartbeat_check_rate, :latencies, :heartbeats, :no_heartbeat
8
8
  attr_accessor :heartbeat_last_check
9
9
 
10
10
  NAME_REGEX = /[^\.]+\.([^\.]+\.?)+/
@@ -50,9 +50,14 @@ module Servicy
50
50
  @heartbeat_port = args[:heartbeat_port]
51
51
  @api = args[:api] || {}
52
52
  @heartbeat_check_rate = args[:heartbeat_check_rate] || 1
53
+ @transport = transport_from_class_or_string(args[:protocol] || Servicy.config.client.transport)
54
+ if @transport
55
+ args[:protocol] = @transport.protocol_string # This is to make sure that it loads correctly after saving to disk.
56
+ end
53
57
  @heartbeat_last_check = 0
54
58
  @latencies = []
55
59
  @heartbeats = []
60
+ @no_heartbeat = !!args[:no_heartbeat]
56
61
  end
57
62
 
58
63
  # Get a configuration value
@@ -99,12 +104,13 @@ module Servicy
99
104
  def self.version_as_number(version)
100
105
  parts = version.split('.')
101
106
  parts.last.gsub(/\D/, '')
102
- parts[0].to_i * 1000 + parts[1].to_i * 100 + parts[2].to_i * 10 + (parts[3] || 0)
107
+ parts[0].to_i * 1000 + parts[1].to_i * 100 + parts[2].to_i * 10 + (1 / (parts[3] || 1).to_f)
103
108
  end
104
109
 
105
110
  # Check weather or not a service is up, based on connecting to the hearbeat
106
111
  # port, and reading a single byte.
107
112
  def up?
113
+ return true if skip_heartbeat?
108
114
  t1 = Time.now
109
115
  s = TCPSocket.new(host, heartbeat_port)
110
116
  Timeout.timeout(5) do
@@ -161,11 +167,37 @@ module Servicy
161
167
  api[method_type] && api[method_type].select { |a| a[:name] == method }.first
162
168
  end
163
169
 
170
+ # Make a call to a remote end-point.
171
+ def remote_call(method, *args)
172
+ # TODO: think about handling errors in a sane way
173
+
174
+ # Encode things for the transports formatter
175
+ args = @transport.format(args)
176
+
177
+ # Make the call
178
+ @transport.remote_request(method, args)
179
+
180
+ # Decode things coming back
181
+ @transport.unformat(result)
182
+ end
183
+
164
184
  private
165
185
 
186
+ def skip_heartbeat?
187
+ !!no_heartbeat
188
+ end
189
+
190
+ def transport_from_class_or_string(transport)
191
+ transport.is_a?(Servicy::Transport) ? transport : transport_from_string(transport)
192
+ end
193
+
194
+ def transport_from_string(transport)
195
+ Servicy::Transport.all.select { |t| t.protocol_string == transport }.first
196
+ end
197
+
166
198
  # These are just to keep us from filling up memory.
167
199
  def record_latency(t1, t2)
168
- @latencies << t2 - t1
200
+ @latencies << t2.to_i - t1.to_i
169
201
  @latencies = @latencies[0...10] if @latencies.length > 10
170
202
  end
171
203
 
data/lib/servicy.rb CHANGED
@@ -1,9 +1,163 @@
1
1
  $: << File.expand_path(File.join(File.dirname(__FILE__)))
2
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
3
  require 'hash'
4
+ require 'formats'
7
5
  require 'server'
8
6
  require 'client'
9
7
  require 'api'
8
+ require 'configurable'
9
+ require 'logger'
10
+
11
+ module Servicy
12
+ # Get a logger instance that we can do something useful with.
13
+ def self.logger
14
+ @logger ||= begin
15
+ stream = config.server.logger_stream
16
+ stream = stream || File.open(File.join('/var', 'log', 'servicy_server.log'), 'a')
17
+ Logger.new(stream)
18
+ end
19
+ end
20
+
21
+ # After this method gets done running, you either have the state of the world
22
+ # exactly the same as it was, or your object is replaced in the global Object
23
+ # scope with a new object that will proxy methods to a remote service
24
+ # provider.
25
+ #
26
+ # To define a service provider is a bit more work, and less auto-magic
27
+ # because the exact details of the infrastructure and architecture of your
28
+ # setup will be very different from others. So Servicy doesn't make
29
+ # assumptions about how you want to do things. That said, it's relatively
30
+ # simple:
31
+ #
32
+ # # In some server class
33
+ # Servicy::Server.new(...).go!
34
+ #
35
+ # # In some service provider, possibly on a different box
36
+ # api = Servicy::Client.create_api_for(MyServiceClass)
37
+ # Servicy::Client.register_service(api)
38
+ # FIXME: @__api, and it's ilk, get overridden in the even that there is more
39
+ # than one.
40
+ def self.included(mod)
41
+ make_service_listener(mod)
42
+
43
+ # Attempt to find the service
44
+ client = Servicy::Client.new(config.server.transport)
45
+ raise 'go to bottom' unless client.connected?
46
+
47
+ # Create a new api object
48
+ @__api = Servicy::Client.create_api_for(mod)
49
+ port = Servicy.config.send(mod.to_s.to_sym).port
50
+ Servicy.logger.debug("Chose port, #{port} for service, #{mod.to_s}")
51
+ @__api.const_set("PORT", port) unless port.nil?
52
+ # TODO: for the other things as well
53
+
54
+ @__service = client.find_service(@__api.search_query)
55
+ raise 'go to bottom' unless @__service
56
+
57
+ # If all went well, then we can wrap the object to call to the remote
58
+ # service.
59
+ client = Servicy::Client.new(config.client.transport.class.new(port: port))
60
+ @__api.set_remote(client)
61
+
62
+ # ... and delegate that ish. We do it by killing off the original object,
63
+ # and replacing it with our own.
64
+ Object.send(:remove_const, mod.name.to_sym)
65
+ Object.const_set(mod.name.to_sym, @__api)
66
+ rescue => e
67
+ # Something went wrong, just give up silently, but don't do anything
68
+ # else; use this object as a regular-ass object. This is to allow you to
69
+ # use your API objects as normal objects in an environment where the
70
+ # object is bundled as part of a gem (,say, ) and there is no remote
71
+ # handler for it: use it locally
72
+ end
73
+
74
+ def self.make_service_listener(mod)
75
+ # If we didn't manage to connect to a remote client, then we are, by
76
+ # definition, the provider. We should start the transports api_server, and
77
+ # dispatch requests here.
78
+ mod.send(:define_singleton_method, :start_servicy_server) do
79
+ begin
80
+ Thread.new do
81
+ begin
82
+ port = Servicy.config.send(self.to_s.to_sym).port || Servicy.config.server.port
83
+ transport = Servicy.config.service.transport
84
+ transport = transport.nil? ? Servicy.config.server.transport : transport
85
+ client = Servicy::Client.new(transport.class.new(port: port))
86
+
87
+ client.transport.start_api do |request|
88
+ method = nil
89
+ begin
90
+ method = self.method(request.method_name.to_sym)
91
+ rescue
92
+ method = self.instance_method(request.method_name.to_sym)
93
+ method = method.bind(self.new)
94
+ end
95
+ things = transport.unformat(request.args)
96
+ args = method.parameters.map do |param|
97
+ things[param.last.to_s]
98
+ end
99
+
100
+ Servicy.logger.info "Calling #{method} with #{args.inspect}"
101
+ begin
102
+ result = method.call(*args)
103
+ Servicy::Transport::Message.api_response(result)
104
+ rescue => e
105
+ Servicy::Transport::Message.error(e.to_s + "\n" + e.backtrace.join("\n"))
106
+ end
107
+ end
108
+
109
+ client.transport.stop
110
+ rescue => e
111
+ Servicy::Transport::Message.error(e.to_s + "\n" + e.backtrace.join("\n"))
112
+ end
113
+ end
114
+
115
+ Servicy.logger.info "Creating #{self.to_s} api"
116
+ api = Servicy::Client.create_api_for(self)
117
+ port = Servicy.config.send(mod.to_s.to_sym).port
118
+ port = port.nil? ? Servicy.config.server.port : port
119
+ api.const_set 'PORT', port
120
+ # TODO: the other things, too
121
+ Servicy.logger.debug("Chose port, #{port} for service, #{mod.to_s}")
122
+
123
+ server_port = Servicy.config.server.port
124
+ transport = Servicy.config.server.transport
125
+ client = Servicy::Client.new(transport.class.new(port: server_port))
126
+ Servicy.logger.info "Registering api, #{api.to_s}"
127
+ client.register_service api
128
+ rescue => e
129
+ Servicy.logger.error "An error occurred with the service: #{e.to_s}\n#{e.backtrace.join("\n")}"
130
+ end
131
+ end
132
+ rescue => e
133
+ # Again, just let it die silently. There is no reason to make things not
134
+ # work locally.
135
+ Servicy.logger.error(e)
136
+ end
137
+
138
+ # Use this method to configure Servicy for other services
139
+ def self.configure(&block)
140
+ conf = Configurable.new
141
+
142
+ # Give some reasonable defaults
143
+ conf.server.host = 'localhost'
144
+ conf.server.transport = Servicy::Transport.InMemory.new
145
+ conf.server.load_balancer = Servicy::RoundRobinLoadBalancer.new
146
+ conf.server.logger_stream = File.open(File.join('/var', 'log', 'servicy_server.log'), 'a')
147
+ conf.server.config_file = File.expand_path("~/.servicy.conf")
148
+ # To set something for a given object, you would do something like:
149
+ # conf.transport.port = 1234
150
+
151
+ conf.client.transport = Servicy::Transport.InMemory.new
152
+ conf.transport.format = Servicy::Formats.JSON
153
+
154
+ conf = yield conf if block_given?
155
+
156
+ # Set the options
157
+ @_config = conf
158
+ end
159
+
160
+ def self.config
161
+ @_config ||= configure
162
+ end
163
+ end
@@ -33,7 +33,7 @@ module Servicy
33
33
 
34
34
  def socket_path
35
35
  @socket_path ||= begin
36
- file = Tempfile.new(@config[:path] || 'servicy-in-memory-transport')
36
+ file = Tempfile.new(@config[:path] || Servicy.config.transport.path || 'servicy-in-memory-transport')
37
37
  file.close
38
38
  path = file.path
39
39
  file.delete
@@ -15,7 +15,8 @@ module Servicy
15
15
  if stuff.is_a?(Hash)
16
16
  @struct = stuff
17
17
  else
18
- @struct = JSON.parse(stuff)
18
+ # TODO: should this be a formatter that I can swap out?
19
+ @struct = JSON.parse(stuff || stuff.to_s)
19
20
  end
20
21
  end
21
22
 
@@ -117,6 +118,25 @@ module Servicy
117
118
  })
118
119
  end
119
120
 
121
+ # Make a remote API call.
122
+ # @param [String] method The method to call on the remote implementor.
123
+ # @param [Hash{String => Object}] args The arguments to pass to the method.
124
+ # These are expected to be pre-formatted by an appropriate Formatter
125
+ def self.api(method, args)
126
+ new({
127
+ message: 'api',
128
+ method_name: method,
129
+ args: args
130
+ })
131
+ end
132
+
133
+ def self.api_response(result)
134
+ new({
135
+ message: 'api_response',
136
+ result: result
137
+ })
138
+ end
139
+
120
140
  # Allow comparing of messages. This doesn't make for useful sorting, at
121
141
  # the moment, but does make it easy to know if two things are saying the
122
142
  # same thing.
@@ -0,0 +1,22 @@
1
+ require 'transports'
2
+
3
+ # A transport which does nothing
4
+ module Servicy
5
+ # This is a simple transport implementation that is to be used primarily for
6
+ # testing, so that I don't have to have a bunch of servers running just to
7
+ # run rspec...
8
+ class NilTransport < Servicy::Transport
9
+ def send(message)
10
+ nil
11
+ end
12
+
13
+ def start
14
+ while true
15
+ end
16
+ end
17
+
18
+ def stop
19
+ nil
20
+ end
21
+ end
22
+ end
@@ -7,44 +7,65 @@ module Servicy
7
7
  # file" business works out well...
8
8
  class TCPTransport < Servicy::Transport
9
9
  HELLO = "Servicy says HI!"
10
+
10
11
  def send(message)
12
+ Servicy.logger.debug("Connecting")
11
13
  socket = TCPSocket.new host, port
12
14
  hello = socket.gets
13
15
  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)
16
+ Servicy.logger.debug("Sending #{message.body}")
17
+ socket.puts message.body
18
+ response = socket.gets
19
+ Servicy.logger.debug("Received #{response}")
20
+ message = Message.new(response)
19
21
  socket.close
20
22
  message
21
23
  end
22
24
 
23
25
  def start(&block)
24
26
  @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
27
+
28
+ loop do
29
+ Thread.start(@server.accept) do |client|
30
+ client.puts HELLO
31
+ message = Message.new client.gets
32
+ Servicy.logger.debug("Got #{message.body}")
33
+ result = block.call(message)
34
+ Servicy.logger.debug("Replying #{result.body}")
35
+ client.puts result.body
36
+ client.close
37
+ Servicy.logger.debug("Closed connection")
32
38
  end
33
39
  end
34
40
  end
41
+ alias_method :start_api, :start
35
42
 
36
43
  def stop
37
44
  @server && @server.close
38
45
  end
39
46
 
47
+ # This makes a request as an actual API consumer over the wire, and returns
48
+ # the results.
49
+ def remote_request(name, args)
50
+ args = format(args)
51
+ response = send(Message.api(name, args))
52
+ if response.struct['error']
53
+ raise response.error
54
+ else
55
+ results = unformat(response.result)
56
+ end
57
+ end
58
+
40
59
  private
41
60
 
42
61
  def host
43
- @config[:host]
62
+ host = Servicy.config.client.host
63
+ host = host.nil? ? 'localhost' : host
64
+ @config[:host] || host
44
65
  end
45
66
 
46
67
  def port
47
- @config[:port]
68
+ @config[:port] || Servicy.config.transport.port
48
69
  end
49
70
  end
50
71
  end
data/lib/transports.rb CHANGED
@@ -16,7 +16,7 @@ module Servicy
16
16
 
17
17
  # Override this method to send a message from {Client} to {Server}
18
18
  def send(messge)
19
- raise 'Not implemented'
19
+ raise NotImplementedError.new
20
20
  end
21
21
 
22
22
  # Override this method to yield a message when received on the {Server}. It
@@ -24,7 +24,12 @@ module Servicy
24
24
  # {Transport::Message} object, and send back the return value of the block
25
25
  # to the client.
26
26
  def start(&block)
27
- raise 'Not implemented'
27
+ raise NotImplementedError.new
28
+ end
29
+
30
+ # Same as start, but used in creation of API handlers.
31
+ def start_api(&block)
32
+ raise NotImplementedError.new
28
33
  end
29
34
 
30
35
  # Called when a transport is stopped
@@ -44,6 +49,39 @@ module Servicy
44
49
  rescue
45
50
  super
46
51
  end
52
+
53
+ def self.all
54
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
55
+ end
56
+
57
+ # Returns a printable string representing what protocol this would be used
58
+ # as. Override if you like.
59
+ def self.protocol_string
60
+ self.name.split("Transport").first
61
+ end
62
+
63
+ # @param [Hash] values Key-value pairs for things to encode.
64
+ def format(values)
65
+ formatter.format(values);
66
+ end
67
+
68
+ def unformat(values)
69
+ formatter.unformat(values)
70
+ end
71
+
72
+ # Method called to make a remote request of an API call. To be implemented
73
+ # per transport.
74
+ # @return [Object] the raw values coming back from the request, to be
75
+ # passed to the formatter for unformatting.
76
+ def remote_request(name, args)
77
+ raise NotImplementedError.new
78
+ end
79
+
80
+ private
81
+
82
+ def formatter
83
+ @formatter ||= @config['format'] || Servicy.config.transport.format
84
+ end
47
85
  end
48
86
  end
49
87
 
data/test.rb CHANGED
@@ -1,74 +1,100 @@
1
1
  require './lib/servicy'
2
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
3
+ Servicy.configure do |config|
4
+ config.server.transport = Servicy::Transport.TCP.new(port: 1234)
5
+ config
22
6
  end
23
7
 
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)
8
+ server = Servicy::Server.new
9
+ Thread.new { server.go! }
33
10
 
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
11
+ class Foobar
42
12
  end
13
+ api = Servicy::Client.create_api_for(Foobar)
43
14
 
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
15
+ server.register(api.search_query)
61
16
 
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)
17
+ class Foobar
18
+ # @param [String] a
19
+ def foo(a)
20
+ puts "If you see this, something went wrong"
21
+ end
67
22
 
68
- # Maybe it would be better to do it with Transport objects...
69
- t = Transport.tcp.new(1235)
70
- api.start_server t
23
+ include Servicy
24
+ end
71
25
 
72
- # Combining registration and server starting
73
- api.register_with('127.0.0.1:1234', Transport.TCP.new(1235))
26
+ f = Foobar.new
27
+ p f
74
28
 
29
+ # class Foo
30
+ # def initialize(thing)
31
+ # @thing = thing
32
+ # end
33
+ #
34
+ # # Do some foobar work
35
+ # # @param [String] a
36
+ # # @param [Integer] b
37
+ # def foobar(a,b)
38
+ # p a
39
+ # p b
40
+ # end
41
+ #
42
+ # # Do something on the class
43
+ # # @return [Hash{Integer => Array<String>}]
44
+ # def self.barbaz
45
+ # puts "OH MY GOD!"
46
+ # { 1 => ['foo', 'bar'] }
47
+ # end
48
+ # end
49
+ #
50
+ # # Create a new class that wraps foo. It will be called FooAPI, and will behave
51
+ # # just like foo, but with contract checking. It will be the place that in the
52
+ # # future we extend to behave more like an API
53
+ # api = Servicy::Client.create_api_for Foo
54
+ # # p api
55
+ #
56
+ # # Instance-y-stuff
57
+ # foo = api.new(:something)
58
+ # foo.foobar('string',1)
59
+ #
60
+ # # Class-y stuff
61
+ # api.barbaz
62
+ #
63
+ # # Now we should get some explosions for failing to comply with the contracts
64
+ # begin
65
+ # foo.foobar(:not_a_string, 'or an integer')
66
+ # rescue => e
67
+ # puts e
68
+ # end
69
+ #
70
+ # ###############################################################################
71
+ # # Making a working api with an api object. This is just some ideas to outline
72
+ # # how I would like the interface to look, not really any working code just yet.
73
+ # #
74
+ #
75
+ # # Finding a registered service and using a remote service instead of a local
76
+ # # object. This would take the API object, gather information about it, make the
77
+ # # query to the service discovery portal, and return a new api model (that is
78
+ # # the same specifications as the original) that talks with the remote service
79
+ # # instead of the local object, raising an error if something goes wrong.
80
+ # transport = Servicy::Transport.TCP.new(port: 1234)
81
+ # api = Servicy::Client.find_service_provider_for(api, transport)
82
+ #
83
+ # # This should also be able to work with non-API objects given to it. In the
84
+ # # case of a library that you don't have installed locally.
85
+ # service = Servicy::Client.find_service(name: 'foobar') # This alredy exists
86
+ # api = service.api
87
+ #
88
+ # # Starting a server with a given client/api object. Ideally, you can start
89
+ # # multipl servers.
90
+ # api.start_server('HTTP', 80)
91
+ # api.start_server('HTTPS', 443)
92
+ # api.start_server('TCP', 1235)
93
+ #
94
+ # # Maybe it would be better to do it with Transport objects...
95
+ # t = Transport.tcp.new(1235)
96
+ # api.start_server t
97
+ #
98
+ # # Combining registration and server starting
99
+ # api.register_with('127.0.0.1:1234', Transport.TCP.new(1235))
100
+ #
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: servicy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Luce
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-08-11 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2014-08-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: contraction
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  description: A service registration and discovery framework, as a server and client
14
28
  library.
15
29
  email: thomas.luce@gmail.com
@@ -25,6 +39,9 @@ files:
25
39
  - bin/servicy
26
40
  - lib/api.rb
27
41
  - lib/client.rb
42
+ - lib/configurable.rb
43
+ - lib/formats.rb
44
+ - lib/formats/json.rb
28
45
  - lib/hash.rb
29
46
  - lib/load_balancer.rb
30
47
  - lib/load_balancer/random.rb
@@ -36,6 +53,7 @@ files:
36
53
  - lib/servicy.rb
37
54
  - lib/transport/in_memory_transport.rb
38
55
  - lib/transport/messages.rb
56
+ - lib/transport/null_transport.rb
39
57
  - lib/transport/tcp_transport.rb
40
58
  - lib/transports.rb
41
59
  - test.rb