From Rails to Hanami (Lotus) Part 1: Container Architecture, Models, Views and Assets
Those who cannot change their minds cannot change anything.
― George Bernard Shaw
The problem
A year ago, my company started a big project from scratch. Given the opportunity, we decided to build a Rails API, that would be the system core, centralizing all the business logic, storing data on Postgresql and making calculations with a dozen of Sidekiq workers. And two Rails client applications: one web interface for customers and another for administrators. Both are practically logicless and access all data from the core API.
All three applications was made using Rails 4.2 and a few common gems to handle validation, authentication, AWS, logging, etc. There was deployed on Docker containers managed by Deis, using separated containers for each app and another for workers (Sidekiq).
After 6 months of development, we launched a beta with restricted access and we had an unpleasant surprise: each application are booting up with almost 500MB of RAM and started to grow. We made a benchmark with a very heavy payload, and the API reaches 4GB of RAM, becoming unresponsive :(
Was our first experience deploying Rails on Docker containers. What’s the problem? Memory leak? Outdated gems? Containers orchestration? The answer never shows up. After a lot of debugging and a little use of caching, the memory consumption was reduced a little bit, but still be a large number for such little load scenario.
Another point, besides that resources consumption problem, during development we realized how much work is needed to maintaining the client apps syncronized with the API. Was the double of work, or in our case, the triple. Literally, a pain in the ass!
Rethinking the Strategy
Rails is a very fun framework and has facilited my work for almost 8 years. I’ve been using it on production successfully since version 2.0, which made me to contribute to several open source projects as well give me more time to focus on business problems.
And now the game is changing: containers has made the microservices architecture cheaper and easily available to our industry. And Rails is still focusing on the monolith :(
It’s time to change the way of thinking: look for an alternative that make productive the development, maintenance and deployment of services (or microservices, if you prefer) on containers, and in a way that each container consumes the minimum of resources possible.
The Hanami Way
I wanted to validate if using the Hanami container architecture, we are able to develop the applications as a monolith but deploys each one separately, and of course, its performance.
To do that, I made a simple prove of concept with an application writing data on Postgres database thought a JSON api and another web application reading this data, applying a search and pagination, rendering HTML.
To get real stats, I deployed these applications on our production infrastructure and made a bunch of benchmark tests, and for my surprise, the results was very well: the application responded with 200 requests/second and the memory consumption was lower than 100MB per each process. In this scenario, means that we can handle 5x more load with Hanami than with Rails with the same memory consumption \o/
The performance checklist was validated. The reduced development time was also validated during the POC.
We was ready to put Hanami on proof and decided to migrate first the read-only applications to Hanami and keep all the write operations with the working Rails API.
And here comes the fun :)
Bootstrap the Hanami Container
Consider that my application is called Bookshelf and has three “apps”, being the api, admin and dashboard.
My first goal is to boot a Hanami container mounting each application individually, since in production they will be served in separately endpoins.
My database is Postgresql and my tests are writen in RSpec. So, I created the Hanami and de API app, running:
$ hanami new bookshelf --application_name=api --db=postgresql --arch=container --test=rspec
create .hanamirc
create .env
create .env.development
create .env.test
create Gemfile
create config.ru
create config/environment.rb
create lib/bookshelf.rb
create lib/config/mapping.rb
create public/.gitkeep
create config/initializers/.gitkeep
create lib/bookshelf/entities/.gitkeep
create lib/bookshelf/repositories/.gitkeep
create lib/bookshelf/mailers/.gitkeep
create lib/bookshelf/mailers/templates/.gitkeep
create spec/bookshelf/entities/.gitkeep
create spec/bookshelf/repositories/.gitkeep
create spec/bookshelf/mailers/.gitkeep
create spec/support/.gitkeep
create db/migrations/.gitkeep
create Rakefile
create .rspec
create spec/spec_helper.rb
create spec/features_helper.rb
create spec/support/capybara.rb
create db/schema.sql
create .gitignore
run git init . from "."
create apps/api/application.rb
create apps/api/config/routes.rb
create apps/api/views/application_layout.rb
create apps/api/templates/application.html.erb
create apps/api/assets/favicon.ico
create apps/api/controllers/.gitkeep
create apps/api/assets/images/.gitkeep
create apps/api/assets/javascripts/.gitkeep
create apps/api/assets/stylesheets/.gitkeep
create spec/api/features/.gitkeep
create spec/api/controllers/.gitkeep
create spec/api/views/.gitkeep
insert config/environment.rb
insert config/environment.rb
append .env.development
append .env.test
To generate the dashboard app, ran:
$ hanami generate app dashboard
create apps/dashboard/application.rb
create apps/dashboard/config/routes.rb
create apps/dashboard/views/application_layout.rb
create apps/dashboard/templates/application.html.erb
create apps/dashboard/assets/favicon.ico
create apps/dashboard/controllers/.gitkeep
create apps/dashboard/assets/images/.gitkeep
create apps/dashboard/assets/javascripts/.gitkeep
create apps/dashboard/assets/stylesheets/.gitkeep
create spec/dashboard/features/.gitkeep
create spec/dashboard/controllers/.gitkeep
create spec/dashboard/views/.gitkeep
insert config/environment.rb
insert config/environment.rb
append .env.development
append .env.test
And for the admin app:
$ hanami generate app admin
create apps/admin/application.rb
create apps/admin/config/routes.rb
create apps/admin/views/application_layout.rb
create apps/admin/templates/application.html.erb
create apps/admin/assets/favicon.ico
create apps/admin/controllers/.gitkeep
create apps/admin/assets/images/.gitkeep
create apps/admin/assets/javascripts/.gitkeep
create apps/admin/assets/stylesheets/.gitkeep
create spec/admin/features/.gitkeep
create spec/admin/controllers/.gitkeep
create spec/admin/views/.gitkeep
insert config/environment.rb
insert config/environment.rb
append .env.development
append .env.test
At this point, the Hanami container is created with the three applications, as follows:
$ tree -L 2 -F
.
├── Gemfile
├── Rakefile
├── apps/
│ ├── admin/
│ ├── api/
│ └── dashboard/
├── config/
│ ├── environment.rb
│ └── initializers/
├── config.ru
├── db/
│ ├── migrations/
│ └── schema.sql
├── lib/
│ ├── bookshelf/
│ ├── bookshelf.rb
│ └── config/
├── public/
└── spec/
├── admin/
├── api/
├── bookshelf/
├── dashboard/
├── features_helper.rb
├── spec_helper.rb
└── support/
Mounting
As previously said, I want to mount each app separately on production. To do this, I changed the config/environment.rb
to following:
require 'rubygems'
require 'bundler/setup'
require 'hanami/setup'
require_relative '../lib/bookshelf'
Hanami::Container.configure do
if ENV["APP"] == "api"
require_relative '../apps/api/application'
mount Api::Application, at: '/'
elsif ENV["APP"] == "dashboard"
require_relative '../apps/dashboard/application'
mount Dashboard::Application, at: '/'
elsif ENV["APP"] == "admin"
require_relative '../apps/admin/application'
mount Admin::Application, at: '/'
else
require_relative '../apps/api/application'
require_relative '../apps/dashboard/application'
require_relative '../apps/admin/application'
mount Api::Application, at: '/api'
mount Dashboard::Application, at: '/dashboard'
mount Admin::Application, at: '/admin'
end
end
Just need to set the APP
env and the Hanami container will be configured like my desire. Else, all apps will be mounted as regular way (for test and development environment).
Database
While developing the POC, I was very frustrated with the Hanami Model because it’s lack of associations and it’s declarative mapping model. TL;DR I decided to use Sequel directly.
First, I removed all “hanami model” configuration on lib/bookshelf.rb
. The file contains only:
Dir["#{ __dir__ }/config/**/*.rb"].each { |file| require_relative file }
Dir["#{ __dir__ }/bookshelf/**/*.rb"].each { |file| require_relative file }
And configured the Sequel connection on lib/config/database.rb
:
require 'sequel'
DB = Sequel.connect(
ENV["BOOKSHELF_DATABASE_URL"],
max_connections: 10,
logger: Logger.new("log/sequel.log")
)
At this point, I was able to boot the application and access the database using hanami console
:
irb(main):001:0> DB["select * from users"]
=> #<Sequel::Postgres::Dataset: "select * from users">
Models
Since my goal is to connect to a previous migrated database schema from Rails, the migrations are not concern for now. There will be shown on next post.
Right now, just need to declare the models and associations using Sequel Model.
By example, consider the Company
and User
models, with the following database schema:
CREATE TABLE companies (
id integer NOT NULL,
name character varying(100) NOT NULL
);
CREATE TABLE users (
id integer NOT NULL,
company_id integer NOT NULL,
name character varying(100) NOT NULL,
email character varying(255) NOT NULL
);
Just need to create the model files, being lib/bookshelf/entities/company.rb
:
class Company < Sequel::Model
one_to_many :users
end
And the lib/bookshelf/entities/user.rb
with the content:
class User < Sequel::Model
many_to_one :company
end
And to validate, with a previous seed data, ran hanami console
:
irb(main):001:0> company = Company.first
=> #<Company @values={:id=>1, :name=>"Acme Inc."}>
irb(main):002:0> company.users
=> [#<User @values={:id=>1, :name=>"User from Acme Inc.", :email=>"[email protected]", :company_id=>1}>]
At this point, the application are configured to read from Postgres with the previous migrated database schema. I just need to copy the models from Rails project and make the necessary changes to Sequel.
For example, the app/models/company.rb
:
class Company < ActiveRecord::Base
belongs_to :account
has_many :users
end
Becomes lib/bookshelf/entities/company.rb
:
class Company < Sequel::Model
many_to_one :account
one_to_many :users
end
Views/Templates
This was the most easily migrated feature. Just copied and pasted the controllers and ERB views from Rails application to Hanami structure and made the necessary changes, like move the controller actions from methods to action classes, template structures, partials and everything worked like a charm.
For example, the Rails controller app/controllers/admin/users_controller.rb
:
class Admin::UsersController < ActionController::Base
def index
@users = User.all
end
def show
@user = User.find_by(id: params[:id])
end
end
Becoming the Hanami action apps/admin/controllers/users/index.rb
:
module Admin::Controllers::Users
class Index
include Admin::Action
expose :users
def call(params)
@users = User.all
end
end
end
And apps/admin/controllers/users/show.rb
:
module Admin::Controllers::Users
class Show
include Admin::Action
expose :user
def call(params)
@user = User.find(id: params[:id])
end
end
end
Fortunately we was on first release and the web features was very simple (don’t have any form or validation, for example). No problems here, but the next topic was a pain in the ass!
Assets
The assets pipeline is a complexity and/or an overhead that I decided to not deal for now (and probably never will on this project). As it was on previous Rails application, assets are plain (javascripts are plain js and stylesheets are plain css), centralized on directory public/assets
separated by application name, like:
/public/assets/admin/javascripts
/public/assets/admin/stylesheets
/public/assets/dashboard/javascripts
/public/assets/dashboard/stylesheets
And I decided to serve them with Rack::Static
, declaring the middleware on config.ru
file:
use Rack::Static, root: "public", urls: %w(/assets)
On templates, just reference the href
starting from /assets
, like:
<link rel="stylesheet" href="/assets/admin/stylesheets/application.css" />
Thats it. Quick and dirty static assets serving.
Wrapping up
Our goal was to migrate from Rails to Hanami in two steps: first replacing the web apps that are basically read-only and keep the API with Rails. And after, migrate all write operations, which is basically the entire API, but to do that, we need that the test suite be migrated too. This will be shown on next post.
For now, we was able to deploy the new Hanami applications (dashboard and admin), with read-operations directly to Postgres using Sequel/Model, serving assets with Rack::Static and the write-operations, like create transactions, calculations and async operations, delegated to the Rails API over HTTP client.
And was successfully made! The first step was concluded with the two Hanami apps deployed on production running on its own mounted “app”. The memory comsumption was between 80MB and 100MB per process, and the response time between 40ms and 200ms, depending of operation.
Yes, it’s a win \o/
Next steps
On next post, I will detail how was solved the topics:
- Sequel plugins
- Timezone issues
- Test environment / RSpec
- DB Fixtures
- Ruby core extensions
- Sidekiq Workers
- I18n
- And the great Hanami migration (API)
If you had any question, do not hesitate to comment below. Code hard and success!