Writing Ruby extensions in Rust: Part 1
Ruby is not known for its performance, and rewriting the whole application in a faster language is rarely an option in the real world. What to do if there are few bottlenecks in otherwise reasonably fast code? Native extensions to the rescue! Usually native extensions are written in C and there are some good tutorials, e.g. this one. But C code like below looks … scary?
#include <ruby.h>
#include <libclipboard.h>
#include "extconf.h"
static clipboard_c *cb = NULL;
VALUE set_text(VALUE _self, VALUE val) {
Check_Type(val, T_STRING);
VALUE result = Qnil;
char *text = clipboard_text(cb);
if (NULL != text) {
result = rb_str_new(text, strlen(text));
free(text);
}
if (false == clipboard_set_text(cb, StringValueCStr(val))) {
rb_raise(rb_eRuntimeError, "Failed to write to clipboard.");
}
return result;
}
VALUE get_text(VALUE _self) {
VALUE result = Qnil;
char *text = clipboard_text(cb);
if (NULL != text) {
result = rb_str_new(text, strlen(text));
free(text);
}
return result;
}
void Init_simple_clipboard() {
cb = clipboard_new(NULL);
if (NULL == cb) {
rb_raise(rb_eRuntimeError, "Failed to create clipboard context.");
}
VALUE mod = rb_define_module("SimpleClipboard");
rb_define_module_function(mod, "get_text", get_text, 0);
rb_define_module_function(mod, "set_text", set_text, 1);
}
Is it possible to avoid at least some of the boilerplate and maybe use a slighty more modern language than C? One possibility is to use Rust and helix. Let’s create simple gem from scratch. I assume that Ruby and bundler
are already installed (if you prefer system Ruby you will need ruby-dev
package (Ubuntu) or similar for your system). We also need to install Rust:
curl https://sh.rustup.rs -sSf | sh
Let’s start from creating gem template:
I chose name helix_csv
for a reason. Later I plan to convert this gem to fast CSV parser. After fixing helix_csv.gemspec
TODOs to make bundler
happy and changing module HelixCsv
to class HelixCSV
in lib
, we need to add helix
dependency:
and then run bundle install --path=vendor/bundle
to install required gems. Next we need to replace Rakefile
content with something like this:
require 'bundler/setup'
require 'helix_runtime/build_task'
HelixRuntime::BuildTask.new do |t|
end
task :default => :build
This will add tasks necessary to compile Rust code. Let’s add Rust template:
cargo
for Rust is what bundler
is for Ruby and much more. This command will create Cargo.toml
(which is Rust’s equivalent of Gemfile
) and Rust code template in src/lib.rs
. We need to add [lib]
section to Cargo.toml
since we need dynamic system library:
and helix
to the [dependencies]
section:
Before we can start writing Rust code we need to create a glue between Ruby and Rust in lib/helix_csv.rb
by adding these two lines to the top of that file:
Now we can finally write gem code. Let’s replace content of src/lib.rs
with:
#[macro_use]
extern crate helix;
ruby! {
class HelixCSV {
struct {
n: i64,
}
def initialize(helix, n: i64) {
HelixCSV { helix, n }
}
def n(&self) -> i64 {
self.n
}
def add(&mut self, m: i64) -> i64 {
self.n += m;
self.n
}
}
}
To build extension we can either run rake build
or just rake
since default target is build
anyway:
$ rake
{}
cargo rustc --release -- -C link-args=-Wl,-undefined,dynamic_lookup
Updating crates.io index
Compiling libcruby-sys v0.7.5
Compiling libc v0.2.43
Compiling cstr-macro v0.1.0
Compiling helix v0.7.5
Compiling helix_csv v0.1.0 (.../helix_csv)
Finished release [optimized] target(s) in 8.50s
Bundler has already created bin/console
which will load our gem, so we can do some testing:
$ bin/console
2.5.3 :001 > helix_csv = HelixCSV.new(1)
=> #<HelixCSV:0x00005587b6c562a8>
2.5.3 :002 > helix_csv.n
=> 1
2.5.3 :003 > helix_csv.add(4)
=> 5
2.5.3 :004 > helix_csv.n
=> 5
Not terribly interesting gem, but it works! We can’t package the gem yet as gem install
needs to build native extension and we need to instruct gem
how to do it. It’s not very complicated though, we only need to add spec.extensions = %w[extconf.rb]
to our helix_csv.gemspec
and script extconf.rb
to the root of our gem. The purpose of this script is to create Makefile
:
abort "Rust compiler required (https://www.rust-lang.org/)" if `which rustc`.empty?
File.open("Makefile", "wb") do |f|
f.puts(<<EOD)
all:
\tbundle --deployment --path vendor/bundle
\tbundle exec rake
clean:
install:
\trm -r vendor/bundle target
EOD
end
Since gem
packager is using git
to retrieve file list as a bare minimum we need to add files to the git cache (with appropriate .gitignore
):
Finaly we can package our gem:
Done! We can now publish the gem, or simply create new directory, add helix_csv-0.1.0.gem
to vendor/cache
, add something like this to Gemfile
:
then run:
$ bundle install --path=vendor/bundle
Fetching gem metadata from https://rubygems.org/....
Installing rake 12.3.1
Using bundler 1.17.1
Installing thor 0.20.3
Installing tomlrb 1.2.7
Installing helix_runtime 0.7.5 with native extensions
Installing helix_csv 0.1.0 with native extensions
Updating files in vendor/cache
Bundle complete! 1 Gemfile dependency, 6 gems now installed.
Bundled gems are installed into `./vendor/bundle`
$ bundle exec irb
2.5.3 :001 > require 'helix_csv'
=> true
2.5.3 :002 > helix_csv = HelixCSV.new(1)
=> #<HelixCSV:0x00005587b6c562a8>
2.5.3 :003 > helix_csv.n
=> 1
2.5.3 :004 > helix_csv.add(4)
=> 5
2.5.3 :005 > helix_csv.n
=> 5
In the part 2 I’m going to implement CSV parser based on Rust CSV crate and do some benchmarks.