Tbpgr Blog

Employee Experience Engineer tbpgr(てぃーびー) のブログ

3つのgemの事例から学ぶRubyにおけるコマンドラインツール設計技法

f:id:tbpg:20161105051712p:plain

3つのgemからRubyCLIツールの設計について学びます。
今回コールドリーディングをするのは

  • bundler
  • itamae
  • serverkit

です。

学習対象

bundler

bundler は Ruby のパッケージ管理ツールです

Repository

github.com

Code Reading

CLI Framework

処理の流れ

  • exe/bundle

コマンドラインからの呼び出しをもとに入力を受付け、
Bundler:CLI.start に受け渡しています。

# 略
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
  • lib/bundler/cli.rb

thor の機能で説明文、オプションが設定されています。
後続の処理に必要となるファイル ( lib/bundler/cli/init.rb ) のみを require し、
Init.new で実際のコマンドの処理を呼び出しています。

module Bundler
  class CLI < Thor
    include Thor::Actions

# 中略

    desc "init [OPTIONS]", "Generates a Gemfile into the current working directory"
    long_desc <<-D
      Init generates a default Gemfile in the current working directory. When adding a
      Gemfile to a gem with a gemspec, the --gemspec option will automatically add each
      dependency listed in the gemspec file to the newly created Gemfile.
    D
    method_option "gemspec", :type => :string, :banner => "Use the specified .gemspec to create the Gemfile"
    def init
      require "lib/bundler/cli/init"
      Init.new(options.dup).run
    end
  • lib/bundler/cli/init.rb

Gemfileのテンプレートを生成するコードが記述されています

# frozen_string_literal: true
module Bundler
  class CLI::Init
    attr_reader :options
    def initialize(options)
      @options = options
    end

    def run
# 中略
        FileUtils.cp(File.expand_path("../../templates/Gemfile", __FILE__), "Gemfile")
      end
    end
  end

構成

コマンドラインツールとしての基本的な構成に関わる部分のみを抜粋しています。

.
├── exe
└── lib
        └── bundler
            └── cli
  • exe

実際に実行されるコマンドを配置する場所。

  • lib/bundler/cli

bundle コマンドで実行する各コマンドがファイル単位で保存されている。
例えば bundle init は lib/bundler/cli/init.rb
bundle exec は lib/bundler/cli/exec.rb

itamae

Itamae は軽量な構成管理ツールです。
同様のツールとしてはPuppet,Chef,Ansible, Serverkit などがあります。

Repository

github.com

Code Reading

CLI Framework

処理の流れ

  • bin/itamae

コマンドラインからの呼び出しをもとに入力を受付け、
Itamae:CLI.start に受け渡しています。

#!/usr/bin/env ruby

require 'itamae/cli'
Itamae::CLI.start
  • lib/itamae/cli.rb

thor の機能で説明文、オプションが設定されています。
Runner.run(recipe_files, backend_type, options) で実際のコマンドの処理を呼び出しています。
ツールの特性上、 itamae ssh / itamae local / itamae docker など
複数のコマンドで似た処理やオプションを利用するため重複処理をDRYにしたメソッドが作成されています。
Itamae::CLI.define_exec_optionsItamae::CLI#run など

require 'itamae'
require 'thor'

module Itamae
  class CLI < Thor
# 略
    desc "ssh RECIPE [RECIPE...]", "Run Itamae via ssh"
    define_exec_options
    option :host, type: :string, aliases: ['-h']
    option :user, type: :string, aliases: ['-u']
    option :key, type: :string, aliases: ['-i']
    option :port, type: :numeric, aliases: ['-p']
    option :ssh_config, type: :string
    option :vagrant, type: :boolean, default: false
    option :ask_password, type: :boolean, default: false
    option :sudo, type: :boolean, default: true
    def ssh(*recipe_files)
      if recipe_files.empty?
        raise "Please specify recipe files."
      end

      unless options[:host] || options[:vagrant]
        raise "Please set '-h <hostname>' or '--vagrant'"
      end

      run(recipe_files, :ssh, options)
    end
# 略
    desc "init NAME", "Create a new project"
    def init(name)
      generator = Generators::Project.new
      generator.destination_root = name
      generator.invoke_all
    end
# 略
    private
# 略
    def run(recipe_files, backend_type, options)
      runner = Runner.run(recipe_files, backend_type, options)
      if options[:detailed_exitcode] && runner.diff?
        exit 2
      end
    end
  end
end
  • lib/itamae/runner.rb, lib/itamae/generators.rb

runnner.rb には itamae local, itamae ssh, itamae docker の実処理が実装されています。
generators.rb には itamae init, itamae generate, itamae destroy の実処理が実装されています。

構成

コマンドラインツールとしての基本的な構成に関わる部分のみを抜粋しています。

├── bin
├── ci
└── lib
        └── itamae
            ├── ext
            ├── generators
            │   └── templates
            ├── handler
            └── resource
  • bin

実際に実行されるコマンドを配置する場所。

  • lib/itamae

bundle コマンドで実行する各コマンドがファイル単位で保存されている。
例えば itamae local, itamae ssh, itamae docker は lib/itamae/runner.rb
itamae init, itamae generate, itamae destroy は lib/itamae/generators.rb

  • lib/generators

ファイル生成関連の個別のファイルに対する処理が保存されている。

└── lib
        └── itamae
                 └── generators
                           ├── cookbook.rb
                           ├── project.rb
                           └── role.rb
  • lib/generators/templates

生成されるファイルのテンプレートが保存されている。

└── lib
        └── itamae
                 └── generators
                            └── templates
                                ├── cookbook
                                │   ├── default.rb
                                │   ├── files
                                │   └── templates
                                ├── project
                                │   ├── Gemfile
                                │   ├── cookbooks
                                │   └── roles
                                └── role
                                    ├── default.rb
                                    ├── files
                                    └── templates

Serverkit

Serverkit は軽量な構成管理ツールです。
同様のツールとしてはPuppet,Chef,Ansible, Itamae などがあります。

Repository

github.com

Code Reading

CLI Framework

処理の流れ

  • bin/serverkit

コマンドラインからの呼び出しをもとに入力を受付け、
Serverkit::Command.new に受け渡しています。

#!/usr/bin/env ruby

$LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__))
require "serverkit"

Serverkit::Command.new(ARGV).call
  • lib/serverkit/command.rb

call 時に第一引数によって処理を振り分けます。

# 略
module Serverkit
# 略
  class Command
# 略
    def call
      setup
      case action_name
      when nil
        raise Errors::MissingActionNameArgumentError
      when "apply"
        apply
      when "check"
        check
      when "inspect"
        _inspect
      when "validate"
        validate
      else
        raise Errors::UnknownActionNameError, action_name
      end
    rescue Errors::Base, Psych::SyntaxError, Slop::MissingArgumentError, Slop::MissingOptionError => exception
      abort "Error: #{exception}"
    end
# 略
  end
end

各分岐ごとに呼び出される private method から更に個別の処理が呼び出されます。

# 略
  def _inspect
    Actions::Inspect.new(action_options).call
  end
# 略
  def apply
    Actions::Apply.new(action_options).call
  end

  def check
    Actions::Check.new(action_options).call
  end

  def validate
    Actions::Validate.new(action_options).call
  end
  • lib/serverkit/actions

個別の処理は actions ディレクトリ配下に保存されています。
例えば apply.rb にはレシピを適用する処理が実装されています。

構成

コマンドラインツールとしての基本的な構成に関わる部分のみを抜粋しています。

.
├── bin
└── lib
        └── serverkit
            ├── serverkit
            └── errors
  • bin

実際に実行されるコマンドを配置する場所。

  • lib/serverkit/actions

各コマンドの実処理が保存される場所

  • lib/serverkit/errros

各種例外が保存される場所

ひとりごと

こんな設計もあるよ、なんであの gem の設計を紹介しないの?
という意見がある場合、あなたが書いた解説記事も読んでみたいです。
楽しみにしています。