Ruby SOA: Less Pain and More Automation, Please!
A Service-Oriented Architecture can be an enormous pain to get right. You can read about Rails folks cutting apart monolithic apps into services… Painfully.
I believe there’s a different approach that can help a lot, and that most modern frameworks will soon be using it. I built a framework that does it for OnLive, and I wish I could say it was an all-original idea. The Rails guys are clearly headed the same direction… Learn it now, avoid the rush!
The gist: you can combine multiple services into a single process in Ruby, while making calls between them seamlessly switch between local and remote. It’s not hard to do now, and it will become really, really easy when your framework supports it.
I already built a framework that supports it: the code is below!
How Does This Work, Now?
We started from Sinatra. The idea was that we could declare routes and the call protocol at the same time. For instance:
require "olaf" require "olaf/service" require "olaf/http" class MyService < Olaf::Service route_name :hello_world get "/helloworld" do |params| # Implement here, just like vanilla Sinatra end end
This lets you implement an incoming “hello, world” request in Sinatra… And also to generate a call to it with the name “hello_world”. You can automatically generate a client and make the call trivially:
The fun comes when you realize that you can run the service locally or remote… And the client will call locally for local, or as HTTP for remote.
You can divide your app up into services, but start and stop them all together in one process. You can run single-threaded without waiting for HTTP responses to come back — so it works much better than if you just build a bunch of Rack apps and tied them together with a single config.ru file.
It’s just a method call locally, but it’s a full remote HTTP call when you run as multiple processes. You do have to declare where it’s running if it’s remote, of course.
Better yet, you can specify a lot more about each call than just a name. Here’s an excerpt from one of our Olaf-based services:
module OLApplication class ApplicationService < Olaf::Service service_name "Application" default_content_type :json # Swagger path prefix descriptions api_path "/", :desc => "Operations about Applications" api_path "/:application_id", :desc => "Operations about Specific Applications" # Here's all the specifics about the next route route_name :list_applications desc "Get a list of applications" param :name, String, "Application Name" param :limit, Fixnum, "Number of applications per page returned" errors 400 => "Invalid request parameters", 503 => "Internal server problem" return_type [ DomainObjects::Application ] # And here's the route itself -- just standard Sinatra get "/" do apps = DomainObjects::Application.list_applications(params) return_value apps end # More routes go here... end end
The call can verify parameters, even locally. And errors can be converted to error objects or to raise exceptions, at your option. Just use a bang method when you want an exception on error.
Does It Work Well?
We used this exact code for a large project at OnLive. The project is dead now — a lot of prototype projects just tell you that you’re going the wrong direction — you should abandon them when that becomes clear. Fail quickly and cheaply.
Olaf worked pretty well. I’ll definitely do the same thing again with a few tweaks.
We used a dual testing setup — running all nine (!) services together locally in a few tests, and running as nine separate Sinatra services in others. That makes sure nobody breaks the nice monolithic/SOA duality you have going, in either direction. I’ll definitely have the dual testing setup again next time.
Of course, every prototype has flaws. Deployment can be harder with nine services, and you have to figure out — will we deploy monolithically? Entirely separate? With four sevices in one process and five in another? That can complicate process management (e.g. God, Monit, Bluepill, Daemontools) and other aspects of deployment (e.g. Capistrano, Chef).
SOA usually means harder process management and harder deployment, and Olaf doesn’t do much to make it easy. It’s possible that we can do that better… But our first attempt didn’t.
One of the cool things about declaring a full interface right in the Sinatra file is that you can generate other bindings, not just Ruby service calls. In fact, you can think of it as combining a callout interface (like Swig, Swagger, etc.) directly with your Sinata file. You can actually generate all sorts of clients — if you wanted a Java wrapper for your HTTP client, for instance, everything you need for that is already built right in.
We used it to attach our servers to Swagger, which was great for testing. We didn’t fully automate generating Swagger, but we got mostly there — and the remaining bits aren’t hard.
We also got to unify a lot of our models with our controller code. The model knows how to render itself in JSON, and the controller can validate against the models. This is going to be a complicated area to get right, but we got a nice start on it. You can roll your API contract right into your models, and have them verified at the service call site.
The Code… And Where To Go Next
You may have seen a link or two to our GitHub repo with this code — feel free to look through. You can adapt all of this to plain-vanilla Sinatra pretty easily. You could especially focus on declaring the service’s requests and the Ruby service client, both local and HTTP.
One thing — don’t use Olaf directly. Olaf is a proof of concept. I think it’s a good one, but you shouldn’t use it verbatim. Instead, adapt the idea of the ServiceClient to your own favorite framework, whether that’s Sinatra, Rails or something else. Our implementation is under MIT License — steal liberally.
I’ll be doing the same thing in my next big Ruby service framework — taking the parts of Olaf that work well, and leaving the rest.
And perhaps I’ll solve those deployment problems… I have some fun ideas!
I hope you do too.