Bundler to narzędzie stworzone przez Carl’a Lerche’a, Yehuda Katz’a, André Arko i inne kontrybucje dla zarządzania zależnościami z Rubygem’ów. Bundler 1.0 został wydany w tym samym czasie co Rails 3 i w Rails’owych projektach występuje najczęściej. Lecz pamiętaj, Bundler nie jest tylko dla Rails’ów!
Czy wiedzieliście, że można używać Bundler’a nie tylko jako manadżer zależności, lecz też do pisania własnych gem’ów? Jest to naprawdę proste, a Bundler dostarcza kilka rzeczy, które mogą pomóc.
Po co tworzyć własnego gem’a? Nie można po prostu wrzucić gdziekolwiek kodu i używać?
Pewnie, że można, ale co, jeśli chcielibyśmy użyć tego kodu w innym miejscu lub podzielić się z innymi?
Dlatego właśnie gem’y są świetne. Można tworzyć własną bibliotekę i gem’y osobno od siebie
i dodać require 'gem'
w bibliotece. Jeśli chcesz użyć gem’a w innej bibliotece,
po prostu robisz małą modyfikację, zamiast wszystko kopiować.
Również: “Sharing is caring”.
Ta instrukcja została stworzona używając wersji 1.9.0 Bundler’a. Możesz kontynuować z inną wersją, ale możesz nie otrzymać takiego samego wyniku. Żeby sprawdzić jaka wersja bundler’a jest aktualnie zainstalowana, uruchom poniższą komendę:
$ bundle -v
Jeśli to potrzebne, możesz zaktualizować Bundler’a do najnowszej wersji wpisując gem install bundler
.
Aby rozpocząć tworzenie nowego gem’a, użyj komendy bundle gem
tak jak niżej:
$ bundle gem foodie
Nasz gem będzie się nazywał foodie
, ponieważ będzie robił kilka rzeczy związanych z jedzeniem,
np. określając je jako “Delicious!” lub “Gross!”.
Komenda ta stworzy wygenerowany folder
dla naszego nowego gem’a i, jeśli mamy zainstalowanego
Git’a, inicjalizuje repozytorium Git’a w tym folderze, żebyśmy mogli rozpocząć commitować od razu.
Jeśli pierwszy raz uruchamiasz bundle gem
, zostaniesz zapytany czy chcesz dodać pliki:
CODE_OF_CONDUCT.md
i LICENSE.txt
w Twoim projekcie. Wygenerowane pliki to:
Gemfile: Używany do zarządzania zależnościami w naszej bibliotece. Folder zawiera linię gemspec
oznaczającą,
że Bundler dołączy również zależności z foodie.gemspec. Najlepszą praktyką jest wyspecyfikowanie wszystkich gem’ów,
używanych przez bibliotekę w gemspec.
Rakefile: Inicjalizuje Bundler’a i dodaje zadania Rake: build
, install
i release
poprzez wywołanie Bundler::GemHelper.install_tasks
. Task build
zbuduje aktualną wersję gem’a i przechowuje ją w folderze pkg, task install
zbuduje i zainstaluje gem w naszym systemie (jakbyśmy użyli gem install
na niej)
i release
wyśle gem’a na serwery Rubygems do użytku publicznego.
CODE_OF_CONDUCT.md: Zawiera “Code of Conduct”, który wszyscy powinni przestrzegać. Zostanie dołączony tylko, gdy wybierzesz odpowiednią opcję.
LICENSE.txt: Zawiera licencję MIT.Includes the MIT license. Zostanie dołączona tylko, gdy wybierzesz odpowiednią opcję.
.gitignore: (jeśli korzystasz z Git-a). Ignoruje wszystko w folderze pkg
(miejsce, gdzie zazwyczaj trafiają wygenerowane pliki po rake build
),
wszystko z roszerzeniem .gem i folder .bundle .
foodie.gemspec: Plik konfiguracyjny Gem-a. Jest to miejsce gdzie podajemy wszystkie informacje dla Rubygems-ów, np. nazwę, opis, stronę główną naszego gem-a. Również tutaj dodajemy zależności, które muszą być zainstalowane, żeby uruchomić gem-a.
lib/foodie.rb: Plik wejściowy kodu. Ten plik jest dołączany przez Bundler-a (lub inne), kiedy
gem jest ładowany. Ten plik definiuje module
, który będzie użyty jako namespace dla kodu naszego gem-a.
Najlepiej właśnie w tym module umieszczać kod…
lib/foodie: Ten folder powinien zawierać cały kod (klasy, etc.) naszego gem-a. Plik lib/foodie.rb jest po to, by ustawić środowisko gem-a, natomiast wszystkie inne części biblioteki powinny trafić to folderu lib/foodie. Jeśli gem będzie można używać na różne sposoby, rozdziel kod tak, by ludzie mogli załączać pojedynczą klasę/plik.
lib/foodie/version.rb: Definiuje Foodie
moduł, a w nim stałą VERSION
.
Jest to plik ładowany przez foodie.gemspec do określenia wersji gem-a.
Kiedy wypuszczamy nową wersję, będziemy zwiększać numer wersji.
Był to podstawowy opis, a teraz przejdźmy do kodzenia!
W tym poradniku, będziemy używać RSpec-a to testowania naszego gem-a. Piszemy testy, by zapewnić, że wszystko uruchomi sie zgodnie z planem i by przestrzec nas przed nieprzyjemnymi problemami w przyszłości.
Aby rozpocząć pisać testy, stworzymy folder spec w katalogu głównym gem-a przez użycie komendy
mkdir spec
. Następnie, dodamy linijkę do foodie.gemspec w bloku Gem::Specification
, aby sprawić że
rspec
będzie wymaganą zależnością przy dewelopowaniu.
spec.add_development_dependency "rspec", "~> 3.2"
Because we have the gemspec
method call in our Gemfile, Bundler will automatically add this
gem to a group called “development” which then we can reference any time we want to load these
gems with the following line:
Bundler.require(:default, :development)
The benefit of putting this dependency specification inside of foodie.gemspec rather than the
Gemfile is that anybody who runs gem install foodie --dev
will get these development
dependencies installed too. This command is used for when people wish to test a gem without
having to fork it or clone it from GitHub.
When we run bundle install
, rspec will be installed for this library and any other library we
use with Bundler, but not for the system. This is an important distinction to make: any gem
installed by Bundler will not muck about with gems installed by gem install
. It is effectively
a sandboxed environment. It is best practice to use Bundler to manage our gems so that we do not
have gem version conflicts.
By running bundle install
, Bundler will generate the extremely important Gemfile.lock
file. This file is responsible for ensuring that every system this library is developed on has
the exact same gems so it should always be checked into version control. For more information
on this file read “THE GEMFILE.LOCK” section of the bundle install
manpage.
Additionally in the bundle install
output, we will see this line:
Using foodie (0.1.0) from source at /path/to/foodie
Bundler detects our gem, loads the gemspec and bundles our gem just like every other gem.
We can write our first test with this framework now in place. For testing, first we create a
folder called spec to put our tests in (mkdir spec
). We then create a new RSpec file for
every class we want to test at the root of the spec directory. If we had multiple facets to our
gem, we would group them underneath a directory such as spec/facet; but this is a simple gem,
so we won’t. Let’s call this new file spec/foodie_spec.rb
and fill it with the following:
describe Foodie::Food do
it "broccoli is gross" do
expect(Foodie::Food.portray("Broccoli")).to eql("Gross!")
end
it "anything else is delicious" do
expect(Foodie::Food.portray("Not Broccoli")).to eql("Delicious!")
end
end
When we run bundle exec rspec spec
again, we’ll be told the Foodie::Food
constant doesn’t
exist. This is true, and we should define it in lib/foodie/food.rb
like this:
module Foodie
class Food
def self.portray(food)
if food.downcase == "broccoli"
"Gross!"
else
"Delicious!"
end
end
end
end
To load this file, we’ll need to add a require line to lib/foodie.rb
for it:
require 'foodie/food'
We will also need to require the lib/foodie.rb
at the top of spec/foodie_spec.rb
:
require 'foodie'
When we run our specs with bundle exec rspec spec
this test will pass:
2 example, 0 failures
Great success! If we’re using Git (or any other source control system), this is a great checkpoint to commit our code. Always remember to commit often!
It’s all well and dandy that we can write our own code, but what if we want to depend on another gem? That’s easy too.
We’re now going to use Active Support’s pluralize
method by calling it using a method from our
gem.
To use another gem, we must first specify it as a dependency in our foodie.gemspec. We can
specify the dependency on the activesupport
gem in foodie.gemspec by adding this line inside
the Gem::Specification
object:
spec.add_dependency "activesupport"
If we wanted to specify a particular version we may use this line:
spec.add_dependency "activesupport", "4.2.0"
Or specify a version constraint:
spec.add_dependency "activesupport", ">= 4.2.0"
However, relying on a version simply greater than the latest-at-the-time is a sure-fire way to
run into problems later on down the line. Try to always use ~>
for specifying dependencies:
spec.add_dependency "activesupport", "~> 4.2.0"
When we run bundle install
again, the activesupport
gem will be installed for us to use. Of
course, like the diligent TDD/BDD zealots we are, we will test our pluralize
method before we
code it. Let’s add this test to spec/food_spec.rb now inside our describe Foodie::Food
block:
it "pluralizes a word" do
expect(Foodie::Food.pluralize("Tomato")).to eql("Tomatoes")
end
Of course when we run this spec with bundle exec rspec spec
it will fail:
expect(Failure/Error: Foodie::Food.pluralize("Tomato")).to eql("Tomatoes")
undefined method `pluralize' for Foodie::Food:Class
We can now define this pluralize
method in lib/foodie/food.rb by first off requiring the part
of Active Support which contains the pluralize
method. This line should go at the top of the
file, just like all good require
s do.
require 'active_support/inflector'
Next, we can define the pluralize
method like this:
def self.pluralize(word)
word.pluralize
end
When we run bundle exec rspec spec
our specs will pass:
3 examples, 0 failures
This brings another checkpoint where it’d be a good idea to commit our efforts so far.
It’s great that we’re able to call our gem’s methods now (all two of them!) and get them to return strings, but everybody knows that the best gems come with command line interfaces (hereafter, “CLI”). You can tell right now just how uncool this gem is because it doesn’t have a CLI, right? It needs one. It craves one.
It deserves one.
Before we go jumping headlong into giving our gem the best darn CLI a gem-with-only-two-methods-that-both-return-useless-strings can have, let’s consider how we’re going to test this first. We’re zealots, remember? Now if only there was a tool we could use. It would have to have a cool name, of course.
Like “Aruba”. BAM
David Chelimsky and Aslak Hellesøy teamed up to create Aruba, a CLI testing tool, which they both use for RSpec and Cucumber, and now we too can use it for testing our gems. Oh hey, speaking of Cucumber that’s also what we’re going to be using to define the Aruba tests. Human-code-client-readable tests are the way of the future, man.
We will define new development dependencies in foodie.gemspec now for the Cucumber things:
spec.add_development_dependency "cucumber"
spec.add_development_dependency "aruba"
Hot. Let’s run bundle install
to get these awesome tools set up.
Our CLI is going to have two methods, which correspond to the two methods which we have defined
in Foodie::Food
. We will now create a features directory where we will make sweet, sweet love
to Aruba to write tests for our CLI. In this directory we’ll create a new file called
features/food.feature and fill it with this juicy code:
Feature: Food
In order to portray or pluralize food
As a CLI
I want to be as objective as possible
Scenario: Broccoli is gross
When I run `foodie portray broccoli`
Then the output should contain "Gross!"
Scenario: Tomato, or Tomato?
When I run `foodie pluralize --word Tomato`
Then the output should contain "Tomatoes"
These scenarios test the CLI our gem will provide. In the When I run
steps, the first word
inside the quotes is the name of our executable, the second is the task name, and any further
text is arguments or options. Yes, it is testing what appears to be the same thing as our
specs. How very observant of you. Gold star! But it’s testing it through a CLI, which makes it
supremely awesome. Contrived examples are in this year.
The first scenario ensures that we can call a specific task and pass it a single argument which then becomes the part of the text that is output. The second scenario ensures effectively the same thing, but we pass that value in as an option rather than an argument.
To run this feature, we use the cucumber
command, but of course because it’s available within
the context of our bundle, we use bundle exec cucumber
like this:
bundle exec cucumber features/
See those yellow things? They’re undefined steps:
When /^I run "([^"]*)"$/ do |arg1|
pending # express the regexp above with the code you wish you had
end
Then /^the output should contain "([^"]*)"$/ do |arg1|
pending # express the regexp above with the code you wish you had
end
We can define them by requiring Aruba. In Cucumber, all .rb files in the features/support directory are automatically required. To prove this to ourselves, we can add a features/support/setup.rb file (create the support directory first) and put in this single line:
require 'aruba/cucumber'
This loads the Cucumber steps provided by Aruba which are the same steps our Cucumber features need to be awesome.
We have to re-run bundle exec cucumber features
, just to see what happens next. We see red. Red
like the blood incessantly seeping from the walls. It contains this cryptic message:
sh: foodie: command not found
OK, so it’s not that cryptic. It just means it can’t find the executable file for our gem. No
worries, we can create a exe directory at the root of our gem, and put a file in it named
foodie. This file has no extension because it’s an executable file rather than a script. We
don’t want to go around calling foodie.rb
everywhere, do we? No, no we don’t. We will fill this
file with this content:
#!/usr/bin/env ruby
print "nothing."
If this file was completely empty, we would run into a non-friendly Errno::ENOEXEC
error. Hey,
speaking of running, we should chmod
this file to be an executable from our terminal:
chmod +x exe/foodie
Alright so we’ve got the executable file, now what? If we re-run our features we get nothing for the output. Nothing! Literally!
got: "nothing."
Our exe/foodie file is empty, which results in this Nothing Travesty. Get rid of the print
"nothing."
line and replace it with all the code required to run our CLI, which consists of two
lines:
require 'foodie/cli'
Foodie::CLI.start
Boom! When we run bundle exec cucumber features
again it will whinge that there’s no
foodie/cli file to require. Before we go into what this file does, we should explain the code
on the other line of the exe/foodie file. The start
method fires up our CLI
class and
will look for a task that matches the one we ask for.
Ok, so it’s therefore obvious that the next step is to create this file, but what does it do?
This new lib/foodie/cli.rb file will define the command line interface using another gem called
Thor
. Thor was created by Yehuda Katz (& collaborators) as an alternative to the Rake build
tool. Thor provides us with a handy API for defining our CLI, including usage banners and help
output. The syntax is very similar to Rake. Additionally, Rails and Bundler both use Thor for
their CLI interface as well as their generator base. Yes, Thor even does generators!
For now we’ll just look at how we can craft a CLI using Thor and then afterwards, if you behave, we’ll look at how to write a generator using it too.
To make this CLI work we’re going to need to create a Foodie::CLI
class and define a start
method on it. Or you know, there’s probably a gem out there for us to use. Like
Thor. Named after the badass lightning god from Norse mythology,
this gem is definitely on the fast-track to being just as badass. This gem is what we’ll be using
to build our CLI interface and then later on the generator (if you behave, remember?).
Let’s define the lib/foodie/cli.rb
file now like this:
require 'thor'
module Foodie
class CLI < Thor
end
end
The Thor
class has a series of methods – such as the start
method we reference back in
exe/foodie
– that we can use to create this CLI. Oh, by the way, our class doesn’t have to be
called CLI
, it’s just best practice to do so. We don’t magically get this Thor
class; we need
to tell our gemspec that we depend on this gem by adding this line underneath our previous
add_dependency
:
spec.add_dependency "thor"
To install this new dependency, we use bundle install
. When we run bundle exec cucumber
features
again, we’ll see that it’s now complaining that it could not find the tasks we’re
calling:
Could not find task "portray"
...
Could not find task "pluralize"
Thor tasks are defined as plain ol’ methods, but with a slight twist. To define the portray
task in our Foodie::CLI
class we will write this inside the Foodie::CLI
class:
desc "portray ITEM", "Determines if a piece of food is gross or delicious"
def portray(name)
puts Foodie::Food.portray(name)
end
The desc
method is the “slight twist” here. The method defined after it becomes a task with the
given description. The first argument for desc
is the usage instructions for the task whilst
the second is the short description of what that task accomplishes. The portray
method is
defined with a single argument, which will be the first argument passed to this task on the
command line. Inside the portray
method we call Foodie::Food.portray
and pass it this
argument.
In the Foodie::CLI
class we’re referencing the Foodie::Food
class without requiring the file
that defines it. Under the require 'thor'
at the top of this file, put this line to require the
file that defines Foodie::Food
:
require 'foodie'
When we re-run our features using bundle exec cucumber features
our first scenario will pass:
2 scenarios (1 failed, 1 passed)
4 steps (1 failed, 3 passed)
The second is still failing because we haven’t defined the pluralize
task. This time rather
than defining a task that takes an argument, we’ll define a task that reads in the value from an
option passed to the task. To define the pluralize
task we use this code in Foodie::CLI
:
desc "pluralize", "Pluralizes a word"
method_option :word, :aliases => "-w"
def pluralize
puts Foodie::Food.pluralize(options[:word])
end
Here there’s the new method_option
method we use which defines, well, a method option. It takes
a hash which indicates the details of an option how they should be returned to our task. Check
out the Thor README for a full list of valid types. We can also define aliases for this method
using the :aliases
option passed to method_option
. Inside the task we reference the value of
the options through the options
hash and we use Foodie::Food.pluralize
to pluralize a word.
When we run our scenarios again with bundle exec cucumber features
both scenarios will be
passing:
2 scenarios (2 passed)
4 steps (4 passed)
We can try executing the CLI app by running bundle exec exe/foodie portray broccoli
.
If we want to add more options later on, we can define them by using the method_options
helper
like this:
method_options :word => :string, :uppercase => :boolean
def pluralize
# accessed as options[:word], options[:uppercase]
end
In this example, options[:word]
will return a String
object, whilst options[:uppercase]
will return either true
or false
, depending on the value it has received.
This introduction should have whet your appetite to learn more about Thor and it’s encouraged
that you do that now. Check out Bundler::CLI
for a great example of using Thor as a CLI tool.
With our features and specs all passing now, we’re at a good point to commit our code.
It was aforementioned that we could use Thor for more than just CLI. That we could use it to create a generator. This is true. We can even create generators, but let’s not get too carried away right now and just focus on creating the one.
You saw that pun coming, right? Yeah, pretty obvious.
We’re going to mix it up a bit and add a new feature to our gem: a generator for a recipes directory. The idea is that we can run our generator like this:
foodie recipe dinner steak
This will generate a recipes directory at the current location, a dinner directory inside that and then a steak.txt file inside that. This steak.txt file will contain the scaffold for a recipe, such as the ingredients and the instructions.
Thankfully for us, Aruba has ways of testing that a generator generates files and directories.
Let’s create a new file called features/generator.feature
and fill it with this content:
Feature: Generating things
In order to generate many a thing
As a CLI newbie
I want foodie to hold my hand, tightly
Scenario: Recipes
When I run `foodie recipe dinner steak`
Then the following files should exist:
| dinner/steak.txt |
Then the file "dinner/steak.txt" should contain:
"""
##### Ingredients #####
Ingredients for delicious steak go here.
##### Instructions #####
Tips on how to make delicious steak go here.
"""
It’s important to note that the word after “delicious” both times is “steak”, which is very delicious. It’s also the last argument we pass in to the command that we run, and therefore should be a dynamic variable in our template. We’ll see how to do this soon.
When we run this feature we’ll be told that it cannot find the dinner/steak.txt file that we asked the generator to do. Why not?
Well, because currently we don’t have a recipe
task that does this for us defined in
Foodie::CLI
. We can define a generator class just like we define a CLI class:
desc "recipe", "Generates a recipe scaffold"
def recipe(group, name)
Foodie::Generators::Recipe.start([group, name])
end
The first argument for this method are the arguments passed to the generator. We will need to require the file for this new class too, which we can do by putting this line at the top of lib/foodie/cli.rb:
require 'foodie/generators/recipe'
To define this class, we inherit from Thor::Group
rather than Thor
. We will also need to
include the Thor::Actions
module to define helper methods for our generator which include the
likes of those able to create files and directories. Because this is a generator class, we will
put it in a new namespace called “generators”, making the location of this file
lib/foodie/generators/recipe.rb:
require 'thor/group'
module Foodie
module Generators
class Recipe < Thor::Group
include Thor::Actions
argument :group, :type => :string
argument :name, :type => :string
end
end
end
By inheriting from Thor::Group
, we’re defining a generator rather than a CLI. When we call
argument
, we are defining arguments for our generator. These are the same arguments in the same
order they are passed in from the recipe
task back in Foodie::CLI
To make this generator, ya know, generate stuff we simply define methods in the class. All
methods defined in a Thor::Group
descendant will be run when start
is called on it. Let’s
define a create_group
method inside this class which will create a directory using the name we
have passed in.
def create_group
empty_directory(group)
end
To put the file in this directory and to save our foodie-friends some typing, we will use the
template
method. This will copy over a file from a pre-defined source location and evaluate it
as if it were an ERB template. We will define a copy_recipe
method to do this now:
def copy_recipe
template("recipe.txt", "#{group}/#{name}.txt")
end
If we had any ERB calls in this file, they would be evaluated and the result would be output in the new template file.
It’s been an awful long time since we ran something. Hey, here’s an idea! Let’s run our
generator! We can do this without using Cucumber by running bundle exec exe/foodie recipe dinner
steak
, but just this once. Generally we’d test it solely through Cucumber. When we run this
command we’ll be told all of this:
create dinner
Could not find "recipe.txt" in any of your source paths. Please invoke Foodie::Generators::Recipe.source_root(PATH) with the PATH containing your templates. Currently you have no source paths.
The first line tells us that the dinner directory has been created. Nothing too fancy there.
The second line is more exciting though! It’s asking us to define the source_root
method for
our generator. That’s easy! We can define it as a class method in Foodie::Generators::Recipe
like this:
def self.source_root
File.dirname(__FILE__) + "/recipe"
end
This tells our generator where to find the template. Now all we need to do is to create the template, which we can put at lib/foodie/generators/recipe/recipe.txt:
##### Ingredients #####
Ingredients for delicious <%= name %> go here.
##### Instructions #####
Tips on how to make delicious <%= name %> go here.
When we use the template
method, the template file is treated like an ERB template which is
evaluated within the current binding
which means that it has access to the same methods and
variables as the method that calls it.
And that’s all! When we run bundle exec cucumber features
all our features will be passing!
3 scenarios (3 passed)
7 steps (7 passed)
Amazing stuff, hey?
If we haven’t already, we should commit all the files for our repository:
git add .
git commit -m "The beginnings of the foodie gem"
This is because the foodie.gemspec
file uses git ls-files
to detect which files should be
added to the gem when we release it.
The final step before releasing our gem is to give it a summary and description in the foodie.gemspec file.
Now we’re going to make sure that our gem is ready to be published. To do this, we can run rake
build
which will build a local copy of our gem and then gem install pkg/foodie-0.1.0.gem
to
install it. Then we can try it locally by running the commands that it provides. Once we know
everything’s working, then we can release the first version.
To release the first version of our gem we can use the rake release
command, providing we have
committed everything. This command does a couple of things. First it builds the gem to the pkg
directory in preparation for a push to Rubygems.org.
Second, it creates a tag for the current commit reflecting the current version and pushes it to the git remote. It’s encouraged that we host the code on GitHub so that others may easily find it.
If this push succeeds then the final step will be the push to Rubygems.org which will now allow other people to download and install the gem.
If we want to release a second version of our gem, we should make our changes and then commit
them to GitHub. Afterwards, we will bump the version number in lib/foodie/version.rb to
whatever we see fit, make another commit to GitHub with a useful message such as “bumped to
0.0.2” and then run rake release
again.
If we want to make this process a little easier we could install the “gem-release” gem with:
gem install gem-release
This gem provides several methods for helping with gem development in general, but most helpful
is the gem bump
command which will bump the gem version to the next patch level. This method
also takes options to do these things:
gem bump --version minor # bumps to the next minor version
gem bump --version major # bumps to the next major version
gem bump --version 1.1.1 # bumps to the specified version
For more information, check out the “gem-release” GitHub repository homepage.
Nie jest to może stuprocentowa instrukcja jak dewelopować gem, to jednak zawiera podstawowe elementy. Naprawdę zachęcam, żeby sprawdzić źródła Bundler-a, Rails-ów i RSpec-a. Są tam świetne przykłady pokazujące, jak pisać swojego własnego gem-a.
Kompletne źródła tego przykładu możesz znaleźć tutaj
Autor: Ryan Bigg. Możesz znaleźć więcej jego poradników tutaj. Przetłumaczył: Jakub Kruczek