This guide will cover:
- How Ruby projects are organised
- How to build gems from scratch
- How to make one gem depend on another gem
- How
require
works to load gems installed on your system
In Ruby (and most other languages) you can choose to write your entire program inside one big file. Normally though, Ruby programs are split across multiple files. What we'll look at in this section is how one file can load code that is included in a separate file. This feature is a major underpinning of how Ruby projects are organised.
We'll start with a Ruby script that exists in a single file:
01-basic-project/project.rb
class Response
attr_reader :answers
def initialize(user:)
@user = user
@answers = []
end
end
class Answer
def initialize(rating:)
@rating = rating
end
end
class User
def initialize(name:)
@name = name
end
end
user = User.new(name: "Ryan")
response = Response.new(user: user)
question_1 = "Do you feel like the work you do is meaningful?"
print "#{question_1} [1-5]"
rating = gets
response.answers << Answer.new(rating: rating.to_i)
question_2 = "Do you feel supported by your manager?"
print "#{question_2} [1-5]"
rating = gets
response.answers << Answer.new(rating: rating.to_i)
At about 40 lines long, this script fits very easily into most editor windows. It's just getting to the point where it's hard to see it all at once, but it's not quite there yet.
But imagine a situation where this code keeps growing, and more classes or
features get added to this file. It would quickly become hard to
work in it, because you would be moving up-and-down the file a lot, jumping to
wherever you needed to be. For instance, if you wanted to add a method to the
Answer
class in this script, you would need to look for class Answer
and then
add the method inside that.
A better way to approach this problem would be to split the different classes and responsibilities of this script into separate files:
02-organised-project
.
├── answer.rb
├── cli.rb
├── response.rb
└── user.rb
Each of these files is then very short and it makes it easier to jump around
the project. If we want to add a new method to the Answer
class now, we know
that the class is defined within answer.rb
, and so we can jump there quickly
using our editor's shortcuts.
In answer.rb
, the code would be:
class Answer
def initialize(rating:)
@rating = rating
end
end
In response.rb
, the code would be:
class Response
attr_reader :answers
def initialize(user:)
@user = user
@answers = []
end
end
And in user.rb
, the code would be:
class User
def initialize(name:)
@name = name
end
end
Then in cli.rb
the code would use all of these classes:
user = User.new(name: "Ryan")
response = Response.new(user: user)
question_1 = "Do you feel like the work you do is meaningful?"
print "#{question_1} [1-5]"
rating = gets
response.answers << Answer.new(rating: rating.to_i)
question_2 = "Do you feel supported by your manager?"
print "#{question_2} [1-5]"
rating = gets
response.answers << Answer.new(rating: rating.to_i)
If we try to run cli.rb
at the moment, it will fail because it doesn't know
where to find the User
class:
cli.rb:1:in `<main>': uninitialized constant User (NameError)
Before the code in cli.rb
can run, we need to tell it where to find the
User
class, and probably also the Response
and Answer
classes. We know
that these classes are in user.rb
, response.rb
and answer.rb
respectively, but how can we tell cli.rb
that? The answer is with
require_relative
:
require_relative 'answer'
require_relative 'response'
require_relative 'user'
user = User.new(name: "Ryan")
response = Response.new(user: user)
question = "Do you feel like the work you do is meaningful?"
puts "#{question} [1-5]"
rating = gets
response.answers << Answer.new(rating: rating.to_i)
The require_relative
works here by looking at files located relative to the
current file; files that are in the same directory.
Now when cli.rb
runs, it will first load the answer.rb
, evaluate that file,
and then do the same with response.rb
and user.rb
. Evaluating these files
will load in the Answer
, Response
and User
classes that our cli.rb
script needs to do its job.
Running ruby cli.rb
will now run through our script with no problems:
Do you feel like the work you do is meaningful? [1-5] 5
Do you feel supported by your manager? [1-5] 5
Separating out the code into separate files makes it much easier to jump through the different files in our project. It's for this reason that it is best practice in Ruby projects to follow the one-class-per-file rule.
Now let's say that we had another project that wanted to make use of these classes, and this project was called "comments". The comments project's code lives in another directory. Here's what the directory structure of the two projects might look like:
├── core
│ ├── answer.rb
│ ├── cli.rb
│ ├── response.rb
│ └── user.rb
└── comments
└── comment.rb
In that comments/comment.rb
file, we might want to make use of the User
class also, to tie together a Comment and a User, for instance. An example of this
would be code like this inside of comment.rb
:
class Comment
def initialize(text, author:)
@text = text
@author = User.new(name: author)
end
end
(Ignoring the fact here that User.new
should probably not be the
responsibility of the Comment
class, but rather the responsibility of
whatever calls Comment.new
...)
So in comment.rb
, we could write this to require the user.rb
file from the
core
project:
require_relative '../core/user'
The ..
here means "in one directory 'up' from the current location". This
will make require_relative
start looking relative to this current file's
location. It will look in the parent directory that core
and comments
reside in together, and then look in the core
subdirectory to find the
user.rb
file.
This will work, but then this means that the comments
and core
projects
must be located in the same directory, together. If either comments
or core
gets moved, then the whole thing falls apart. This won't do! There has to be a
better way.
Indeed, there is a better way. That better way is to separate these directories into gems, and then to very clearly define a dependency between the two gems. This is a common approach within the Ruby community. You have probably already a gem or two.
In the previous example, comments's code depends on features from the core codebase, and so there should be a dependency introduced between comments and core: comments depends on core.
Let's move our code into a gem structure now. We can generate two new gems by
running the bundle gem
command twice:
bundle gem jep_core
bundle gem jep_comments
Let's look at the structure of the jep_comments
gem:
.
├── Gemfile
├── README.md
├── Rakefile
├── bin
│ ├── console
│ └── setup
├── jep_comments.gemspec
├── lib
│ ├── jep_comments
│ │ └── version.rb
│ └── jep_comments.rb
└── spec
├── jep_comments_spec.rb
└── spec_helper.rb
The jep_core
gem is almost identical, but just where it would say jep_comments
in the above example it would say jep_core
instead.
At the top-level of this gem, we have a file called jep_comments.gemspec
.
This file is the gem specification and this file's job is to contain data
about what your gem is named, a description of its functionality, who wrote it,
and most importantly: this gem's dependencies.
Near the bottom of this file, we can see jep_comments
gem's dependencies listed:
spec.add_development_dependency "bundler", "~> 1.16"
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "rspec", "~> 3.0"
These three lines say that when we're developing this gem, we require three
other gems to make our gem development life easy: the bundler
, rake
and
rspec
gems.
The second argument to add_development_dependency
is the required version
number of this package. The little ~>
is called a "twiddle-wakka" or
"tilde-wakka" and is explained here.
To make the jep_comments
gem depend on the jep_core
gem, we need to list it as a
regular dependency, which we can do with this line, underneath the development
dependencies:
spec.add_dependency "jep_core", "~> 0.1.0"
By adding a dependency here, we're now stating that in order for jep_comments
to
work at all, it needs jep_core
. Otherwise, what's the point? The
gemspec says that jep_comments
has a dependency on jep_core
, and so it
probably wouldn't work without jep_core
.
For us to be able to use the jep_comments
gem, we'll need to first install the
jep_core
gem. Let's look at how we can install the jep_core
gem, and then
look at how we can install the jep_comments
gem.
Normally, we wouldn't have to manually install jep_core
because the
jep_core
gem would be available on RubyGems, and by installing jep_comments
it would automatically install jep_core
too, because we list it as a
dependency in jep_comments
's gemspec. You might've noticed this happening
when you install the rails
gem: it doesn't just install the rails
gem but
it also installs all the dependencies needed for rails
too.
Here's the beginning of the output of gem install rails
as an example:
Fetching: concurrent-ruby-1.0.5.gem (100%)
Successfully installed concurrent-ruby-1.0.5
Fetching: i18n-0.9.1.gem (100%)
Successfully installed i18n-0.9.1
Fetching: thread_safe-0.3.6.gem (100%)
Successfully installed thread_safe-0.3.6
Fetching: tzinfo-1.2.4.gem (100%)
Successfully installed tzinfo-1.2.4
Fetching: activesupport-5.1.4.gem (100%)
...
We didn't ask for these gems to be installed, but they are anyway because
they're listed as dependencies of the rails
gem, or dependencies of those
dependencies.
Our life isn't so easy for the jep_core
and jep_comments
gem, because these
two gems do not exist on RubyGems.org yet. So we must install them manually.
To install the jep_core
gem, we need to first build it, which we can do with
the gem build
command.
We'll need to be inside the jep_core
directory to run this command:
gem build jep_core.gemspec
Unfortunately, we'll see an error the first time we run this command:
ERROR: While executing gem ... (Gem::InvalidSpecificationException)
"FIXME" or "TODO" is not a description
This error is happening because the description of our gem inside the gemspec is still the default description.
Before we can run gem build
successfully, we will need to change a few lines
in our jep_core.gemspec
file:
spec.summary = %q{TODO: Write a short summary, because RubyGems requires one.}
spec.description = %q{TODO: Write a longer description or delete this line.}
Let's change these lines now to just the summary line:
spec.summary = %q{The core part for the JEP}
This removes the word "TODO" from the description and summary, which should
make that error go away. Let's try running gem build
again:
gem build jep_core.gemspec
When we run this command, we'll see this output:
WARNING: licenses is empty, but is recommended. Use a license identifier from
http://spdx.org/licenses or 'Nonstandard' for a nonstandard license.
Successfully built RubyGem
Name: jep_core
Version: 0.1.0
File: jep_core-0.1.0.gem
The first line indicates a warning: the gem build
command expects our gemspec
to include license information, but it doesn't. We can make this warning go
away with a small addition to our gemspec, under the summary:
spec.license = "MIT"
We can run gem build jep_core.gemspec
again to verify that this warning has
gone away.
Now to the more important part of the output of gem build
:
Successfully built RubyGem
Name: jep_core
Version: 0.1.0
File: jep_core-0.1.0.gem
The gem build
command has read our gemspec and has built a brand new gem for
us. This file contains all the code necessary for your gem, namely
everything but the spec
directory. Users of the gem don't need the tests when
using this gem; only the developers of the gem need it. What's included in the
gem file is determined by the spec.files = ...
code in the gem's gemspec.
Now that we've built the gem, we can try to install it with gem install
:
gem install jep_core-0.1.0.gem
You might've installed a gem before by just using its name, using a command
like gem install jep_core
or gem install rails
. This would make RubyGems
look for that gem on rubygems.org. What we're doing here is different: we're
specifying a local file to use for our gem install
process. The gem install
process will only use this local file to install our gem.
When we run this command, we'll see that it near-instantaneously installs our gem:
Successfully installed jep_core-0.1.0
1 gem installed
It's here that I should mention that there's a shortcut for gem build
and
gem install
, and that is rake install
. This will build a new gem, putting
it inside the pkg
directory, and then install it, all in one easy step! Let's
try it now:
jep_core 0.1.0 built to pkg/jep_core-0.1.0.gem.
jep_core (0.1.0) installed.
Hooray! We've got one of our two gems installed. We'll use rake install
in
the future instead of gem build
and gem install
. Now let's look at installing
the jep_comments
gem.
Installing the jep_comments
gem is a matter of following the same steps that we
ran through for the jep_core
gem:
- Fix up the summary in
jep_comments.gemspec
- Add license information to
jep_comments.gemspec
. - Run
rake install
When we run that rake install
command, we'll see this:
jep_comments 0.1.0 built to pkg/jep_comments-0.1.0.gem.
jep_comments (0.1.0) installed.
Hooray on this one too! We've now got both of our gems installed. However, these gems are both still just skeletons. We will need to move our code in from our old projects to make them actually do something useful.
Inside both the jep_core
and jep_comments
gems, there is a lib
directory.
This directory is where the code for our gems will live. Here's what the
jep_core/lib
directory looks like:
├── jep_core
│ └── version.rb
└── jep_core.rb
And here's what the jep_comments/lib
directory looks like:
├── jep_comments
│ └── version.rb
└── jep_comment.rb
These are mostly the same, just the names are different. Let's take the files
from our earlier examples and put them into our gems' lib
directory. We'll
put answer.rb
response.rb
and user.rb
inside of jep_core/lib/jep_core
,
and we'll put comment.rb
inside of jep_comments/lib/jep_comments
.
jep_core/lib/jep_core/answer.rb
module JepCore
class Answer
def initialize(rating:)
@rating = rating
end
end
end
jep_core/lib/jep_core/response.rb
class Response
attr_reader :answers
def initialize(user:)
@user = user
@answers = []
end
end
jep_core/lib/jep_core/user.rb
module JepCore
class User
def initialize(name:)
@name = name
end
end
end
jep_comments/lib/jep_comments/comment.rb
require_relative '../core/user'
module JepComment
class Comment
def initialize(text, author:)
@text = text
@author = JepCore::User.new(name: author)
end
end
There's a small thing we'll need to change in this comment.rb
file, and
that's the require_relative
at the top. We'll need to change it to this:
require "jep_core/user"
This is because we now want to refer to the user.rb
file from within the
jep_core
gem. But how does Ruby know where to find this file? The answer lies
in how we've structured our gems, and how require
works.
We've put these files at lib/jep_core
and lib/jep_comments
, rather than
just under lib
in both projects for a very good reason. This is so that the
files do not conflict with each other. Why would the files conflict? I'm glad
you asked!
When you're using gems in Ruby, their lib
directories get added to a list of
directories called the load path. The load path is where Ruby goes looking
for files to require them. So in the case of our jep_core
and jep_comments
gems, both of their lib
directories would be on the load path.
If we had a file in jep_core/lib
that was called user.rb
and one also in
jep_comments/lib
that was also called user.rb
, and then in our project we
tried doing this:
require "user"
How would Ruby know that we wanted the one from jep_core
and not
jep_comments
or vice-versa? By putting these files inside a sub-directory of
lib
, we avoid this confusion:
# "give me the user.rb" file from jep_core"
require "jep_core/user"
# "give me the user.rb" file from jep_comments"
require "jep_comments/user"
How does Ruby know where to find these files in the first place after we've
installed the gem? Well, an easy way to see this in action would be to try it
out in irb
. We've got both of our gems installed now, so this next part
should be a cinch.
Let's open up irb
and try working with these gems. It's not important where
you open irb
; it will work anywhere on your machine. This is because of when
you install gems, they are made globally available by default.
We can require the jep_core
and jep_comments
gem like this:
irb(main):001:0> require "jep_core"
true
irb(main):002:0> require "jep_comments"
true
(NOTE: I have a technical explanation for how require
finds the gem's file
on my blog)
The true
return value here tells us that Ruby has successfully found these
files and is requiring them for the first time. If Ruby had required them
before-hand, we would see false
here.
But we can't use those gems' classes yet:
irb(main):003:0> JepCore::User.new(name: "Ryan")
JepCore::User.new(name: "Ryan")
NameError: uninitialized constant JepCore::User
from (irb):3
from /Users/ryanbigg/.rubies/ruby-2.4.1/bin/irb:11:in `<main>'
Huh? Didn't we require the gem? Shouldn't it just know about our gem's classes?
Yes to the first question, no to the second. Requiring a gem doesn't magically
load all that gem's classes. What that does is require the jep_core.rb
file
at the root of our project, which just has this content in it:
require "jep_core/version"
module JepCore
# Your code goes here...
end
When we do require 'jep_core'
, it just loads / requires this one file. There
is nothing in our code that tells it where to find the JepCore::User
class at
the moment, and so that class is uninitialized.
This jep_core.rb
file should be considered as the "entrypoint" to this
project. Whatever should be made available from this gem should be required
here, in this file. This is so that we can simply do require "jep_core"
and
then the whole project is loaded.
So at the top of this file, let's put a few more require
calls so that our
User
and Response
classes are loaded:
require "jep_core/answer"
require "jep_core/response"
require "jep_core/user"
require "jep_core/version"
module JepCore
# Your code goes here...
end
That's better! When this gem is loaded, it will now also load the
jep_core/response.rb
and jep_core/user.rb
files too. We could use
require_relative
here too:
require_relative "jep_core/answer"
require_relative "jep_core/response"
require_relative "jep_core/user"
require_relative "jep_core/version"
This is because the jep_core.rb
file lives inside lib
, and
jep_core/answer.rb
and friends are located relative to the jep_core.rb
file. But this is more typing than is necessary, so we'll stick with require
.
require "jep_core/answer"
require "jep_core/response"
require "jep_core/user"
require "jep_core/version"
Going back to irb
and running through the same steps as before will not get
us any further:
irb(main):001:0> require 'jep_core'
irb(main):002:0> JepCore::User.new(name: "Ryan")
NameError: uninitialized constant JepCore::User
from (irb):2
from /Users/ryanbigg/.rubies/ruby-2.4.1/bin/irb:11:in `<main>'
This is because Ruby is still loading the old version of our gem. We will need
to install the new version of our gem to make Ruby load the new code. Let's do
this now by going into the jep_core
gem and re-running this command:
rake install
With the gem newly rebuilt + installed, our code should now work:
irb(main):001:0> require "jep_core"
=> true
irb(main):002:0> JepCore::User.new(name: "Ryan")
=> #<JepCore::User:0x007fb3390d20c0 @name="Ryan">
This is a good start. We'll see the same kind of problem with our
jep_comments
gem if we try to use a class from there:
irb(main):003:0> JepComments::Comment.new("A new comment!", author: "Ryan")
NameError: uninitialized constant JepComments::Comment
Let's fix this now. We'll go into the jep_comments
gem and open up the
lib/jep_comments.rb
file, and then we'll add a require
for the
jep_comments/comment.rb
file:
require "jep_comments/comment"
Because we've made changes to the jep_comments
gem, we will need to
re-install it too. Let's run this command inside the jep_comments
gem to do
that:
rake install
We'll then go back into our irb
prompt and try using the
JepComments::Comment
class again:
irb(main):001:0> require "jep_comments"
=> true
irb(main):002:0> JepComments::Comment.new("A new comment!", author: "[email protected]")
=> #<JepComments::Comment:0x007ff95810b5b8 @text="A new comment!", @author=#<JepCore::User:0x007ff95810b540 @name="[email protected]">>
This line creates a new instance of the JepComments::Comment
class, which
links to a JepCore::User
class... but we didn't require jep_core
in irb
yet. What we're doing here is possible because of the require
inside of
jep_comments/lib/jep_comments/comment.rb
:
require "jep_core/user"
This line tells Ruby that jep_comments/lib/jep_comments/comment.rb
requires
jep_core/user
. Ruby will dutifully load that file, and then continue with
executing the remainder of the file once jep_core/user
has been loaded.
In this guide you've seen how to share code across different projects, by
introducing dependencies between them, listing them in the gemspec and by
cross-requiring files too. This guide has given you insight into how require
works, and also how to build gems from scratch.
- The
cli.rb
file was left out of thejep_core
gem. Where is the right place to put this file? How could we make it so that someone who had thejep_core
gem installed could run the commandrun_survey
and it would prompt them for the questions?