- tl;dr
- 作ったもの
- やっぱり Thor
— そーなんです
— あとは… - コードをどーやって分割するか
— あくまでも
— ですので… - テスト
— コマンドラインの実行をどのようにテストするか (1)
— コマンドラインの実行をどのようにテストするか (2)
— スタブ
—– 2 つのアプローチ
—– AWS SDK for Ruby におけるスタブ
—– Rspec からスタブを利用する
— rubocop 警察
—– Travis CI - 以上
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 がとても便利だと思います. 例えば, 上述の例で実行されている instances
や buckets
のサブコマンドをメソッドとして記述することで, 簡単にサブコマンドを追加することが出来ます.
以下, 実装例です.
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 には
instances
やbuckets
の処理内容をテストする際に利用するスタブを定義 (テストについは後述) - 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 点のアプローチが取れると考えています.
- Docker で仮想的な AWS 環境を用意する (いつか別の記事で書く予定です)
- スタブを利用する
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) ...
詳細は以下のドキュメントに記載されています.
このドキュメントによると, スタブ化した場合, 特にデータを用意しない場合には, 以下のようなデータを返却するとのことです.
- リストは空の配列
- マップは空のハッシュ
- 数値は 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.rb に
stub_responses: true
を追加
# spec/spec_helper.rb require 'rspec' require 'sample_cli' Aws.config.update(stub_responses: true) ...
- スタブデータは lib/stub/${対象 AWS サービス}.rb を追加 (以下は S3 の
list_buckets
やlist_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 cli
や have object keys by cli
) と, サブコマンドから呼ばれるメソッド (have bucket names
や have 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
テスト結果は以下の通りです.
以上
AWS SDK for Ruby を利用した CLI ツールのサンプル的なものを実装検討してみました. 素人のたわごとになりますので, いろいろとツッコミどころがあるとは思いますが, これをテンプレートとしてより良いオレオレツールが作れるようになると嬉しいなあ. あと, 先人が書かれたコードを読むというのは本当に勉強になりました.