2018-02-19

bundlerコードリーディング【exec編】

bundler (1.16.1)のコードリーディングをしました。今回は bundle exec のコードを追ってみます。

exe/bundleはBundler::CLI.startを呼び出します

#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler"

# ...
require "bundler/friendly_errors"
Bundler.with_friendly_errors do
  require "bundler/cli"

  # Allow any command to use --help flag to show help for that command
  help_flags = %w[--help -h]
  help_flag_used = ARGV.any? {|a| help_flags.include? a }
  args = help_flag_used ? Bundler::CLI.reformatted_help_args(ARGV) : ARGV

  Bundler::CLI.start(args, :debug => true)
end

Bundler::CLIはThorを継承しています

require "bundler"
require "bundler/vendored_thor"

module Bundler
  class CLI < Thor

    def self.start(*)
      super
    rescue Exception => e
      Bundler.ui = UI::Shell.new
      raise e
    ensure
      Bundler::SharedHelpers.print_major_deprecations!
    end

execコマンドを実行すると#execが実行されます。#execはBundler::CLI::Exec#runを実行します。

    desc "exec [OPTIONS]", "Run the command in context of the bundle"
    method_option :keep_file_descriptors, :type => :boolean, :default => false
    long_desc <<-D
      Exec runs a command, providing it access to the gems in the bundle. While using
      bundle exec you can require and call the bundled gems as if they were installed
      into the system wide RubyGems repository.
    D
    map "e" => "exec"
    def exec(*args)
      require "bundler/cli/exec"
      Exec.new(options, args).run
    end

Bundler::CLI::Exec#runはBundler::SharedHelpers.set_bundle_environmentで環境変数を操作してから、Bundler.whichでコマンドのパスを検索してkernel_execで実行します(rubyのシェバンが付いていてdisable_exec_loadがfalseの場合はkernel_loadを実行します)

# frozen_string_literal: true

module Bundler
  class CLI::Exec

    def run
      validate_cmd!
      SharedHelpers.set_bundle_environment
      if bin_path = Bundler.which(cmd)
        if !Bundler.settings[:disable_exec_load] && ruby_shebang?(bin_path)
          return kernel_load(bin_path, *args)
        end
        # First, try to exec directly to something in PATH
        if Bundler.current_ruby.jruby_18?
          kernel_exec(bin_path, *args)
        else
          kernel_exec([bin_path, cmd], *args)
        end
      else
        # exec using the given command
        kernel_exec(cmd, *args)
      end
    end

Bundler.whichは引数のファイルが存在しているか、実行可能かどうかを現在のパスや環境変数PATHから判別して存在すれば引数のファイル名をそのまま返します。

module Bundler
  class << self
    def which(executable)
      if File.file?(executable) && File.executable?(executable)
        executable
      elsif paths = ENV["PATH"]
        quote = '"'.freeze
        paths.split(File::PATH_SEPARATOR).find do |path|
          path = path[1..-2] if path.start_with?(quote) && path.end_with?(quote)
          executable_path = File.expand_path(executable, path)
          return executable_path if File.file?(executable_path) && File.executable?(executable_path)
        end
      end
    end

kernel_execはその名の通りexec、kernel_loadはloadします。

    def kernel_exec(*args)
      ui = Bundler.ui
      Bundler.ui = nil
      Kernel.exec(*args)
# ...
    end

    def kernel_load(file, *args)
      args.pop if args.last.is_a?(Hash)
      ARGV.replace(args)
      $0 = file
      Process.setproctitle(process_title(file, args)) if Process.respond_to?(:setproctitle)
      ui = Bundler.ui
      Bundler.ui = nil
      require "bundler/setup"
      signals = Signal.list.keys - RESERVED_SIGNALS
      signals.each {|s| trap(s, "DEFAULT") }
      Kernel.load(file)
# ...
    end

Bundler::SharedHelpers.set_bundle_environmentは以下の環境変数の値を書き換えます

 module Bundler
  module SharedHelpers
    def set_bundle_environment
      set_bundle_variables
      set_path
      set_rubyopt
      set_rubylib
    end

    def set_bundle_variables
      begin
        Bundler::SharedHelpers.set_env "BUNDLE_BIN_PATH", Bundler.rubygems.bin_path("bundler", "bundle", VERSION)
      rescue Gem::GemNotFoundException
        Bundler::SharedHelpers.set_env "BUNDLE_BIN_PATH", File.expand_path("../../../exe/bundle", __FILE__)
      end

      # Set BUNDLE_GEMFILE
      Bundler::SharedHelpers.set_env "BUNDLE_GEMFILE", find_gemfile(:order_matters).to_s
      Bundler::SharedHelpers.set_env "BUNDLER_VERSION", Bundler::VERSION
    end

    def set_path
      validate_bundle_path
      paths = (ENV["PATH"] || "").split(File::PATH_SEPARATOR)
      paths.unshift "#{Bundler.bundle_path}/bin"
      Bundler::SharedHelpers.set_env "PATH", paths.uniq.join(File::PATH_SEPARATOR)
    end

    def set_rubyopt
      rubyopt = [ENV["RUBYOPT"]].compact
      return if !rubyopt.empty? && rubyopt.first =~ %r{-rbundler/setup}
      rubyopt.unshift %(-rbundler/setup)
      Bundler::SharedHelpers.set_env "RUBYOPT", rubyopt.join(" ")
    end

    def set_rubylib
      rubylib = (ENV["RUBYLIB"] || "").split(File::PATH_SEPARATOR)
      rubylib.unshift bundler_ruby_lib
      Bundler::SharedHelpers.set_env "RUBYLIB", rubylib.uniq.join(File::PATH_SEPARATOR)
    end

Bundler::SharedHelpers.set_envメソッドでは元の値をプレフィックス(EnvironmentPreserver::BUNDLER_PREFIX)を付けて環境変数に保存してから、ENVを上書きしています。

    def set_env(key, value)
      raise ArgumentError, "new key #{key}" unless EnvironmentPreserver::BUNDLER_KEYS.include?(key)
      orig_key = "#{EnvironmentPreserver::BUNDLER_PREFIX}#{key}"
      orig = ENV[key]
      orig ||= EnvironmentPreserver::INTENTIONALLY_NIL
      ENV[orig_key] ||= orig

      ENV[key] = value
    end

PATHに #{Bundler.bundle_path}/bin が設定されることで、bundle installしたライブラリの実行ファイルを実行できます。また、RUBYOPTに -rbundler/setup が設定されるので、ruby実行時にbundler/setupがrequireされることになります。

bundler/setupはBundler.setupを呼び出します

# frozen_string_literal: true

require "bundler/shared_helpers"

if Bundler::SharedHelpers.in_bundle?
  require "bundler"

  if STDOUT.tty? || ENV["BUNDLER_FORCE_TTY"]
    begin
      Bundler.setup
    rescue Bundler::BundlerError => e
# ...
  else
    Bundler.setup
  end

  # Add bundler to the load path after disabling system gems
  bundler_lib = File.expand_path("../..", __FILE__)
  $LOAD_PATH.unshift(bundler_lib) unless $LOAD_PATH.include?(bundler_lib)

  Bundler.ui = nil
end

Bundler.setupはBundler::Runtime#setupを呼び出します。#definitionを呼び出したタイミングでBundler.configureが呼ばれ、GEM_PATHとGEM_HOMEが設定されています。

module Bundler
  class << self
    def setup(*groups)
      # Return if all groups are already loaded
      return @setup if defined?(@setup) && @setup

      definition.validate_runtime!

      SharedHelpers.print_major_deprecations!

      if groups.empty?
        # Load all groups, but only once
        @setup = load.setup
      else
        load.setup(*groups)
      end
    end

Bundler::Runtime#setupでは$LOAD_PATHの操作を行います。

# frozen_string_literal: true

module Bundler
  class Runtime

    def setup(*groups)
# ...
      SharedHelpers.set_bundle_environment
      Bundler.rubygems.replace_entrypoints(specs)

      # Activate the specs
      load_paths = specs.map do |spec|
# ...
        spec.load_paths.reject {|path| $LOAD_PATH.include?(path) }
      end.reverse.flatten

      # See Gem::Specification#add_self_to_load_path (since RubyGems 1.8)
      if insert_index = Bundler.rubygems.load_path_insert_index
        # Gem directories must come after -I and ENV['RUBYLIB']
        $LOAD_PATH.insert(insert_index, *load_paths)
      else
        # We are probably testing in core, -I and RUBYLIB don't apply
        $LOAD_PATH.unshift(*load_paths)
      end

      setup_manpath

      lock(:preserve_unknown_sections => true)

      self
    end

specsはGemfileから取得した依存ライブラリの情報です。ここからload_pathsを取得し、$LOAD_PATHに追加します。これでRubyスクリプト上でbundlerでインストールした依存ライブラリのrequireができるようになります。

おまけ1:Bundler.with_clean_env

Bundler.with_clean_envを使うとbundle execやbundler/setupで設定した環境変数をリセットしてスクリプトを実行できます。
def with_clean_env
  with_env(clean_env) { yield }
end

original_envはENVのbundlerの設定前の値になります。設定前の判別はプレフィックス EnvironmentPreserver::BUNDLER_PREFIX がついている EnvironmentPreserver::BUNDLER_KEYS の環境変数から復元します。

def clean_env
  Bundler::SharedHelpers.major_deprecation(2, "`Bundler.clean_env` has weird edge cases, use `.original_env` instead")
  env = original_env

  if env.key?("BUNDLER_ORIG_MANPATH")
    env["MANPATH"] = env["BUNDLER_ORIG_MANPATH"]
  end

  env.delete_if {|k, _| k[0, 7] == "BUNDLE_" }

  if env.key?("RUBYOPT")
    env["RUBYOPT"] = env["RUBYOPT"].sub "-rbundler/setup", ""
  end

  if env.key?("RUBYLIB")
    rubylib = env["RUBYLIB"].split(File::PATH_SEPARATOR)
    rubylib.delete(File.expand_path("..", __FILE__))
    env["RUBYLIB"] = rubylib.join(File::PATH_SEPARATOR)
  end

  env
end

Bundler::EnvironmentPreserver#restoreで元のENV値を取得しています

# frozen_string_literal: true

module Bundler
  class EnvironmentPreserver

    # @return [Hash]
    def restore
      env = @original.clone
      @keys.each do |key|
        value_original = env[@prefix + key]
        next if value_original.nil? || value_original.empty?
        if value_original == INTENTIONALLY_NIL
          env.delete(key)
        else
          env[key] = value_original
        end
        env.delete(@prefix + key)
      end
      env
    end
  end
end

with_envは与えられたハッシュ値でENVをreplaceしブロックをyield後、backupからもとの環境変数に復元します。

# @param env [Hash]
def with_env(env)
  backup = ENV.to_hash
  ENV.replace(env)
  yield
ensure
  ENV.replace(backup)
end

おまけ2:bundleで設定される環境変数を確認

$ bundle exec 'echo $PATH' | grep -o ${PWD}/vendor/bundle/ruby/2.5.0/bin:
# => /path/to/project/vendor/bundle/2.5.0/bin:

$ bundle exec 'echo $BUNDLE_GEMFILE'
# => /path/to/project/Gemfile

$ bundle exec 'echo $BUNDLER_VERSION'
 # => 1.16.1

$ bundle exec 'echo $BUNDLE_BIN_PATH' 
# => ${HOME}/.rbenv/versions/2.5.0/lib/ruby/gems/2.5.0/gems/bundler-1.16.1/exe/bundle
&nbsp;
$ bundle exec 'echo $RUBYOPT' 
# => -rbundler/setup

$ bundle exec 'echo $RUBYLIB' 
# => ${HOME}/.rbenv/versions/2.5.0/lib/ruby/gems/2.5.0/gems/bundler-1.16.1/lib:
#    ${HOME}/.rbenv/plugins/gem-src/lib:
#    ${HOME}/.rbenv/rbenv.d/exec/gem-rehash

$ bundle exec ruby -e 'puts ENV["GEM_HOME"]'
# => /path/to/project/vendor/bundle/ruby/2.5.0
このエントリーをはてなブックマークに追加