ンンンパ

ふとしです

移転しました

Go 言語でつくったボットを GitHub -> CircleCI -> Bluemix と自動デプロイできるようにした

Golang でのボット作成では、1 個目は自 PC で動かすことしか考えていませんでしたが、2 個目はきちんとどこかにデプロイすることを目標に作成していましたので、ついでに自動でやれるようにしました。

Heroku では 24 時間稼働のボット用途には不向きだと思われたので、IBM Bluemix にデプロイ先に選びました。Bluemix もデフォルトで Golang をサポートしていますが、ビルトインの buildpack だと Godeps のバージョンをきちんと読んでくれなかったり (これは気のせいかもしれない) したのでgithub にあるものを利用したりして色々とあれでしたが、うまく行きました。

circle.yml の様子。

machine:
  environment:
    REPO_ROOT: "${HOME}/.go_workspace/src/YOUR_DOMAIN/YOUR_PACKAGE_DIR"

dependencies:
  pre:
    - mkdir -p ${REPO_ROOT}
    - cp -rf ./* ${REPO_ROOT}
    - go get github.com/tools/godep
    - curl -v -L -o cf-cli_amd64.deb 'https://cli.run.pivotal.io/stable?release=debian64&source=github'
    - sudo dpkg -i cf-cli_amd64.deb
    - cf -v

test:
  pre:
    - go vet ./...
  override:
    - cd ${REPO_ROOT} && godep go test ./...
  post:
    - cf api https://api.au-syd.bluemix.net
    - cf login -u $BLUEMIX_USER -p $BLUEMIX_PASSWORD
    - cf target -o $BLUEMIX_ORG -s $BLUEMIX_SPACE
    - cf a

deployment:
  production:
    branch: master
    commands:
      - cf push APP_NAME -b https://github.com/cloudfoundry/go-buildpack.git

とにかくプッシュ、テスト、デプロイまでをやれるようにとツギハギしたので、まだ無駄があると思いますが、とりあえずこれでアップできます。

つまずきポイントとしては、

  • ${HOME}/.go_workspace/src/ へリポジトリを展開しないと、go get -t -d -v ./... でリモートからパッケージ fetch しようとして死んだ (YOUR_DOMAIN を github ではないところにしているため、みつからない)
  • cf api でエリア指定を間違えた (だいたいの例はアメリカ南部になっているが、シドニーを使っている)
  • cf のインターフェースが gem install cf のものとちょっとちがう

などがありました。

基本的にはテストが通ったら cf で Bluemix にデプロイするというだけなので、すでに cf でのデプロイを済ませている人は、設定の際にあらためて考えることはない感じですね。

Slack で動いてるボットの処理が長い場合、フィードバックとしてインジケーターを出すということをやった

いま golang の練習用に作成しているボットには、URL をわたすと、そのサイトのキャプチャを撮影する機能があります。諸事情からボットのいるマシンとは別の場所、Heroku に設置していますが、起動が遅かったり、キャプチャ自体が遅かったりするので、ちゃんとやっていってるのかわかりません。そこで、ローディングインジケーターを出すことにしました。

できあがり

f:id:mmmpa:20161120045725g:plain

「こはる」とは社の Slack にいるボットですが、対抗して golang でつくっているのが「ごはる」です。かわいいですね。

アイコンの元ネタには以下を使わせてもらっております。

github.com

インジケーター

まずインジケーターを作成しました。実際はベタッと書いてから切りだしましたが、とにかくまずインジケーターを用意します。できあがりを想像してワクワクするのが原動力です。

type CaptureLoadingIndicator struct {
    indicators []string
    length     int
    step       int
}

func (c *CaptureLoadingIndicator) initialize(indicators []string) {
    c.indicators = indicators
    c.length = len(c.indicators)
    c.step = c.length - 1
}

func (c *CaptureLoadingIndicator) next() string {
    c.step = ((c.step + 1) % c.length)
    return c.indicators[c.step]
}

func createCaptureLoadingIndicator() *CaptureLoadingIndicator {
    c := (&CaptureLoadingIndicator{})
    c.initialize([]string{"▖", "▘", "▝", "▗"})

    return c
}

goroutine

キャプチャ機能では、キャプチャされた画像は Heroku からダイレクトに Slack に投稿されますが、キャプチャできたかどうかは Response で受けとれます。よって、Heroku へのリクエスト開始をインジケーターの開始、Response が帰ってくれば終了するとします。

細かい処理ははぶいて、goroutine 部です。http.PostForm は同期的に処理されますから、基本的に以降の処理をブロックします。これを goroutine に追いだして本プロセスとは並行に行いつつ、終わるまでの間はインジケーターを表示します。

Big Sky :: golang の channel を使ったテクニックあれこれを参考に、処理が終わるまで待つ処理を書きます。

// インジケーターを表示するメッセージの TS を取得する
var ind slack.ChatPostMessagesResponse
slack.ChatPostMessage.Strike(&ind, string(m.Channel), "starting...")

// 以下で goroutine ポーリングする
q := make(chan CaptureServerResult, 2)

go func() {
    // Heroku へリクエスト開始
    resp, err := http.PostForm(captureServer, values)
    // リクエスト終わり
    q <- CaptureServerResult{resp, err}
}()

indicator := createCaptureLoadingIndicator()
for {
    if len(q) > 0 {
        break
    }
    // 上記で取得した TS をターゲットとして、chat.update をかける
    slack.ChatUpdate.Strike(nil, string(m.Channel), ind.TS, fmt.Sprintf("loading `%v`", indicator.next()))
    // update 間隔は加減しましょう
    time.Sleep(200 * time.Millisecond)
}

slack.ChatUpdate.Strike(nil, string(m.Channel), ind.TS, "loaded")

resp := <- q
// response をもとにした処理が続く

<- q は処理をブロックしてしまうので、なんの動きもない重い処理、待ちの時に他の処理をさせておきたい場合はチェッカーとしては使えないんですね。

しかし、Channel をうまく利用することにより、簡単に実現することが出来ました (先達の知識に感謝)。こういうのは大体、開始や interval 処理ではなく、終了がめんどくさかったりするのですが、それが特に問題にならずよかったです。

あと func(){}() ができるのが色々便利ですね。

Ruby 外から Web アクセスする何か (Capybara とか、cli とか) を RSpec でテストするときのアクセス先をモックする

mmmpa.mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmpa.net

のテストをこの方法で行いました。

本編

たとえば、Capybara は各種ブラウザを介するアクセスのため、webmock が効かず、別の gem が必要なのは有名です。 https://github.com/oesmith/puffing-billy

Capybara テストする際はこれでいいのですが、Capybara テストする場合には不便かもしれませんね。

また、バッククォートや Open3 を用いて呼びだすような cli、たとえば siege などのターゲットをモックすることはできません。cli におんぶにだっこの gem を開発するときのテストに、かなり不便ですね。

RSpec 開始時にサーバーをたてる

望んだ結果を返すアクセス先があればいいので、テスト中に起動する仮想サーバーをたてます。

public ディレクトリに適当な HTML を用意した上で、

RSpec.configure do |config|
  require 'webrick'
  config.before(:suite) do
    port = ENV['MOCK_PORT'] || 3000
    host = ENV['MOCK_HOST'] || '127.0.0.1'
    started = false

    Thread.start do
      WEBrick::HTTPServer.new(
        DocumentRoot: File.expand_path('./public/', __dir__),
        BindAddress: host,
        Port: port,
        AccessLog: [],
        StartCallback: ->{ started = true }
        Logger: WEBrick::Log::new("/dev/null", 7)
      ).tap { |server|
        Signal.trap(:INT) { server.shutdown }
        server.start
      }
    end

    while !started
      sleep 0.5
    end
  end
end

Thread はあたらしいものを用意しないと、sever.start の時点で RSpec のプロセスが停滞してしまいますので注意しましょう。

また、別 Thread になるので server.start が完了するしないにかかわらずテストに突入、テストが落ちるということがあるので、StartCallback を使って、きちんとサーバーがスタートしたことを確認してからはじめましょう。

単純なアクセス以外

さらに、リダイレクトなど、単純な HTML アクセス以外の処理が必要ならば、インスタンス servermount すると処理を追加できます。

# たとえばリダイレクト
# Rails を模するなら、301(Moved) ではなく 302(Found)
server.mount_proc('/redirect') do |req, res|
  res.set_redirect(WEBrick::HTTPStatus::Found, '/redirected.html')
end

できあがり

これで、以下のようなテストが、自由に行えるようになりました。

expect(`siege -t 10s http://127.0.0.1:3000/foo.html`).to be_truthy

例は siege 自体のテストのようになってしまっていますが、実際は取得した結果をあれこれしたものをテストしました。

雑ではありますが、とても楽に、多くのアクセス先を用意できるようになりました。

siege をラップして多少細かい情報をまとめる gem 書いた。

github.com

Usage

たとえば Rails 内でこうやる。

re = SiegeSiege.run(
      time: 20,
      concurrent: 4,
      user_agent: false,
      urls:
        [
          "http://localhost:3002#{students_path}",
          "http://localhost:3002#{students_path} POST name=abc",
          SiegeSiege::URL.new("http://localhost:3002#{students_path}", :post, {name: 'abc'}),
        ] + Student.ids.shuffle[0..2].map { |id| "http://localhost:3002#{student_path(id)}" }
    )

するとこういうのが得られる。

普通に使ってもコンソールに出てきてたやつ

re.total_result                                                                                                                                                                                                                                                                                                                                                                                  
=> {:command=>"siege -v -c 4 -t 20s -r 1 -R /tmp/20161030-21093-1lo07ca -f /tmp/20161030-21093-13f1kj6",
 :defaulting_to_time_based_testing=>{:value=>20.0, :unit=>"seconds"},
 :transactions=>{:value=>96.0, :unit=>"hits"},
 :availability=>{:value=>75.0, :unit=>"%"},
 :elapsed_time=>{:value=>19.47, :unit=>"secs"},
 :data_transferred=>{:value=>2.03, :unit=>"MB"},
 :response_time=>{:value=>0.8, :unit=>"secs"},
 :transaction_rate=>{:value=>4.93, :unit=>"trans/sec"},
 :throughput=>{:value=>0.1, :unit=>"MB/sec"},
 :concurrency=>{:value=>3.93, :unit=>""},
 :successful_transactions=>{:value=>96.0, :unit=>""},
 :failed_transactions=>{:value=>32.0, :unit=>""},
 :longest_transaction=>{:value=>10.65, :unit=>""},
 :shortest_transaction=>{:value=>0.01, :unit=>""}}

アクセスに使った URL 毎に平均レスポンス時間とか

re.average_log                                                                                                                                                                                                                                                                                                                                                                                   
=> [#<struct SiegeSiege::AverageLog id=6, url="http://localhost:3002/students/10763", count=17, secs=0.691, siege_url=#<SiegeSiege::URL:0x007fbee6efe870 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/10763">>,
 #<struct SiegeSiege::AverageLog id=0, url="http://localhost:3002/students", count=16, secs=1.276, siege_url=#<SiegeSiege::URL:0x007fbee6efeb18 @http_method=:get, @parameter={}, @url="http://localhost:3002/students">>,
 #<struct SiegeSiege::AverageLog id=5, url="http://localhost:3002/students/39284", count=16, secs=0.767, siege_url=#<SiegeSiege::URL:0x007fbee6efe938 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/39284">>,
 #<struct SiegeSiege::AverageLog id=7, url="http://localhost:3002/students/94576", count=17, secs=0.108, siege_url=#<SiegeSiege::URL:0x007fbee6efe7a8 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/94576">>,
 #<struct SiegeSiege::AverageLog id=3, url="http://localhost:3002/students", count=15, secs=1.367, siege_url=#<SiegeSiege::URL:0x007fbee6cd9518 @http_method=:post, @parameter={:name=>"abc"}, @url="http://localhost:3002/students">>,
 #<struct SiegeSiege::AverageLog id=1, url="http://localhost:3002/students", count=15, secs=0.651, siege_url=#<SiegeSiege::URL:0x007fbee6efea50 @http_method=:post, @parameter="name=abc", @url="http://localhost:3002/students">>]

標準出力をまとめたやつ

re.raw_log
=> [#<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=10.09, bytes=633, url="http://localhost:3002/students/10763", id=6, date=Sun, 30 Oct 2016 13:10:31 +0000, siege_url=#<SiegeSiege::URL:0x007fbee6efe870 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/10763">>,
 #<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=10.29, bytes=45579, url="http://localhost:3002/students", id=0, date=Sun, 30 Oct 2016 13:10:31 +0000, siege_url=#<SiegeSiege::URL:0x007fbee6efeb18 @http_method=:get, @parameter={}, @url="http://localhost:3002/students">>,
 #<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=10.24, bytes=633, url="http://localhost:3002/students/39284", id=5, date=Sun, 30 Oct 2016 13:10:31 +0000, siege_url=#<SiegeSiege::URL:0x007fbee6efe938 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/39284">>,
 #<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=0.25, bytes=633, url="http://localhost:3002/students/94576", id=7, date=Sun, 30 Oct 2016 13:10:41 +0000, siege_url=#
# 略

所感

カジュアルにテストできて便利ですね、みたいなツールで、詳細な情報を取ろうとするのはやめといた方がいいと思いました。(適切なやつをさがそう)

Capybara で Chromedriver をつかってモバイルモードでテストする時の Capybara.register_driver とか

だいたいのサイトは User Agent で切り替えてると思うので、わざわざモバイルモードでテストが必要なのかしらとか思わないでもない (ダブルタップとか、スワイプをテストする?)、が一応メモ。

Capybara に Driver として登録

# chromedriver configuration

# chrome の起動オプションが使える http://peter.sh/experiments/chromium-command-line-switches/
# デフォルトではサブのディスプレイに表示してしまうので、ずらしている
default_args = %w(
  --window-position=2560,0
)

# ブラウザの外枠 (スクリーンショット撮って計測しよう)
chrome_frame_offset = {
  w: 10,
  h: 86
}

# プリセットの商品のセッティングを使う場合。
# DevTools で選べるモバイルから必要なものをピックアップ。(リストデータくれよ)
[
  {name: 'Apple iPhone 6 Plus', w: 414, h: 736}, # :apple_iphone_6_plus
  {name: 'Google Nexus 7', w: 600, h: 960},      # :google_nexus_7
].map { |configure|
  configure[:w] += chrome_frame_offset[:w]
  configure[:h] += chrome_frame_offset[:h]
  configure
}.each do |configure|
  Capybara.register_driver configure[:name].gsub(' ', '_').downcase.to_sym do |app|
    Capybara::Selenium::Driver.new(
      app,
      browser: :chrome,
      desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
        'chromeOptions' => {
          args: [
            "--window-size=#{configure[:w]},#{configure[:h]}"
          ] + default_args,
          mobileEmulation: {deviceName: configure[:name]}
        }
      )
    )
  end
end

# 独自のセッティングでモバイルモードを使う場合
# これはイッパツで全画面をスクリーンショットしたかった時の設定
Capybara.register_driver :chrome_dummy_mobile do |app|
  w = 320
  h = 1600

  Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
      'chromeOptions' => {
        args: [
          "--window-size=#{w + chrome_frame_offset[:w]},#{h + chrome_frame_offset[:h]}"
        ] + default_args,
        mobileEmulation: {
          deviceMetrics: {
            width: w,
            height: h,
            pixelRatio: 2.0
          },
          userAgent:
            'Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1'
        }
      }
    )
  )
end

選んで使う

# 登録しておいた driver から選ぶ
Capybara.configure do |config|
  config.run_server = false
  config.default_driver = :apple_iphone_6_plus
  config.app_host = 'http://googole.com/'
end

RSpec とか Capybara の Example 内で使いたいメソッドをクラス単位でまとめて使いやすくする gem を書いた。

これ

github.com

先週の gem は 80 行ぐらいだったんですが、今回は 40 行です。

なぜ

入社してからこっち、ずっと Vue.js で SPA 制作という業務に従事していましたが、UI 系の宿命で (一部しか) テストがない。

そんななかで、わりと分岐の多い (難しくはない) 実装をすることになって、これは手作業では (変更の都度に) 確認しきれんなということで Capybara に出張ってもらっていました。テスト維持コスト問題というものがあるので、業務リポジトリには入っていない、ローカルでのみ存在する Capybara です。かわいいよ。

そんななかで findall を直書きしていると、途中での変更抜きにしても、見栄えがとてもつらいことになります。

経路によっては他の Example にも出てくるので、メソッド化をするわけですが、しかし、ページごとにちがう submit_button があったりするので、 registration_page_submit_buttonshopping_cart_submit_button など、なんとかして名前をかえていく必要がありまして、これが読みづらい。

というわけで Registration.submit_buttonShoppingCart.submit_button というように簡単にわけられるようにしました。

これが

def registration_page_submit_button
  find('button', text: 'Register Now!')
end

def shopping_cart_submit_button
  find('button', text: 'Buy Now!')
end

feature 'register then buy' do
  scenario do
    visit '/resistration'
    
    # input data to some form
    
    registration_page_submit_button.click

    visit '/cart'
    
    # input data to some form
    
    shopping_cart_submit_button.click
  end
end

こうなる

class RegistrationPage < RSpecMethodGrouping::Base
  def submit_button
   find('button', text: 'Register Now!')
  end
end
class ShoppingCart < RSpecMethodGrouping::Base
  def submit_button
   find('button', text: 'Buy Now!')
  end
end
feature 'register then buy' do
  scenario do
    visit '/resistration'
    
    # input data to some form
    
    RegistrationPage.submit_button.click
    
    visit '/cart'
    
    # input data to some form
    
    ShoppingCart.submit_button.click
  end
end

よみやすい!

かどうかは人によるかと思いますが、メソッドの管理がしやすくなったのでヨシとします。

この量ではわかりにくいかもしれませんが、他画面遷移 + 要素めちゃ多いとかになると、結構効いてきます。

テスト内の隠蔽どうなの問題

テストに使うデータを過度に隠蔽すると、テストの意図まで隠蔽されて、まるでわからないという問題があります。

しかし、これは、テストの主題ではなく、ページ内の要素を特定しておく (変更時はイッパツで対応できる) のが主目的なので、特に問題ないと思います。

もりもり Capybara でスクリーンショットを撮っていきましょう。

最近 SQL にはまっているので、ついでに Rails というか ActiveRecord で発行された SQL query をカウントする gem を書いた。

Installation

gem 'a_r_q_logger'

github.com

せつめい

ActiveRecord::Base サブクラスのインスタンス生成をできるだけ抑えれば、それだけ処理時間が抑えられるのは自明とされています。

そこで、今は、できるだけ少ない queries に抑えることを目的として SQL の練習をしていますので、すぐわかるようにしました。

その練習を進めていく上で、その 1 query の処理時間が気になっており、SQL のみの処理時間もとれるようにしましたというか、出発点はこっちです。

pry(main)> ARQLogger.log { 3.times { TestModel.has_children(10).map(&:children_count) } } 
=> #<struct ARQLogger::Result count=3, msec=1508.4>

from 句で前もって as しておくのと、CASEWHEN ごとに (複数の) 集約関数を書くのではどっちが速いのか気になって、こういうことをしていたのです。 (おそらくオプティマイザがいい感じにしてくれるおかげで、CASE に同じ式を何回も書くほうが速かった)

テストでも使える

query を抑えようと書いたかっこいい生 SQLが、善意の第三者により書きかえられるということはありがちなことですが、amount of queries を抑えようと書いた生 SQL が、その善意により too many queries になっては目もあてられません。

query 発行量はコントロールできる要素ですから、これはテストで縛っておくことができます。

たとえば以下は N + 1 の解決法でよく出てくる includes の付与テストです。

it do
  expect(ARQLogger.log {
    TestModel.includes(:test_child_models).all.each { |m| m.test_child_models.map(&:name) }
  }.count).to eq(2)
end
it do
  expect(ARQLogger.log {
    TestModel.all.each { |m| m.test_child_models.map(&:name) }
  }.count).to eq(11)
end

久しぶりに gem を書くのが目的だった

どこかにありそうだなと思って軽く調べて、そんな感じの gem もありましたが軽く無視って書きました。

たのしかたです。