Showing posts with label jruby. Show all posts
Showing posts with label jruby. Show all posts

Tuesday, July 17, 2012

The DRb JRuby Bridge

This is a technique that has proved useful when a Ruby application needs to call some Java code, but writing the application entirely in JRuby is not appropriate.

Examples:
* A required Ruby module is not compatible with JRuby (e.g. Qt)
* The Java code is only needed by optional parts of the system such as plugins

The solution is to use Distributed Ruby (DRb) to bridge between a Ruby application and a JRuby process. A JRuby application is written that contains a DRb service which acts as a facade for the Java code. A Ruby class provides an API for managing the JRuby process. The Ruby application uses the API to start the JRuby process, connect to the facade object via DRb, and stop the JRuby process when it is no longer needed.


The following example provides a simple implementation which provides access to the Apache Tika library. This is, of course, provided only for illustration purposes; Tika comes with a much more useful command-line utility, and a (J)ruby-tika project already exists.

The directory structure for this example is as follows:

  bin/test_app.rb
  lib/tika-app-1.1.jar
  lib/tika_service.rb
  lib/tika_service_jruby


The JRuby application, tika_service_jruby, requires the java module and the tika jar. It also uses the tika_service module in order to define default port numbers and such.


#!/usr/bin/env jruby


raise ScriptError.new("Tika requires JRuby") unless RUBY_PLATFORM =~ /java/


require 'java'
require 'tika-app-1.1.jar'
require 'tika_service'


# =============================================================================
module Tika


  # ------------------------------------------------------------------------
  # Namespaces for Tika plugins


  module ContentHandler
    Body = Java::org.apache.tika.sax.BodyContentHandler
    Boilerpipe = Java::org.apache.tika.parser.html.BoilerpipeContentHandler
    Writeout = Java::org.apache.tika.sax.WriteOutContentHandler
  end


  module Parser
    Auto = Java::org.apache.tika.parser.AutoDetectParser
  end


  module Detector
    Default = Java::org.apache.tika.detect.DefaultDetector
    Language = Java::org.apache.tika.language.LanguageIdentifier
  end


  Metadata = Java::org.apache.tika.metadata.Metadata


  class Service
    # ----------------------------------------------------------------------
    # JRuby Bridge


    # Number of clients connected to TikaServer
    attr_reader :usage_count


    def initialize
      @usage_count = 0
      Tika::Detector::Language.initProfiles
    end


    def inc_usage; @usage_count += 1; end


    def dec_usage; @usage_count -= 1; end


    def stop_if_unused; DRb.stop_service if (usage_count <= 0); end


    def self.drb_start(port)
      port ||= DEFAULT_PORT


      DRb.start_service "druby://localhost:#{port.to_i}", self.new
      puts "tika daemon started (#{Process.pid}). Connect to #{DRb.uri}"
     
      trap('HUP') { DRb.stop_service; Tika::Service.drb_start(port) }
      trap('INT') { puts 'Stopping tika daemon'; DRb.stop_service }


      DRb.thread.join
    end


    # ----------------------------------------------------------------------
    # Tika Facade


    def parse(str)
      input = java.io.ByteArrayInputStream.new(str.to_java.get_bytes)
      content = Tika::ContentHandler::Body.new(-1)
      metadata = Tika::Metadata.new


      Tika::Parser::Auto.new.parse(input, content, metadata)
      lang = Tika::Detector::Language.new(input.to_string)


      { :content => content.to_string, 
        :language => lang.getLanguage(),
        :metadata => metadata_to_hash(metadata) }
    end


    def metadata_to_hash(mdata)
      h = {}
      Metadata.constants.each do |name| 
        begin
          val = mdata.get(Metadata.const_get name)
          h[name.downcase.to_sym] = val if val
        rescue NameError
          # nop
        end
      end
      h
    end


  end
end


# ----------------------------------------------------------------------
# main()
Tika::Service.drb_start ARGV.first if __FILE__ == $0


The details of the Tika Facade are not of interest here. What is important for the technique is the if __FILE__ == 0 line, the drb_start class method, and the inc_usage, dec_usage, and stop_if_unused instance methods. These will be used by the Ruby tika_service module to manage the Tika::Service instance.

When run, this application starts a DRb instance on the requested port number, starts a Tika::Service instance, and returns a DRb Proxy object for that instance when a DRb client connects.


The Ruby module, tika_service.rb, uses fork-exec to launch a JRuby process running tika_service_jruby application. Note that the port number for the DRb service to listen on is passed as an argument to tika_service_jruby.

#!/usr/bin/env ruby

require 'drb'


module Tika


  class Service
   DAEMON = File.join(File.dirname(__FILE__), 'tika_service_jruby')
    DEFAULT_PORT = 44344
    DEFAULT_URI = "druby://localhost:#{DEFAULT_PORT}"
    TIMEOUT = 300    # in 100-ms increments


    # Return command to launch JRuby interpreter
    def self.get_jruby
      # 1. detect system JRuby
      jruby = `which jruby`
      return jruby.chomp if (! jruby.empty?)


      # 2. detect RVM-managed JRuby
      return nil if (`which rvm`).empty?
      jruby = `rvm list`.split("\n").select { |rb| rb.include? 'jruby' }.first
      return nil if (! jruby)


      "rvm #{jruby.strip.split(' ').first} do ruby "
    end


    # Replace current process with JRuby running Tika Service
    def self.exec(port)
      jruby = get_jruby
      Kernel.exec "#{jruby} #{DAEMON} #{port || ''}" if jruby


      $stderr.puts "No JRUBY found!"
      return 1
    end


    def self.start
      return @pid if @pid
      @pid = Process.fork do
        exit(::Tika::Service::exec DEFAULT_PORT)
      end
      Process.detach(@pid)


      connected = false
      TIMEOUT.times do
        begin
          DRb::DRbObject.new_with_uri(DEFAULT_URI).to_s
          connected = true
          break
        rescue DRb::DRbConnError
          sleep 0.1
        end
      end
      raise "Could not connect to #{DEFAULT_URI}" if ! connected
    end


    def self.stop
      service_send(:stop_if_unused)
    end


    # this will return a new Tika DRuby connection
    def self.service_send(method, *args)
      begin
        obj = DRb::DRbObject.new_with_uri(DEFAULT_URI)
        obj.send(method, *args)
        obj
      rescue DRb::DRbConnError => e
        $stderr.puts "Could not connect to #{DEFAULT_URI}"
        raise e
      end
    end


    def self.connect
      service_send(:inc_usage)
    end


    def self.disconnect
      service_send(:dec_usage)
    end


  end
end


The API provided by this module is straightforward: an application uses the start/stop class methods to execute or terminate the JRuby process as-needed, and the connect/disconnect methods to obtain (and free) a DRb Proxy object for the "remote" Tika::Service instance.

The only complications are in the detection of the JRuby interpreter (including support for RVM-managed interpreters) and the timeout while waiting for JRuby to initialize (which can take many seconds).



The test_app.rb example application is a simple proof-of-concept. It takes any number of filenames as arguments, and uses Tika to analyze the contents of each file. The results are printed to STDOUT via inspect.


#!/usr/bin/env ruby
require 'tika_service'


Tika::Service.start
begin
  tika = Tika::Service.connect
  ARGV.each { |x| File.open(x, 'rb') {|f| puts tika.parse(f.read).inspect} } if tika
ensure
  Tika::Service.disconnect
  Tika::Service.stop
end


The real meat of this technique lies in the tika_service module. This contains a Service class that will manage a JRuby application in a manner that conforms to an abstract Service API (start/stop, connect/disconnect) and which can be generalized to support any number of specific JRuby-based services.


Update: A generalized (but entirely untested) version is up on Github.

Wednesday, November 9, 2011

JRuby and RubyGems

For reasons too tangential to cover here, it was decided to port a Ruby 1.8.7 project to JRuby (>= 1.6.0). This worked well enough, except for the fatal flaw in JRuby: using gems.

To begin with, getting *any* gem with native extensions to build in JRuby requires some manual intervention.

The reason? The RVM JRuby has its Ruby include files under cext/src/include/, instead of lib/native/include like the other RVM Rubies. This appears to be a known issue.

A symlink handily solves the problem:

bash$ cd /usr/local/rvm/rubies/jruby-head
bash$ ln -s cext/src/include lib/native/include

Making this change will allow most gems with native extensions (e.q. pg, sqlite
3, etc) to be built.


The qtbindings gem, however, still fails with an error like the following:

bash$ sudo rvm jruby-head do gem install qtbindings                                
CMake Error at /usr/share/cmake-2.8/Modules/FindPackageHandleStandardArgs.cmake:91 (MESSAGE):
  Could NOT find Ruby (missing: RUBY_INCLUDE_DIR)
Call Stack (most recent call first):
  /usr/share/cmake-2.8/Modules/FindPackageHandleStandardArgs.cmake:252 (_FPHSA_FAILURE_MESSAGE)
  cmake/modules/FindRuby.cmake:249 (FIND_PACKAGE_HANDLE_STANDARD_ARGS)
  CMakeLists.txt:18 (FIND_PACKAGE)

-- Configuring incomplete, errors occurred!
make: [build] Error 1 (ignored)
cd ext/build; make
make[1]: Entering directory `/usr/local/rvm/gems/jruby-head/gems/qtbindings-4.6.3.4/ext/build'
make[1]: *** No targets specified and no makefile found.  Stop.
make[1]: Leaving directory `/usr/local/rvm/gems/jruby-head/gems/qtbindings-4.6.3.4/ext/build'
make: *** [build] Error 2

Unpacking the gem, fixing the extconf.rb to properly find JRuby, and repacking the gem gets past these problems, but ultimately fails when compiling the actual C extension code for Qt4.

The reason? Qt4 uses heavy inspection of the stack frame in order to implement its signal/slot mechanisms, and JRuby does not provide access to this information. Until equivalent functions for rb_frame_callee() and the like are provided by JRuby, Qt4 will remain incompatible with it.


Postscript: For the curious, the following lines (or variants thereof) can be added to extconf.rb to fix the Cmake build issues:

    file.puts "-DRUBY_EXECUTABLE=/usr/local/rvm/rubies/jruby-head/bin/jruby \\"
    file.puts "-DRUBY_LIBRARY=/usr/local/rvm/rubies/jruby-head/lib/native/x86_64-Linux/libjruby-cext.so \\"
    file.puts "-DRUBY_LIB_PATH=/usr/local/rvm/rubies/jruby-head/lib/native/x86_64-Linux \\"
    file.puts "-DRUBY_INCLUDE_DIR=/usr/local/rvm/rubies/jruby-head/cext/src/include/ruby \\"
    file.puts "-DRUBY_CONFIG_INCLUDE_DIR=/usr/local/rvm/rubies/jruby-head/cext/src/include/ruby \\"
    file.puts "-DCMAKE_INSTALL_PREFIX=/usr/local/rvm/rubies/jruby-head \\"

The lines go in the 'else' block of the 'if windows' condition, where the other cmake options are defined (around line 216, or search for '-DENABLE_SMOKE').