ようへいの日々精進XP

よかろうもん

AWS SDK for Ruby を利用した CLI ツールのサンプル的なものを実装検討した

tl;dr

AWS SDK for Ruby を使ってコマンドラインツールを作る時にテストまで含めた雛形みたいなのがあったら楽だよなーと思いつつ, ソフトバンク日本シリーズを制したり, 楽しそうな JAWS FESTA の様子が SNS のタイムラインに流れてくるのを羨ましく横目に見つつ, もくもくサンプル的な何かを作ってみました.

作ったもの

github.com

これ.

EC2 インスタンス ID 一覧, S3 バケット名一覧を返すだけのあくまでもサンプル的なものとなります.

$ bundle exec sample-cli --help
Commands:
  sample-cli buckets         # list up bucket name.
  sample-cli help [COMMAND]  # Describe available commands or one specific command
  sample-cli input WORD      # input words print.
  sample-cli instances       # list up instance ids.
  sample-cli version         # version print.

環境変数AWS_PROFILE と AWS_REGION を定義して利用することを想定しています.

$ export AWS_PROFILE=xxxxxxxxxxxxxxxxxxx
$ export AWS_REGION=ap-northeast-1
$ bundle exec sample-cli instances
i-xxxxxxxxxxxxxxxx1
i-xxxxxxxxxxxxxxxx2
i-xxxxxxxxxxxxxxxx3
i-xxxxxxxxxxxxxxxx4
i-xxxxxxxxxxxxxxxx5
$ bundle exec sample-cli buckets
bucket-a
bucket-b
bucket-c
bucket-d
bucket-e
...

で, コマンドラインツールを作る上でいろいろと検討した内容を以下の通り書いていきたいと思います. 誤りや認識不足がありおかしなことが書かれているかと思いますので, コメント等で指摘いただければ幸いでございます.

やっぱり Thor

そーなんです

コマンドラインツールを作る際に引数の処理等を良しなに面倒を見てくれる erikhuda/thor がとても便利だと思います. 例えば, 上述の例で実行されている instancesbuckets のサブコマンドをメソッドとして記述することで, 簡単にサブコマンドを追加することが出来ます.

以下, 実装例です.

module SampleCli
  class CLI < Thor
    desc 'version', 'version print.'
    def version
      puts SampleCli::VERSION
    end

    desc 'input WORD', 'input words print.'
    def input(word = nil)
      puts 'Please input `word`' if word.nil?
      puts word
    end

    desc 'instances', 'list up instance ids.'
    def instances
      ec2 = SampleCli::Ec2.new
      puts ec2.instances
    end

    desc 'buckets', 'list up bucket name.'
    def buckets
      s3 = SampleCli::S3.new
      puts s3.buckets
    end
  end
end

Thor クラスを継承して, 上記のように実装しておくと, 以下のような感じでサブコマンド化してくれます.

$ bundle exec sample-cli --help
Commands:
  sample-cli buckets         # list up bucket name.
  sample-cli help [COMMAND]  # Describe available commands or one specific command
  sample-cli input WORD      # input words print.
  sample-cli instances       # list up instance ids.
  sample-cli version         # version print.

これだけでも十分にテンションが上がります.

あとは...

各サブコマンドで呼ばれる処理を実装していくことで, それなりにコマンドラインツールが数分で出来上がってしまいます.

コードをどーやって分割するか

あくまでも

好みの問題になってしまうかもしれませんが, 今回は以下のようにファイルを分割してみました. よく利用するツールのフォルダ構成を程よく参考させて頂きました.

$ tree -L 3 .
.
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
│   ├── console
│   └── setup
├── exe
│   └── sample-cli
├── lib
│   ├── sample_cli
│   │   ├── cli.rb
│   │   ├── client.rb
│   │   ├── ec2.rb
│   │   ├── s3.rb
│   │   ├── stub
│   │   ├── stub.rb
│   │   └── version.rb
│   └── sample_cli.rb
├── sample-cli.gemspec
├── spec
│   ├── default_spec.rb
│   ├── ec2_spec.rb
│   ├── s3_spec.rb
│   └── spec_helper.rb
└── vendor
    └── bundle
        └── ruby
  • lib/sample_cli/cli.rb には個々のサブコマンドを実装 (ここを見れば, このコマンドにはどんなサブコマンドがあるか解る)
  • lib/sample_cli/client.rb には個々のサブコマンドで利用する処理の初期化処理 (主に AWS SDK for Ruby の認証処理とか) を実装
  • lib/sample_cli/ec2.rb にはサブコマンド instances で利用する処理を実装
  • lib/sample_cli/s3.rb にはサブコマンド buckets で利用する処理を実装
  • lib/sample_cli/stub.rb には stub ディレクトリ以下のスタブを読み込む処理を実装 (テストについては後述)
  • lib/sample_cli/stub/*.rb には instancesbuckets の処理内容をテストする際に利用するスタブを定義 (テストについは後述)
  • lib/sample_cli/sample_cli.rb には, 上記の各ファイルを require する処理を実装

という感じです.

ですので...

今後, S3 オブジェクトの一覧を取得するサブコマンドを追加する場合には, lib/sample_cli/cli.rb に objects という名前のメソッドを追加して, lib/sample_cli/s3.rb に実際の処理を追加していく感じを想定しています.

# lib/sample_cli/cli.rb に以下を追加
...
desc 'objects', 'list up S3 objects'
option :bucket, type: :string, aliases: '-b', desc: 'S3 バケットを指定する.'
def objects
  s3 = SampleCli::S3.new
  puts s3.objects(option[:bucket])
end
...
  
# lib/sample_cli/s3.rb に以下を追加
def objects(bucket)
  puts list_objects(bucket)
end

private

def list_objects(bucket)
  objects = []
  options = { bucket: bucket }
  loop do
    res = s3.list_objects_v2(options)
    objects << res.contents.map(&:key)
    options[:continuation_token] = res.next_continuation_token
    break unless options[:continuation_token]
  end
  objects
end

尚, stub まわりの処理については, awspec を参考にさせて頂きました. 有難うございました.

テスト

コマンドラインの実行をどのようにテストするか (1)

Thor のテストが参考になることを最近知りました ありがとうございます. コマンド出力を以下の capture というメソッドで取得して, その出力をパースして評価しているようです.

# https://github.com/erikhuda/thor/blob/master/spec/helper.rb#L51-L62
...
  def capture(stream)
    begin
      stream = stream.to_s
      eval "$#{stream} = StringIO.new"
      yield
      result = eval("$#{stream}").string
    ensure
      eval("$#{stream} = #{stream.upcase}")
    end

    result
  end
...

このメソッドは, eval で文字列をコマンドとして実行されるようになっていて, 一見, 何をやっているか良く解りませんでしたが, よく見ると以下のような処理になっています.

$stdout = StringIO.new  # 標準出力の出力先を StringIO クラスに変更
yield                    # ブロックで渡された処理を実行する
output = $stdout.string # 標準入力を変数 output に代入
$stdout = STDOUT        # 標準出力の出力先を STDOUT に戻す (デフォルトが STDOUT なので)

これをそのまま spec_helper.rb に押し込んでおいて, テストには以下のように実装しました.

describe 'sample_cli check subcommand objects' do
  it 'have object keys by cli' do
    output = capture(:stdout) { SampleCli::CLI.start(%w{objects --bucket=foo}) }
    expect(output).to match('foo\nbar\nbaz/key\n')
  end
end

このテストの場合, 先述のオブジェクト一覧を取得する objects サブコマンドの正常系テストを想定しています.

sample-cli objects --bucket=foo

これを実行すると以下のようにテストがパスします.

$ bundle exec rake spec:s3
...

sample_cli check subcommand buckets
  have bucket names
  have bucket names by cli

sample_cli check subcommand objects
  have object keys
  have object keys by cli

Finished in 0.11143 seconds (files took 3.24 seconds to load)
4 examples, 0 failures

コマンドラインの実行をどのようにテストするか (2)

これは, Docker を利用して仮想的な AWS 環境, Ruby の実行環境を用意し, 実際にコマンドを AWS 環境に対して実行する方法を検討しました. これについては, 別の記事でまとめたいと思います.

スタブ

2 つのアプローチ

AWS 環境に対するテストを行う場合, 実際の AWS リソースを叩くというのは出来るだけ避けたいところです. もちろん, テスト用の環境を AWS 上に用意して実際にリソースを叩くことでより精度の高いテスト結果を得られる可能性があると思いますが, うっかり変更してはいけない環境を操作してしまう事故の可能性も 0 ではありませんし, 利用料が発生することも有り得ます. これらを解決する方法として, 以下の 2 点のアプローチが取れると考えています.

  1. Docker で仮想的な AWS 環境を用意する (いつか別の記事で書く予定です)
  2. スタブを利用する

1 については, 前節の「コマンドラインの実行をどのようにテストするか (2)」でも触れていますが, localstack や moto 等のローカル環境に AWS 環境を実行するツールを Docker 上で起動して, その環境に対して実際のコマンドを発行して結果を解析してテストを行います.

AWS SDK for Ruby におけるスタブ

本節 (今回) は 2 のスタブを利用したテストについて検討しています. スタブを利用する場合, AWS SDK for Ruby ではクライアントを初期化する際に以下のように指定することで, スタブ化されたデータを返すようになります.

require 'aws-sdk'

s3 = Aws::S3::Client.new(stub_responses: true)
...

詳細は以下のドキュメントに記載されています.

docs.aws.amazon.com

このドキュメントによると, スタブ化した場合, 特にデータを用意しない場合には, 以下のようなデータを返却するとのことです.

  • リストは空の配列
  • マップは空のハッシュ
  • 数値は 0
  • 日付は now

例えば, S3 バケットの一覧を取得する list_buckets というメソッドをスタブ化した場合には以下のような結果が返ってきます.

# スタブデータを用意しない場合
irb(main):001:0> require 'aws-sdk-s3'
=> true
irb(main):002:0> s3 = Aws::S3::Client.new(stub_responses: true)
=> #<Aws::S3::Client>
irb(main):003:0> s3.list_buckets
=> #<struct Aws::S3::Types::ListBucketsOutput buckets=[], owner=#<struct Aws::S3::Types::Owner display_name="DisplayName", id="ID">>
irb(main):004:0> s3.list_buckets.buckets
=> []

# スタブデータを用意した場合 (foo と bar というバケット名を返されることを想定する)
irb(main):005:0> bucket_data = s3.stub_data(:list_buckets, :buckets => [{name:'foo'}, {name:'bar'}])
=> #<struct Aws::S3::Types::ListBucketsOutput buckets=[#<struct Aws::S3::Types::Bucket name="foo", creation_date=nil>, #<struct Aws::S3::Types::Bucket name="bar", creation_date=nil>], owner=#<struct Aws::S3::Types::Owner display_name="DisplayName", id="ID">>
irb(main):006:0> s3.stub_responses(:list_buckets, bucket_data)
=> [{:data=>#<struct Aws::S3::Types::ListBucketsOutput buckets=[#<struct Aws::S3::Types::Bucket name="foo", creation_date=nil>, #<struct Aws::S3::Types::Bucket name="bar", creation_date=nil>], owner=#<struct Aws::S3::Types::Owner display_name="DisplayName", id="ID">>}]
irb(main):007:0> s3.list_buckets.buckets
=> [#<struct Aws::S3::Types::Bucket name="foo", creation_date=nil>, #<struct Aws::S3::Types::Bucket name="bar", creation_date=nil>]
irb(main):008:0> s3.list_buckets.buckets.map(&:name)
=> ["foo", "bar"]

Rspec からスタブを利用する

今回は awspec の実装をそのまま参考にさせて頂いて, 以下のようにスタブが利用されるように実装を行いました.

  • spec/spec_helper.rbstub_responses: true を追加
# spec/spec_helper.rb
require 'rspec'
require 'sample_cli'

Aws.config.update(stub_responses: true)
...
  • スタブデータは lib/stub/${対象 AWS サービス}.rb を追加 (以下は S3 の list_bucketslist_object_v2 メソッドのスタブデータ)
# lib/stub/s3.rb
Aws.config[:s3] = {
  stub_responses: {
    list_buckets: {
      buckets: [
        {
          name: 'foo'
        },
        {
          name: 'bar'
        }
      ]
    },
    list_objects_v2: {
      contents: [
        {
          key: 'foo'
        },
        {
          key: 'bar'
        },
        {
          key: 'baz/key'
        }
      ]
    }
  }
}
  • スタブデータを各テストに読み込む為の小さなクラスメソッドを用意
# lib/stub.rb
module SampleCli
  class Stub
    def self.load(type)
      require File.dirname(__FILE__) + '/stub/' + type
    end
  end
end
  • テストは以下のように実装
require 'spec_helper'
SampleCli::Stub.load 's3'

describe 'sample_cli check subcommand buckets' do
  it 'have bucket names' do
    expect(SampleCli::S3.new.buckets).to eq(%w(foo bar))
  end

  it 'have bucket names by cli' do
    output = capture(:stdout) { SampleCli::CLI.start(%w{buckets}) }
    expect(output).to match('foo\nbar\n')
  end
end

describe 'sample_cli check subcommand objects' do
  it 'have object keys' do
    expect(SampleCli::S3.new.objects('foo')).to match(%w(foo bar baz/key))
  end

  it 'have object keys by cli' do
    output = capture(:stdout) { SampleCli::CLI.start(%w{objects --bucket=foo}) }
    expect(output).to match('foo\nbar\nbaz/key\n')
  end
end

上記の通り, テストはサブコマンドを実行した場合 (have bucket names by clihave object keys by cli) と, サブコマンドから呼ばれるメソッド (have bucket nameshave object keys) に対してテストを行っています. これを以下のように実行してテストします.

$ bundle exec rake spec:s3
...

sample_cli check subcommand buckets
  have bucket names
  have bucket names by cli

sample_cli check subcommand objects
  have object keys
  have object keys by cli

Finished in 0.11505 seconds (files took 3.01 seconds to load)
4 examples, 0 failures

いい感じです.

rubocop 警察

せっかくなので, rubocop でコーディングスタイルに準拠しているかもチェックしています. 例えば, 以下のようなコードは警察の取締対象となります.

  it "have object keys by cli" do
    output = capture(:stdout) { SampleCli::CLI.start(%w{objects --bucket=foo}) }
    expect(output).to match("foo\nbar\nbaz/key\n")
  end

容疑は Prefer single-quoted strings when you don't need string interpolation or special symbols. です. 実際の取締の様子です.

$ bundle exec rake spec:rubocop
Running RuboCop...
Inspecting 18 files
.....C............

Offenses:

spec/s3_spec.rb:20:6: C: Prefer single-quoted strings when you don't need string interpolation or special symbols.
  it "have object keys by cli" do
     ^^^^^^^^^^^^^^^^^^^^^^^^^

18 files inspected, 1 offense detected
RuboCop failed!

釈放される為にはダブルクウォートをシングルクォートに書き換えることで釈放されます.

  it 'have object keys by cli' do
    output = capture(:stdout) { SampleCli::CLI.start(%w{objects --bucket=foo}) }
    expect(output).to match('foo\nbar\nbaz/key\n')
  end

釈放の様子です.

$ bundle exec rake spec:rubocop
Running RuboCop...
Inspecting 18 files
..................

18 files inspected, no offenses detected

いい感じですね.

Travis CI

こちらもせっかくなので Travis CI でテストを走らせます. .travis.yml は以下の通りです.

sudo: false
language: ruby
rvm:
  - 2.5.1
before_install: gem install bundler -v 1.16.2

テスト結果は以下の通りです.

f:id:inokara:20181105082953p:plain

以上

AWS SDK for Ruby を利用した CLI ツールのサンプル的なものを実装検討してみました. 素人のたわごとになりますので, いろいろとツッコミどころがあるとは思いますが, これをテンプレートとしてより良いオレオレツールが作れるようになると嬉しいなあ. あと, 先人が書かれたコードを読むというのは本当に勉強になりました.