In Progress
Unit 1, Lesson 1
In Progress

Optional Gem

Video transcript & code

Let's say we've written a little command line application which takes a URL and returns a shortened version.

./kiturl 'https://rubytapas.com'

We've taken a… novel algorithmic approach with this particular program.

In the interests of increasing the level of positivity in the world, no matter what URL the user provides, we return a shortened URL which resolves to emergencykittens.com.

#!/usr/bin/env ruby
if url = ARGV[0]
  short_url = "http://bit.ly/JwaEub"
  puts "Here is your shortened URL:"
  puts short_url
else
  puts "Usage: #{$0} URL_TO_SHORTEN"
end

One day, we think of a new feature we'd like this program to have. Instead of forcing the user to copy and paste from the command line, we'd like to have it automatically copy the URL to the system clipboard.

This is straightforward enough. As it happens there's a Rubygem that does all the hard work for us.

So all we have to do is require the gem.

And then add some code to copy the URL to the clipboard and inform the user.

#!/usr/bin/env ruby
require "clipboard"

if url = ARGV[0]
  short_url = "http://bit.ly/JwaEub"
  puts "Here is your shortened URL:"
  puts short_url
  Clipboard.copy(short_url)
  puts "URL has also been copied to the clipboard."
else
  puts "Usage: #{$0} URL_TO_SHORTEN"
end

This is fine, except for one little thing: we've now added a required dependency to our gem. It's for a feature that's nice to have, but not essential.

Wouldn't it be cool if we could enable the clipboard functionality if the gem is installed, but continue working without it if not?

Here's how we can make that possible.

First, we move the require statement right before the code that uses the clipboard gem.

require will raise a LoadError if it can't find the requested loadable feature.

So we surround the whole section with a begin…rescue…end construct.

For the rescue, we specify that it should only be looking for LoadError exceptions.

In the rescue block, we add a note to remind ourselves that it's an intentional no-op.

#!/usr/bin/env ruby
if url = ARGV[0]
  short_url = "http://bit.ly/JwaEub"
  puts "Here is your shortened URL:"
  puts short_url
  begin
    require "clipboard"
    Clipboard.copy(short_url)
    puts "URL has also been copied to the clipboard."
  rescue LoadError
    # NOOP
  end
else
  puts "Usage: #{$0} URL_TO_SHORTEN"
end

Let's try this out. We run it once, without the clipboard gem installed.

In the output we can see that the clipboard section was silently skipped.

./kiturl2 'https://rubytapas.com'

Then we install the clipboard gem and run it again.

This time, we see the notification that our URL has been copied to the clipboard.

gem install clipboard
./kiturl2 'https://rubytapas.com'

So. This works.

It's also one of the most common forms of optional loading that I've run into in Ruby projects in the wild.

But is it ideal? Not really. There's a lurking problem here, which is that we've specified no version information for the optional gem.

That means that if the user has only an old, incompatible version of the gem installed, it will be loaded and then likely cause a crash.

It also means that if the clipboard gem maintainer releases a new, 2.0 version with an incompatible API, and a user upgrades to this new version, our program will stop working right.

We need to specify a range of acceptable versions for this dependency. But this is an optional dependency loaded at runtime. We can't add this version specification to a .gemspec or a Gemfile.

What do we do? We add an invocation of the gem Kernel method. As arguments, we supply the name of the gem, and a string specifying the acceptable version range.

If you're not familiar with the gem method, what it does is activate a gem. Activation is not the same as loading. To activate a gem, Rubygems looks up where the gem can be found, and adds that directory to Ruby's load path. In order to find the right directory, it takes into account any version specification that was supplied.

I'm not going to explain the details of the version specification string today. If you've ever specified gem versions in a project Gemfile, it's the same syntax. The string we've used here specifies that we need a major version of 1. In other words, version 1.0, 1.0.1, or 1.2.3 would all be fine. But version 0.6.0 would be unacceptable, and so would version 2.0.0.

As I said before, this line simply activates the gem. We still need to load it, using require, on the next line.

But after the activation, we've effectively locked down the possibilities for which veresion of the gem gets loaded by the require.

Now that we are activating a gem, we change our rescue line to look for Gem::LoadError exceptions.

That's the exception we expect to see if no acceptable version could be activated.

#!/usr/bin/env ruby
if url = ARGV[0]
  short_url = "http://bit.ly/JwaEub"
  puts "Here is your shortened URL:"
  puts short_url
  begin
    gem "clipboard", "~> 1.0"
    require "clipboard"
    Clipboard.copy(short_url)
    puts "URL has also been copied to the clipboard."
  rescue Gem::LoadError
    # NOOP
  end
else
  puts "Usage: #{$0} URL_TO_SHORTEN"
end
gem uninstall clipboard
./kiturl3 'https://rubytapas.com'
gem install clipboard
./kiturl3 'https://rubytapas.com'

This version is robust and future-proof.

…although, if you're anything like me, you might still be bothered by the fact that we are using exception handling to deal with a non-exceptional circumstance. Isn't there some way to do this without waiting for an exception to be raised?

Yes, in fact, there is. I'm going to show it to you. But, be forewarned: I'm also going to tell you you probably shouldn't use it.

In order to eliminate the exceptions-as-control-flow smell, we get rid of the rescue section, and convert the =begin to an if.

For the if condition, we say:

Gem::Specification.find_all_by_name("clipboard", "~> 1.0").any?

This is a mouthfull! But it's fairly self-describing. This line tells Rubygems to look up any and all gem specifications it knows about matching the name "clipboard" and complying with the version specifier "~> 1.0". Then it asks the resulting collection if it has any elements.

#!/usr/bin/env ruby
if url = ARGV[0]
  short_url = "http://bit.ly/JwaEub"
  puts "Here is your shortened URL:"
  puts short_url
  if Gem::Specification.find_all_by_name("clipboard", "~> 1.0").any?
    gem "clipboard", "~> 1.0"
    require "clipboard"
    Clipboard.copy(short_url)
    puts "URL has also been copied to the clipboard."
  end
else
  puts "Usage: #{$0} URL_TO_SHORTEN"
end
gem uninstall clipboard
./kiturl4 'https://rubytapas.com'
gem install clipboard
./kiturl4 'https://rubytapas.com'

So there you go, that's the no-exception version. It works fine. And it gives us a nice reminder that Rubygems is not a black box! We can ask it about the gems it knows about, and get useful answers.

All that said, I ran this code past Eric Hodel. Eric is one of the creators and maintainers of Rubygems. He said he prefers the exception-rescuing version, because it's shorter, and because it doesn't require us to specify the version constraint twice.

So that brings us back around to this code.

#!/usr/bin/env ruby
if url = ARGV[0]
  short_url = "http://bit.ly/JwaEub"
  puts "Here is your shortened URL:"
  puts short_url
  begin
    gem "clipboard", "~> 1.0"
    require "clipboard"
    Clipboard.copy(short_url)
    puts "URL has also been copied to the clipboard."
  rescue Gem::LoadError
    # NOOP
  end
else
  puts "Usage: #{$0} URL_TO_SHORTEN"
end

If you ever want to insert an optional feature in your code that enables itself only when a certain gem is available, this is how to do it. Happy hacking!

Responses