¿Cómo funciona RubyGems?

by Gastón Ramos

Desde hace un tiempo que estoy interesado en conocer un poco más en detalle como funciona rubygems, así que hoy decidí comenzar a aprender más sobre rubygems y cómo las gemas son cargadas en memoria.

Bueno, comencemos:
Entro al intérprete interactivo de ruby e intento requerir la bioblioteca activemodel:


ruby-1.9.2-p136 :001 > Gem.loaded_specs
 => {}
ruby-1.9.2-p136 :002 > require 'active_model'
 => true 
ruby-1.9.2-p136 :003 > Gem.loaded_specs
 => {"activemodel"=>#<Gem::Specification name=activemodel version=3.0.4>, 
"activesupport"=>#<Gem::Specification name=activesupport version=3.0.4>, 
"builder"=>#<Gem::Specification name=builder version=2.1.2>, 
"i18n"=>#<Gem::Specification name=i18n version=0.5.0>}

Cómo podemos observar aparecen varias gemas nuevas cargadas, active_model y sus dependencias, pero ¿cómo es que esto se lleva cabo?

Bien si nos fijamos en /lib/rubygems.rb en el repo de rubygems al final de todo hace esto:


require 'rubygems/custom_require' unless Gem::GEM_PRELUDE_SUCKAGE

y si vamos al archivo lib/rubygems/custom_require.rb vemos que lo hace es redefinir el método require de Kernel, y activa la gema de esta forma:


Gem.try_activate(path)

podemos probar esto directamente en el intérprete:


ruby-1.9.2-p136 > Gem.try_activate 'active_resource'
=> true
ruby-1.9.2-p136 :007 > Gem.loaded_specs['activeresource']
 => #<Gem::Specification name=activeresource version=3.0.4> 


Entonces vamos de nuevo al archivo rubygems.rb y miramos como es que funciona Gem.try_activate:

Lo primero que hace es buscar la gema que estamos intentando requerir, y lo hace así:


 spec = Gem.searcher.find path

Gem.searcher es una instancia de Gem::GemPathSearcher entre otras cosas tiene la lista de todas las gemspecs instaldas para después poder hacér búsqueda con Gem::GemPathSearcher#find. Entonces siguiendo con Gem.try_activate, si encuentra alguna coincidencia ejecuta

Gem.activate spec.name, "= #{spec.version}"

como podemos ver saca la versión de la spec encontrada (que es la más alta instalada), entonces ahora vamos a ver

Gem.activate

Éste método retorna true si la gema fué activada, false si ya está cargada o una excepción en cualquier otro caso (no se pudo cargar por algún motivo). La manera en que activa la gema es agregando el path de la misma al $LOAD_PATH de ruby.


 def self.activate(gem, *requirements)
   if requirements.last.is_a?(Hash)
      options = requirements.pop
   else
      options = {}
   end
  
   # Esta línea parsea los requerimientos que vienen como argumento
   # y si no viene ninguno pone los defaults, los requerimientos no son
   # otra cosa que restricciones de la versión de la gema,
   # el requerimiento por defecto es >= 0

   requirements = Gem::Requirement.default if requirements.empty?
   
   # Básicamente Gem::Dependency es un contenedor de 
   # Gem name, requirement

   dep = Gem::Dependency.new(gem, requirements)

   
   sources = options[:sources] || []

   # creo que esta línea require especial antención 
   # (veremos los detalles más adelante) pero por ahora nos 
   # alcanza con saber que Gem._unresolved crea una lista 
   # de objetos Gem::DependencyList (que primero está vacía) 
   # de dependecias sin resolver
   
   matches = _unresolved.find_all { |spec| spec.satisfies_requirement? dep }

   if matches.empty? then
      matches = Gem.source_index.find_name(dep.name, dep.requirement)

      # Si no ecuentra la gema buscando con el nombre y los 
      # requerimientos reporta el error
      report_activate_error(dep) if matches.empty?
      
      # Si la gema ya está cargada (cualquier versión)
      if @loaded_specs[dep.name] then
        # This gem is already loaded.  If the currently loaded gem is not in the
        # list of candidate gems, then we have a version conflict.
        existing_spec = @loaded_specs[dep.name]
        
        # Pregunta si la gema que está cargada es la misma versión 
        # que la  quiero activar
        unless matches.any? { |spec| spec.version == existing_spec.version } then
          sources_message = sources.map { |spec| spec.full_name }
          stack_message = @loaded_stacks[dep.name].map { |spec| spec.full_name }

          msg = "can't activate #{dep} for #{sources_message.inspect}, "
          msg << "already activated #{existing_spec.full_name} for "
          msg << "#{stack_message.inspect}"

          e = Gem::LoadError.new msg
          e.name = dep.name
          e.requirement = dep.requirement

          raise e
        end
        
        # La gema que quiero cargar ya está cargada
        return false
      end
    end
    
    # La gema no está cargada entonces activa la última versión
    spec = matches.last

    conf = spec.conflicts
    unless conf.empty? then
      why = conf.map { |k,v| "#{k} depends on #{v.join(", ")}" }.join ", "

      raise LoadError, "Unable to activate #{spec.full_name}, but #{why}"
    end

    return false if spec.loaded?

    spec.loaded = true
    @loaded_specs[spec.name]  = spec
    @loaded_stacks[spec.name] = sources.dup

    spec.runtime_dependencies.each do |spec_dep|
      next if Gem.loaded_specs.include? spec_dep.name
      specs = Gem.source_index.search spec_dep, true

      _unresolved.add(*specs)
    end

    current = Hash.new { |h, name| h[name] = Gem::Dependency.new name }
    Gem.loaded_specs.each do |n,s|
      s.runtime_dependencies.each do |s_dep|
        current[s_dep.name] = current[s_dep.name].merge s_dep
      end
    end

    _unresolved.remove_specs_unsatisfied_by current

    require_paths = spec.require_paths.map do |path|
      File.join spec.full_gem_path, path
    end

    # gem directories must come after -I and ENV['RUBYLIB']
    insert_index = load_path_insert_index

    if insert_index then
      # gem directories must come after -I and ENV['RUBYLIB']
      $LOAD_PATH.insert(insert_index, *require_paths)
    else
      # we are probably testing in core, -I and RUBYLIB don't apply
      $LOAD_PATH.unshift(*require_paths)
    end

    return true
  end


Si alguien encuentra algún en error en mi seguimiento hasta acá le pido que haga un comentario en este post, Continuará…