Stargate

Codeship Status for jobandtalent/stargate

Stargate

Stargate is a portal between ruby (and in the future not any) apps. It facilitates creating internal micro-services and allows to scale your logic pretty much infinitely.

Motivation & Foreword

Writing micro-services comes with huge overhead and problems on all ends:

  • Services often speak pseudo REST interfaces, too complex and too troublesome.
  • Consumers need client libraries that are either generated boilerplate code or crated libraries that break more often then they actually work.
  • Testing on both ends is complicated and not really accurate. Often you still have to test your client libs too. Sometimes even generators that create your client libraries must be tested.
  • Client libraries add layer of abstraction that's completely unnecessary and introduces confusion. Different services speak through different REST "practices". The HTTP verbs and headers holy war keeps going, no peace.

IMHO simple RPC services are hero solving this problems. They come with different ones, though, JSON-RPC being the best example.

Here are key objectives I took for this project:

  • Simple protocol and exchange format for the start: HTTP + JSON. Minimum effort.
  • Replaceable protocols and exchange formats in the long run.
  • Server versioning out of the box.
  • No REST practices.
  • Client code meta-generated from server definitions. One client for all.
  • Client forced to respect the versioning system.
  • Easy for testing.

Finally, the main objective to call remote methods and obtain remote data just as you would call the method locally. Real key for me was to create a system where it doesn't matter if method or class are executed locally or remotely. Fire, get results, forget. No thinking of what's under the hood. The essence of distributed computing without affecting principles of our programming language.

Installation

Add this line to your application's Gemfile:

gem 'stargate', '0.1.3'

And then execute:

$ bundle

Or install it yourself as:

$ gem install stargate --version 0.1.3

Usage

  1. Define your service:

    module NightsWatch
      class NewBrother
        def self.oath
          "I pledge my life and honor to the Night's Watch, for this night and all the nights to come."
        end
    
        def self.call(name)
          Brother.new(name)
        end
      end
    
      class Brother
        attr_accessor :name
    
        def initialize(name)
          @name = name
        end
    
        def knowledge
          if name == "Jon Snow"
            "nothing"
          else
            "something"
          end
        end
      end
    end
    

Plain ruby object. Class methods are your entry points.

NOTE: Class method should return either basic type (String, Numeric, Float, TrueClass, FalseClass, NilClass), Hash of basic types, object of our class or Array of basics or such objects. That's first restriction - if you ask me I already write compliant code like this for years.

  1. Put it on the wire! Add config.ru like this one:

    require 'stargate/server'
    require 'stargate/server/transport/sinatra'
    
    class NightsWatch
      # *snip*
    end
    
    registry = Stargate::Server::Registry.new do
      version 1 do
        serve NightsWatch::NewBrother do
          class_methods :call, :oath
        end
    
        serve NightsWatch::Brother do
          class_methods :create, :update
          attributes :name
          readers :knowledge
        end
      end
    end
    
    run Stargate::Server::Transport::Sinatra.new(registry)
    

Entry points are our class methods that shall be exposed over the network. They are remotely callable. The accessors are simple all assessor attributes that should be exposed in case of returning object of that class. Finally readers are read only attributes. Final note, the as option of serve block defines exposed name of the class.

NOTE: Here comes second restriction. You can expose only class methods to be callable. Instances are simply treated as data structures. If you expose message, it's result will be cached at the moment of serialization and only result passed over the wire. How do I call instance methods then? You don't.

Q: So how can I call ActiveRecord::Base#save on model object? A: Forget model objects, what you get is eventually a data structure. The structure is remotely stateless. Q: What does it mean "remotely stateless"? A: It means that you can make local changes to the object, but persistence of those changes must go through a service, either local or remote. Persistence methods like #save should be called only at the end of the food chain, in the final service. Q: Show me an example?

  class Book < ActiveRecord::Base
    # *snip*
  end

  class CreateBook
    def self.call(book)
      unless book.valid?
        raise "Uups, the book info is not complete!"
      end

      book.save
    end
  end

Q: What? It looks like Rails controller. Why I can't just call save directly? A: Don't think in the space of operations. Think in processes and define them clearly. Yes it's extra code initially. No it's not a boilerplate. This way you can easily test, mock and track your business processes.

  1. Set up your client code:

    require 'stargate/client'
    
    module NightsWatch
    end
    
    require_remote 'http+json://localhost:9292/v1'
    
    class NightsWatch::Brother
      def knowledge_description
        "#{name} knows #{knowledge}"
      end
    
      def hello
        "Hello, I'm #{name}!"
      end
    end
    
    jon = NightsWatch::NewBrother.call('Jon Snow')
    puts jon.hello
    puts jon.knowledge_description
    puts jon.class.name
    puts '----'
    
    sam = NightsWatch::NewBrother.call('Samwell Tarly')
    puts sam.knowledge_description
    puts '----'
    
    puts NightsWatch::NewBrother.oath
    

As you can see, single client handles everything. Also the way you connect to remote service is pretty much like requiring any other file or library. Client automatically fetches definitions of exposed classes from remote location (this is done once and can be easily cached for the eventuality of service downtime) and injects all registered classes into global namespace. Now injected classes behave pretty much like they'd be defined locally, with the only exception that under the hood they call remote API to execute stuff and obtain results. As you can also see in NightsWatch::Brother proxy class, we can easily extend these classes with plain ruby code.

NOTE: One probably unsupported or at least troublesome thing at the moment is inheritance from the proxy class.

That's it! The services are stateless and can be trivially load balanced or put behind the proxy. The client is one and only and doesn't require you to write more than few lines of code. No custom stuff to learn or define before making use of remote services.

Check examples directory to try these examples out.

Under the hood

The mechanism is uber simple. Expose two endpoints (with whatever protocol you prefer) on the server side without affecting original code. In case of HTTP transport that would be:

GET  /v{version}/definitions.json
POST /v{version}/{klass_name}.{method}

First returns JSON (or however encoded) definitions of exposed classes. Later is an entry point for calling class methods of exposed classes. It takes encoded arguments as POST request body.

That's it for the server!

Now the client at require_remote time fetches definitions and injects configured classes inheriting from Stargate::Client::Proxy. All exposed methods are defined dynamically and they pass through to remote calls.

Development

You have two options to work with this project. The docker flow is suggested since solves problems of compatibility of tools.

Manual Setup

First off, make sure you have Ruby 2.2+ and latest version of Bundler on your machine. After checking out the repo, you can install dependencies and prepare the project with:

$ bin/setup

Now you can run tests:

$ bundle exec rake spec

You can also connect to interactive prompt that will allow you to experiment. To do this, run:

$ bundle exec bin/console

To install this gem onto your local machine, run:

$ bundle exec rake install

Setup with Docker

If you're lazy and don't wanna get into how the setup works, here's something for you. This project comes fully dockerized. Install docker toolchain and then go for:

$ docker-compose build

All done, you can do testing and fiddling around:

$ docker-compose run facebook_integration bash
root@xyyyyxx:/usr/local/src/rake-bump# bundle exec rake spec
root@xyyyyxx:/usr/local/src/rake-bump# bundle exec bin/console

Releasing

TODO: Add link to rake-bump release instructions...

Contributing

Bug reports and pull requests are welcome here.