アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Rubocop Performanceを測ってみた。前編

morishitaです。

以前、弊社のWebエンジニアキエンが次のエントリで紹介した prontoによる自動レビューですが、今ではほかのRailsアプリケーションにも導入して使っています。

tech.actindi.net

うっかりしたコードを書くと容赦のない指摘コメントが付きます。
その多くはコードフォーマットに関するものなのですが、時々rubocop-performanceにより「遅いかもしれないので書き直しましょう」という指摘をされます。

へーそうなのかーと素直に修正してきたのですが、ツールを活用するのはいいのだけど、盲従するのは良くないぁと心に引っかかるものを感じていました。

JuanitoFatas/fast-rubyにも測定結果があるのですが、ざっと見て古すぎるRubyバージョン(2.2など)での結果が更新されていないものが結構あるなぁと思いました。
また、Rubyのバージョンによる差も見てみたかったので、今回、自分で確認してみました。

Rubocop Performance とは

Rubocopのプラグインで性能低下につながるコードを指摘し修正を促す Cop の集合です。

最近、Rubocop本体から分離され rubocop-hq/rubocop-performanceでリポジトリが公開されています。

Rubocop Performanceには次の Cop が含まれています。

  1. Performance/Caller
  2. Performance/CaseWhenSplat
  3. Performance/Casecmp
  4. Performance/ChainArrayAllocation
  5. Performance/CompareWithBlock
  6. Performance/Count
  7. Performance/Detect
  8. Performance/DoubleStartEndWith
  9. Performance/EndWith
  10. Performance/FixedSize
  11. Performance/FlatMap
  12. Performance/InefficientHashSearch
  13. Performance/OpenStruct
  14. Performance/RangeInclude
  15. Performance/RedundantBlockCall
  16. Performance/RedundantMatch
  17. Performance/RedundantMerge
  18. Performance/RegexpMatch
  19. Performance/ReverseEach
  20. Performance/Size
  21. Performance/StartWith
  22. Performance/StringReplacement
  23. Performance/TimesMap
  24. Performance/UnfreezeString
  25. Performance/UriDefaultParser

もっとたくさんあるかと思っていたのですが、25個なんですね。

計測について

計測には BenchmarkDriver を利用しました。

Rubocopのドキュメントに badgood の例が掲載されていますが、基本的にはそれをBenchmarkDriverで計測してみて比較しました。 例をなるべく変更せずに計測する方針で行いましたが、文字列、配列、ハッシュなどは定数にして使い回すようにしています。
Cop が論点にしているポイントだけをなるべく計測するため、これらの生成コストを計測に含めないようにするためです。

計測に利用したコードはこのエントリにも掲載しますが、次のリポジトリにも置いておきます。

rubocop-performance-measurements

単に badgood を計測するだけでなく複数のRubyのバージョンで計測しています。
一応、Rubocopはまだ、Ruby 2.3 をサポートしているようなので、 それ以降のバージョンということで次のRubyバージョンで計測しました。

  • 2.3.8
  • 2.4.6
  • 2.5.4
  • 2.6.3
  • 2.7.0-preview1

一部、Ruby 2.3.8 では実装されていないメソッドに関する Cop では 2.3.8 を除外して計測しました。

結果は秒あたりの実行回数 ips (Iteration per second = i/s)で示します。
各結果ともbenchmark_driver-output-gruffによるグラフで示しますが、グラフが長いほうが高速ということです。

また、結果の値自体は計測環境の性能により変わります。 なので、サンプル間の差に着目してください。

では順に見ていきます。

1. Performance/Caller

メソッドの呼び出し元を取得するために caller[n] より、caller(n..n).firstを使おうという Cop ですね。

次のコードで計測しました。

# rubocop-performance Performance/Caller

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    def bad_sample1
      caller[1]
    end

    def bad_sample2
      caller.first
    end

    def bad_sample3
      caller_locations.first
    end

    def good_sample1
      caller(1..1).first
    end

    def good_sample2
      caller_locations(1..1).first
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ bad_sample3 }
  x.report %{ good_sample1 }
  x.report %{ good_sample2 }
end

上記コードの実行結果は次のとおりです。

f:id:HeRo:20190716081946p:plain
Performance/Callerの計測結果

結果からはどちらでも差がないように思われます。 callerよりcaller_locationsを使ったほうが良さそうということはわかるのですが、 caller(n..n).first の性能的優位性は感じられません。

こんな小さなコードではなく、Railsの中などで使うとメソッドの呼び出し階層が深くなるので効果が出てくるのでしょうか。

2. Performance/CaseWhenSplat

casewhen に splat展開(配列展開。 例:*array)を含むものを後ろに持ってきたほうがパフォーマンスが向上する可能性があるという Cop ですね。

次のコードで計測しました。

# rubocop-performance Performance/CaseWhenSplat

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    BAR = 2
    BAZ = 3
    FOOBAR = 4
    CONDITION = [1, 2, 3, 4]

    def bad_sample1
      foo = BAZ
      case foo
      when *CONDITION
        BAR
      when BAZ
        FOOBAR
      end
    end

    def bad_sample2
      foo = 5
      case foo
      when *[1, 2, 3, 4]
        BAR
      when 5
        FOOBAR
      end
    end

    def good_sample1
      foo = BAZ
      case foo
      when BAZ
        FOOBAR
      when *CONDITION
        BAR
      end
    end

    def good_sample2
      foo = 5
      case foo
      when 1, 2, 3, 4
        BAR
      when 5
        BAZ
      end
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ good_sample1 }
  x.report %{ good_sample2 }
end

結果は次のとおりです。

f:id:HeRo:20190716082148p:plain
Performance/CaseWhenSplatの計測結果

good_sample のほうがパフォーマンスがいいです。

whenは上から評価されます。なのでsplat展開する条件以外にヒットするケースが多い場合にはsplat展開する条件を後ろに置くことで、splat展開の回数が減り、その分コストが少ないということだと思います。

bad_sample1の結果を見るとruby2.6以降はsplat展開の性能もずいぶん上がっているのだなとわかりますね。

3. Performance/Casecmp

大文字、小文字関係なく文字列の一致を判定するにはcasecompメソッドを使いましょうというCopです。

次のコードで計測しました。

# rubocop-performance Performance/Casecmp

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    STR_U = 'ABC'
    STR_L = 'abc'

    def bad_sample1
      STR_U.downcase == STR_L
    end

    def bad_sample2
      STR_L.upcase.eql? STR_U
    end

    def bad_sample3
      STR_L == STR_U.downcase
    end

    def bad_sample4
      STR_U.eql? STR_L.upcase
    end

    def bad_sample5
      STR_U.downcase == STR_U.downcase
    end

    def good_sample1
      STR_L.casecmp(STR_U).zero?
    end

    def good_sample2
      STR_U.casecmp(STR_L).zero?
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ bad_sample3 }
  x.report %{ bad_sample4 }
  x.report %{ bad_sample5 }
  x.report %{ good_sample1 }
  x.report %{ good_sample2 }
end

結果は次のとおりです。

f:id:HeRo:20190716082531p:plain
Performance/Casecmpの計測結果

good_sample のほうが速いですね。 'ABC'.downcase == 'abc' みたいなコードは書きがちですが、改めたほうが良さそうです。

4. Performance/ChainArrayAllocation

Arrayのメソッドcompact, flatten, mapなどはメソッドチェインして使いがちです。
しかし、その都度中間状態の配列が生成されるのでcompact!, flatten!, map!で元の配列を書き換えたほうが速いよという Cop です。

# rubocop-performance Performance/ChainArrayAllocation

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    def bad_sample
      array = ["a", "b", "c"]
      array.compact.flatten.map { |x| x.downcase }
    end

    def good_sample
      array = ["a", "b", "c"]
      array.compact!
      array.flatten!
      array.map! { |x| x.downcase }
      array
    end
  RUBY

  x.report %{ bad_sample }
  x.report %{ good_sample }
end

結果は次のとおりです。

f:id:HeRo:20190716082716p:plain
Performance/ChainArrayAllocationの計測結果

もっと差がつくかと思ったのですが、それほどでもないです。
もっと大きな配列なら差が広がるかもしれないですが、メソッドチェーンのほうがコードがスッキリし、読みやすい場合も多いので配列が大きくなくて、ループの中でないなら気にするほどでは無いかと思います。

5. Performance/CompareWithBlock

オブジェクトやハッシュの属性値でsortminmaxで比較するときに、比較する条件をブロックで渡すよりも、sort_bymin_bymax_byを使おうという Cop です。

次のコードで計測しました。

# rubocop-performance Performance/CompareWithBlock

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    class Elm
      attr_accessor :foo
      def initialize(foo)
        @foo = foo
      end
    end

    ARRAY = [Elm.new(5), Elm.new(4), Elm.new(3), Elm.new(2), Elm.new(1)]
    HASH_ARRAY = [{ foo: 5 }, { foo: 4 }, { foo: 3 }, { foo: 2 }, { foo: 1 }]

    def bad_sample_sort1
      ARRAY.sort { |a, b| a.foo <=> b.foo }
    end

    def bad_sample_max
      ARRAY.max { |a, b| a.foo <=> b.foo }
    end

    def bad_sample_min
      ARRAY.min { |a, b| a.foo <=> b.foo }
    end

    def bad_sample_sort_hash
      HASH_ARRAY.sort { |a, b| a[:foo] <=> b[:foo] }
    end

    def good_sample_sort1
      ARRAY.sort_by(&:foo)
    end

    def good_sample_sort2
      ARRAY.sort_by { |v| v.foo }
    end

    def good_sample_sort3
      ARRAY.sort_by do |var|
        var.foo
      end
    end

    def good_sample_max
      ARRAY.max_by(&:foo)
    end

    def good_sample_min
      ARRAY.min_by(&:foo)
    end

    def good_sample_sort_hash
      HASH_ARRAY.sort_by { |a| a[:foo] }
    end
  RUBY

  x.report %{ bad_sample_sort1 }
  x.report %{ good_sample_sort1 }
  x.report %{ good_sample_sort2 }
  x.report %{ good_sample_sort3 }
  x.report %{ bad_sample_max }
  x.report %{ good_sample_max }
  x.report %{ bad_sample_min }
  x.report %{ good_sample_min }
  x.report %{ bad_sample_sort_hash }
  x.report %{ good_sample_sort_hash }
end

結果は次のとおりです。

f:id:HeRo:20190716083005p:plain
Performance/CompareWithBlockの計測結果

うーん、どのRubyのバージョンでも bad_sample のほうがパフォーマンス良さそうですね。 計測の仕方が良くないのかなぁ。

6. Performance/Count

配列の要素数を数える場合に、すべての要素ではなく条件に一致するものだけ数えたい場合があります。 Array#selectなどで数えたい要素だけに絞り込んでからcountするよりも、Array#count に数える条件を与えるほうが良いという Cop です。

計測に使ったコードは次のとおりです。 ActiveRecordに関する例がドキュメントにはあるのですが、それは含めていません。

# rubocop-performance Performance/Count

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    class Elm
      attr_accessor :value
      def initialize(value)
        @value = value
      end
    end

    NUM_ARRAY = [1, 2, 3]
    OBJ_ARRAY = [Elm.new(5), Elm.new(4), Elm.new(3), Elm.new(2), Elm.new(5)]

    def bad_sample1
      NUM_ARRAY.select { |e| e > 2 }.size
    end

    def bad_sample2
      NUM_ARRAY.reject { |e| e > 2 }.size
    end

    def bad_sample3
      NUM_ARRAY.select { |e| e > 2 }.length
    end

    def bad_sample4
      NUM_ARRAY.reject { |e| e > 2 }.length
    end

    def bad_sample5
      NUM_ARRAY.select { |e| e > 2 }.count { |e| e.odd? }
    end

    def bad_sample6
      NUM_ARRAY.reject { |e| e > 2 }.count { |e| e.even? }
    end

    def bad_sample7
      OBJ_ARRAY.select(&:value).count
    end

    def good_sample1
      NUM_ARRAY.count { |e| e > 2 }
    end

    def good_sample2
      NUM_ARRAY.count { |e| e < 2 }
    end

    def good_sample3
      NUM_ARRAY.count { |e| e > 2 && e.odd? }
    end

    def good_sample4
      NUM_ARRAY.count { |e| e < 2 && e.even? }
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ bad_sample3 }
  x.report %{ bad_sample4 }
  x.report %{ bad_sample5 }
  x.report %{ bad_sample6 }
  x.report %{ bad_sample7 }
  x.report %{ good_sample1 }
  x.report %{ good_sample2 }
  x.report %{ good_sample3 }
  x.report %{ good_sample4 }
end

計測結果は次のとおりです。

f:id:HeRo:20190716083150p:plain
Performance/Countの計測結果

good_sampleのほうが総じて結果が良いですね。
その差はあんまり大きくありませんが、配列が大きくなると顕著に差が広がるかもしれません。コードも短くなるのでRubocopに従ったほうがいいかと思います。

コードの簡潔さという点でも、Array#select{}.size よりも Array#count{} の方がスッキリしていていいと思います。

7. Performance/Detect

配列の中から条件に一致する最初の要素や最後の要素を取り出す場合には selectfirstlast を組み合わせるよりdetectメソッドを使いましょうという Cop です。 detectメソッドは、find の別名なので find を使ってもいいです。

計測コードは次のとおりです。

# rubocop-performance Performance/Detect

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    ARRAY = [1, 1, 0, 1, 0, 1, 0, 1 ,0 ,1]

    def bad_sample1
      ARRAY.select { |item| item > 0 }.first
    end

    def bad_sample2
      ARRAY.select { |item| item > 0 }.last
    end

    def bad_sample3
      ARRAY.find_all { |item| item > 0 }.first
    end
    def bad_sample4
      ARRAY.find_all { |item| item > 0 }.last
    end

    def good_sample1
      ARRAY.detect { |item| item > 0 }
    end

    def good_sample2
      ARRAY.reverse.detect { |item| item > 0 }
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ bad_sample3 }
  x.report %{ bad_sample4 }
  x.report %{ good_sample1 }
  x.report %{ good_sample2 }
end

計測結果は次のとおりです。

f:id:HeRo:20190716083401p:plain
Performance/Detectの計測結果

good_sample1 の優位性がはっきりしていますねRubocopの指摘に従ったほうが良さそうです。
good_sample2はそれほどパフォーマンスよくないです。reverseメソッドの性能がイマイチなのでしょうか。

StartWith/EndWith

String#start_with?String#end_with? に関する Cop は次の3つあります。

3つ合わせて見ていきます。

8. Performance/DoubleStartEndWith

まずは、Performance/DoubleStartEndWith
例えばaまたはbで始まるかどうかを判定しないのなら、start_with?|| でつないで2回使うのではなく start_with? に2つ引数を渡したほうが速いという Cop です。

計測に使ったコードは次のとおりです。

# rubocop-performance Performance/DoubleStartEndWith

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    module Some
      CONST = 'c'
    end

    STR = 'hogehoge'
    VAR1 = 'a'
    VAR2 = 'b'

    def bad_sample1
      STR.start_with?('a') || STR.start_with?(Some::CONST)
    end

    def bad_sample2
      STR.start_with?('a', 'b') || STR.start_with?('c')
    end

    def bad_sample3
      STR.end_with?(VAR1) || STR.end_with?(VAR2)
    end

    def good_sample1
      STR.start_with?('a', Some::CONST)
    end

    def good_sample2
      STR.start_with?('a', 'b', 'c')
    end

    def good_sample3
      STR.end_with?(VAR1, VAR2)
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ bad_sample3 }
  x.report %{ good_sample1 }
  x.report %{ good_sample2 }
  x.report %{ good_sample3 }
end

結果は次のとおりです。

f:id:HeRo:20190716083700p:plain
Performance/DoubleStartEndWithの計測結果

good_sampleのほうがパフォーマンスいいですね。
String#start_with?String#end_with?って複数の引数を取れるんですね。速いしコードがスッキリするのでいいですね。

9. Performance/EndWith

続いてString#end_with?です。
正規表現で文字列の末尾を調べるよりString#end_with?を使ったほうが速いというものです。

計測のためのコードを2つに分けました。
理由は match?メソッドです。これはRuby 2.3にはないメソッドだからこのメソッドを含む例と含まない例で分けました。

まずは、match?以外の例の比較。
計測コードは次のとおりです。

# frozen_string_literal: true

# rubocop-performance Performance/EndWith

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    REGEX = /bc\Z/
    END_STR = 'bc'

    def bad_sample1
      'abc' =~ REGEX
    end

    def bad_sample2
      'abc'.match(REGEX)
    end

    def good_sample
      'abc'.end_with?(END_STR)
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ good_sample }
end

この結果は次のとおりです。

f:id:HeRo:20190716083822p:plain
Performance/EndWith(match?以外)の計測結果

good_sample(String#end_with?)が圧倒的に速いですね。 素直にRubocopに従いましょう。

続いて、match?を含む計測の比較です。 Ruby 2.3以外で比較します。

計測コードは次のとおりです。

# rubocop-performance Performance/EndWith

require 'benchmark_driver'

output = :gruff
versions = ['2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    REGEX = /bc\Z/
    END_STR = 'bc'

    def bad_sample1
      'abc'.match?(REGEX)
    end

    def bad_sample2
      'abc' =~ REGEX
    end

    def bad_sample3
      'abc'.match(REGEX)
    end

    def good_sample
      'abc'.end_with?(END_STR)
    end
  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ bad_sample3 }
  x.report %{ good_sample }
end

計測結果は次のとおりです。

f:id:HeRo:20190716083943p:plain
Performance/EndWith(match?含む)の計測結果

bad_sample1(match?)も速いですが、good_sample(end_with?)が更に速いです。

21. Performance/StartWith

最後にString#start_with?です。 match?を含めた比較をします。 なので Ruby 2.3以外での計測です。

計測に使ったコードは次のとおりです。

# rubocop-performance Performance/StartWith

require 'benchmark_driver'

output = :gruff
versions = ['2.4.6', '2.5.4', '2.6.3', '2.7.0-preview1']

Benchmark.driver(output: output) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    STR = 'abc'
    RE = /\Aab/
    START_STR = 'ab'

    def bad_sample1
      STR.match?(RE)
    end

    def bad_sample2
      STR =~ RE
    end

    def bad_sample3
      STR.match(RE)
    end

    def good_sample
      STR.start_with?(START_STR)
    end

  RUBY

  x.report %{ bad_sample1 }
  x.report %{ bad_sample2 }
  x.report %{ bad_sample3 }
  x.report %{ good_sample }
end

計測結果は次のとおりです。

f:id:HeRo:20190716084103p:plain
Performance/StartWithの計測結果

やはりgood_sample(start_with?)は速いですね。 こっちを使っていきましょう。

つづきます

まだまだ Rubocop Perfomanceには Copがありますが、少々長くなってきたので、今回はここまで。

次のエントリに続けます。

tech.actindi.net

最後に

アクトインディではエンジニアを募集しています。 actindi.net

いこーよにWebチケットサービスをリリースしました

こんにちは、nakamuraです。もはや2ヶ月前ほどになりますが、タイトルの通り、いこーよでWebチケットが購入できるようになりました。リリース当初はまだ未実装だった機能もどんどんリリースされ、これからもますます便利になっていくサービスですので、ぜひご利用ください!

さて、そんな待ちに待ったWebチケットの機能を、この記事で紹介したいと思います!

1. Webチケットを選択

まず、Webチケットが購入できる施設さんには、タイトルの下に緑色のバッヂが表示されます。

※以降の操作は全て、スマートフォンでアクセスされた場合を表示しています。

gyazo.com

そして、ページ中央部の「おトクな各種チケット」欄に購入対象のWebチケットが表示されるので、お好みのチケットを選択してください。

すると、購入できるチケット種別ごとに金額が表示されているチケット詳細ページに遷移するので、購入する枚数を選択します。

gyazo.com

枚数が1枚以上になると画面下部にあるボタンが反転するので、ここから購入フローに遷移します。

gyazo.com

ログインが済んでいない場合は、↓のページに遷移するので、ログインもしくは新規会員登録をお願いします。 ログイン後は、自動的に購入フローに遷移します。

gyazo.com

2. 購入フロー

初回はまず、購入者情報の入力ページに遷移しますので、必須項目を入力してください。2回目の購入からは、直接確認ページへ遷移します。

gyazo.com

購入者情報、枚数が正しいか確認してください。

gyazo.com

枚数の修正が必要な場合は、ページ下部の「枚数を選択しなおす」ボタンで修正が可能です。 修正の必要がない場合は、「お支払い情報を入力する」ボタンで支払い情報の入力ページへ遷移します。

gyazo.com

次に、クレジットカード情報を入力してください。ページ下部にある「次回以降もこのカード情報を使う」にチェックを入れると次回以降は入力の必要がなくなります。

gyazo.com

最後に、「チケットの購入を完了する」ボタンで購入が完了します。

gyazo.com

正常に購入が完了すると↓のページが表示されます。

gyazo.com

3. Webチケットを使う

チケット購入後はいつでもマイページの「チケットを見る」ボタンで購入したチケットを確認することが可能です。

gyazo.com

あとは、お出かけした際に「チケットを利用する」ボタンでWebチケットページに遷移し、表示されるメッセージに従い、チケットを使用してください。 (※現時点でチケットの購入、使用はスマートフォンを対象にしています)

gyazo.com

最後に

アクトインディのWebチケットサービスはこれからもどんどん内容を充実させて行く予定ですので、お出かけの際はぜひ、Webチケットが利用可能かどうかご確認ください!

また、アクトインディでは、Webチケットサービスをいっしょに改善していきたいというエンジニアも大募集中です!

VCRでWeb APIのモックを楽しよう!

morishitaです。

最近、Stripeを利用した決済処理の実装をしていました。

tech.actindi.net

tech.actindi.net

サーバーサイド(Rails)でStripe APIを利用しており、そのテストの実装にvcrを利用しました。
以前から使っていましたが、改めて便利だと思ったのでご紹介。

外部APIを叩くテストのツラミ

システム開発をしているとすでにある他のシステムを力を借りるということがあります。
他のシステムのAPIを利用するということですね。

あるいは、ひとつのシステムに必要なすべての機能を実装せず機能ごとにシステムを分割し、お互いにAPIで呼び合ってサービスを実現することもあります。
マイクロサービスアーキテクチャってやつですね。

システム間連携する手段としては古くはSOAP、最近ではgRPCなど様々な方法がありますが、現状最も使われているのはHTTPプロトコルのREST APIではないでしょうか。

そのようなシステムのテストを実装する際、いちいちAPIを叩くのは大変です。
なぜなら、テスト対象のシステムの他にAPIを提供するシステムの準備が必要になるからです。
それに他社サービスをテストのために大量にコールするようなことをしては迷惑になりますし、従量課金のAPIだったらコストも馬鹿になりません。
なのでモックすると思います。

Railsのテストでよく使われるライブラリにwebmockがあります。
単にAPIへのコールをモックし、その成否程度が必要なだけならいいのですが、レスポンスの内容により処理が変わるとなるとそれを実装しないといけません。

私はここ最近、決済サービスStripeを利用したRailsアプリケーションを開発していたと冒頭書きましたが、お客様からお金をいただくサービスなのでテストもしっかり実装しました。

StripeのAPIは物によっては約3KBのレスポンスを返してきます。
レスポンスボディだけそんなにデータを返してくる、加えて、レスポンスヘッダもあります。それらをテストケースで必要な分用意するのは大変です。

もちろんレスポンス全部を処理に利用するわけではないので使う部分だけをモック実装してもいいのですが、モックとはいえできるだけ本物に近づけたい。

そう考えると… webmock 辛い。

VCRという救い

vcrを使えば、そんな面倒なWeb APIのモック実装から解放されます。

vcrもwebmock同様Web APIをモックするためのライブラリですが、自分でモックを実装する必要がありません。
設定にもよりますが、モックデータが無ければ、実際にAPIをコールしそのリクエスト/レスポンスをYAMLファイルに保存してくれます。
2回目以降は実際にはAPIを叩かずに保存したYAMLファイルからレスポンスを作ってモックしてくれます。

レスポンスヘッダを含め、同じレスポンスを再現でき本物に限りなく近いモックを使ってテストできます。

VCRを使う

前置きが長くなりましたが、RSpecで使う前提でVCRの使い方を紹介します。

インストールは Gemfileのtestグループにでもgem 'vcr'を追加してbundle installすればOKです。

設定

spec_helpler.rbでVCR設定します。

当社のとあるプロダクトでは次のような設定で利用しています。

require 'vcr'
VCR.configure do |c|
  c.cassette_library_dir = 'spec/vcr' # カセットを保存するルートディレクトリ
  c.hook_into :webmock # 利用するモックライブラリ(内部ではwebmockを利用しています)
  c.configure_rspec_metadata! 
  c.allow_http_connections_when_no_cassette = true # VCRを使わない場所ではHTTP通信を許可する
  c.default_cassette_options = {
    record: :new_episodes, # カセットがなければAPIをコールしてそれを記録する
    match_requests_on: [:method, :path, :query, :body], # カセットを引き当てる条件
  }
  c.before_record do |interaction| # カセット保存前の処理の設定
    interaction.response.body.force_encoding 'UTF-8'
    interaction.response.body = JSON.pretty_generate(JSON.parse(interaction.response.body)) if interaction.response.body.present?
  end
end

VCRでは保存するモックデータをカセット(cassette) と呼びます1
どのような設定をしているかはコメントでだいたい記述しましたが、ポイントを説明します。

record: :new_episodes はカセットがなければ実際にAPIを叩いてそれを記録します。あればAPIを叩かずカセットを再生します。
match_requests_onはリクエストに対応するカセットを何によって判定するかを設定します。
上記の例では[:method, :path, :query, :body],を指定しています。これはリクエストのメソッドとパス、 クエリそしてリクエストボディが一致するカセットを再生するという意味です。

c.before_recordのブロックではカセットを保存する前の処理を実装しています。
上記の例ではレスポンスボディはUTF-8とみなし、しかもJSONとみなして見やすく出力するようにしています2

記録されるカセットの例を示します。
これはStripeの Refund(払い戻し)APIの例です。
ひとつのAPIのリクエスト/レスポンスのみを含んでいますが、一連の処理で複数回のAPIコールがあれば、すべてひとつのファイルに記録されます。

---
http_interactions:
- request:
    method: post
    uri: https://api.stripe.com/v1/refunds
    body:
      encoding: UTF-8
      string: charge=ch_XXXXXXXXXXXXXXXXXXXXXXXX
    headers:
      User-Agent:
      - Stripe/v1 RubyBindings/4.9.1
      Authorization:
      - Bearer rk_test_F7uEYj6rWVCmzjOl7tm0qo6n004oYskC75
      - Bearer rk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
      Content-Type:
      - application/x-www-form-urlencoded
      X-Stripe-Client-User-Agent:
      - '{"bindings_version":"4.9.1","lang":"ruby","lang_version":"2.6.2 p47 (2019-03-13)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
        version 4.9.125-linuxkit (root@659b6d51c354) (gcc version 6.4.0 (Alpine 6.4.0)
        ) #1 SMP Fri Sep 7 08:20:28 UTC 2018","hostname":"XXXXXXXXXX"}'
      Accept-Encoding:
      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
      Accept:
      - "*/*"
      Connection:
      - keep-alive
      Keep-Alive:
      - '30'
  response:
    status:
      code: 200
      message: OK
    headers:
      Server:
      - nginx
      Date:
      - Sat, 30 Mar 2019 16:15:08 GMT
      Content-Type:
      - application/json
      Content-Length:
      - '495'
      Connection:
      - keep-alive
      Access-Control-Allow-Credentials:
      - 'true'
      Access-Control-Allow-Methods:
      - GET, POST, HEAD, OPTIONS, DELETE
      Access-Control-Allow-Origin:
      - "*"
      Access-Control-Expose-Headers:
      - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required
      Access-Control-Max-Age:
      - '300'
      Cache-Control:
      - no-cache, no-store
      Original-Request:
      - req_XXXXXXXXXXXXXX
      Request-Id:
      - req_XXXXXXXXXXXXXX
      Stripe-Version:
      - '2018-09-24'
      Strict-Transport-Security:
      - max-age=31556926; includeSubDomains; preload
    body:
      encoding: UTF-8
      string: |-
        {
          "id": "re_XXXXXXXXXXXXXXXXXXXXXXXX",
          "object": "refund",
          "amount": 1810,
          "balance_transaction": "txn_XXXXXXXXXXXXXXXXXXXXXXXX",
          "charge": "ch_XXXXXXXXXXXXXXXXXXXXXXXX",
          "created": 1553962508,
          "currency": "jpy",
          "reason": null,
          "receipt_number": null,
          "source_transfer_reversal": null,
          "status": "succeeded",
          "transfer_reversal": null
        }
    http_version: 
  recorded_at: Sat, 30 Mar 2019 16:15:08 GMT
recorded_with: VCR 4.0.0

テストケースでの利用

さて、上記の設定を行い、実際のテストケースでどのように使うかを見ていきます。

決済を取り消す処理を実装したコントローラのSpecです。

describe RefundController, type: :controller do
  describe 'POST #refund' do
    subject { post :refund, params: { id: 'XXXXXXXXXXXXXX' }, format: :json }

    context '正常に払い戻せる場合', vcr: { cassette_name: 'refund-success' } do
      it 'Refund (払い戻し)が作られる' do
        expect { subject }.to change { Refund.count }.by(1)
        expect(subject).to have_http_status(:success)
      end
    end
  end
end

vcr: { cassette_name: 'refund-success' }の部分以外はいたって普通のコントローラスペックかと思います。 これだけでHTTP通信をモックしたテストになっています。

vcr: { cassette_name: 'refund-success' }は次のことを設定しています。

  • このコンテキストVCRを有効にする
  • カセットファイルはspec/vcr/refund-success.yaml

カセットファイル名は指定しなくてもコンテキスト名で作ってくれる機能あります。
ただ、昔VCRをアップデートしたら2バイト文字があるとうまく動かないトラブルに見舞われたことがあり、それ以来私は指定しています3

spec/vcr/refund-success.yamlの中身は上記のカセットの例になります。
初めて実行したときには実際にStripeのAPIにアクセスし、カセットファイルに記録します。2回目以降はカセットが再生されます。

とても簡単ですね。webmockのようにモックを準備するコードは一切なしでOKです。
カセットファイルを作り直したいときには、カセットファイルを削除して再実行するだけです。

注意点

とっても楽にWeb APIをモックしたテストが実装できるのですが、利用には注意が必要です。

それはモックはモック。フェイクであって本物ではないということです。

これはVCR特有ではなくモックに共通した注意点です。
モックしたAPIの仕様の変更は常にチェックが必要です。当たり前の話ですが、モックは作ったときの仕様のままです。
APIの仕様が変更されればそれに追従する必要があります。
さもないとテストは通るけど本番適用すると動かないというトラブルに繋がります。

外部に公開されているAPIなら移行期間を設けると思うので、すぐに古い仕様のAPIが使えなくなることはないと思いますが、放置すると廃止される可能性もあるので注意が必要でしょう。

それ以上に注意が必要なのは非公開の社内APIだったりします。
移行期間など設けず、変更してしまうなんてことが起こりがちです。 それが利用者にとって破壊的な変更だとトラブルになります。 それぞれのプロダクトのチーム間で変更について共有し、注意しあえるコミュニケーションが重要です。

まとめ

VCRでWeb APIのモックを楽しよう!

最後に

アクトインディではエンジニアを募集しています。


  1. VCR=Video Cassette Recorderからのメタファーですね。

  2. レスポンスボディがUTF-8のJSONと決めつけた実装になっていますが、APIの仕様によります。でもまあ、一般的でしょう。

  3. 今はもう修正されているのでしょうか。未確認です。コンテキスト名を変更したらカセットファイル名も変更必要になるのでその理由からも指定したほうがいいと思いっています。

Simplecovで一部クラスでカバレッジが計測されない場合の解決方法

morishitaです。

アクトインディではRailsアプリケーションのテストをRSpecで書いています。
そして、テストカバレッジをSimpleCovを使って計測しています。

何故かカバレッジが計測できないクラスがあったのですが、計測できるように解決した件を紹介します。

tl;dr

  • SimpleCov.startは次のコードの前に実行する
require File.expand_path('../../config/environment', __FILE__)
  • .simplecov ファイルにSimpleCov.startを実行するコードを書いてRSpecの起動するクリプトで最初にrequire 'simplecov'したほうが楽。

改善前の状況

例えばいこレポではこんな感じでプルリクエストにPushするとテストが実行され、 その結果がカバレッジとともにSlackに通知される仕組みになっています1

f:id:HeRo:20190513211431p:plain
改善前

そこそこ高いカバレッジを維持できているのですが、 一部どうしても計測できないコードがあって悩んでいました。

f:id:HeRo:20190513211530p:plain
改善前 モデル

Userモデルはログインユーザを表します。
なのでテストで使わないわけがないのですが 0.0 % となっています。

SimpleCov.start の位置が重要

結論から言うと計測したいクラスがロードされるよりも先に SimpleCov.startが実行される必要があります。

そんなつもりはないのに、SimpleCov.startの前にロードしちゃっているありがちな例は次のケースです。

  1. FactoryBotのFactoryのStatic attributesでロードしてしまっている
  2. Initilizerでロードしてしまっている

1.のStatic attributes は FactoryBot 5.0以降で廃止されているので、最新のFactoryBotを使っていれば関係ないはずです。
Static attributes ってどんなの? って方はDeprecating static attributes in factory_bot 4.11を参照ください。対処法も載っています。

2.のケースですがrspec-railsを利用している場合、あんまり意識しなくても、spec_helper.rb にSimplecovの設定を実装していればテスト対象のコードより先にロードされます。 というのもrails generate rspec:installコマンドで生成される rails_helper.rbは最初にspec_helper.rb をロードするようになっているからです。

しかし、rspec-railsの3.6.0未満のバージョンで生成したrails_helper.rbを使っているとハマる可能性があります。私はこれにやられました。

解決するにはrails_helper.rbの次のコードより前にSimpleCov.startを実行すれば良いです。

require File.expand_path('../../config/environment', __FILE__)

つまり、rails_helper.rbの上記コード行より前でspec_helper.rbrequireしてやればいいのです。

test-queue

一方、アクトインディではCI環境だと test-queueを使ってRSpecを並列実行しています。 その場合、rspecコマンドではなく、TestQueue::Runner::RSpecを継承したクラスを実装して独自のRSpec起動スクリプトを用意して使います。そこでもハマっていました。

その起動スクリプト内でも次のコードが存在していました。

require File.expand_path('../../config/environment', __FILE__)

特に test-queueSimpleCov.start を実行しなくても、rails_helper.rbは各specファイルでrequireしていて読み込まれます。
それで見落としがちなのですが、やはりここでも上記コードより前にSimpleCov.startしてやる必要あるのです。

.simplecovによる設定

SimpleCov.startを実行すると言っても1行書くだけではないと思います。

例えば、上記のような設定で計測したいとします。

  • 結果出力のフォーマッタを指定
  • Railsの標準的な設定を導入
  • 7行以下のファイルは無視する

すると次のようなコードが必要になります。

require 'simplecov-json'
SimpleCov.formatters = [
  SimpleCov::Formatter::HTMLFormatter,
  SimpleCov::Formatter::JSONFormatter,
]
SimpleCov.start 'rails' do
  add_filter do |source_file|
    source_file.lines.count < 7
  end
end

前述の通り、最初に実行する必要があるため、複数のRSpec起動するスクリプトがあるとそれぞれに記載する必要があります。
それぞれにコピー&ペーストするのはDRYでないのでファイルを分けてrequireすると思います。
しかし、.simplecovという名前でプロジェクトのルートに置けばrequire 'simplecov'simplecov のロードと同時に .simplecov の内容を実行してくれます。
各起動スクリプトの最初でrequire 'simplecov'してやればいいだけなので、この方法で設定したほうがシンプルでいいと思います。

改善した結果

さて、こうして改善した結果を確認してみましょう。

f:id:HeRo:20190513211607p:plain
改善後

お、カバレッジが少し大きくなりました。
で、問題のUserクラスはというと…。

f:id:HeRo:20190513211626p:plain
改善後 モデル

やりました! 計測できています!

実は…

このエントリで紹介した解決方法はSimpleCovのREADMEに書いてありました。
やはりドキュメントは時々ちゃんと読まないとダメだなと反省しました。

最後に

アクトインディでは計測しながらコードを改善していきたいエンジニアを募集しています。

Active Flagで効率的にフラグを実装する

morishitaです。

今回はActive FlagというGemを紹介します。
このGemはActiveRecordのモデルでBIt Arrayなカラムを扱いやすくしてくれます。

github.com

こういう要件ってありますよね?

  • ON/OFFできるユーザ設定をたくさん持たせたい
  • 選択肢を複数選択できる選択項目を持たせたい

これらをDBに保存できるように実現するにはどのような実装をするでしょうか?

前者の場合、素朴に実装するとBooleanを格納する属性を設定項目分だけ作る方法が考えられます。
1つ、2つの項目ならそれでもいいでしょう。
でもそれ以上になると、テーブルのカラムがやたら増えてしまうのでもっとスッキリ実装できないかなぁと考えてしまいます。

なんとか1カラムに押し込めようとすると、JSONにしたり、true/falseのカンマ区切りリストを格納する方法もあるでしょう。
そして、そんなことをすると後で設定項目を見直す際に「ある設定をONにしているユーザはどれくらいいるのか集計してほしい」とか言われ、SQLで解決しようとするとLIKEや正規表現を駆使することになります。
JSONのキーの命名次第では割と辛かったりしますし、true/falseのカンマ区切りリストだと…考えたくもないですね。
(モデルにロードして全件スキャンっていう手もありますが、小さいサービスならそれもいいでしょう。でも数百万とかになると…。うわぁ)

後者の場合、選択された項目のIDをカンマ区切りで格納したりするのでしょうか?
これも、抽出や集計する必要があるとなかなか辛いと思います。 選択肢そのものが別モデルとして独立できるほどであれば、別テーブルにして1対多の関係で管理してもいいかと思います。
でも、ON/OFF を管理したいだけで別テーブル作るのもなぁ…って場合もありますよね。

Bit Arrayという解決

Bit Arrayを使えば、テーブル的にはスッキリ解決できます。

例えば、Personモデルがあり、話せる言語を英語スペイン語中国語フランス語日本語 から選択するケースを考えます。もちろん複数選択ありです。

このデータを格納するためにinteger型のカラム languages を設けます。

それぞれの言語を次の数で表します。

  • 英語 english = 1
  • スペイン語 spanish = 2
  • 中国語 chinese = 4
  • フランス語 french = 8
  • 日本語 japanese = 16

例えば、英語が話せるPersonlanguages=1、英語と中国語と日本語が話せればlanguages=21というふうに上記で示した数を合計した数を格納します。これで複数の言語が選択された場合も問題なくデータを保存できます。

ん? それで、ちゃんとどんな選択の場合も表現できるの? って思うかもしれませんが、大丈夫です。
それぞれの言語の数字を2進数に変換するとよくわかります。

言語 10進数表現 2進数表現
英語 english 1 00001
スペイン語 spanish 2 00010
中国語 chinese 4 00100
フランス語 french 8 01000
日本語 japanese 16 10000

で、英語中国語日本語を選択する場合は次のようになります。

english:  00001
chinese:  00100
japanese: 10101
----------------
ビットOR    10101 (2進数) => 21 (10進数)

各言語の数字を2進数で表すと各桁がそれぞれの言語を表しているとみなせます。
そしてビットORを取ることにより複数の選択肢の選択状態を表現できます。

これですべての選択の組み合わせを表現できるということがわかりますね。

クエリー

検索するときにはビットANDを使うことにより検索可能です。

例えば、スペイン語(2)またはフランス語(8)のどちらかを選択しているpersonを抽出するには次の様なSQLとなります。

SELECT * FROM person WHERE languages & 10 > 0;

スペイン語(2)とフランス語(8)の両方を選択しているpersonを抽出するには次の様なSQLとなります。

SELECT * FROM person WHERE languages = 10;

メリット・デメリット

  • メリット
    • 1カラムで複数の選択肢の選択状態を格納できる
    • 選択肢が増えてもDBのスキーマは変わらない
    • JSONなどで格納する場合に比べ検索しやすい
      • index を付けても肥大しにくい

  • デメリット
    • DBの値からどれが選択されているのかぱっと見わかりにくい
    • ビット計算に慣れないと値の格納、レコード抽出が分かりづらい

Active Flag

さて、この様なカラムの実装をサポートしてくれるライブラリがactive_flagです。

Active Flagを使った実装

まずはGemfileに次を追加して bundle install でインストールします。

gem 'active_flag'

そしてマイグレーションですが、Bit Arrayを格納するカラムは単なる整数型のカラムを使います。 次のような感じで実装します。

class CreatePerson < ActiveRecord::Migration[5.2]
  def change
    create_table :peaple, comment: 'パーソン' do |t|
      t.integer :languages, null: false, default: 0, limit: 8
    end
  end

次にモデルの実装は、次のとおりです。:languagesに対する選択肢を定義するだけです。簡単ですね。

class Person < ApplicationRecord
  flag :languages, [:english, :spanish, :chinese, :french, :japanese]
end

これで、インスタンスとクラスには次のメソッドが追加されます。

# インスタンスメソッド
## 設定されている値の取得
profile.languages                   #=> #<ActiveFlag::Value: {:english, :japanese}>
profile.languages.english?          #=> true
profile.languages.set?(:english)    #=> true
profile.languages.unset?(:english)  #=> false

## 値のセット、アンセット
profile.languages.set(:spanish)
profile.languages.unset(:japanese)
profile.languages.raw               #=> 3
profile.languages.to_a              #=> [:english, :spanish]

## 複数の値の同時設定
profile.languages = [:spanish, :japanese]

# クラスメソッド
## 選択肢の取得
Profile.languages.maps              
#=> {:english=>1, :spanish=>2, :chinese=>4, :french=>8, :japanese=>16 }
Profile.languages.humans            
#=> {:english=>"English", :spanish=>"Spanish", :chinese=>"Chinese", :french=>"French", :japanese=>"Japanese"}
Profile.languages.pairs             
#=> {"English"=>:english, "Spanish"=>:spanish, "Chinese"=>:chinese, "French"=>:french, "Japanese"=>:japanese}

# スコープメソッド
## 抽出
Profile.where_languages(:french, :spanish)  
#=> SELECT * FROM profiles WHERE languages & 10 > 0

## バルクセット
Profile.languages.set_all!(:chinese)        
#=> UPDATE "profiles" SET languages = COALESCE(languages, 0) | 4

## バルクアンセット
Profile.languages.unset_all!(:chinese)      
#=> UPDATE "profiles" SET languages = COALESCE(languages, 0) & ~4

どの選択肢の値がいくつなのかを自分で管理しなくてもよしなにやってくれます。 そして自分でビット演算子を使ってクエリを作らなくても便利なメソッドで値の取得・設定、レコードの抽出が可能となります。 しかもi18nにも対応しており、言語ファイルを用意すれば複数言語対応で表示も可能です。

プロダクトでの利用

いこレポでは現在サイトリニューアル中です。
記事の種別や分類を整理して訪問ユーザが記事の分類や目的別に記事を探しやすくなる予定です。
段階的にリリースするスケジュールですでに一部は公開済みです。

そのため、Active Flagを使って記事にフラグを追加し、分類に利用しています。

report.iko-yo.net

最後に

いこレポももうすぐ2周年。おかげさまで月間数百万PVを超えるサイトに成長しました。

アクトインディでは一緒にサービスをグロースさせていくエンジニアを募集しています。

RubyKaigi 2019に参加しました

こんにちは、kanekoです。2018年の秋からアルバイトとして勤めていましたが、この4月に正式に社員としてジョインしました!そしてこれがはじめてのエントリーです。

記念すべきファーストエントリーは2019年4月18日〜20日に福岡国際会議場で開催されたRubyKaigiに参加してきたレポートです。

去年と今年の話

RubyKaigiに参加したのは今年が2回目でした。 後述しますが今年は”ヘルパー”という、当日に運営の手伝いをする一員をしていました。 それはそれでRubyKaigiへの見え方が変わったのですが、一参加者としても、主に以下の2点から、RubyKaigiが違って見えました。

  • セッションの決め方

去年はタイトルを見ても全くピンとこなかったので、登壇者が知っている方だったら「なんだかよくわからないけれどとりあえずこれを聞こう」という具合にセッションを選んでいました。決め手がなかったらメインホールのセッションを聞いていました。
今年は、「この内容を聞いてみたい」と思って選ぶようになりました。また、登壇者について「この人はこういうことをやっている人だからこのような話をするんだな」というのを知って聞けることが増えました。単に人だけで選んでいた時よりも内容が入ってきて楽しかったです。その点は自分ですごく成長を感じました。(登壇内容自体はすごく高度なので、理解度はそんなに高くないですが...)

  • 聞くときの意識

去年は2日間しか参加できなかったのですが、2日分で聞けるだけのセッションは全部聞きました。 去年は今年以上に理解が追いついていない状態だったのですが、わからないなりに必死に聞こえる単語をPCに書き上げて「これをわかるようになりたい」と思ったりもしていました。メモ取りマシーンみたいにメモを取っていたのでタイピング力向上という副産物が生まれたくらいですw

今年は、高度で理解が追いつかないというのは未だありましたが、前回に比べ「今の開発にどう活かせそうか」と考えたり「自分でもこういうことをやってみたい」と感じながら聞けるようになり、その点は去年にない楽しさだったと思います。自分のレベルが上がったらもっともっとこの割合が増えていくのだろうと思うので、「やっていくぞ!」という気持ちが高まっていくのも感じました。

セッションの感想

すばらしい発表をたくさん聞くことができました。そのうちいくつか簡単に感想を。

Building a game for the Nintendo Switch using Ruby

完全にタイトルで選んで聞いたセッションです笑
すごくインパクトがあって、見ていて「わーーー!!!なんだこれーーー!!!すごーーー!!」と思いました。

すごい人たちって本当に楽しそうにコードの話をするので話を聞くこと自体が楽しいなと感じているのですが、このセッションがまさにそれでした。 Rubyでできることって自分が思っているよりもうんと広くて自由なんだなとも感じました。

Best practices in web API client development

スライドがこちらに。 speakerdeck.com

今業務では新規の開発をしているのですが、APIのサーバー側もクライアント側も作ったり手を加えたりしているので、ぜひ聴きたいと思って聴きに行きました。

内容全てが参考になったのですが、特に「単一責任の原則」についての箇所は、自分が今業務でやっていて一番抜けがちなので、グサグサ刺さりました。

Optimization Techniques Used by the Benchmark Winners

このセッションについては、実は聴いていても全然分からなかったです。
ですが、その日の夜にご本人と偶然お話できまして、「受付をしていたので英語で質問を受けることがあったのだけどきちんと答えられなくて、英語を話せるようになりたいと思った」という話題を出しました。
そうしたら「来年の松本に行く?」「その時にまた話そう」と言ってくださって、”彼ともっと言語面でのストレスなく話たいし技術的な話を一緒にできるようになりたい!”とめちゃくちゃ思いました!

彼のこのセッションが全部わかるような英語力と技術力をつけたいなという点で、私の中ですごく印象に残るセッションになりました。

ヘルパーをしたこと

さて、当日の運営のお手伝いをする人は”ヘルパー”と呼ばれるのですが、今年そのヘルパーをやらせていただきました。3日間受付をさせていただきました。 結論から言って、このような素晴らしいイベントの運営のお手伝いができたこと、しかも錚錚たる方達とご一緒できたことは、大変光栄であり、ありがたいことでした。心から感謝いたします。

このようなことを書くのは生意気かもしれないのですが、語弊を恐れずに書くと、ヘルパーをしたことでRubyKaigiが自分にとってずっと身近なものに感じられました。 今年初めて参加した知人が感想で「Rubyも人間が書いているんだとわかった」と述べていたのですが、それと同様の感覚かもしれません。私は当日の、しかもほんの一部しかみてはいないけれど、RubyKaigiをつくるみなさんの思いに直接触れられたような気がしました。

今年のRubyKaigiのTシャツとパーカーと名札は大事にします!

f:id:neko314:20190425184746j:plain
3日目の帰りには会場からこんなメッセージが

全体を通して

今年このように参加してみて、またある登壇者からお話を伺って一番思ったことは「登壇できるくらいのスキルやアイディアや思いを持って開発できる人になりたい」ということです。
”すごい”人、”強い”人たちのように、心が踊るようなコードを書いてみたいです。今はまだ「やってみたい」と言うことしかできないし、そうなるまでにはすごく時間が必要かもしれません。
しかし、少し前までは「"あちら側"と"こちら側"」のような感覚で登壇者やコミッター、運営の皆様を遠くで眺めていたような私にとって「自分にもやればできる」と思えたこともKaigiEffectと言えそうです。

最後に

以上、感想のほんの一部ですが、参加レポートでした! 大事なことを書いていませんでしたが、このRubyKaigiには業務として 経費も会社で立て替えてもらって参加しました! 働きやすさもカンファレンスへの参加のしやすさも抜群のアクトインディで一緒に働く仲間を募集しています!気になる方はいつでもご連絡ください!

actindi.net

Serverless Framework Jets Rubyを触ってみました

こんにちは。Webエンジニアのnakamuraです。

Serveless Frameworkを勉強するにあたって、Ruby製のサーバーレスフレームワークJetsを使ってみました。 サーバーレスフレームワークと言っても、どこから始めていったらいいのかわからなかったので、RailsライクなJetsは非常に助かりました。 今回は、Dockerでの環境構築でJetsを動かすまでを紹介します。

Docker環境でJetsをインストール

まず、ローカル環境に作業用ディレクトリを準備します。

$ mkdir src
$ cd src

次に、Dockerでruby2.6のコンテナを作成。

$ docker run -it --rm -v ${PWD}:/usr/src/app ruby:2.6 bash

コンテナが作成されたらyarnをインストールします。

root@4aac8522c43b:/# apt-get update -qq && apt-get install -y build-essential
root@4aac8522c43b:/# curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
root@4aac8522c43b:/# echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
root@4aac8522c43b:/# apt-get update && apt-get install yarn

コンテナ側の作業ディレクトリに移動して、jetsをインストールします。

root@3883f5341486:# cd /usr/src/app
root@3883f5341486:/usr/src/app# gem install jets

ここで一旦、exitしてコンテナから抜けます。

root@3883f5341486:/usr/src/app# exit

docker-composeを準備

まず、空のdockerfileを準備し、編集します。

FROM ruby:2.6

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev
RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - && \
    apt-get install nodejs
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
    echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
    apt-get update && apt-get install -y yarn

RUN mkdir /usr/src/app
WORKDIR /usr/src/app
COPY Gemfile* /usr/src/app/
RUN bundle install
COPY . /usr/src/app/

次にdocker-compose.ymlを以下のように編集。

version: '3'
services:
  db:
    image: mysql:5.7.17
    ports:
      - "3306:3306"
    volumes:
      - db-data:/var/lib/mysql
    env_file:
      - .env.development

  web:
    build: .
    command: /bin/sh -c "bundle exec jets server --host '0.0.0.0'"
    ports:
      - "8888:8888"
    volumes:
      - .:/usr/src/app
    environment:
      RAILS_ENV: development
    env_file:
      - .env.development
    depends_on:
      - db

volumes:
  db-data:

database.ymlのpassword部分を変更。

password: <%= ENV['MYSQL_ROOT_PASSWORD'] %>

env.developmentにMYSQL_ROOT_PASSWORDとDB_HOSTを追記。

MYSQL_ROOT_PASSWORD=長いパスワード
DB_HOST=db

docker-compose upを実行し、JetsのWelcomeページが表示されれば成功です。

アプリケーション作成開始

scaffoldで雛形を作成してみます。

$ docker-compose exec web jets generate scaffold Post title:string

すると、railsと似たようなファイルが作成されます。

      invoke  active_record
      create    db/migrate/20190201085745_create_posts.rb
      create    app/models/post.rb
      invoke  resource_route
       route    resources :posts
      invoke  scaffold_controller
      create    app/controllers/posts_controller.rb
      invoke    erb
      create      app/views/posts
      create      app/views/posts/index.html.erb
      create      app/views/posts/edit.html.erb
      create      app/views/posts/show.html.erb
      create      app/views/posts/new.html.erb
      create      app/views/posts/_form.html.erb
      invoke    helper
      create      app/helpers/posts_helper.rb

migrateを実行し、http://localhost:8888/postsにアクセスするとお馴染みのCMSが表示されるはずです。

$ docker-compose exec web jets db:create db:migrate

参考URL

参考にさせていただいたサイト

Jets Ruby Serverless Framework

Ruby 製サーバーレスフレームワークの Jets を検証してみたら、Rails ライクに使えていい感じだった - Qiita

最後に

JetsはRailsを触ったことがある人ならかなり学習コスト少なめで始められるのではと感じました。 引き続き、Jetsを触りながら、AWSデプロイ周辺を学習していこうと思います。

アクトインディでは、エンジニアを募集しているので、興味のある方はご連絡よろしくお願いします!

actindi.net

いこレポのRailsを5.1.2から5.2.2にアップグレードしました

morishitaです。

Rails 6のリリースも見えてきたので、やっといこレポのRailsをアップグレードしました。

具体的には、Rails 5.1.2 → 5.2.2 のアップグレードを実施しました。それについて紹介します。

アップグレードまでの道のり

アップグレードはセオリー通り次のステップで進めました。

  1. テストカバレッジを上げる
  2. Railsのバージョンを保ったまま、他のgemをアップデートする
  3. Railsをアップグレードする

テストカバレッジを上げる

アップグレードの事前準備ですね。 いこレポでは RSpecでテストを実装しており、SimpleCovでカバレッジを計測しています。
これで計測できるのは行カバレッジですが、次のような状況なので拠り所としてよかろうと判断しました。

  • if文がむちゃくちゃ多い処理はない
  • あるif文による分岐の結果が次の分岐に影響するような処理も少ない

目標カバレッジは90%としました。それに対して計測値は約85%。

どこが足りてないのかと調べたらモデルスペックもある主要なモデルで計測されていないものがあります。

設定ミスもなさそうでした。 test-queueが原因かと思い、やめてみても結果は変わらず。
とりあえず、他にすべきことを先に片付けようとFactoryBot の DEPRECATION WARNING1に対応しました。

> DEPRECATION WARNING: Static attributes will be removed in FactoryBot 5.0. Please use dynamic attributes instead by wrapping the attribute value in a block

すると、何ということでしょう!カバレッジも一気に上がりました。
確認すると、計測されていなかったモデルのカバレッジが計測されています。

どうやらSimpleCov.startより先にロードされたクラスは測定対象にならないようです。
Factoryから静的属性の値として参照されているクラスは SimpleCov.start より先にロードされてしまうために測定対象から外れていたようです。
問題のモデルも定数のいくつかが静的属性の値として参照されていました。

それが動的属性に変更することにより最初にFactoryが利用されるときに評価されるようになったため SimpleCov.start よりあとでロードされ計測対象として扱われるようになったようです。

これによりカバレッジ92%を超え、目標をクリアしました。

gemをアップデート

準備は整ったので、どんどんgemをアップデートしていきました。 もともと、gemのアップデートをサボりがちだったので古くなっているものが結構多かったです。

本番環境の動作に影響が少ないdevelopmentグループとtestグループのgemからアップデートしました。 各gemのアップデートは次の手順で進めました。

  1. 変更履歴を確認し、破壊的な変更が入っていないか確認
  2. アップデートして、Rspecをすべてパスさせる
  3. ステージングにデプロイして動作確認
  4. 問題なければ本番環境に適用。

ほとんどコードの変更無しでアップデートできましたが、uglifier を 1.3.0 -> 4.1.202 にアップデートしたら次のエラーでプリコンパイルできなくなりました。

Uglifier::Error: Unexpected token: keyword (const). To use ES6 syntax, harmony mode must be enabled with Uglifier.new(:harmony => true)

この対処として config/environments/production.rb で、次の様に変更しました3

#config.assets.js_compressor = :uglifier #変更前
config.assets.js_compressor = Uglifier.new(harmony: true) # 変更後

Webpackerのアップグレード

一番手間がかかったのはWebpackerでした。

Webpacker2.0.0 からのアップデートでしたが 3.0.0台に上げると設定ファイルがいろいろ変わってしまうので先送りしていました。

面倒とはいえ、ビルドの仕組みなのでJSとSASSがエラーなくトランスパイルできれば、その後の問題は少ないのではないかとも思っていました。 そして、アップデートするからには Webpack4Babel7 にしようとも決めました。

アップデート作業の時点で、Webpack4に対応予定の Webpacker4はまだ正式リリースされておらず4.0.0.pre.3が最新リリースでした。トランスパイルしたものの動作に問題がなければ、正式版でなくても構わないと割り切りました。 Webpacker3に一旦アップデートするステップも意味がないと思ったのでスキップしました。 ということで 2.0.0 → 4.0.0.pre.3 のアップデートです。

次の手順でアップデートを進めました。

  1. Webpackerに関わる設定ファイルを一旦全部捨る。
  2. Webpacker4bundle exec rails webpacker:install:vueで設定ファイル群を生成
    • 既存設定を変更していくより、Webpacker4の設定を変更したほうが効率的だろうと判断。
  3. 生成した設定に追加で必要な設定を足していく
    • 次に関する設定を追加 Pug, SASS, Workbox, splitChunks

PugとSASSはVueモジュールの中で使っています。以前は必要なNPMパッケージをインストールするだけで、設定しなくてもvue-loaderがよしなにやってくれた気がするのですが4、それぞれローダーの設定ファイルを追加して動くようにしました。

Workboxは組み込む際のインタフェースの変更に伴う修正はしましたが、設定内容そのものは既存の設定から変更なく組み込めました。

splitChunksの導入

Webpack4 では CommonsChunk が廃止され、splitChunks に変わりました。もともと、CommonsChunkを使っていたので、splitChunksに移行したいと思ったのですが、Webpacker4.0.0.pre.3では、まだ未サポートでした。

でも、ドキュメントには設定方法が記載されているしなーと思ったら、4.0.0.pre.3より進んでいる作業当時のmasterではすでに実装が含まれる様子。まあ、どうせ導入しようとしているのもpreリリース版だし、動けばいいのだ! とWebpacker4のバージョンをその時のmasterHEADに変更しsplitChunksも使えるようになりました。

bundle exec rails webpacker:install:vueがインストールするVue.jsも当時の最新版で、もともと使っていたバージョンより新しいことを途中まで失念していました。が、特に動作に問題なさそうだったので結果オーライでそのままとしました(Vue.js 2.3.4 -> 2.5.20, Vuex 2.3.1 -> 2.5.0のアップデートとりました)。

Railsのアップデート

さていよいよRails(5.1.2 → 5.2.2)のアップデートです。

永久保存版Railsアップデートガイド - pixiv insideを参考に、次の2つを拠り所に作業を進めました。

Rails 5.1.6.1までのアップデートはパッチバージョン以下のアップデートなので大きな影響はなかろうと思われたので次の2フェーズに分けました。

  • 第1フェーズ:Rails 5.1.2 -> 5.1.6.1
  • 第2フェーズ:Rails 5.1.6.1 -> 5.2.2

各フェーズとも、Railsのリリースバージョンを1つづつアップデートしては動作確認していきました。

第1フェーズ Rails 5.1.2 -> 5.1.6.1

Rails 5.1.5 にアップグレードしたところで、次のようなCapybaraのバージョン不整合が発生しました。

can't activate capybara (~> 2.13), already activated capybara-3.12.0. Make sure all dependencies are added to Gemfile. System test integration requires Rails >= 5.1 and has a hard dependency on a webserver and capybara, please add capybara to your Gemfile and configure a webserver (e.g. Capybara.server = :webrick) before attempting to use system tests.

調べると、RailsのコードでCapybaraのバージョンを~> 2.13に指定してしまってました5

RSpecがFailするどころかはじまりもしないので困りました。Capybaraのバージョン下げる以外にどうにも対処法がなさそうなので、せっかく3.12.0にアップでデートしたのに2.13に下げました。

これ以外は特に詰まるところなく Rails 5.1.6.1 までアップデートできました。 第一フェーズではRails 5.1.6.1での動作確認後、すぐに本番適用しました。

第2フェーズ Rails 5.1.6.1 -> 5.2.2

Railsのアップデート自体では特に詰まるところなくアップデートできました。

Rails 5.2で導入されたいくつかの機能がありますが、次の様に対処しました。

  • Active Storage: 見送り
    • ディスコンと言いながらセキュリティアップデートやAWS SDKの変更への対応など必要なメンテナンスは続いているためPaperClipの利用を継続
  • HTTP/2 Early Hints:見送り
    • 導入したかったのですが、AWS ALBがサポートしていないので。
  • Content Security Policy:ちょっと先送り
    • すでに導入済みのネットワーク広告への影響を検証してから導入予定
  • Credentials:導入済
    • あとあと変更するのは面倒だっったので encrypted secrets から移行

まあ、保守的ですが、上記のとおりです。

Credentials は encrypted secretsと違って Rails.env で値を切り替える仕組みがないのがちょっと不便ですね。

リリース

Rails 5.2.2へのアップグレードのリリースに際しては、予期せぬエラーが発生すればすぐに戻せるよう次のような手順で実施しました。

  1. 旧環境を複製して新環境を作る
  2. 新旧の環境へのトラフィックをRoute53Weighted Routingを利用して振り分けられる様に設定する
  3. トラフィックを徐々に旧環境から新環境に移動させる。

いこレポは Elastic Beanstalkを利用しています。なので、旧環境を複製してもう1つ同じ環境を作るのは、数クリック、5,6分の作業です。

Route53Weighted Routingを設定したら、ClouwdWatchでエラーの発生を監視しつつ新環境の重みを増やしつつ、旧環境の重みを増やしていきました。もしもエラーが想定以上に発生すればすぐに旧環境に戻すことができます6

今回の場合、エラーは発生しなかったのでスムーズに新環境に移行できました。

最後に

アクトインディでは既存サービスもしっかりメンテしながら日々開発を進めています。 一緒に開発してくれるエンジニアを募集しています。


  1. 次の警告が出ていました。

  2. だいぶサボっておりました。

  3. https://github.com/lautis/uglifier/issues/127

  4. うろ覚えです。

  5. https://github.com/rails/rails/blob/0ae59ea828ed20141af0d4c9ed9130eb47ce55f3/actionpack/lib/action_dispatch/system_test_case.rb#L1

  6. 実際にはWeighted RoutingのTTL=60secのタイムラグは発生します。

Webど素人のSierがWebエンジニアになんとかなれた話

Webエンジニアのnakamuraです。 今回はタイトルにもあります通りWebのことなんてHTMLぐらいしかしらなかったメーカー系のSierだった僕がどのような経緯でWebエンジニアになったかを紹介したいと思います。 これから、Webエンジニアになりたいと思っている人たちに多少なりとも参考になる部分があれば幸いです。

「システムエンジニアだった頃の話」

今から20年くらい前に大学の情報系学科を卒業した僕は、新卒でメーカー系のIT企業に就職しました。 システムエンジニアとして、いくつかのプロジェクトで開発を担当し、働く環境にも、仕事に対するやりがいにも、特に不満はなかったので、当時の僕はこのまま経験を積んでこの会社にずっといるんだろうと本気で考えていました。 ところが、4年目くらいになった頃に、開発はもういいから、PMの方に進んでと言われ、まだまだ開発をやっていたかった僕はこのあと数年後に退職し、英語が使えるようになれば転職にも困らないかなくらいの軽い気持ちで8ヶ月の語学留学を決意します。

「渡英した頃の話」

渡英した当時(2007年くらい)は、ポンドがめちゃくちゃ高かったので、みるみる貯金が減っていきました。 なので、語学学校の空き時間でできるオンラインショップの梱包作業員の募集に応募することにしました。

いざ、面接に望むと僕の履歴書を見たイギリス人が、「ちょうどPHPのECサイトをRailsに移行しようと思ってるんだけど、エンジニアやってたんだったら、やってみない?やるならVISA取るから。」と。

もともと、1つのプロダクトを、全体を通して関わってみたい思っていたので、Webに関する知識は皆無でしたが、一緒に勉強しながらでいいからということだったので、やってみることにしました。 この頃の僕は、Rubyを少し知っていたくらいでRailsのことなんてまったく知りませんでした。

で、当時、渡された1冊の本がagile-web-development-with-railsです。 Railsのことを知らなかった僕は、Railsの読みやすさに衝撃を受けます。 そこから、とりあえず、チュートリアルに従ってサンプルアプリケーションを作り、わからないところはあとで振り返るということにして、どんどん先に進みました。 半年くらいして、なんとなく仕組みがわかるようになってから、Railscastsなんかも見るようにして、アプリケーションのアップデートを繰り返していました。

それから、自力で構築したブログサイトをリリースし、その後に、同僚のお手伝いで日本料理教室の予約サイトを2、3ヶ月かけて構築しました。

同時に、ECサイトの開発も同僚と2人で進めていたのですが、当時のRailsに関する僕らの知識ではPHPで実現出来ていたことがRailsだとなかなかうまく実装することができず悪戦苦闘していました。

そして、しばらくすると、同僚のイギリス人が転職活動を始め、数ヶ月後に転職することになり、メインだったECサイトの移行を1人でやらざるを得なくなりました。

この頃は、ロンドンの物価の高さに加え、近所の子供たちにマフィンを投げつけられたり、ロンドンの天気に憂鬱になったりと、今すぐにでも日本へ帰国したい気持ちでいっぱいでした。

ただ、ECサイトもほぼほぼ完成に近づいていたので、リリースを諦めきれず、なんとかリリースにこぎつけることができました。

以下が、その時に利用していた主なサービスになります。

・サーバー www.brightbox.com

・検索エンジン sphinxsearch.com

・メール配信サーバー www.mandrill.com

・カード決済 www.sagepay.co.uk

ECサイトを無事リリースしたあとは、様々な機能強化を行いながら、5年くらいで当初から目標にしていた売り上げ額に到達することができました。 その間、ECサイトの他にも、いくつかのアプリケーションをリリースしていましたが、自分は果たして日本でもう一度働けるのか?ということを思い始め、帰国を決意しました。 8ヶ月の滞在のつもりが、最終的に8年ちょっとの滞在になってました。

「帰国してからの話」

帰国後は、すぐにGreenとPaizaを使って転職活動を始めましたが、久しぶりの地元の居心地の良さに感激し、地元の市役所で働くことになります。 結果的に2年半ほど働くのですが、やはり、もう一度、プロダクト開発に携わってみたいという希望があったので、またまた転職を決意します。 その間、やっていたことと言えば、Paizaでひたすらrubyの問題を解いたり、EveryDay RailsでRspecを復習したり、Ansibleを勉強したり、新しいバージョンのRails本を読んだりしながら、小さいアプリケーションの作成を続けていました。

「最後に」

なんとか日本でWebエンジニアとして採用され、1人で開発していたころの違いに衝撃を受けている毎日ですが、1人の時には出来ていなかったコードレビュー、使用していなかったプロジェクト管理ツール、インテグレーションツールの有用性を思い知らされるなど、充実した日々を過ごせているように思います。

僕がWebエンジニアになるきっかけになった10年前と今では、エンジニアに求められる知識や技術も幅広くなっていると思いますが、まずは小さいアプリケーションを作ることからはじめてみてはいかがでしょうか?

アクトインディでは、一緒に働いてくれるエンジニアを募集しております。 興味のある方はぜひお越しください!

Rubyの文字列連結、最速は?

morishitaです。

先日、文字列を繰り返し結合するようなコードを書いていました。
文字列の連結するのに StringIO を利用していました。

イメージとしてはこんな感じですね。

io = String.new
io.write '文字列'
io.write '文字列'
# 〜中略: たくさんの繰り返し 〜
str = io.string

そのコードをレビューしてもらっていたときに次のようなツッコミをもらいました。

String ではなく StringIO を使ってるのってなんでです?
(効率良かったりするのかしら…?)

私の Web エンジニア人生の最初の言語は Java だったのですが、 Java では大量に文字列を連携する場合には StringBufferを使うのが鉄則です。
(しばらく Java から遠ざかっているのですが、今でもそうですよね? )

で、Ruby のStringIOは Java のStringBufferみたいなものかなと思ってこれまでもなんとなく使っていました。

そのときに議論になったのは +演算子による文字列結合はインスタンスがその都度生成するのでダメだろう。
では、StringIO#write; StringIO#string って String#concat より速いの? ということでした。

で、調べました。

インスタンスの生成はどうなっているのか

まずは、インスタンスが変わるのか変わらないのかを次のコードで確認しました。

puts '-- a += b --'
a = 'a'
b = 'b'
puts "a.object_id:        #{a.object_id}"
a += b
puts "(a += b).object_id: #{a.object_id}"

puts "\n-- a.concat b --"
a = 'a'
b = 'b'
puts "a.object_id:            #{a.object_id}"
a.concat b
puts "(a.concat b).object_id: #{a.object_id}"

puts "\n-- StringIO --"
a = StringIO.new
a.write 'a'
b = 'b'
puts "a.object_id:           #{a.object_id}"
a.write b
puts "(a.write b).object_id: #{a.object_id}"

その結果は次の通り。

-- a += b --
a.object_id:        70242235991440
(a += b).object_id: 70242235991280

-- a.concat b --
a.object_id:            70242235990920
(a.concat b).object_id: 70242235990920

-- StringIO --
a.object_id:           70242235990600
(a.write b).object_id: 70242235990600

まあ、予想通り+演算子はその都度インスタンスが生成されるので論外。
StringIOString#concatは変わりません。

ベンチマーク

次のコードで文字列連結の速度を計測。

require 'benchmark'
n = 10000
m = 1000
Benchmark.bm do |r|
  r.report 'String#+' do
    n.times do
      x = ''
      m.times do
        x += 'a'
      end
    end
  end
  r.report 'String#concat' do
    n.times do
      x = ''
      m.times do
        x.concat('a')
      end
    end
  end
  r.report 'StringIO#write' do
    n.times do
      x = StringIO.new
      m.times do
        x.write('a')
      end
      x.string
    end
  end
end

この結果は次の通り。

                  user     system      total        real
String#+        4.120000   0.170000   4.290000 (  4.286334)
String#concat   2.110000   0.000000   2.110000 (  2.112879)
StringIO#write  1.520000   0.010000   1.530000 (  1.528513)

ほほー、StringIO#write速いね。ということでそのプルリクは LGTM となり、マージしてデプロイされました。

でも...

このエントリを書きながら、ふと気になって次も確認してみました。

require 'benchmark'
n = 10000
m = 1000
Benchmark.bm do |r|
  r.report 'String#<<     ' do
    n.times do
      x = ''
      m.times do
        x << 'a'
      end
    end
  end
end

で、その結果。

                  user     system      total        real
String#<<       1.180000   0.000000   1.180000 (  1.185162)

あ…。

まとめ

Ruby の文字列連結の最速は String#<<

最後に

アクトインディではこんな感じでレビューしあいながら開発しています。
一緒にレビューを楽しみながら開発しませんか?

エンジニア募集中です。

actindi.net