In the previous article, I created a dedicated gem for a JSON parser. In this article, I show how to embed it into a Ruby on Rails project and enable autoreload for the gem's classes in the development environment.
To use a gem in a Rails project, the default way is to publish the gem, either on a public or a private gem server. There's a nice guide on rubygems.org for this. I did not want to publish my gem on a public gem server, and setting up a private gem server that would be available to both my development and production environments seemed like a burden.
I chose instead to embed the gem in my Ruby on Rails project: the source of the gem is inside the Rails project, in the same git repository. It makes sense to me, because the gem is dedicated to this Rails application. And if later I want to share the gem, it can easily be moved to its own repository.
TL;DR
1. The source files of the gem are put into a directory embedded_gems
inside the Rails project.
2. The gem is referenced in the Gemfile
of the Rails project with the path:
option.
gem 'format_gem', path: './embedded_gems/format_gem'
3. For the development environment, autoreload is enabled with a dedicated Zeitwerk::Loader
and a file watcher, to reload the gem
when a source file is modified, just like for the rest of the Rails app.
Benefits
-
The main project and the gem are in the same git repository: both can be deployed with a single
git pull
; Also related modifications of the app and the gem are in the same commit. - In the development environment, the classes of the gem are reloaded on each request without restarting the server, big time saver.
- This solution does not require the installation/usage of a gem server.
Step-by-step walkthrough
To illustrate the solution, I'll create a gem FormatGem
containing a service. I'll embed the gem in a Rails project called FormatApp
, and have
a simple controller to display the health status of the service.
This article is split into 5 sections:
- Create the Rails project
- Create the gem project
- Embed the gem in the Rails project
- Setup autoreload for the development environment
- Summary
Prerequisite
I'm using Rails 6.1.3 in this article, and ruby
and gem
of course.
$ rails --version; ruby --version; gem --version
Rails 6.1.3
ruby 2.7.1p83
Gem 3.1.2
Create the Rails project
First I create the Rails project. It's a good opportunity to try the new option --minimal
to get a lightweight application skeleton (that's perfect for this example, I just need a controller).
$ rails new format_app --minimal
create
create README.md
create Rakefile
...
run bundle install
Fetching gem metadata from https://rubygems.org/....
Resolving dependencies...
...
Using sass-rails 6.0.0
Using sqlite3 1.4.2
Bundle complete! 7 Gemfile dependencies, 54 gems now installed.
Immediately check the Rails project is working properly by starting the server.
$ cd format_app
format_app$ rails s
=> Booting Puma
=> Rails 6.1.3 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Listening on http://127.0.0.1:3000
I add a controller/action for the health status. For this, I create app/controllers/status_controller.rb
with a health
action,
and declare the route to this action in config/routes.rb
class StatusController < ApplicationController
def health
messages = []
messages << 'Rails app is alive'
messages << 'Service check to be implemented' # Here I'll add the service status
render plain: messages.join("\n")
end
end
Rails.application.routes.draw do
get '/health', to: 'status#health'
end
Really classic Rails stuff. I check http://127.0.0.1:3000/health
, it returns the expected messages.
Create the gem project
In the previous article Tokenization with Flex and Ruby, I used the bundle
command to create the gem.
This command generates a lot of files, and most of them are only useful for gems that will be published and versioned. That's not my use case, so this time I'll create the gem
from scratch. That's really simple, there's only one required file, the .gemspec
file.
The gem is called format_gem
. I create a directory embedded_gems/format_gem
in the root directory of the Rails project,
and put in it the following format_gem.gemspec
file.
Gem::Specification.new do |spec|
spec.name = 'format_gem'
spec.authors = ['me']
spec.summary = 'Gem containing FormatGem::FormatService'
spec.version = '0.1'
spec.files = Dir['lib/**/*']
end
I chose to fill only the required Gemspec attributes.
Note that the gem will include all files of the lib
directory;
I immediately create the service FormatGem::FormatService
. The source file of the class must be put in a format_gem
directory,
to match with the module name. That's important for the autoreload that will be set up in the last section. So here is lib/format_gem/format_service.rb
:
module FormatGem
class FormatService
def self.health
"FormatService is ready"
end
end
end
I check the gem before going on the next step, with the gem build
command.
format_app/embedded_gems/format_gem$ gem build
WARNING: licenses is empty, but is recommended. Use a license identifier from http://spdx.org/licenses or 'Nonstandard' for a nonstandard license.
WARNING: no homepage specified
WARNING: See http://guides.rubygems.org/specification-reference/ for help
Successfully built RubyGem
Name: format_gem
Version: 0.1
File: format_gem-0.1.gem
The gem has been built with success. Ignore the warnings : that's just because I omitted some recommended Gemspec attributes. The gem build
command have created a
format_gem-0.1.gem
file that won't be used. It can be safely deleted.
format_app/embedded_gems/format_gem$ rm format_gem-0.1.gem
Embed the gem in the Rails project
The gem must be added to the Gemfile
of the Rails project. As the gem is not published on any server, I use the path:
option
to indicate the source directory of the gem.
# Embedded gems
gem 'format_gem', path: './embedded_gems/format_gem'
And I update the controller to call the service.
require 'format_gem/format_service'
class StatusController < ApplicationController
def health
messages = []
messages << 'Rails app is alive'
messages << FormatGem::FormatService.health
render plain: messages.join("\n")
end
end
I restart the Rails server because the Gemfile
as been modified, and check that http://127.0.0.1:3000/health
returns the expected messages.
Setup autoreload for the development environment
Thanks to its class loader, Rails automatically reloads classes on each request if the application files have changed in the development environment.
Since Rails 6, the default class loader is Zeitwerk. Unfortunately, it works only for files in the app
directory.
In the current state of my application, the modifications in the gem, like in FormatService
, are not applied until the Rails server is restarted.
Let's fix this :
- I set up a dedicated Zeitwerk class loader for the gem. I use
Zeitwerk::Registry.loader_for_gem(path)
so I can specify the root path of the gem. - I instantiate an
ActiveSupport::FileUpdateChecker
to watch all the files of the gem, and trigger reload when a file is modified - I plug the watcher to check the files on each request with
execute_if_updated
As autoreload is useful only the development environment, I implement all this operation in development.rb
, in the Rails.application.configure
block:
...
# Autoreload for each embedded gem
Rails.root.join('embedded_gems').children.each do |gem_path|
# Create a Zeitwerk class loader for each gem
gem_lib_path = gem_path.join('lib').join(gem_path.basename) # i.e. '.../format_gem/lib/format_gem'
gem_loader = Zeitwerk::Registry.loader_for_gem(gem_lib_path)
gem_loader.enable_reloading
gem_loader.setup
# Create a file watcher that will reload the gem classes when a file changes
file_watcher = ActiveSupport::FileUpdateChecker.new(gem_path.glob('**/*')) do
gem_loader.reload
end
# Plug it to Rails to be executed on each request
Rails.application.reloaders << Class.new do
def initialize(file_watcher)
@file_watcher = file_watcher
end
def updated?
@file_watcher.execute_if_updated
end
end.new(file_watcher)
end
I restart the rails server a last time to integrate the changes of development.rb
. Now any change in FormatService
is immediately available in the Rails application without restarting the server. Note however it doesn't work for gems with C extensions (even when the .so
is modified, classes are not reloaded).
Summary
Most of the time, the gems are meant to be shared and reused among projects, and so they're published. But for the gems that are dedicated to only one project,
embedding the gems like this is a good alternative, and it simplifies the gems management. Format Express uses gems like this for each formatter,
and it works like a charm.
The autoreloading trick in the development environment saves a lot of time, and it can be enabled for any local gem, as long as the gem is included in the Gemfile
with the path:
option.