Connecting the components of a distributed systems is no easy feat. Should I use REST? An RPC system like Apache Thrift? Protocol Buffers? SOAP? How do I document these components’ APIs? What’s the best way to write client bindings? The Internet offers a wide array of possible solutions, all of which I’ve found to be either incompatible with my needs (to, at minimum, work in a web browser) or heavy (XML sucks).
The prevalence of these remote APIs in your system presents some challenges: What’s the best way to document these APIs such that programmers can consume their interface without knowing precisely how they are implemented? How do we ensure that this documentation stays synchronized with its backing implementation? (If you’re not convinced that you need to write any API documentation, read this.)
With Barrister – like other RPC frameworks such as Apache Thrift or SOAP – you write “living documentation” in the form of an IDL – or interface definition language – file. In this file, the programmer describes (using a Barrister-specific grammar) the methods of the component interface, their input parameters, and corresponding return values. The documentation is “living” because – at runtime – the Barrister library will use it to enforce the format API requests and responses.
For example, the following IDL file describes the interface of a component in a fictionalized distributed system:
In this IDL file, we have a single component interface:
TodoManager has a few methods on it that will enable an API client to do all the todo-related things it needs to do: reading, creating, updating, and deleting. We’ve defined what each method expects to receive (an
TodoProperty) and return (a boolean, a
Todo, or an array of
Todos). API calls and responses are encoded as JSON-RPC 2.0 messages, so you can think of
strings as their JSON equivalents, with structs representing more complex, user-defined objects.
Did you notice that the interface definition contains no mention of transport? This is an important characteristic of the Barrister RPC system, and allows how the request got to the API to be decoupled from what the message is actually asking of the API server. Transport is thought of as an implementation detail instead of being inherent to the design of the API. You can transmit the message using HTTP, SMTP, or using whichever application-level protocol you wish. Alternatively, you can enqueue the request in a centralized message broker (such as Redis or RabbitMQ) to be dequeued later by the API server – the possibilities are endless, and afford the developer a great deal of flexibility (over say, than you’ll get with REST). (Click here for more information on how JSON-RPC frameworks enable flexible distributed system architectures.)
When you’re satisfied with your IDL file, you compile it to the JSON that the Barrister server library knows how to consume. You can use the hosted compiler:
…or install one locally:
After compiling to JSON, you’re ready to implement your
TodoManager. For an examples of what this could look like, I’ve built a few reference implementations:
Other demos: Golang, Node.js, Ruby
In each of these examples, the Barrister library validates that inbound API requests and outbound API responses conform to the IDL. If the server returns data that doesn’t match the IDL, you’ll see an error. Similarly, if the message originating from your API client is malformed – you send the
deleteTodo method a string instead of an int – you’ll see an error:
All of this ensures that API documentation and implementation are always modified in lockstep; you can look in one place (the published IDL file) to determine how to consume a component’s API. In the unlikely event that your application code (client or server) gets out of sync with the IDL, Barrister will fail fast with an intelligible error message.
For dynamic languages, Barrister provides bindings that allow your client to automatically build an API proxy (at runtime) after issuing an initial RPC request to your server for its IDL. This is superior to other solutions (e.g. Apache Thrift or Protocol Buffers) which require a client-side compile step (which developers oftentimes forget) or from writing your own REST client (which developers need to build and maintain… for every target language). After loading the server’s IDL, the client can enforce that outbound RPC requests are well-formed. This doesn’t save you from needing to update the application code that consumes the API client itself, but it substantially reduces the amount of time spent debugging problems that arise when the API changes unexpectedly.
For demonstration purposes, I’ve written some code to consume the TodoManager API through a Barrister client:
Note that it’s possible to communicating to an API running Barrister RPC without using a Barrister RPC client; messages are simply encoded as JSON-RPC 2.0 requests and responses – so you could roll your own client, if desired.
Code generators are available for creating clients in statically-typed languages which can provide type safety at compile time. For additional information about Barrister support for static languages, check out the Go and Java bindings.
Oftentimes – especially when communicating between a mobile client and server – transporting data to and from an API can be expensive. Combining API requests/responses can help reduce this cost substantially. Using the Barrister client’s batch API, you can easily condense multiple API request-response cycles into one – without requiring modifications to the API server.
To demonstrate, we’ll batch 4 calls to “createTodo” to our TodoManager:
In addition to the JSON-RPC 2.0 error codes (resulting from invalid method calls, issues parsing requests and responses, etc.), Barrister provides a mechanism by which you can specify your own, application-level errors. Here, the server has been modified to return custom errors when:
These codes (and corresponding messages) allow you to communicate errors to API clients at the level of granularity appropriate for your application. This affords you a level of expressiveness not possible with, say, REST (which would force you to map your system’s errors to a few HTTP status codes). The client can branch on these error codes and take appropriate action (show an error message, raise an error, etc.).
APIs can be versioned by routing inbound RPC requests to to Barrister servers validating against different IDLs, with a an adapter used to transform legacy request/response messages to your newer format. In the following example, we’ve introduced a second version of our API in which the deleteTodo method accepts an id instead of the full todo:
To provide backwards compatibility, you’ll simply add a new “v2” route to the web application that wraps your TodoManager. All inbound requests are passed through a new adapter, whose roll it is to transmute from one API request format to the other:
Binding different version of the API to different HTTP routes enables us to validate RPC requests and responses against the correct IDL file. This same pattern could be adapted for use with a Redis list or RabbitMQ queue; simply include the API version in the list name and you’re off to the races.
Barrister RPC requests are sent as self-contained JSON-RPC 2.0 encoded messages. Since the message contains everything the server needs to respond to a method call (the name of the remote interface, the method name, and parameters), the choice of transport can be seen as an implementation detail. This is different than requests sent to a RESTful interface, where the server’s action is informed by properties of the HTTP request itself (HTTP method, query string parameters, or headers).
It is my experience that the flexibility of RPC enables developers to take advantage of a far greater number of platforms and deployment configurations than if they were to have built a system of RESTful APIs. It is occasionally the case that pieces of your system cannot communicate with each other via HTTP – for example, if deploying system components as instances of Heroku dynos. Using RPC, the developer can send messages between dynos using a centralized Redis database. This would simply not be possible with HTTP, given the constraints of Heroku.
Sending messages between system components with HTTP is also slow relative to say, enqueuing dequeuing messages from a centralized Redis database. It is my opinion that you should not use HTTP for communicating between internal components of a distributed system, if your platform allows for it. (For more information about HTTP versus Redis for RPC, click here.)
Over the last few years, Barrister RPC has played a significant role in the development of my distributed systems – both large, and small. From simple apps built with Backbone and Rails complex Python microservice deploys, Barrister’s transport-agnostic, type safe, fail-fast approach to server/client communication has substantially improved my ability to link the pieces of my systems together – without fear of breaking things in the process. Suspend your fear of acronyms like IDL and RPC and give Barrister a try – it’s not all banks and XML out there!
Some additional reading on microservices, RPC, and distributed systems that I’ve found interesting / helpful:
Follow me on Twitter: @lasericus
Send me an email: email@example.com