Using Controller Actions as Rack Applications
Rails is a remarkable framework to get your web application up and running quickly. Many startups have used it successfully to launch their site. With that said, one can also fall into the software-monolith trap if you're not careful. Numerous concerns can become entangled within a Rails project repository. Once this scenario is identified, refactoring becomes a focal point for a sprint or two but often loses momentum as other business priorities come into play.
This article examines a simple technique you can use to extend Rails applications and move towards a service-oriented architectural style. Using Rails, you can host microservices with minimal effort based on your existing codebase. This can result in both a quick win for the business and a foundation for continued refactoring of your overall app.
As the old adage says, “Rome wasn’t built in a day.” The goal is to make your app a little-less-monolith every sprint.
There are advantages to refactoring when you are already working in a section of code. It allows you to deliver value along the way, which in turn inspires continued investments in more refactoring. There is less context-switching involved and overarching economies of scale can be leveraged.
Extending the Ruby Quiz Application
Ruby Quiz is a simple Rails application that asks the user five quiz questions. It provides feedback after each question and shows the user their total score at the end. Questions are stored in a database table of the same name, and the results are recorded in an attempts table. The web page is shown below.
Consider that management wants to look at metrics from users taking the quizzes. Are the quizzes too hard or too easy? What is the average score? Do most users complete the entire quiz?
The desired architecture is to add a reporting database and a new web front-end to enable managers to perform this analysis. Rails can be used to quickly launch a new reporting web application, however standing up a reporting database is more work than will fit into a single sprint.
Thus, the decision is made to add new reporting services to expose the data. A service-based approach prevents the new web app from having to read from the same database, thus avoiding any database contention. Our target architecture looks like this.
Microservices in Minutes
We want to quickly add REST services that query the database and return data from the attempts table. This table has the results of each participant who took the quiz. The new reporting services ideally will use the same Attempt model class that our current quiz application uses.
We already have a QuizController with related logic and code, but we want a separate endpoint for our service. Putting the services behind a new DNS entry gives us flexibility going forward. It positions us for the future as we build out the larger reporting capability.
Here is where Rails can help out. Each controller action method is in itself a logical service endpoint. This happens normally when we add entries to the routes.rb file, and corresponding requests are sent to the designated controller.
However, we can take this a step farther. Controller action methods are essentially their own Rack applications that respond to HTTP requests. Rack is the middleware that sits between Rails and the web server. We can use the rackup file to start processes that wire the endpoint directly to a controller method we write. Using this approach allows the new microservices to leverage existing model classes as well as CI/CD pipelines.
This may not be our long-term approach, but it's a step in the right direction. Should this microservice run in a different container or application? Should the code move to its own repository? Maybe, but you can defer those decisions until you gather more data. Continue to build out the rest of your target architecture and keep an eye on your service metrics.
Let’s begin by adding a microservice that returns a list of participants who took the quiz. To keep it simple, no parameters are required at first. The service just returns a list of the user names. A new participants method is added to the QuizController as shown here.
class QuizController < ApplicationController
# Other controller methods omitted here
# This is our new service method
def participants
response = {}
response["participants"] = Attempt.all.map { |attempt| attempt.taker }
render :json => JSON[response]
end
end
Note that we don’t even add a route for this method. Why is that? Because we wire the domain and port binding directly to the controller action. The rackup file is where the magic happens. Unless specified, the Puma web server looks for the config.ru
file. The line of significance in the default version of this file runs the entire Rails application.
run Rails.application
For the new microservice, a separate rackup file config_svc_participant.ru
is created. The existing config.ru
file is retained for use with the web app. The new rackup file tells Rack to run an application based on the participant method of the controller.
# This file is used by Rack-based servers to start the application.
require_relative 'config/environment'
run QuizController.action(:participants)
The following command specifies this rackup file and runs the service on port 8080 to avoid conflicting with the default web app port 3000.
bundle exec puma -b tcp://0.0.0.0:8080 config_svc_participant.ru
For testing, assume we have Darren, Alice, and Bob take the quiz. Using Postman to invoke the API, we get the following result showing our three participants.
LIkewise, we can add a summary microservice that returns the average and high scores. The code for this is as follows.
def summary
sum_of_correct_answers = 0
high_score = 0
attempts = Attempt.all
attempts.each do |attempt|
# Maintain the sum so we can calculate the average later
sum_of_correct_answers = sum_of_correct_answers + attempt.number_correct
# Check if this is the new high score
if attempt.number_correct > high_score
high_score = attempt.number_correct
end
end
response = {}
response["number_of_participants"] = attempts.size
response["average_score"] = (sum_of_correct_answers.to_f / attempts.size.to_f).round(2)
response["high_score"] = high_score
render :json => JSON[response]
end
We create a rackup file for this microservice as well and run it on port 8081. If the scores for the three participants were 3, 5, and 3, the service returns the following data.
{
"number_of_participants": 3,
"average_score": 3.67,
"high_score": 5
}
Hope you found this useful and let us know what micro-applications you are able to build on Rails.
Leveraging Containers for Scalability
There are a number of great reasons to use containers as a deployment mechanism. For the purpose of this topic, scalability is a primary factor. We just added a few services that will be served by the same application. Logically, they appear as distinct microservices but we used the same application to get them up and running quickly.
In many cases, your web application and your services will have different scaling needs. The usage patterns and resource utilization will be different. Containers provide a great way to flexibly scale for these various use cases.
The different commands we used earlier to run the application for each service become the CMD statement of your Dockerfile. Only the domain/port binding and rackup file need to change for each service you deploy
I hope you find this pattern useful and look forward to seeing the great services that you build!
Related topics:
Software Architecture, Ruby on Rails, developer productivity
Originally appeared on Engineyard.com
Top comments (1)
How do you authenticate the microservice ?