require 'timeout' module Servicy class Service include Comparable attr_reader :name, :version, :host, :port, :heartbeat_port, :protocol, :api, :heartbeat_check_rate, :latencies, :heartbeats, :no_heartbeat attr_accessor :heartbeat_last_check NAME_REGEX = /[^\.]+\.([^\.]+\.?)+/ VERSION_REGEX = /\d+\.\d+\.\d+(-p?\d+)?/ # Create a new service. # (see Servicy::Server#register) def initialize(args={}) args = args.inject({}) { |h,(k,v)| h[k.to_sym] = v; h } raise ArgumentError.new("Must provide a service name") unless args[:name] raise ArgumentError.new("Must provide a service host") unless args[:host] raise ArgumentError.new("Must provide a service port") unless args[:port] unless args[:name] =~ NAME_REGEX raise ArgumentError.new("Service name must be in inverse-domain format") end args[:version] ||= '1.0.0' unless args[:version] =~ VERSION_REGEX raise ArgumentError.new("Service version must be in formation M.m.r(-p)?") end if !args[:port].is_a?(Fixnum) || args[:port] < 1 || args[:port] > 65535 raise ArgumentError.new("Service port must be an integer betwee 1-65535") end args[:heartbeat_port] ||= args[:port] if !args[:heartbeat_port].is_a?(Fixnum) || args[:heartbeat_port] < 1 || args[:heartbeat_port] > 65535 raise ArgumentError.new("Service heartbeat port must be an integer betwee 1-65535") end if args[:api] raise ArgumentError.new("Service api not correctly defiend") unless check_api(args[:api]) end args[:protocol] ||= 'HTTP/S' @name = args[:name] @version = args[:version] @host = args[:host] @protocol = args[:protocol] @port = args[:port] @heartbeat_port = args[:heartbeat_port] @api = args[:api] || {} @heartbeat_check_rate = args[:heartbeat_check_rate] || 1 @transport = transport_from_class_or_string(args[:protocol] || Servicy.config.client.transport) if @transport args[:protocol] = @transport.protocol_string # This is to make sure that it loads correctly after saving to disk. end @heartbeat_last_check = 0 @latencies = [] @heartbeats = [] @no_heartbeat = !!args[:no_heartbeat] end # Get a configuration value # This method exists mostly so that I wouldn't have to change a bunch of # early tests, and because I know some people like to access things like a # hash. # @param[Symbol] thing The config value to get. # @return [Object,nil] The value if found, nil otherwise. def [](thing) return self.send(thing) if self.respond_to?(thing) nil end # Build a hash of the configuration values for this object. # Used to dump json configurations. # @return [Hash] Configuration data for the Service def as_json { name: name, version: version, host: host, protocol: protocol, port: port, heartbeat_port: heartbeat_port, api: api } end # Used by the Comparable mixin for comparing two services. # (see Comparable) def <=>(other) self.as_json <=> other.as_json end # Returns the version of the current service as a number that we can use # for comparisons to other version numbers. # @return [Integer] A number representing the version def version_as_number self.class.version_as_number(version) end # (see #version_as_number) # @param [String] A version string as per {Servicy::Server#register} def self.version_as_number(version) parts = version.split('.') parts.last.gsub(/\D/, '') parts[0].to_i * 1000 + parts[1].to_i * 100 + parts[2].to_i * 10 + (1 / (parts[3] || 1).to_f) end # Check weather or not a service is up, based on connecting to the hearbeat # port, and reading a single byte. def up? return true if skip_heartbeat? t1 = Time.now s = TCPSocket.new(host, heartbeat_port) Timeout.timeout(5) do s.recvfrom(1) end record_heartbeat(1) return true rescue record_heartbeat(0) return false ensure s.close rescue nil t2 = Time.now record_latency(t1, t2) end # Returns what the avg latency for a service is, based on the timings of # their heartbeat connections. # @return [Float] avg latency is ms def avg_latency latencies.reduce(0.0, &:+) / latencies.length.to_f end # Returns the uptime for a service based on heartbeat checks as a float # between 0 and 1 -- a percentage. # @return [Float] avg uptime as a percentage (0..1) def uptime heartbeats.reduce(0, &:+) / heartbeats.length.to_f end # Get a nice, printable name # return [String] def to_s name + "#" + host end # Returns a hash with the configuration options needed for registration and # remote service operation. def to_h { name: name, version: version, host: host, port: port, heartbeat_port: heartbeat_port, protocol: protocol, api: api } end # Return the api for a given method (instance, or class) or nil if not # found. def api_for(method_type, method) api[method_type] && api[method_type].select { |a| a[:name] == method }.first end # Make a call to a remote end-point. def remote_call(method, *args) # TODO: think about handling errors in a sane way # Encode things for the transports formatter args = @transport.format(args) # Make the call @transport.remote_request(method, args) # Decode things coming back @transport.unformat(result) end private def skip_heartbeat? !!no_heartbeat end def transport_from_class_or_string(transport) transport.is_a?(Servicy::Transport) ? transport : transport_from_string(transport) end def transport_from_string(transport) Servicy::Transport.all.select { |t| t.protocol_string == transport }.first end # These are just to keep us from filling up memory. def record_latency(t1, t2) @latencies << t2.to_i - t1.to_i @latencies = @latencies[0...10] if @latencies.length > 10 end def record_heartbeat(h) @heartbeats << h @heartbeats = @heartbeats[0...10] if @heartbeats.length > 10 end # The api is broken into two kinds of methods; instance and class. Each can # define method name, argument number and types, and return types. def check_api(api_def) raise ArgumentError.new("API can only define instance and class methods") unless api_def.contains_only?(:instance, :class) api_def.each do |(_, methods)| # Each method is itself a hash of name, args, return type, and docs. methods.each do |method| raise ArgumentError.new("Methods can only contain name, args, return, and docs") unless method.contains_only?(:name, :args, :return, :docs) raise ArgumentError.new("Methods must define a name") unless method.include? :name method[:args].each do |arg| # Each argument is an optional name, and optional type, and an # option "required" flag. raise ArgumentError.new("Arguments can only define name, type, and required") unless arg.contains_only?(:name, :type, :required) end return false if method[:return] && !method[:return].contains_only?(:type) end end end end end