Building a Gem for Rails 3
This is a 'work in progress' - any and all corrections, comments, suggestionswelcome!
Believe it or not, it’s not that hard.
Here’s the basic outline:
- lay out a basic Ruby gem, with the normal directory structure
- decide how you need to hook into Rails
- if you need to patch some of Rails internal structures - such as
add some functionality to ActiveController - then you need to write
a Railstie - if you need to add a controller, a model, view, rake task or a generator -
then you probably should use an Engine. [the difference between a Railtie
and an Engine is that an Engine is a Railtie with more stuff] - Or, if you want to embed an entire Application into another, you can use
subclass Rails::Application. If you need to write an Application, then
you need to find somebody who knows how to do it. - Or, if you want to make a Plugin, you should forget it and just build a gem
and either implement a Railtie or an Engine
- if you need to patch some of Rails internal structures - such as
- build, test as usual. Below I’ll show how you can hook your local, development
gem into a locally run Rails app. - package and ship out to github.com and rubygems.org
In everything which follows, I’m assuming that you are adding functionality to
Rails which requires some sort of ‘initialization’.
What does ‘requires initialization’ mean?
Ok - when does your gem ‘require initialization’?
- if you need to run a rake task or a generator to install some stuff in order for
your gem to work - then it ‘requires initialization’. In fact, you should probably
create an Engine. - if you’re adding controllers, views, etc, then you need an Engine
- if you want to monkey patch ActiveController or one of the other basic Rails
classes, then you will want to ‘include MyGemModule’ into that Rails class when
it is autoloaded. For that you need to insert yourself into the autoload sequence
and so you ‘require initialization’. Specifically, you need at least a Railtie. - finally, if you’re not doing any of this and there is some way for your gem
to provide services without any startup initialization and without living in any
namespace Rails knows about, then you don’t need initialization.
So, the answer is - pretty much all gems which will extend Rails are going to
‘require initialization’ and your initialization stuff goes into your Railtie
or Engine.
So …
What goes into your Railtie / Engine
Surprisingly little - but figuring out what that ‘little’ is can be
daunting.
Here’s the Railtie I wrote for my
manage_meta
gem:module ManageMeta
class Railtie < Rails::Railtie
initializer "application_controller.initialize_manage_meta" do
ActiveSupport.on_load(:action_controller) do
include ManageMeta
end
end
end
end
There’s a lot going on here in 9 lines (typical Ruby compactness!)
Before going into this code, it will help to put it in the context of how
this gem is structured.
- the gem and github repository are both named manage_meta
- all the code which does something is in
manage_meta/lib
- the file which is required is named
manage_meta/lib/manage_meta.rb
. All it does
isrequire
files frommanage_meta/lib/manage_meta/
.
- It always requires
manage_meta/lib/manage_meta/manage_meta.rb
: this allows
me to write unit tests which are independent of Rails - It conditionally
require
s the Railtie.
- It always requires
Here it is:
require 'manage_meta/manage_meta'
require 'manage_meta/railtie' if defined? Rails
OK - so all we need to do is get our Rails app to include
manage_meta/lib/manage_meta.rb
.But this is a distraction right now. We’ll get back to it when we go
over the Rails boot process.
By the way, the names are significant!
- The root directory
manage_meta
, the top-level require filemanage_meta/lib/manage_meta.rb
,
and the subdirectory of libmanage_meta/lib/manage_meta/
need to all be
the gem name. - I think that the Railtie (or Engine) really needs to be named
railtie.rb
(orengine.rb
) in order for Rails to find it. [this is a guess which I may
never resolve because: 1. it works when I do it like this and 2. there’s no reason
not to. If anybody can confirm this, I’ll remove this caveat and give them credit] - module ManageMeta - I’m extending my module with some Rails specific code.
While not visible here, this is conditionally included inlib/manage_meta.rb
OK, back to the Railtie:
- module ManageMeta
Your gem must be namespaced to a Module and you have to define your Railtie (or Engine)
within that module. - class Railtie < Rails::Railtie (or class Engine < Rails::Engine)
Sure, you can call it Bob if you want, but why bother? It’s real name is
ManageMeta::Railtie or MyGem::Railtie - which is safely namespaced, so there
won’t be any conflict here.
The important thing is that this get’s you all the stuff in Railtie - most importantly,
for what we’re talking about here, this is where you get the ability to initialize
by callinginitializer
[defined inrails/lib/rails/inializable.rb
(see the pattern?
everything is a gem)] - initializer “applicationcontroller.initializemanage_meta” do …
initializer
takes a name, a block, and (optionally) a couple of options. The
option keys are:before
and:after
and we’re going to ignore them for now.
It builds anInitializer
instance and stuffs it into the array-like object named
initializers
. I say it’s array-like because all the Initializer instances are
in sequence, but the options allow anyone in the know to place their specific
Initializer.
Let’s just take it on faith that as Rails boots, it goes throughinitializers
and runs the code blocks we pass in.
Here’s the
initializer
code:def initializer(name, opts = {}, &blk)
raise ArgumentError, "A block must be passed when defining an initializer" unless blk
opts[:after] ||= initializers.last.name unless initializers.empty? || initializers.find { |i| i.name == opts[:before] }
initializers << Initializer.new(name, nil, opts, &blk)
end
- ActiveSuport.onload(:actioncontroller) do ; include ManageMeta ; end
Finally we get to the place we were going.
ActiveSupport
seems to be pretty much a support library which just lays around like
a sleeping dog until you poke it to do something. Then it does it and goes back to
sleep.
One of the thingsActiveSupport
provides is support for creating queues of code
blocks which can be ‘run later’. This support is defined in
active_support/lib/active_support/lazy_load_hooks.rb
- which defines 3 class
methods:
- on_load(name, options, &block) - which is we use to add our code to a hook
- runloadhooks(name, base = Object) - which runs all the defined hooks. We don’t
touch this - but it’s really important. - execute_hook(base, options, block) - which we never touch - it’s used by
run_load_hooks
to actually run the code block on the object
So, here’s the short version of what those 9 lines do:
It adds the statement
include ManageMeta
to the autoload sequence which is triggeredwhen ActionController is first loaded.
It does it by adding that code to the
on_load
chain keyed to the name :action_controller
.Let’s Try to Generalize This
OK - so how did I know how to do this?
Code crawling - of course.
I grep’ed the Rails 3.0.6 gems for calls to
run_load_hooks
and found the followingkeys defined:
:action_mailer
:action_controller
:action_view
:active_record
:i18n
:before_initialize
:before_eager_load
:after_initialize
:before_configuration
The first four (
action_mailer
, action_controller
, action_view
, and active_record
)all call
run_load_hooks
at the end of to hack these base classes, you just copy the code above and use the appropriate
name
.So far I haven’t found a need to hook into any of the other 5, so I’ll just leave
those as an exercise for the reader. [send me a link to what you find and I’ll
add it here]
But but but … What about Engines?
Well, one place you need an
Engine
if you have some stuff in the app
directory.So what you do is add an
app
directory to your gem, structure it like a Railsapp
directory - and it “just works”.Another place is if you want some rake tasks.
What you do then is create a directory called
manage_meta/lib/tasks/
and putyour rake tasks in files called
a_task.rake
- and it “just works” too.Another place is when you need a generator.
Here you need to call the
generate
I haven’t messed with building a generator yet - but it’s on the list.
As soon as I get to it (and have at least a less vague idea of what to do), I’ll
add some stuff here
But … But … But … How does Rails Find my Gem and All this Goodness?
Glad you asked.
The answer is “The bundler did it!”
If you look in your Rails app in
config/boot.rb
, you will find a file whichcontains something like:
require 'rubygems'
# Set up gems listed in the Gemfile.
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE'])
Here’s an easy way to see what this does.
This is my Ruby environment in the top directory of a Rails site I’m working
on. [I’m using rvm, so it shows the ruby and stuff I have set up for this site]
This is just simply running
irb
, so the bundler setup stuff isn’t run.Here’s the default $LOAD_PATH
mike:clovetech2 mike$ irb
ruby-1.9.2-p180 :001 > puts $:.join "\n"
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/site_ruby/1.9.1
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/site_ruby/1.9.1/x86_64-darwin10.7.3
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/site_ruby
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/vendor_ruby/1.9.1
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/vendor_ruby/1.9.1/x86_64-darwin10.7.3
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/vendor_ruby
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/1.9.1
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/1.9.1/x86_64-darwin10.7.3
=> nil
Here’s what $LOAD_PATH looks like after
bundler/setup
is require
d. All thoseextra files are pulled in from the Gemfile.
Most of the new stuff are gems which I’ve pulled from
rubygems.org
and whichare stuffed
rvm
’s gemset for the Ruby I’m using. It also adds in my developmentgems. More below
mike:clovetech2 mike$ rails c
Loading development environment (Rails 3.0.6)
ruby-1.9.2-p180 :001 > puts $:.join "\n"
/Users/mike/Rails/clovetech2/lib
/Users/mike/Rails/clovetech2/vendor
/Users/mike/Rails/clovetech2/app/controllers
/Users/mike/Rails/clovetech2/app/helpers
/Users/mike/Rails/clovetech2/app/mailers
/Users/mike/Rails/clovetech2/app/models
/Users/mike/Rails/Mikes-Gems/use_tinymce/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/webrat-0.7.3/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/sqlite3-ruby-1.3.3/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/sqlite3-1.3.3/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/rails-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/railties-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/thor-0.14.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/nokogiri-1.4.4/lib
/Users/mike/Rails/Mikes-Gems/manage_meta/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/database_cleaner-0.6.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/autotest-rails-4.1.0/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/annotate-models-1.0.4/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/activeresource-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/activerecord-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/arel-2.0.9/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/actionmailer-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/mail-2.2.15/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/treetop-1.4.9/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/polyglot-0.3.1/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/mime-types-1.16/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/actionpack-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/tzinfo-0.3.26/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/rack-test-0.5.7/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/rack-mount-0.6.14/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/rack-1.2.2/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/erubis-2.6.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/activemodel-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/i18n-0.5.0/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/builder-2.1.2/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/activesupport-3.0.6/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/abstract-1.0.0/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/ZenTest-4.5.0/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@global/gems/rake-0.8.7/lib
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/bundler-1.0.10/lib
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/site_ruby/1.9.1
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/site_ruby/1.9.1/x86_64-darwin10.7.3
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/site_ruby
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/vendor_ruby/1.9.1
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/vendor_ruby/1.9.1/x86_64-darwin10.7.3
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/vendor_ruby
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/1.9.1
/Users/mike/.rvm/rubies/ruby-1.9.2-p180/lib/ruby/1.9.1/x86_64-darwin10.7.3
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/actionpack-3.0.6/lib/action_controller/vendor/html-scanner
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/rack-mount-0.6.14/lib/rack/mount/vendor/multimap
/Users/mike/.rvm/gems/ruby-1.9.2-p180@clovetech2/gems/rack-mount-0.6.14/lib/rack/mount/vendor/regin
=> nil
Take a look at
/Users/mike/Rails/Mikes-Gems/use_tinymce/lib
and
/Users/mike/Rails/Mikes-Gems/manage_meta/lib
. Those are my developmentgems.
bundler has hooked my development gems into the load path.
I pulled that off by specifying those gems using:
`gem "manage_meta", :path => "~/Rails/Mikes-Gems/manage_meta"`
Take a look at the bundler site’s
for a discussion of the Gemfile
options.
And Wait … There’s More
Notice that only the
lib
directories for each included gem are added tothe $LOAD_PATH.
So how does the Engine get to the
app
directory and how does rake
findrake tasks?
The answer - as usual - is convention.
Directory structure is important.
app
has to be locatable in a standard wayusing
File.expand_path
. For example, I strongly suspect that Rails::Enginecontains something which effectively does:
$LOAD_PATH << File.expand_path('../../../app', __FILE__)
(it doesn’t look like this - Rails is far more generic and self-programming than
something this straightforward)
Anyway - the thing to realize is that - for mere mortals - it’s best to stick
to the “correct” directory structure and naming conventions.
So if you build it and it doesn’t work - check your names and directory structure.
A Little about Initializers
railties/lib/rails/initializable.rb
defines two classes, some class methods,and an instance method:
The classes are:
- Initializer - which is a named container for a block of code. It has provision
for acontext
[which turns out to be a binding in which to run the code block]
andbefore
andafter
properties which are used to provide a sorting order.
(more below) - Collection < Array - which is an array which can be sorted using the
tsort
method in the standard Ruby library. Sorting uses thebefore
andafter
properties.
The class methods are:
initializers
- which is a Collectioninitializers_chain
- which constructs aninitializers
instance for the class
which calls it. Specifically, it consists of allancestor
classes and modules
whichrespond_to?
initializer
[in reverse order, so the the initializations
are processed from the top of the class/module hierarchy down to the lowest
descendent (which is self)]initializers_for(binding)
- returns an initializers chain where each
Initializer instance is bound to bindinginitializer(name, opts = {}, &blk)
- whom we’ve seen before up here
initializers
- constructs and caches theinitializer_chain
constructed for this
instance of the class which mixes in Rails::Initializable.
But, once you know it’s there and how to find the keys you need, it makes a lot of
sense and is clearly very efficient, flexible, and powerful
4 comments:
Great stuff, thanks! Very detailed.
I am having trouble geting this to work as a plugin. Do you know if it will work as a plugin?
@Gerberland: this is how to write a gem which is installed in Rails 3.0 as a gem - not a plugin. Plugins go in vendors. Gems go wherever gems live and can be shared between applications without code duplication.
Rails 3.x is moving away from plugins and to everything being gems - including all the components of Rails.
This note only deals with things which can be created as Railties, Engines and Applications, not Plugins.
Please re-read the bullets at the top of this piece where I'm recommending that you not build a plugin.
Does this help?
Really helpful. Have lot of lightbulb moments.
Post a Comment