2019年4月29日月曜日

golang の goroutine がどれくらい効果が簡単に検証してみた

概要

goroutine は golang で簡単に使える並列化の仕組みです
どんな場面で効果がありそうなのか実際に試してみました

環境

  • macOS 10.14.4
  • golang 1.11.5

goroutine なし

ある外部サイトに連続してアクセスするような処理を考えます
またそれぞれのアクセスは疎結合になっておりレスポンスに応じて次の処理に進むといったことはないものとします
例えば 10 回連続でアクセスする場合に単純に直列で処理する場合には以下のようになると思います

package main

import (
    "fmt"
    "net/http"
)

func call(c int) {
    for i := 0; i < c; i++ {
        resp, err := http.Get("http://localhost:9292/")
        if err != nil {
            fmt.Println(err)
            return
        }
        fmt.Println(resp.StatusCode)
    }
}

func main() {
    for i := 0; i < 10; i++ {
        call(1)
    }
}

これを自分の環境で何度か実行してみるとだいたい 10 - 20 秒の範囲で完了しました

real    0m16.395s
user    0m0.101s
sys     0m0.045s

これを goroutine で並列化するとどれくらい速度が上がるかを考えます

goroutine あり (5多重)

10 回を 5 多重なのでそれぞれ 2 回づつ外部サイトにアクセスします
goroutine 版に書き換えると以下のようになります

package main

import (
    "fmt"
    "net/http"
    "sync"
)

func call(c int) {
    for i := 0; i < c; i++ {
        resp, err := http.Get("http://localhost:9292/")
        if err != nil {
            fmt.Println(err)
            return
        }
        fmt.Println(resp.StatusCode)
    }
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(c int) {
            call(2)
            defer wg.Done()
        }(2)
    }
    wg.Wait()
}

これを自分の環境で実行してみるとだいたい 2 - 4 秒くらいの間で終了するようになりました
比率で言うと 5 倍ほど早くなっています
単純に並列化した分だけ速度が速くなっていると言えると思います

real    0m2.986s
user    0m0.096s
sys     0m0.037s

sync.waitGroup を使ってそれぞれの goroutine が終了するのを待つようになっています
なので例えばどれか 1 つの goroutine だけレスポンスが低下するとそれに引きづられて全体の速度も下がるという懸念はあります

ちょっと考察

今回のサンプルのように並列化する処理同士が干渉し合わないような場合には goroutine はかなり効果的だと思います
また sync.waitGroup も使わずに完全に goroutine に任せられる場合には更に速度の向上が見込めると思います

当然ですが、goroutine はメインのプロセスが終了すると goroutine が終了しているかどうかに関わらず強制終了していまいます
今回はメイン側のプロセスを停止させないために sync.waitGroup を使っていますがそもそもメインプロセスがデーモンプロセスになっているのであれば不要な場合もあります

処理を並列化してその後の処理が goroutine の処理の終了に関わらずできるのであれば投げてそのまま次に進んでもいいですが、そうでない場合は sync.waitGroup などの仕組みを使って待つ必要が出てきます
それは仕方ないというかシステムの都合にもよるかなと思います

そもそも goroutine を使わない

goroutine は標準のパッケージだけを使って簡単に並列化できるのが一番のメリットかなと思います
ただ今回のケースのように処理がお互いに疎結合になっていたり結果に依存していない場合には向いていますがそうでない場合にはいろいろと考慮が必要です
目的や使っている技術や環境にもよりますが単純に並列化したいのであれば go-workers を使うのも手かなと思います
goroutine とは違い外部のパッケージを使うのと Redis も使います
ただサブプロセスではなくなるのでメイン側のプロセスが死んでもサブプロセス (ワーカー) は死にません
ワーカープロセスを単純に増やせば多重度も簡単に上げられます
またメンテナンスもしやすくなると思います

といった具合に状況に応じては goroutine で並列化しないで別の仕組み (パッケージなど) を使ったほうがうまく合致する場合があることを頭に入れておくと良いと思います
何でもかんでも goroutine ではなく状況に応じて別の方法を検討したほうが良いということです

それをどう判断すればいいのか

じゃあ goroutine を使うかどうかをどう判断すればいいかですがこれは正直経験則しかないかなと思っています
もちろん事前にどちらも効果測定したりテストしたりして感触を確認することはできますが、その場合は 2 通り実装しなければいけません
設計の段階でどの方法の並列化がベストなのか間違いなく判断できるのは、そっいった経験を積んだプログラマでないと難しいかもしれません、、

最後に

goroutine を使うと実際に処理が速くなるのを確認してみました
また状況に応じては goroutine よりも別の仕組みを使ったほうが良さそうだということも検討してみました
自分はそこまで golang に詳しいわけではないので参考程度に見てもらえると助かります

2019年4月28日日曜日

docker rm で force 削除した場合はネットワークから切断しなければならない

概要

docker rm --force を使った場合本来の削除プロセスではなくなるため手動でネットワークからの切断をしなければいけません

環境

  • Ubuntu 16.04 LTS
  • docker 18.09.2

状況

docker stop でも docker kill でもコンテナが停止しない場合に rm --force を使います
その後コンテナを再作成して docker start しようとすると以下のようなエラーが発生します

Error response from daemon: endpoint with name C1 already exists in network bridge
Error: failed to start containers: C1

エラー文の通り force 削除したコンテナをネットワークから削除する必要があります

対応方法

  • docker network inspect bridge

でぶら下がっているコンテナを確認します
Containers の項目の中に force 削除したコンテナがいるはずです

Containers": {                                                                                                         
   "2c5472b7827e7bb4b68d53551dd851d10ac93b5701bb7f5f303d9dad2d4eb294": {
       "Name": "C1",
       "EndpointID": "406a9e1968ff0e11d2c384f16220fa87b66e24362f2e9ab6cf5cafec777e058f",
       "MacAddress": "02:42:ac:11:00:06",
       "IPv4Address": "172.17.0.6/16",
       "IPv6Address": ""
   }
}

コンテナ ID (上記だと 2c5472b7827e7bb4b68d53551dd851d10ac93b5701bb7f5f303d9dad2d4eb2 ) を指定して削除しましょう
またオプションで --force を指定します

  • docker network disconnect --force bridge 2c5472b7827e7bb4b68d53551dd851d10ac93b5701bb7f5f303d9dad2d4eb2

もしコンテナ ID で削除できない場合はコンテナ名や EndpointID を指定してみましょう
自分は (理由は不明ですが) コンテナ名をしたら disconnect できる場合がありました
これでコンテナを作り直してスタートすれば OK です

  • docker create --name C1 C1_image
  • docker start C1

参考サイト

2019年4月27日土曜日

Sinatra のロギングを考える

概要

Sinatra で logger を扱う方法を考えます
普通に logger を使えばいいのですが少し考慮すべきことがあるのでその辺りを踏まえて使い方などを紹介したいと思います

環境

  • macOS 10.14.4
  • Ruby 2.6.2p47
    • sinatra 2.0.5

準備

  • bundle init
  • vim Gemfile
gem "sinatra"
  • bundle install --path vendor

サンプルアプリ

  • vim config.ru
require './app.rb'
run MyApp
  • vim app.rb
require 'sinatra/base'

class MyApp < Sinatra::Base
  get '/' do
    logger.info 'ok'
    'ok'
  end
end
  • bundle exec rackup config.ru
  • curl 'localhost:9292'

で動作するアプリを考えます

普通にロギング

素直に logger を使ってロギングします
クラス内で logger を定義するのがポイントです

require 'sinatra/base'
require 'logger'

# logger = Logger.new(STDOUT) # ここはダメ

class MyApp < Sinatra::Base
  # set :logger, Logger.new(STDOUT) # これもダメ
  logger = Logger.new(STDOUT)

  get '/' do
    logger.info 'ok'
    'ok'
  end
end
I, [2019-04-24T12:42:03.457670 #11026]  INFO -- : ok

thin を使った場合は

  • vim Gemfile
gem "thin"
  • bundle install --path vendor
  • bundle exec rackup config.ru

で追加して普通に起動してもこれまで通りログがでます

フォーマットを変更する

logger.formatter を使うだけです

require 'sinatra/base'
require 'logger'

class MyApp < Sinatra::Base
  logger = Logger.new(STDOUT).tap do |logger|
    logger.formatter = proc do |severity, datetime, progname, msg|
      "[#{datetime} #{severity}] #{msg}\n"
    end
  end

  get '/' do
    logger.info 'ok'
    'ok'
  end
end

logger.info の部分がこんな感じで表示されるようになります

[2019-04-24 13:53:15 +0900 INFO] ok

Sinatra のデフォルトのログのフォーマットが違う

デフォルトの Sinatra では標準出力に特定のパスへのアクセスログを出してくれます

::1 - - [24/Apr/2019:12:48:00 +0900] "GET / HTTP/1.1" 200 2 0.0192

これが logger.info を使った場合とは違います
内部的には Rack::CommonLoggerFORMAT を使っておりこれが定数です
基本的にはフォーマットは変えられないので Rack::CommonLogger のログを抑制します

require 'sinatra/base'
require 'logger'

module Rack
  class CommonLogger
    def call(env)
      # do nothing
      @app.call(env)
    end
  end
end

class MyApp < Sinatra::Base
  logger = Logger.new(STDOUT).tap do |logger|
    logger.formatter = proc do |severity, datetime, progname, msg|
      "[#{datetime} #{severity}] #{msg}\n"
    end
  end

  get '/' do
    logger.info 'ok'
    'ok'
  end
end

という感じでモンキーパッチを当てるか起動時に抑制するオプションを付与します

  • bundle exec rackup -q config.ru

実はモンキーパッチを使えば無理矢理フォーマットを変えることができる

上記の仕組みを使えば Rack::CommonLogger::FORMAT を書き換えることができます

module Rack
  class CommonLogger
    FORMAT = %{%s - %s [%s] "%s %s%s %s" %d %s %0.4f\n}
  end
end

class MyApp < Sinatra::Base
  logger = Logger.new(STDOUT).tap do |logger|
    logger.formatter = proc do |severity, datetime, progname, msg|
      "[#{datetime} #{severity}] #{msg}\n"
    end
  end

  get '/' do
    logger.info 'ok'
    'ok'
  end
end

フォーマットは Apache httpd の Common ログに合わせてあります
ただこのフォーマットに合わないとログが表示されなくなるだけなのでもしやるとしたら特定の文字列をくっつけるくらいしかできないかなと思います

また定数のオーバーライドになるので Ruby の警告文も出るのでおすすめはしません
基本は抑制して独自の logger だけを使って出力するようにしたほうが良いと思います

標準出力には独自の logger だけ出力してデフォルトの Rack::CommonLogger はファイルに出力する

なんてこともできます
enable :logging を設定してしまうと標準出力に出てしまうので設定しないようにしてください

require 'sinatra/base'
require 'logger'

class MyApp < Sinatra::Base
  configure do
    # enable :logging
    file = File.new("rack.log", 'a+')
    file.sync = true
    use Rack::CommonLogger, file
  end

  logger = Logger.new(STDOUT).tap do |logger|
    logger.formatter = proc do |severity, datetime, progname, msg|
      "[#{datetime} #{severity}] #{msg}\n"
    end
  end

  get '/' do
    logger.info 'ok'
    'ok'
  end
end

あとは起動する際に -q で抑制すれば OK です
-q よりも enable :logging が優先されてしまうので設定しないでねという感じです

  • bundle exec rackup -q config.ru

最後に

Sinatra のロギング戦略を考えてみました
デフォルトの Rack のログをどうするかがポイントかなと思います
個人的にはデフォルトは抑制してすべて独自の logger による出力でいいかなと思います
また独自の logger もすべて標準出力でいいかなと思います
というのも基本は docker で動かすので docker 側でロギングドライバを使ってどこかに飛ばすなりファイルに落とすなりすればいいかなと

参考サイト

2019年4月26日金曜日

Ubuntu で Linuxbrew を試してみた

概要

macOS ではメジャーな Homebrew の Linux 版が出たので試してみました
インストールから基本的な使い方まで紹介したいと思います

環境

  • Ubuntu 16.04 LTS
  • Linuxbrew 2.1.1

各種バージョン

インストールするには gcc や glibc、カーネルバージョン、CPU アーキテクチャなどが要件を満たしている必要があります

  • GCC 4.4 or newer
  • Linux 2.6.32 or newer
  • Glibc 2.12 or newer
  • 64-bit x86_64 CPU

Ubuntu での各種バージョンの確認方法は以下の通りです

  • gcc --version
gcc (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  • ldd --version
ldd (Ubuntu GLIBC 2.23-0ubuntu10) 2.23
Copyright (C) 2016 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
  • uname -an
Linux ubuntu-xenial 4.4.0-143-generic #169-Ubuntu SMP Thu Feb 7 07:56:38 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

これらは以下のコマンドでインストールできるのでインストールされていない場合はインストールしましょう

  • sudo apt install build-essential

インストール

ワンライナーで可能です

  • sh -c "$(curl -fsSL https://raw.githubusercontent.com/Linuxbrew/install/master/install.sh)"

ちなみに root ユーザで実行しようとすると怒られるので一般ユーザで実行してください

インストール中は実行しているコマンドが流れるので何をしているか詳細を確認できます
問題なく上記のコマンドが実行できたら profile にセットアップ用の設定を入れます
これらのコマンドはインストールコマンドを実行後に表示されるので、表示されたコマンドを実行してください

  • echo 'eval $(/home/linuxbrew/.linuxbrew/bin/brew shellenv)' >>~/.profile
  • eval $(/home/linuxbrew/.linuxbrew/bin/brew shellenv)

1 行目で実行したコマンドは 2 行目で実行しているコマンドを .profile に書き込んでいるだけです
なので 2 行目を実行せずにログアウト -> ログインでも OK です

書き込んだら以下のコマンドを実行してインストールが問題なく完了したか確認しましょう

  • brew --version

とりあえずインストールしてみる

なんでもいいのでインストールしてみましょう

  • brew install tmux

apt と違って root 権限が必要ないです

これで tmux コマンドが使えるようになっていると思います

パスを確認してみる

まずコマンドがどこにインストールされているですが /home/linuxbrew/.linuxbrew/bin/tmux にありました
このパスは先程 .profile に書き込んだ /home/linuxbrew/.linuxbrew/bin/brew shellenv を実行することで $PATH に追加してくれています

インストールした tmux 自体は /home/linuxbrew/.linuxbrew/Cellar/tmux/2.8_1 にありここの bin から /home/linuxbrew/.linuxbrew/bin にシンボリックリンクが貼られています (この辺りは Homebrew と同じ仕組みだと思います)

Brewfile

普通に使えます

  • brew bundle dump

で現在のインストールしたフォームラや tap の情報を Brewfile に書き込んでくれます

tap "homebrew/bundle"
tap "homebrew/core"
brew "tmux"
brew "redis"

例えば brew "redis" を追加で書き込んだとします
それをインストールするには

  • brew bundle

と実行します

バックグラウンド起動するには

Homebrew には brew services というコマンドがあり例えば

  • brew services start redis-server
  • brew services list

という感じでプロセスの管理を行えます
Linuxbrew でも試してみたのですがどうやら services はまだサポートされていないようです
コマンドを実行してみたところ Error: brew services is supported only on macOS となったので間違いないと思います

.service ファイルを作成して systemctl に組み込むことは可能ですが面倒なので微妙な感じはします
なのでバックグラウンドで実行する場合は &nohup を使うしかなさそうです

最後に

Ubuntu16.04 で Linuxbrew を試してみました
少ししか触っていませんが Homebrew と同じ感じで使えると思います
ただ一部のコマンドがまだ動作しないのでその辺りは注意が必要です

あとは apt と混在するのが面倒な気もするので使うならどちらかのほうが良いかなと思います
apt の場合はインストールしたいバッケージのバージョンが最新でないケースが多いですが Linuxbrew は基本的には最新です
ただ、特殊なパスにインストールされたり systemctl 対応していないなどもあるので開発などを進めていると Ubuntu 特有のハマりポイントも出てくるかもしれません
aptsystemctl とは完全に隔離したものとして使うのであれば特に問題は出ないかなと思います

参考サイト

2019年4月25日木曜日

curl で querystring を複数行で書く方法

概要

よく POST で JSON を複数行で実現する方法はあるのですが GET で querystring で複数行というのは見かけなかったのでやってみました

環境

  • macOS 10.14.4

やり方

例えば curl 'localhost:4567/?key=value1&key2=value2' を複数行で書いてみます

curl `echo \
'https://localhost:4567/'\
'?key1=value1'\
'&key2=value2'`

こんな感じで書けます
どうやっているかというとヒアドキュメントを使って文字列を出力しそれを curl に食わせているだけです

変数を展開したいのであればダブルクォートを使いましょう

v=value2
curl `echo \
"https://localhost:4567/"\
"?key1=value1"\
"&key2=${v}"`

単純に文字列を食わせればいいだけなのでファイルに書いてそれを出力するとかでもいいかなと思います

力技感はありますが bash なのでこれくらいで妥協するのが良いと思います

2019年4月24日水曜日

Ruby の式展開は to_s メソッドが呼ばれている

概要

Ruby ではダブルクォートで囲った文字列リテラル内で #{} を使って式を囲むとその式が評価された結果で展開されます
普通は文字列変数などを展開するのに使います

内部ではそのインスタンスが持つ to_s メソッドがコールされているようでこれを利用すれば文字列リテラル以外にも自分で作成したクラスのインスタンスを式展開することができます

環境

  • macOS 10.14.4
  • Ruby 2.6.2p47

とりあえずサンプルコード

  • vim app.rb
class Hoge
  def to_s
    "hoge"
  end
end

h = Hoge.new
puts "#{h}"
  • ruby app.rb

これで実行すると「hoge」と表示されます
hHoge クラスのインスタンスですがちゃんと式展開されたときに to_s がコールされその返り値の値が表示されています

to_s がない場合

class Hoge
end

h = Hoge.new
puts "#{h}"

この場合は p と同じ結果が得られます

#<Hoge:0x00007f84a2857d88>

また to_s が最終的に文字列を返さない場合も p が呼ばれたときと同じような表示なります
なので数字を返す場合はそこで to_s してあげましょう

class Hoge
  def to_s
    1.to_s
  end
end

「式」なので if 文も書ける

こんな感じにすると true の場合だけ特定の文字列を表示することもできる

puts "#{"hoge" if true}"

こんな感じにも書ける

puts "#{if false then "not print this" else "print this" end}"

たださすがにこうなると読みにくいので素直にメソッドにしたほうがいいと思います

2019年4月23日火曜日

ruby の puts が docker logs でリアルタイムに表示されないときの対応

概要

ruby の puts でデバッグしているとコンテナが停止しないとログが表示されない現象が発生することがあります
今回はその対処方法を紹介します

環境

  • macOS 10.14.4
  • Ruby 2.6.2p47
  • docker 18.09.2

サンプルコード

Ruby は以下のような感じとします

puts "start"
sleep 10
puts "hoge"
sleep 10
puts "fuga"
sleep 10
puts "end"

本来なら 10 秒おきにそれぞれの puts 情報が表示されてほしいのですが docker logs -f などで見るとそうなりません
ちなみに Dockerfile は以下の通りです

FROM ruby:latest

ADD . /home
WORKDIR /home

CMD ["ruby", "app.rb"]

対処方法

docker では有名ですが /proc/1/fd/1 を使います
docker では CMD で指定したコマンドは基本的には 1 番になります (意図的に変更しない限り)
/proc/1/fd/1 はプロセス 1 番のファイルディスクリプタで標準出力へのリダイレクトになっています
このファイルに直接書き込むことで標準出力を強制してさせます

$stdout = IO.new(IO.sysopen("/proc/1/fd/1", "w"), "w")
$stdout.sync = true
STDOUT = $stdout

puts "start"
sleep 10
puts "hoge"
sleep 10
puts "fuga"
sleep 10
puts "end"

これでイメージを作成し直し run してみるとちゃんとリアルタイムに logs で確認できると思います
app.rb:3: warning: already initialized constant STDOUT が表示されてしまいますが不要な場合は -W0ruby 実行時に指定すれば OK です

CMD ["ruby", "-W0", "app.rb"]

logger でも同じように対処できる

logger を使ってロギングしている場合も同様の現象になると思います
その場合は以下のようにしましょう

require 'logger'
logger = Logger.new(STDOUT)

logger.info "start"
sleep 10
logger.info "hoge"
sleep 10
logger.info "fuga"
sleep 10
logger.info "end"

これだと表示されないので以下のようにします

require 'logger'
$stdout = IO.new(IO.sysopen("/proc/1/fd/1", "w"),"w")
$stdout.sync = true
STDOUT = $stdout
logger = Logger.new(STDOUT)

logger.info "start"
sleep 10
logger.info "hoge"
sleep 10
logger.info "fuga"
sleep 10
logger.info "end"

これで表示されるようになると思います

Exception も考慮しなくていいのか

例えば以下のような感じだとエラーメッセージの部分は表示されます

$stdout = IO.new(IO.sysopen("/proc/1/fd/1", "w"),"w")
$stdout.sync = true
STDOUT = $stdout
$stderr = IO.new(IO.sysopen("/proc/1/fd/1", "w"),"w")
$stderr.sync = true
STDERR = $stderr

puts "start"
sleep 1
puts "hoge"
sleep 1
puts "fuga"
begin
  5/0
rescue => e
  puts e.full_message
end
sleep 1
puts "end"

puts e.full_message の部分だけですが以下のようにちゃんと logs でもリアルタイムに確認できます

app.rb:14:in `/': divided by 0 (ZeroDivisionError)
        from app.rb:14:in `<main>'

普通に ruby を実行したときと違うのは「Traceback (most recent call last):」が表示されない部分だけかなと思います
標準エラーも操作してみましたが結果は変わらなかったので STDOUT だけで十分なのかなと思います

$stderr = IO.new(IO.sysopen("/proc/1/fd/2", "w"),"w")
$stderr.sync = true
STDERR = $stderr

最後に

なぜか ruby の puts が docker logs -f でリアルタイムに表示されなかったのでその対処をしてみました
ファイルディスクリプタを直接操作することで解決しましたがちょっと気持ち悪い感じはします
docker のロギングドライバを変えるという方法はありかもしれませんがそもそも docker がログを拾えていないとドライバを変えても意味がないので結局ファイルディスクリプタを操作しなければいけないかもしれません

他の言語だとこういう現象はあまり遭遇しないので Ruby のイメージが原因なのかもしれません
ちなみに ruby の docker イメージバージョンは 2.6.3p62 なのでだいぶ最新を使いました

$ docker run -it --rm ruby ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux]

参考サイト

2019年4月22日月曜日

ruby から google スプレッドシートを操作してみた

概要

ruby から Google スプレッドシートの Read/Write を行ってみました
SpreadSheet の API バージョンは v4 になります
認証も OAuth を使った認証と API Key を使った認証の 2 つの方法を紹介します

環境

  • macOS 10.14.4
  • Ruby 2.6.2p47

ライブラリインストール

  • bundle init
  • vim Gemfile
gem "google-api-client"
  • bundle install --path vendor

API の有効化

このページ にアクセスすると自動的に Quickstart のプロジェクトの作成と SpreadSheet API の有効化、認証情報を作成してくれます
credentials.json もダウンロードできるのでしましょう

API コンソール から作成されたプロジェクトや認証情報を確認することができます

Getting Started (with OAuth)

とりあえずコピペで動作するサンプルがあるのでそれを動かしてみます
認証が OAuth なので一旦ブラウザで認証して得られたトークンをターミナルに貼り付けます

  • vim app.rb
require 'google/apis/sheets_v4'
require 'googleauth'
require 'googleauth/stores/file_token_store'
require 'fileutils'

OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'.freeze
APPLICATION_NAME = 'Google Sheets API Ruby Quickstart'.freeze
CREDENTIALS_PATH = 'credentials.json'.freeze
TOKEN_PATH = 'token.yaml'.freeze
SCOPE = Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY

def authorize
  client_id = Google::Auth::ClientId.from_file(CREDENTIALS_PATH)
  token_store = Google::Auth::Stores::FileTokenStore.new(file: TOKEN_PATH)
  authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
  user_id = 'default'
  credentials = authorizer.get_credentials(user_id)
  if credentials.nil?
    url = authorizer.get_authorization_url(base_url: OOB_URI)
    puts 'Open the following URL in the browser and enter the ' \
         "resulting code after authorization:\n" + url
    code = gets
    credentials = authorizer.get_and_store_credentials_from_code(
      user_id: user_id, code: code, base_url: OOB_URI
    )
  end
  credentials
end

service = Google::Apis::SheetsV4::SheetsService.new
service.client_options.application_name = APPLICATION_NAME
service.authorization = authorize

# https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit
spreadsheet_id = '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms'
range = 'Class Data!A2:E'
response = service.get_spreadsheet_values(spreadsheet_id, range)
puts 'Name, Major:'
puts 'No data found.' if response.values.empty?
response.values.each do |row|
  puts "#{row[0]}, #{row[4]}"
end
  • bundle exec ruby app

として実行するとアクセスしてほしい URL が表示されるのでブラウザに貼り付けてアクセスします

Open the following URL in the browser and enter the resulting code after authorization:
https://accounts.google.com/o/oauth2/auth?access_type=offline&approval_prompt=force&client_id=1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com&include_granted_scopes=true&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/spreadsheets.readonly

OAuth の認証画面になるので認証ユーザを選択します

「QuickStart」というアプリから SpreadSheet の読み込み権限を与えても OK か確認する画面になるので許可しましょう

するとトークンが得られるのでそれをターミナルに貼り付けます

トークンは token.yaml に保存されて以降はそれを使ってアクセスするようになります

認証に成功するとテスト用のスプレッドシートから情報を読み込んで表示してくれます

ちなみに Google アカウントの「サードパーティによるアクセス」を確認すると許可した「Quickstart」アプリが追加されていることが確認できます

API key を使って認証する

基本は OAuth を使いましょう
ですが、自分のスプレッドシートにしかアクセスしなかったりバッチなど CLI 環境しか使えない場合にはブラウザ認証するのは面倒です
なので、例のごとく API Key を使った認証でスプレッドシートにアクセスしてみます

スプレッドシートを共有モードにする

API Key を使ってスプレッドシートにアクセスするにはスプレッドシートを共有モードにする必要があります
スプレッドシートにアクセスして「共有」から共有可能なリンクを取得できるようにしましょう

またスプレッドシートの ID も取得しておきましょう
ID は共有可能リンクにも含まれているオムニバーに表示されているランダムな文字列になります

API Key の作成

コンソールの認証情報管理画面から「API キー」を作成しましょう
このキーを使って API をコールします
とりあえず今回はテストなので制限なしで作成しました

API Key を使ってアクセスするスクリプト

とりあえず Read してみます
ちなみにアクセスするスプレッドシートは以下のような感じでデータを入れています

  • vim app.rb
require 'google/apis/sheets_v4'

service = Google::Apis::SheetsV4::SheetsService.new
service.key = 'your_api_key'
spreadsheet_id = 'your_spreadsheet_id'
range = 'A1:B10'
response = service.get_spreadsheet_values(spreadsheet_id, range)
puts 'No data found.' if response.values.empty?
response.values.each do |row|
  puts "#{row[0]}, #{row[1]}"
end
  • bundle exec ruby app.rb

これで実行すると A 列と B 列の 10 行目までの値が標準出力に表示されると思います

あれ共有可能リンクなら API Key がなくても閲覧できるから API Key は必要ないんじゃないの

と思うと思います
ですが API v4 からは API 経由で共有可能リンクにアクセスする場合は API Key がないと forbidden: The request is missing a valid API key. のエラーが発生するようになっています

Write してみる

では最後に Write です
残念ながら Write は OAuth2 認証でないと呼べない API なので認証を OAuth2 に書き換えます

  • vim app.rb
require 'google/apis/sheets_v4'
require 'googleauth'
require 'googleauth/stores/file_token_store'
require 'fileutils'

OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'.freeze
APPLICATION_NAME = 'Google Sheets API Ruby Quickstart'.freeze
CREDENTIALS_PATH = 'credentials.json'.freeze
TOKEN_PATH = 'token.yaml'.freeze
SCOPE = Google::Apis::SheetsV4::AUTH_SPREADSHEETS

def authorize
  client_id = Google::Auth::ClientId.from_file(CREDENTIALS_PATH)
  token_store = Google::Auth::Stores::FileTokenStore.new(file: TOKEN_PATH)
  authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
  user_id = 'default'
  credentials = authorizer.get_credentials(user_id)
  if credentials.nil?
    url = authorizer.get_authorization_url(base_url: OOB_URI)
    puts 'Open the following URL in the browser and enter the ' \
         "resulting code after authorization:\n" + url
    code = gets
    credentials = authorizer.get_and_store_credentials_from_code(
      user_id: user_id, code: code, base_url: OOB_URI
    )
  end
  credentials
end

service = Google::Apis::SheetsV4::SheetsService.new
service.client_options.application_name = APPLICATION_NAME
service.authorization = authorize

spreadsheet_id = 'your_spreadsheet_id'
range = 'A11'
data = Google::Apis::SheetsV4::ValueRange.new
data.major_dimension = 'ROWS'
data.range = 'A11'
data.values = [["hoge"]]
options = {
  value_input_option: 'RAW'
}
response = service.update_spreadsheet_value(spreadsheet_id, range, data, options)
puts response.updated_cells
  • bundle exec ruby app.rb

先ほどと同じようにブラウザに OAuth の URL を貼り付けてアクセスしアプリからのアクセスを許可します
認証部分で変更している点はスコープです
書き込みの権限が必要なので以下のようにしています
SCOPE = Google::Apis::SheetsV4::AUTH_SPREADSHEETS

書き込みに使うメソッドは update_spreadsheet_value になります
これに必要な引数は「スプレッドシート ID」と「書き換えるセル情報」と「データ」と「オプション」になります
その中でデータは Google::Apis::SheetsV4::ValueRange というクラスのオブジェクトでなければなりません (内部的にはただ JSON に変換しているだけ)

data = Google::Apis::SheetsV4::ValueRange.new
data.major_dimension = 'ROWS'
data.range = 'A11'
data.values = [["hoge"]]
options = {
  value_input_option: 'RAW'
}

major_dimension は range で指定した範囲で行 (ROWS) ベースで取得するのか列 (COLUMNS) ベースで取得するのかを決めることができます
例えば range=A1:B2,majorDimension=ROWS という感じでリクエストした場合は [[1,2],[3,4]] と返ってきます
range=A1:B2,majorDimension=COLUMNS という風にリクエストすると [[1,3],[2,4]] という風に返ってきます
今回は返り値を使うわけではないのでとりあえず ROWS を入れています

values は多重配列で指定します
とりあえず文字列を設定するだけの values にしています

options の value_input_option はデータが参照データなのか生データなのかなどを指定するためのオプションです
今回は文字列データをセルに書き込みたいので「RAW」を指定します

返ってくる値は UpdateValuesResponse になっています
とりあえず上記は更新できたセルの数を表示しています
1 つ更新されているので成功です

最後に

Ruby から Google スプレッドシートを操作してみました
認証部分に少しクセがあるので注意が必要です
書き込み等行う場合は OAuth 認証が必須で、読み込みだけであれば API Key だけでもいけます

API の呼び出し方がよくわからない場合は参考サイトにある URL から API Explorer を使って実際にブラウザコールしてみるのが手っ取り早いと思います

なお自分はすでにプロジェクトを削除して認証情報などもありませんがテストなどで作成したプロジェクトなどの場合はセキュリティのために削除するようにしましょう

参考サイト

2019年4月21日日曜日

gomock を使ってモックテストを書いてみる

概要

gomock を使ってモックテストを書いてみました
基本的な使い方やポイントを紹介します

環境

  • macOS 10.14.4
  • go 1.11.5

gomock のインストール

  • go get github.com/golang/mock/gomock
  • go install github.com/golang/mock/mockgen

サンプルアプリ

簡単な Web アプリを用意しました
アプリは別に Web アプリでなくても OK です
この中のハンドラからコールされている Hello インタフェースの関数 Echo()を gomock を使って mock してみたいと思います

  • vim $GOPATH/src/github.com/hawksnowlog/a/main.go
package main

import (
    "net/http"
)

type Hello interface {
    Echo() string
}

type HelloST struct {
    Msg string
}

func (st HelloST) Echo() string {
    return st.Msg
}

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    st := HelloST{"HELLO"}
    w.Write([]byte(st.Echo()))
}

func main() {
    http.HandleFunc("/", HelloHandler)
    http.ListenAndServe(":8080", nil)
}

mock を作成する

  • mkdir mock
  • mockgen -source main.go

で標準出力に mock 用のコードが出力されます
問題なければファイルに記載します

  • mockgen -source main.go -destination mock/mock.go

自動で生成されるコードなので mock.go の内容は省略します

mock を使ったテストを書く

生成した mock を使ってテストを書いてみます

  • vim $GOPATH/src/github.com/hawksnowlog/a/main_test.go
package main

import (
    "fmt"
    "github.com/golang/mock/gomock"
    "github.com/hawksnowlog/a/mock"
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHello(t *testing.T) {
    // mock
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    hello := mock_main.NewMockHello(ctrl)
    hello.EXPECT().Echo().Return("HELLO!!")
    // call api
    testserver := httptest.NewServer(http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(hello.Echo()))
    }))
    defer testserver.Close()
    res, err := http.Get(testserver.URL)
    if err != nil {
        t.Error(err)
    }
    b, err := ioutil.ReadAll(res.Body)
    defer res.Body.Close()
    if err != nil {
        t.Error(err)
    }
    fmt.Println(string(b))
    if string(b) != "HELLO!!" {
        t.Error("error")
    }
}
  • go test github.com/hawksnowlog/a

解説

まず Echo() 関数を mock します
Controller を作成しそれを元にインタフェースの mock を作成します
mock を作成したらインタフェースにある関数を更に mock します

ctrl := gomock.NewController(t)
defer ctrl.Finish()
hello := mock_main.NewMockHello(ctrl)
hello.EXPECT().Echo().Return("HELLO!!")

関数を mock する際は EXPECT().関数名().Return() という感じで定義します
今回であれば本来返るべき「HELLO」の代わりに「HELLO!!」を返すように定義します

あとは今回は net/http のテストなので httptest を使ってハンドラを定義します
ハンドラは実際のインタフェースの関数ではなく mock したインタフェースの関数を使うように定義します

これで実際にサーバにリクエストが行っても mock された関数がコールされるようになります

本当は既存のハンドラをコールしてそこで呼び出している関数を mock したい

本当は以下のようにテスト側でハンドラを呼び出して

testserver := httptest.NewServer(http.HandlerFunc(HelloHandler))

main.go のハンドラで呼び出されている st.Echo() の代わりに mock の hello.Echo() を呼び出してほしかったのですができませんでした

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    st := HelloST{"HELLO"}
    w.Write([]byte(st.Echo()))
}

他の言語の話の出すのはどうかと思いますがイメージとしては ruby の rspec-mock のようにアプリ側の呼び出しをそっくりそのまま mock が返すようにしたかった感じです
今回の場合だとハンドラを結局再定義しているので、そこが微妙な点かなと思います

最後に

gomock を使ってモックテストを書いてみました
とりあえず使い方は理解できたかなと思います
net/http の場合は結局ハンドラを再定義する必要があったのでそこが何とも微妙な感じでした

また interface を元に mock となる構造体を作成するので interface がない場合は今回の方法を使うのは厳しいかなと思います

2019年4月20日土曜日

net/http を httptest を使ってテストする方法

概要

net/http には net/http/httptest という標準パッケージがありこれを使えばテストを書くことができます
今回は net/http/httptest パッケージを使ってテストを書いてみました

環境

  • macOS 10.14.4
  • go 1.11.5

サンプルアプリ

  • vim $GOPATH/src/github.com/hawksnowlog/a/main.go
package main

import (
    "fmt"
    "net/http"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Println("hello")
    w.Write([]byte("hello"))
}

func main() {
    http.HandleFunc("/", HelloHandler)
    http.ListenAndServe(":8080", nil)
}

/ にアクセスすると「hello」と返す簡単なアプリです
実際にアプリのハンドラに通っていることを確認するために fmt.Println を記載しています

基本的なテスト

ではテストを記載します
xxx_test.go というテストファイルを同一ディレクトリに置きましょう

  • vim $GOPATH/src/github.com/hawksnowlog/a/main_test.go
package main

import (
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestRoot(t *testing.T) {
    testserver := httptest.NewServer(http.HandlerFunc(HelloHandler))
    defer testserver.Close()
    res, err := http.Get(testserver.URL)
    if err != nil {
        t.Error(err)
    }
    hello, err := ioutil.ReadAll(res.Body)
    defer res.Body.Close()
    if err != nil {
        t.Error(err)
    }
    if res.StatusCode != 200 {
        t.Error("a response code is not 200")
    }
    if string(hello) != "hello" {
        t.Error("a response is not hello")
    }
}
  • go fmt github.com/hawksnowlog/a
  • go test github.com/hawksnowlog/a

でテスト出来ます

解説

まずはテストサーバを作成します
アプリ側で作成したハンドラを使って作成します

testserver := httptest.NewServer(http.HandlerFunc(HelloHandler))
defer testserver.Close()

ちゃんと Close するのを忘れないようにしましょう
作成したサーバに GET リクエストを送信するには http.Get を使います

res, err := http.Get(testserver.URL)

あとは受け取ったレスポンスをチェックすれば OK です

実際にアプリを動かしてテストしても OK

実は net/http/httptest で NewServer すると指定したハンドラを持つアプリがテスト用に立ち上がり LISTEN しています
上記の場合ただそこに http.Get しているだけです
なので httptest を使わずに以下のように書くこともできます

  • go build github.com/hawksnowlog/a
  • ./a

で普通にアプリを立ち上げたあとに、そのアプリ目掛けて http.Get すれば OK です

  • vim $GOPATH/src/github.com/hawksnowlog/a/main_test.go
package main

import (
    "io/ioutil"
    "net/http"
    "testing"
)

func TestRoot(t *testing.T) {
    res, err := http.Get("http://localhost:8080")
    if err != nil {
        t.Error(err)
    }
    hello, err := ioutil.ReadAll(res.Body)
    defer res.Body.Close()
    if err != nil {
        t.Error(err)
    }
    if res.StatusCode != 200 {
        t.Error("a response code is not 200")
    }
    if string(hello) != "hello" {
        t.Error("a response is not hello")
    }
}
  • go test github.com/hawksnowlog/a

ハンドラを mock するには

ハンドラを mock したいのであれば単純にアプリのハンドラを使わずにテスト側でハンドラを定義すれば OK です

  • vim $GOPATH/src/github.com/hawksnowlog/a/main_test.go
package main

import (
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"
)

func mockHelloHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("mock hello"))
}

func TestRoot(t *testing.T) {
    testserver := httptest.NewServer(http.HandlerFunc(mockHelloHandler))
    defer testserver.Close()
    res, err := http.Get(testserver.URL)
    if err != nil {
        t.Error(err)
    }
    hello, err := ioutil.ReadAll(res.Body)
    defer res.Body.Close()
    if err != nil {
        t.Error(err)
    }
    if res.StatusCode != 200 {
        t.Error("a response code is not 200")
    }
    if string(hello) != "mock hello" {
        t.Error("a response is not mock hello")
    }
}
  • go test github.com/hawksnowlog/a

ハンドラ内の特定の処理だけ mock したい場合には面倒ですがその部分だけ mock したハンドラを定義すれば OK です
もしくは gomock というパッケージがあるのでこれを使って特定のメソッドを mock するという方法もあります

最後に

net/http/httpclient を使って net/http をテストしてみました
基本的にはテストしたいハンドラ指定したり mock してテストします
これだけでも基本的なテストは十分にできるかなと思います