In my last post, I covered how to get started writing code in C and wiring it up to your Ruby code. While that code technically will work in JRuby, it’s preferred to write native JRuby extensions in Java rather than in C. In this article we’ll add JRuby support to our gem.
Let’s start with the Java code itself. Just like we did in C, we need to define
the business logic itself as a Java method, and some glue code to set up modules
and expose the methods to the Ruby runtime. The JRuby APIs are fairly easy to
google, but getting things wired up correctly is quite challenging if you’re new
to Java. The slightly obtuse-looking wiring here mostly concerns getting a
reference to the current JRuby runtime, which parts of the API need (e.g. see
RubyArray.newArray()
).
Here’s the required code, which we put in
ext/faye_websocket/FayeWebSocketService.java
:
package com.jcoglan.faye;
import java.lang.Long;
import java.io.IOException;
import org.jruby.Ruby;
import org.jruby.RubyArray;
import org.jruby.RubyClass;
import org.jruby.RubyFixnum;
import org.jruby.RubyModule;
import org.jruby.RubyObject;
import org.jruby.anno.JRubyMethod;
import org.jruby.runtime.ObjectAllocator;
import org.jruby.runtime.ThreadContext;
import org.jruby.runtime.builtin.IRubyObject;
import org.jruby.runtime.load.BasicLibraryService;
public class FayeWebSocketService implements BasicLibraryService {
private Ruby runtime;
// Initial setup function. Takes a reference to the current JRuby runtime and
// sets up our modules. For JRuby, we will define mask() as an instance method
// on a specially created class, Faye::WebSocketMask.
public boolean basicLoad(Ruby runtime) throws IOException {
this.runtime = runtime;
RubyModule faye = runtime.defineModule("Faye");
// Create the WebSocketMask class. defineClassUnder() takes a name, a
// reference to the superclass -- runtime.getObject() gets you the Object
// class for the current runtime -- and an allocator function that says
// which Java object to constuct when you call new() on the class.
RubyClass webSocket = faye.defineClassUnder("WebSocketMask", runtime.getObject(), new ObjectAllocator() {
public IRubyObject allocate(Ruby runtime, RubyClass rubyClass) {
return new WebSocket(runtime, rubyClass);
}
});
webSocket.defineAnnotatedMethods(WebSocket.class);
return true;
}
// The Java class that backs the Ruby class Faye::WebSocketMask. Its methods
// annotated with @JRubyMethod become exposed as instance methods on the Ruby
// class through the call to defineAnnotatedMethods() above.
public class WebSocket extends RubyObject {
public WebSocket(final Ruby runtime, RubyClass rubyClass) {
super(runtime, rubyClass);
}
@JRubyMethod
public IRubyObject mask(ThreadContext context, IRubyObject payload, IRubyObject mask) {
int n = ((RubyArray)payload).getLength(), i;
long p, m;
RubyArray unmasked = RubyArray.newArray(runtime, n);
long[] maskArray = {
(Long)((RubyArray)mask).get(0),
(Long)((RubyArray)mask).get(1),
(Long)((RubyArray)mask).get(2),
(Long)((RubyArray)mask).get(3)
};
for (i = 0; i < n; i++) {
p = (Long)((RubyArray)payload).get(i);
m = maskArray[i % 4];
unmasked.set(i, p ^ m);
}
return unmasked;
}
}
}
There’s another strategy you can use for JRuby, which is more like FFI: you define the logic you want in pure Java, and then tell JRuby to expose that to Ruby, mapping data types between the two runtimes. I tried that for this problem and it ended up being slower, so I went with the approach above which uses the JRuby APIs directly.
Next thing we need is code to compile it. Find the Rakefile from the C example and modify it like so: we need to define a different compiler task based on which runtime we’re using.
# Rakefile
spec = Gem::Specification.load('faye-websocket.gemspec')
if RUBY_PLATFORM =~ /java/
require 'rake/javaextensiontask'
Rake::JavaExtensionTask.new('faye_websocket', spec)
else
require 'rake/extensiontask'
Rake::ExtensionTask.new('faye_websocket', spec)
end
You should be able to run rake compile
on JRuby and it should just work. It
will create a new file in lib
called faye_websocket.jar
, which is the
compiled Java bytecode package.
We also need a little more glue to load this code on JRuby, and we need some
additional logic to map our intended single method WebSocket.mask()
onto the
Java method WebSocketMask#mask()
. Create the file lib/faye.rb
with this
content:
# lib/faye.rb
# This loads either faye_websocket.so, faye_websocket.bundle or
# faye_websocket.jar, depending on your Ruby platform and OS
require File.expand_path('../faye_websocket', __FILE__)
module Faye
module WebSocket
if RUBY_PLATFORM =~ /java/
require 'jruby'
com.jcoglan.faye.FayeWebSocketService.new.basicLoad(JRuby.runtime)
def self.mask(*args)
@mask ||= WebSocketMask.new
@mask.mask(*args)
end
end
end
end
As you can see, we need to use the JRuby API to load the extension when running
on JRuby. This code will load the native code and then add any glue we need to
make everything work correctly. If you run rake compile && irb -r ./lib/faye
on either MRI or JRuby you’ll find that the Faye::WebSocket.mask()
method
works as expected.
Finally there’s the question of packaging. Unlike compiled C code, compiled Java code is portable to any JVM, so JRuby extensions are not compiled on site. Instead, you put the .jar file in your gem. Your gemspec needs to tell Rubygems to compile on site for MRI, but include the .jar for JRuby.
# faye-webosocket.gemspec
Gem::Specification.new do |s|
s.name = "faye-websocket"
s.version = "0.4.0"
s.summary = "WebSockets for Ruby"
s.author = "James Coglan"
files = Dir.glob("ext/**/*.{c,java,rb}") +
Dir.glob("lib/**/*.rb")
if RUBY_PLATFORM =~ /java/
s.platform = "java"
files << "lib/faye_websocket.jar"
else
s.extensions << "ext/faye_websocket/extconf.rb"
end
s.files = files
s.add_development_dependency "rake-compiler"
end
I’ve put "lib/faye_websocket.jar"
as an explicit addition rather than
including it in the Dir.glob
for two reasons: you don’t want it in the MRI
gem, and you want gem build
to fail if the file is missing when building on
JRuby, which can easily happen if you forget.
Now the final part, which isn’t obvious the first time you do this: you need to
release two gems, one generic one and one for JRuby. The s.platform = "java"
line means you’ll get a different gem file when building on JRuby. All you need
to do is build the gem on two different platforms, and remember to compile
before building the JRuby gems.
$ rbenv shell 1.9.3-p194
$ gem build faye-websocket.gemspec
Successfully built RubyGem
Name: faye-websocket
Version: 0.4.0
File: faye-websocket-0.4.0.gem
$ rbenv shell jruby-1.6.7
$ rake compile
install -c tmp/java/faye_websocket/faye_websocket.jar lib/faye_websocket.jar
$ gem build faye-websocket.gemspec
Successfully built RubyGem
Name: faye-websocket
Version: 0.4.0
File: faye-websocket-0.4.0-java.gem
Just push those two gem files to Rubygems.org, and you’re done.