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

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

VSCode の Jupyter で Ruby を使う

morishitaです。 今回は小ネタを1つ。

VSCode の Jupyter 拡張は手軽に Jupyter Notebook が使えて便利です。
Python さえインストールされていれば、必要なライブラリがインストールされていなくても拡張子 .ipynb の Notebook ファイルを開いたときにダイアログが表示され、指示に従うとインストールしてくれます。 その後は Notebook ファイル内でコードを実行することが可能になります。
ローカルで Jupyter サーバを動かすよりも手軽に Notebook ファイルを利用できます。

Jupyter Notebook で Ruby を使いたい

Juypter Notebook は Juypter kernel と呼ばれるライブラリが必要で、 Python を利用するときには ipython kernel を利用します。
Juypter kernel は各種言語用のものが実装されており、次のページに一覧があります。

実に様々な言語向けの kernel がありますが、アクトインディでは主に Ruby を用いて開発しているので、 Ruby を使えるようにしたいと思いました。
前述のリストで調べると IRuby という kernel があるようです。
IRuby の README をざっと見ると Docker イメージがあるようなので、これを使ってみようと思います。

VSCode + Remote - Containers で IRuby 環境を作る

VSCode には Docker コンテナ環境をあたかもローカル環境のように利用できる Remote - Containers 拡張があります。 これを使って、 IRuby を使える Jupyter 環境を作ります。

必要なソフトウェアは次の通りです。

devcontainer.json を作る

適当なディレクトリ(ここでは仮に jupyter-ruby という名前とします)を作って、そこを VSCode で開きます。

その中に .devcontainer というディレクトリを作り、更にその中に devcontainer.json というファイルを作り、次の内容を書き込み保存します。

// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.205.2/containers/ruby
{
  "name": "rubydata/datascience",
  "image": "rubydata/datascience-notebook:latest",

  // Set *default* container specific settings.json values on container create.
  "settings": {},

  // Add the IDs of extensions you want installed when the container is created.
  "extensions": [
    "rebornix.Ruby",
    "ms-python.python",  
    "ms-python.vscode-pylance",
    "ms-toolsai.jupyter",
    "ms-toolsai.jupyter-keymap",
    "julialang.language",
    "Ikuyadeu.r"

  ],
  "mounts": [
    "type=bind,src=${localWorkspaceFolder}/notebooks,dst=/notebooks"
  ],
  "workspaceFolder": "/notebooks",
  "initializeCommand": "mkdir -p notebooks"
}

次のような構成になったかと思います。

jupyter-ruby
  └ .devcontainer
        └ devcontainer.json

この.devcontainer/devcontainer.json ファイルは Remote - Containers の設定ファイルで接続するコンテナのイメージやコンテナ側にインストールする VSCode の拡張などを設定します。

ここまで準備できたら、VSCode の左下の赤い部分をクリックします。

f:id:HeRo:20211112015844p:plain
Remote Container

コマンドパレットが開き、いくつかメニューが表示されると思います。
その中の Reopen in Container を選択します。

f:id:HeRo:20211112020127p:plain
Reopen in Container を選択

すると、必要なイメージが pull され、コンテナが起動し、それに接続した状態で VSCode が開き直します。

もしかすると右下に Pylance をインストールしろとかダイアログが表示されるかもしれません。素直に指示に従ってください。

これで準備完了です。

もし、コンテナでなくローカルファイルシステムで VSCode を開き直したい場合には再び、左下の赤い角をクリックして、Reopen Folder Locally を選択します。

Ruby の Notebook を作ってみる

試しに sample.ipynb というファイルを作ってみましょう。

右上の方に「Python 3.x.x 64-bit」というような表示があるのでクリックするとコマンドパレットが表示されます。その中から Ruby を選択します。

f:id:HeRo:20211112015922p:plain
Rubyを選択

あとはコードブロックに Ruby のコードを書いて実行ボタンをクリックすると実行結果が表示されます。

f:id:HeRo:20211112015951p:plain
Ruby を実行

pandas も使える

利用したイメージの Dockerfileを見ると、Pandas の Ruby ラッパーなどもインストールされており、利用できるようです。

f:id:HeRo:20211112020016p:plain
Pandas も使える

他の言語も使える。

利用しているイメージでは Ruby の他に Python ももちろん使えますし、 Julia や R のカーネルもインストールされているようなのでこれらも利用できます。

まとめ

VSCode の Jupyter 拡張と Remote - Container 拡張を利用して、Ruby が利用できる Jupyter 環境の作り方について紹介しました。

本気でデータを集計したりするにはライブラリが揃っているし、情報量も多いので Python を使うほうがいいと思います。Pandas が使えると言っても所詮はラッパーで中で Python 使っているわけですし。

よく開発中にこのメソッドってどんな動きだったっけ ? と調べる場合に irb などで実際に実行してみたりすると思います。そんなとき代わりに jupyter を使うと、実行結果が保存できて便利かなと思います。

最後に

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

actindi.net

Rails の入力フォームのエラー表示のカスタマイズ

morishitaです。

Web アプリケーションではユーザの入力を求めるために入力フォームを実装することがあります。 HTML の <form><input><select> タグなどを使ってテキストボックスやラジオボタン、セレクトボックスなどで構成する UI ですね。

f:id:HeRo:20210912003502p:plain
入力フォームの例

Rails ではフォームを構成するためにフォームヘルパーと呼ばれる一連のヘルパーメソッドが提供されており、ビューテンプレートの中で利用できます。 それらを使って比較的簡単にモデルオブジェクトと連動した HTML フォームを実装できます。
rails generate を使えば動作するテンプレートの生成もできます。

人間というのはとかく間違うものなので、入力フォームがあれば必ず間違った値を入力したり、入力を忘れたりする人がいます。
間違った値をそのまま受け入れシステムを壊さないようにモデルの保存時に検出する仕組みとしてバリデーションがあります。

通常、バリデーションエラーがあれば入力フォームに戻し、間違いの修正を促します。
いわゆるエラー表示ですね。
その時、何がどう間違っていたのか? どう修正すればいいのかを示さないとストレスフルな UX となります。
逆にわかりやすければユーザは再入力という手間を求められても、納得し使いやすいとさえ思ってもらえるかもしれません。

Rails のフォームヘルパーはこのエラー表示もサポートしています。

前置きが長くなりましたが、本エントリはこの Rails のフォームヘルパーエラー表示について書こうと思います。
なお、全て rails 6.1.4.1 での結果です。

Rails の標準のフォームエラー表示

次の様なモデルを登録する入力フォームの例で進めます。

class Book < ApplicationRecord
  enum genre: { novel: 0, bussiness: 1, technology: 2, hobby: 3, commic: 4, magazine: 5, other: 99 }
  enum media: { paper: 0, kindle: 1, epub: 2, pdf: 3}

  validates :title, presence: true
  validates :genre, presence: true
  validates :media, presence: true
end

このモデルを次の様な実装のフォームで登録します。
ちなみにこのテンプレートは sccarfold で生成したものベースにしています。

<%= form_with(model: book) do |form| %>
  <% if book.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(book.errors.count, "error") %> prohibited this book from being saved:</h2>

      <ul>
        <% book.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div class="field">
    <%= form.label :memo %>
    <%= form.text_area :memo %>
  </div>

  <div class="field">
    <%= form.label :genre %>
    <%= form.select :genre, Book.genres.keys.prepend('') %>
  </div>

  <div class="field">
    <%= form.label :media %>
    <%= form.collection_radio_buttons :media, Book.media, :first, :first %>
  </div>

  <div class="field">
    <%= form.label :already_read %>
    <%= form.check_box :already_read %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

このフォームのデフォルトのエラー表示は次のようになっています。
左が初期状態で、右がエラー状態です。

f:id:HeRo:20210912003552p:plain
デフォルトのエラー表示の例

フォームのレイアウトが崩れてしまっています。 原因はエラー時に Rails のフォームタグヘルパーがタグを追加するからです。

次の HTML の断片は初期状態の書名入力欄です。

<div class="field">
  <label for="book_title">書名</label>
  <input type="text" name="book[title]" id="book_title">
</div>

次がエラー発生時の書名入力欄の HTML ソースです。 <label> タグと <input> タグが <div class="field_with_errors"></div> で囲われています。

<div class="field">
  <div class="field_with_errors">
    <label for="book_title">書名</label>
  </div>
  <div class="field_with_errors">
    <input type="text" value="" name="book[title]" id="book_title">
  </div>
</div>

<div> はブロック要素なので改行が入ってしまいフォームのレイアウトが崩れます。

また、scaffold で作成すると CSS も出力されますが、 .field_with_errors には次の様に実装されています。

.field_with_errors {
  padding: 2px;
  background-color: red;
  display: table;
}

手っ取り早くレイアウト崩れを防ぐには display: table;display: inline-block; に変更すればいいです。
変更してみた結果は次のとおりです。

f:id:HeRo:20210912003639p:plain
CSSで少しレイアウトを整えたエラー表示の例

カスタマイズする方法

Rails が挿入してくれる <div class="field_with_errors"></div> ですが、レイアウト崩れの原因になりがちです。崩れを防いだり、独自のデザインを入れたいと思うとカスタマイズしたいということになります。

カスタマイズの方法としては次の3つの方法があります1

  1. config/application.rbconfig.action_view.field_error_proc を設定する
  2. イニシャライザで ActionView::Base.field_error_proc を実装する
  3. カスタムフォームビルダーを実装する

これらを順に見ていきます。

config.action_view.field_error_proc を設定する

最もシンプルなカスタマイズ方法は application.rb に次の様な設定を追加することです。

config.action_view.field_error_proc = Proc.new { |html_tag, instance| %(<span class="field_with_errors">#{html_tag}</span>).html_safe }

この例は、単純にデフォルトの実装を <div><span> に変更しているだけです2
この設定の結果表示したものは次の通りです。

f:id:HeRo:20210912003730p:plain
config.action_view.field_error_proc を設定したエラー表示の例

<div><span> に変わっているだけですが、出力される HTML も示しておきます。

<div class="field">
  <span class="field_with_errors">
    <label for="book_title">書名</label>
  </span>
  <span class="field_with_errors">
    <input type="text" value="" name="book[title]" id="book_title">
  </span>
</div>

ActionView::Base.field_error_proc を実装する

本質的には1つ目の方法と同じです。
もう少し手の混んだ実装をしたいけれど、 application.rb をごちゃごちゃさせたくない場合には、config/initializers にファイルを追加して、 ActionView::Base.field_error_proc を実装すればいいでしょう。 ちなみに application.rbconfig.action_view.field_error_proc も設定した場合には application.rb が優先され initializers の実装は無視されます。

以下に実装例を示します。
バリデーションエラーメッセージを各入力項目の直後に表示するようにしています。

ActionView::Base.field_error_proc = Proc.new do |html_tag, instance|
  if instance.kind_of?(ActionView::Helpers::Tags::Label)
    html_tag.html_safe
  else
    method_name = instance.instance_variable_get(:@method_name)
    errors = instance.object.errors.full_messages_for(method_name)
    errors_tag = errors.map do |error|
      %(<span class="error-msg">#{error}</span>)
    end

    html = <<~EOM
    <div class="field_with_errors">
      #{html_tag}<br>
      #{errors_tag.join}
    </div>
    EOM
    html.html_safe
  end
end

Proc のブロック引数には次がセットされています。

  • html_tag
    • ActiveSupport::SafeBuffer3 のインスタンス
    • <label for="book_title">書名</label> といった文字列としての HTML タグ
  • instance
    • フォームヘルパーが生成するフォームを構成するクラスのインスタンス
    • ActionView::Helpers::Tags::LabelActionView::Helpers::Tags::TextField など

上記の実装の表示結果は次の様になります。

f:id:HeRo:20210912012216p:plain
Initializer でカスタマイズしたエラー表示の例

書名やジャンルはもう少し CSS で見栄えを整えればいい感じにできそうです。
でもラジオボタンでは4つあるボタンごとにメッセージが表示されてしまっています。
できるなら、ひとかたまりのラジオボタンの下に1回だけメッセージを表示したいところです。 しかし、それはちょっとむずかしいです。

ラジオボタンでは instanceActionView::Helpers::Tags::RadioButton インスタンスが渡されます。 この例のフォームの場合、媒体選択欄は四者択一のラジオボタンになっており、それぞれのラジオボタンごとに処理が実行されます。
出力される媒体選択欄の HTML は次のとおりです。

<div class="field">
  <label for="book_media">媒体</label>
  <input type="hidden" name="book[media]" value="">
  <div class="field_with_errors">
    <input type="radio" value="paper" name="book[media]" id="book_media_paper"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_paper">paper</label>
  <div class="field_with_errors">
    <input type="radio" value="kindle" name="book[media]" id="book_media_kindle"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_kindle">kindle</label>
  <div class="field_with_errors">
    <input type="radio" value="epub" name="book[media]" id="book_media_epub"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_epub">epub</label>
  <div class="field_with_errors">
    <input type="radio" value="pdf" name="book[media]" id="book_media_pdf"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_pdf">pdf</label>
</div>

先に示したようにフォームの実装では collection_radio_buttons ヘルパーを使っていますが、分解されて4つのラジオボタンそれぞれに対する処理となるようです。
結局、一連のラジオボタンの最後がどれなのかわからないので、ひとかたまりのラジオボタン群の後に1回だけエラーメッセージを表示するというのができないのです。

カスタムフォームビルダーを実装する

さて、最後のカスタマイズ方法はカスタムフォームビルダーを実装し、フォームに適用する方法です。

カスタムフォームビルダーは ActionView::Helpers::FormBuilder を継承して実装します。
ActionView::Helpers::FormBuilder には text_fieldcheck_box などといったいわゆるフォームタグヘルパーが実装されています。それらをオーバーライドすることによりカスタマイズできるということです。

以下は実装例を示します。
この例ではフォームに必要なタグヘルパーのみオーバーライドしていますが、必要に応じて他のヘルパーも実装すれば良いと思います。

class CustomFormBuilder < ActionView::Helpers::FormBuilder
  def text_field(method, options = {})
    super + ererror_messageror(method)
  end

  def select(method, choices = nil,  options = {})
    super + error_message(method)
  end

  def collection_radio_buttons(method, collection, value_method, text_method, options = {})
    super + error_message(method)
  end

  private

  def error_message(method)
    return if @object.errors[method].blank?

    @template.content_tag(:div, class: 'error-message') do
      @object.errors.full_messages_for(method).each do |msg|
        @template.concat(@template.content_tag(:div, msg))
      end
    end
  end
end

ActionView::Helpers::FormBuilder に実装されているデフォルトの実装の出力を super で得て、それに error_message メソッドで生成したエラーメッセージを追加しています。

このカスタムフォームビルダーを利用するには、フォームのテンプレートで form_with の引数として次のように指定します。

<%= form_with(model: book, builder: CustomFormBuilder) do |form| %>

で、表示結果は次の様になります。

f:id:HeRo:20210912003917p:plain
カスタムフォームビルダーによるエラー表示の例

出力される書名の入力欄部分の HTML は次の様になります。

ラジオボタン部分の HTML 出力は次の様になり、エラーメッセージも1回だけの出力を実現できています。

<div class="field">
  <div class="field_with_errors">
      <label for="book_media">媒体</label>
  </div>
  <input type="hidden" name="book[media]" value="">
  <div class="field_with_errors">
    <input type="radio" value="paper" name="book[media]" id="book_media_paper">
  </div>
  <label for="book_media_paper">paper</label>
  <div class="field_with_errors">
    <input type="radio" value="kindle" name="book[media]" id="book_media_kindle">
  </div>
  <label for="book_media_kindle">kindle</label>
  <div class="field_with_errors">
    <input type="radio" value="epub" name="book[media]" id="book_media_epub">
  </div>
  <label for="book_media_epub">epub</label>
  <div class="field_with_errors">
    <input type="radio" value="pdf" name="book[media]" id="book_media_pdf">
  </div>
  <label for="book_media_pdf">pdf</label>
  <div class="error-message">
    <div>媒体を入力してください</div>
  </div>
</div>

また、この方法は適用したいフォームのみに適用できる、すなわち影響範囲が小さいというメリットもあります。

まとめ

本エントリで紹介した次の3つのカスタマイズ方法の比較をしてみます。

  • 方法1: config/application.rbconfig.action_view.field_error_proc を設定する
  • 方法2: イニシャライザで ActionView::Base.field_error_proc を実装する
  • 方法3: カスタムフォームビルダーを実装する
方法 実装
しやすさ
カスタマイズ
柔軟性
適用範囲
方法1 アプリケーション全体
方法2 アプリケーション全体
方法3 適用フォームのみ

実装しやすさは実装する場所が指定される方法1が劣り、他の方法は独立ファイルに実装できるので同じくらい実装しやすいと思います。

カスタマイズ柔軟性は方法1、2ではラジオボタンのように複数のフォームタグで構成されるものはどれがひとかたまりなのか判別できないので劣ります。一方フォームタグヘルパー単位でカスタムできる方法3が有利と考えます。

適用範囲ですが、方法1,2は実施するとアプリケーション内のすべてのフォームに影響するのでフォームの多いとすべての影響確認が大変で使いづらいと思います。方法3なら各フォームに設定を追加する手間は増えるものの影響範囲が限定的で使いやすいのではないでしょうか。

最後にこれらのカスタマイズを同時に実装した場合について述べます。
方法1と方法2をともに実装した場合には方法1の実装が優先され、方法2は無視されます。
方法1(または方法2)と方法3をともに実装した場合には方法1(または方法2)が適用された上に方法3の実装も有効となります。
例えば、全部の方法を実装すると方法1+方法3の結果となります。

参考

最後に

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

actindi.net


  1. そもそもフォームヘルパーを使わず実装するという方法ももちろんあります。

  2. デフォルトの実装はこちら

  3. XSS 攻撃などを避けるために出力の安全性を担保するための文字列バッファ。ソースはこちら

VCR 設定 Tips

morishitaです。

外部 API 等へのアクセスする処理のテストを実装する際、毎回 API を叩くわけにもいかないのでモックすることになると思います。

そんなときに便利な Gem が VCR です。 次のエントリで以前紹介しました。

tech.actindi.net

VCR を簡単に説明すると、次の機能を持ったライブラリです。

  • テスト中に発生する HTTP 通信を YAML ファイルに記録する。
  • 次の実行から API を叩かずに記録した情報をレスポンスとして返す。

詳しくは上記のエントリで紹介していますので、「VCR ってなに?」だった方は先にそちらを読んでから本エントリを読むほうがわかりやすいかと思います。

基本の設定

RSpec でテストを実装する場合、 spec_helper.rb 等に次のような設定を追加します。

VCR.configure do |config|
  config.cassette_library_dir = 'spec/vcr' # ①
  config.hook_into :webmock # ②
  config.configure_rspec_metadata! # ③
  config.allow_http_connections_when_no_cassette = true # ④
  config.default_cassette_options = {
    record: :new_episodes, # ⑤
    match_requests_on: %i[method path query body_as_json], # ⑥
  }
end

①は VCR が通信を記録する YAML ファイル(カセット)を保存するルートディレクトリを指定しています。
②は VCR の内部で利用するモックライブラリを指定しています。指定したものは別途インストールが必要です。
③は RSpec の describevcr: キーで個別に VCR の設定を上書きできるようにするための設定です。まあ、おまじないと思って書いておけばいいです。
①②③が RSpec との組み合わせで利用する場合の最低限必要な設定です。

④は VCR を使用しないテスト場合に通信を許可するかどうかで true なので許可しています。
⑤はカセットがなければ実際に通信して記録するという設定。
⑥はリクエストの一意性をどのリクエストの要素で判定するかを設定しています。API の仕様に合わせて調整します。

基本の設定は以上ですが、以下知っておくと便利な設定を紹介します。

リクエスト/レスポンスを見やすく整形する

モダンな API では application/json なレスポンスを返すものがほとんどでしょう。
VCR はなにも設定しなければ API のレスポンスをそのまま保存します。
殆どの API はサイズを減らすため改行やスペース、インデントが省かれているので見やすい JSON 形式ではありません。
改行やインデントを入れて見やすい形式で保存するには次のようなコードを設定に追加します。

  config.before_record do |interaction|
    interaction.response.body.force_encoding 'UTF-8'
    response_content_type = interaction.response.headers['Content-Type'].first
    if response_content_type.include?('application/json')
      interaction.response.body = JSON.pretty_generate(JSON.parse(interaction.response.body))
    end
  end

config.before_record にブロックを渡すとそれを保存前に実行してくれます。
ブロック変数にリクエスト等のデータが渡されるので保存前のデータをここで弄ってやるわけです。
レスポンスボディは interaction.response.body に格納されています。
上記コードではそれを 'Content-Type''application/json' の場合にのみ JSON.pretty_generate で見やすく整形しています。

一方、リクエストでは GET だとリクエストボディはありません。POST の場合でも Content-Typeapplication/x-www-form-urlencoded などが多く、 JSON であることは比較的少ないと思います。
リクエストボディでも JSON を送信するようなものは例えば GraphQL API です。そういう API ではやはりリクエストボディも整形したくなるでしょう。
これも同様の設定を実装すれば見やすい形に整形できます。

VCR.configure do |config|
  config.before_record do |interaction|
    interaction.request.body.force_encoding 'UTF-8'
    request_content_type = interaction.request.headers['Content-Type'].first
    if request_content_type == 'application/json' && !interaction.request.body.empty?
      interaction.request.body = JSON.pretty_generate(JSON.parse(interaction.request.body))
    end
  end
end

レスポンスのときとの違いは interaction.request.body を整形しているところだけですね。

秘密情報をマスクする

アクセストークンで認証する API の場合、リクエストでトークンを送ることになるわけですが、VCR の出力にはリクエストヘッダ等も含まれており、それも記録されてしまいます。
そんなコードをリポジトリに入れるわけにはいかないですね。

そういった秘密情報は次の設定を追加することでマスクできます。

VCR.configure do |config|
  if ENV['ACCESS_TOKEN']
    masked_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    config.filter_sensitive_data(masked_token) { ENV['ACCESS_TOKEN'] }
  end
end

config.filter_sensitive_data はブロックの戻り値の文字列を引数に与えた文字列で置換してくれます。上記例では環境変数 ENV['ACCESS_TOKEN'] に秘密情報が格納されているとして、 それに一致する文字列を masked_token の値で置換します。 プロダクションコード側でも環境変数などでコード外からトークンの情報を渡すように実装すると思うのでこれで秘密をリポジトリに入れることはなくなります。

API の仕様で秘密情報が格納されているヘッダなどの場所は決まっているはずです。なのでその場所をピンポイントで置換する実装も可能です。しかし、その場合 API のヘッダーや Cookie の仕様まで知っている必要があるでしょう。 長時間変わらないセッション ID や、計算で算出する動的なトークンが格納されている場合には、リクエスト・レスポンスの構造を調べてマスクする必要があるでしょう1

しかし、固定のトークンで認証する API は多く、それらを利用するプロダクションコードでも環境変数などで渡すことが多いと思います。ならばその環境変数の値をとにかく置換するようにしたほうが API の詳細を知らなくても良くて手っ取り早く、 API の仕様が変わっても影響されずに確実にマスクできるのでおすすめです。

参考:Filter sensitive data - Configuration - Vcr - VCR - Relish

デバッグログを出力する

設定とはいえ、リクエストやレスポンスをパースしていじるようなプログラムを含むので、うっかりするとバグを作り込んでしまいエラーが発生することもあります。エラーが発生すると記録されるはずのカセットの YAML ファイルが出力されないので「あれ?」となります。修正するにはなにが起こっているのか知る必要がありますが、何も出力されません。
その場合には次のように debug_logger を設定してやると VCR のログが出力されるようになります。

VCR.configure do |config|
  # 〜 略 〜
  config.debug_logger = File.open('vcr-debug.log', 'w')
  # 〜 略 〜
end

リクエストで送信する値やアクセス先のリソースの状態によりリクエスト・レスポンスの構造が変わるような API があります。構造が変わると言っても値がなければキーがなくなるなどですが、存在しないキーの値に操作しようとするとエラーになりますし、キーはあっても null 値でもエラーになります。
そのように存在したりしなかったりする API の部分を掴んでいじるようなコードを VCR の設定に実装すると、エラーになったりならなかったりしてハマりがちです。でも、なにが起こっているのかわかれば解決できるでしょう。

まとめ

このエントリで紹介した設定を全部含む設定全体は次のようになります。

VCR.configure do |config|
  config.debug_logger = File.open('vcr-debug.log', 'w') # for debugging
  config.cassette_library_dir = 'spec/vcr'
  config.hook_into :webmock
  config.configure_rspec_metadata!
  config.allow_http_connections_when_no_cassette = true
  config.default_cassette_options = {
    record: :new_episodes,
    match_requests_on: %i[method path query body_as_json],
  }

  if ENV['ACCESS_TOKEN']
    masked_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    config.filter_sensitive_data(masked_token) { ENV['ACCESS_TOKEN'] }
  end

  config.before_record do |interaction|
    interaction.request.body.force_encoding 'UTF-8'
    request_content_type = interaction.request.headers['Content-Type'].first
    if request_content_type == 'application/json' && !interaction.request.body.empty?
      interaction.request.body = JSON.pretty_generate(JSON.parse(interaction.request.body))
    end

    interaction.response.body.force_encoding 'UTF-8'
    response_content_type = interaction.response.headers['Content-Type'].first
    if response_content_type.include?('application/json')
      interaction.response.body = JSON.pretty_generate(JSON.parse(interaction.response.body))
    end
  end
end

最後に

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

actindi.net


  1. そのような動的なトークンはおそらく認証 API などで取得するか、固定の文字列(アカウント ID など)を種にして計算するものでは思われます。認証 API にアクセスする場合にはパスワード等を送信することになると思うので、それも秘密情報になりますし、取得したトークンの有効期限がながければリポジトリに入れるのは心配です。計算で求めるものは算出ロジックは公開されていると思うので計算例が多ければ種がバレる可能性もあると思われます。

失敗事例の共有。テストが実行できていないのにCI/CDが通ってリリースしてしまった話

はじめまして
アクトインディでWebエンジニア職として採用されて4ヶ月目。
つい最近試用期間が終了して、正式に正社員になったばかりの s4na です。

今回は私の失敗事例を共有させていただきます。

はじまりはリリース後のふとした違和感でした。

最初にその違和感に気づいたのは、チームのメンバーでした。
(本当に感謝しております 🙏 )

f:id:s4na:20200910133907p:plain

f:id:s4na:20200910134118p:plain

たしかに、テストのカバレッジが50%以上も落ちてしまっていました。

・・・私のリリースから。

何かやってしまったみたいです 😢

リリース時のCIのログを見てみると、テストでActive Modelのロードに失敗していました。

An error occurred while loading ./spec/controllers/xxx/xxx/xxx_controller_spec.rb.
Failure/Error: ActiveRecord::Migration.maintain_test_schema!

ActiveRecord::PendingMigrationError:


  Migrations are pending. To resolve this issue, run:

マイグレーションに失敗しているみたいです。

でも、なぜか、本番データベースでのマイグレーションは成功している

本番のデータベースを見てもマイグレーションに成功している。
データの中身を見てみても、カラムは追加されている

どういうことだ??? 🤔

原因判明!!

トラブルなことを聞きつけて、チームメンバーと一緒に通話しながらPRを調査していると、スキーマ定義の日時がおかしいことが判明しました。
(一緒に調べてくださったメンバーに感謝 🙏 )

今回私がリリースする前に、コンフリクトが発生したのですが、その際に古い日時(自分が作成した日時)を優先してしまっていました。そして、そのあとステージングで検証していませんでした。
ステージングで検証していたと思っていたのですが、やったと思っていたのはコンフリクト解消する前だったみたいです・・・

なぜ古い日時(自分が作成した日時)を設定するとテストが実行されなかったのか

マイグレーションファイルとスキーマ定義の日時がずれていると、テストが失敗するためです。

RailsのActiveRecordではDBの状態を管理するために、マイグレーションという仕組みを導入しています。
マイグレーションでは3つの日時を管理しています。

  1. それぞれのマイグレーションファイルの作成日時
  2. スキーマの日時 👈 ここが今回おかしかったところです。
  3. データベース内に取り込んだマイグレーションファイルの作成日時

マイグレーションファイルではどこまでの変更を取り込めば良いかというのを「2.スキーマの日時」で管理しています。
今回そこが一番新しい日時になっていなかったので、不整合となり一部のテストが実行されていませんでした。

詳しくは 👉 Railsガイド Active Record マイグレーション

ではなぜCI/CDが落ちて止まらなかったのか?

まだ再現できていないので未確定なのですが、テストがエラーで落ちなかった原因は、テストを実行する処理内でマイグレーション時のエラーの考慮が漏れていたからだと思われます。

アクトインディではテストの実行を分散処理のライブラリをカスタムして使っていて、その中で「1. テストに向けて準備、2. テストの分散実行、3. サマリーの表示」を行っています。

通常、「1. テストに向けての準備」で失敗するとエラー終了します。
しかしマイグレーションのズレを検知するのは「2. テストの分散実行」内で spec_helper.rbActiveRecord::Migration.maintain_test_schema! をロードしたタイミングになっていました。
ここで失敗すると、そのまま「3. サマリー表示」がされてしまい、正常終了していたようです。

今回の失敗を通して学んだこと

失敗は精神的にダメージを受けるので良くないことではあります。
ですが、普段調べないようなところまでコードを読むという利点があるので、良いきっかけにしていきたいと思いました。

また、引き続き調査を行っていきたいと思います。

時刻の扱いでミスしたので懺悔を

こんにちは!エンジニアのkanekoです。

私はタイムゾーンの考慮に毎回苦戦しています。

そして、最近やらかしが発覚したので反省を書きます。

プロローグ:サービスのデフォルトタイムゾーン

私はいこーよで買えるWEBチケットの商品取り扱いプロダクトの開発を担当しています。

このプロダクトでのタイムゾーン はこのようになっています。

タイムゾーン
アプリケーション UTC
データベース UTC
サーバー JST

アプリケーションもUTCです。これが私は毎回辛い。ここは日本。ここは日本なんだ。

第1章:土日祝日だけ監視したい

いこーよのユーザーさんは、週末にいく先のチケットをお出かけ先が決まったら購入し利用される方が多い傾向があります。 一方、会社として基本は暦通りの営業日なので土日はおやすみです。*1

そこで、サービス開始*2から間も無く、「会社が休みの土日祝日だけデータをチェックする」というバッチジョブを作ることになりました。 そこにアサインしたのはこの私。

私「何時に、どれくらいの頻度でやります?」
ディレクター「深夜帯はアクティブな人が少ないし朝6:00から3時間おきにしよう」
私「わかりました!任せてください!」

そして、データをチェックしておかしければ通知を飛ばすようにしこのようなコードを書きました。

require 'holiday_jp'
(略)
def call
  current_time = Time.zone.now
  today = current_time.to_date
  return unless weekend_or_holiday?(today)
(略)
end

private

def weekend_or_holiday?(today)
  today.saturday? || today.sunday? || HolidayJp.holiday?(today)
end

勘が良い方ならお分かりですね。

第2章:そして発覚の時

ある土曜日の朝9時、このバッチによる通知が飛んできました。 その時は結構な自体だったので、対応可能なメンバーで

「商品落とします」
「私はこれを確認します」
「影響範囲を調べます」
「関係者に連絡します」

という感じで対処を最優先に動きました。

後日、振り返りのミーティングがありました。
冷静なエンジニア「状況を踏まえると朝6時に通知来てないのおかしくないですか?」
私(えっ)
人々「本当だ。後3時間早く気づけたかもしれない」

第3章:一体なぜこんなことに

土曜の朝6時にサーバー自体はJSTなのでジョブ自体は実行されていました。 しかし、アプリケーションはUTCなので、current_time = Time.zone.now の戻り値はUTCの時刻です。

2020/02/01 6:00 に実行(土曜日)したとすると

require 'holiday_jp'
(略)
def call
  current_time = Time.zone.now # => 2020-01-31 21:00, 
  today = current_time.to_date # => 2020-01-31
  return unless weekend_or_holiday?(today) # ここでreturnして終了!!!!!
(略)
end

private

def weekend_or_holiday?(today) # => false
  today.saturday? || today.sunday? || HolidayJp.holiday?(today)
end

となります。

ちなみにテストは書いていましたが、テストではこの事象には気づけませんでした。

プロローグ: それでも生きていく

「改めて振り返るととてもとてもとても単純なことなのに、どうして気づけなかったの・・・」と思うばかり。

私がタイムゾーン に弱いばっかりに検知が遅くなってしまったことは本当に申し訳なく思います。 でもこれを機にいくつかの記事を参考に読んだり、考え方を質問したりして、少ーし成長できたような気もします。 今後は同じ原因で何かの過ちをうみ出さぬよう、時の扱いには細心の注意を払ってコードを書いくぞと心に固く誓っている次第です。

後書き

以上、私の個人的な失敗と誓いの表明でした!

今回は、社内の人が「エンジニアじゃなくてもわかるように書いてよ」って以前言っていたのを思い出して仕立ててみました。 いかがだったでしょうか。

さて、アクトインディでは共に働く仲間を大募集しています。 共に時を超えていきたい方はぜひご連絡ください!

actindi.net

*1:休日にベントを開催するなどしているので、平日以外に出勤している社員もいます。敬意を込めてご紹介

*2:2019/5/22にリリースしました

Pronto を Github Action で実行する

morishitaです。

rubocop や brakeman などの静的コード解析ツールを Prontoで実行するとPull Request にコメントで指摘されるので便利です。というのは、以前、キエンが紹介しました。

tech.actindi.net

このときは、AWSの Lambda と CodeBuild で実行していたのですが、次の理由からGithub Actionに移行しました。

  • 複数のAWSのサービスで構成しており仕組みが少々複雑
  • 時間を短縮したい(CodeBuild のプロビジョニングとソースコード取得だけで3分ほどかかっていた)
  • コスト削減可能では?(今の Github Action の利用量なら無料枠で収まるはず)

最初は公開Actionの利用を検討

上記の理由が最初にあったわけではなく、 きっかけはPronto Ruby を見つけたことでした。 これを使えば Github 上で簡単にできちゃうんじゃないかと。 すると上記が満たせるのではないかと。

で、v2.3.1(2020/01/29時点の最新版)試してみたのですが、なんか依存関係でエラーが出るので利用を諦めました1

ProntoのREADMEに載ってた!

仕方ないので、独自アクションを作ろうかと、Pronto の README を見返してみると、GitHub Actions Integrationに設定方法が載っていました! なーんだ。

Github の Workflow定義

で、GitHub Actions Integrationを参考にして、次の様に .github/workflow/pronto.ayml を作りました。

name: Pronto
on:
  pull_request:
    types: [opened, synchronize]
jobs:
  pronto:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6'
      - run: gem install --no-document pronto pronto-rubocop pronto-brakeman pronto-rails_best_practices rubocop-performance rubocop-rails rubocop-rspec
      - run: PRONTO_PULL_REQUEST_ID="$(jq --raw-output .number "$GITHUB_EVENT_PATH")" PRONTO_GITHUB_ACCESS_TOKEN="${{ secrets.GITHUB_TOKEN }}" pronto run -r rubocop rails_best_practices brakeman -f github_status github_pr -c origin/master

やっていることはシンプルで、次をやってるだけです。

  • actions/checkout@v1 でコードを取くる
  • actions/setup-ruby@v1 で pronto 関連の gem をインストール & Pronto実行

ちなみに、actions/checkout は v2 がリリースされています。しかし、v2ではコードのフェッチするdepthなどが設定できるようになっている反面、それらを設定する必要があり、面倒だったので v1 を利用しています。

Prontoのフォーマッタ github_pr を利用することで、GithubのPRにコメントでrubocopのエラーを指摘してくれます。

移行結果

さて、CodeBuildからの移行の理由に時間短縮を挙げていました。 では、Github Actionだとどうなのか?

次の結果は一例ですが、だいたい2.5分くらいで終わるようになりました。

f:id:HeRo:20200129072630p:plain
実行結果

かかっている時間の内訳はだいたい次の通り。

  • コードのチェックアウトに1分
  • Proto 一式のインストールに1分
  • その他、Prontoの実行時間などに30秒程度

CodeBuildだと2分以上かかっていたコードのチェックアウトは約半分の時間に短縮できました。 データの移動がGithub内のみになった効果かなと思います。

毎回、Pronto一式をインストールするのは無駄かなぁと思ったのですが、 1分ちょっとしかかかってないので許容範囲かと思いました(この時点では)。

これまでよりも短い時間でチェックでき、しかも無料枠に様っている現状ではゼロコストとなり移行してよかったなと思いました(この時点では)。

やっぱり Github Action化!

と、これで終わりにしようかと思ったのですが、やはり時間が経つにつれ毎回Pronto一式をインストールするのは無駄に思えてきました。 それで、予めPronto一式をインストールした Docker イメージを利用する Github Actions を作成しました。

Pronto Action · Actions · GitHub Marketplace

当初はDockerイメージをPullするのに結局1分くらいかかってしまうのではないかと思っていたのですが、そうではありませんでした。 結果は次のとおりです。

f:id:HeRo:20200129072731p:plain
Pronto Actionでの実行結果

ソースのチェックアウトの時間は変わりませんが、Gemのインストールに相当するビルドで、イメージのPullをしています。その時間、10秒程度! 処理全体でも1分近く減らして、1.5分程度と最初の状態と比べるとかなりの時間短縮となりました。

まとめ

  • Dockerイメージ化したGithub Actionを利用することで、3分以上 -> 1.5分 と半減以上を時間短縮達成しました。
  • Railsアプリの Gemfile から Pronto 関連のgemを除去できました。
  • 無料枠範囲内なので(今のところ)コストゼロ!
  • Pronto を実行する汎用的な Github Action を公開しました。

最後に

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


  1. 以前のバージョンも試していたのですが、なかなかうまく動くバージョンがなく個人的な感想ですが不安定な印象です。車輪の再発明は避けたかったのですが。

Ruby 2.7がリリースされましたね!

morishitaです。

年末もだいぶ押し迫ってきました。
当社も今日が2019年の仕事納めです。

最近、仕事でRubyを書いていない私ですが、今年の最後はRubyの話題で締めくくろうと思います。

Ruby 2.7 リリース !

今年も年末恒例のRubyの新バージョンがリリースされましたね。

www.ruby-lang.org

次のような新機能、改善が含まれているようです。

  • Pattern Matching
  • REPL improvement
  • Compaction GC
  • 開始値省略範囲式
  • JITコンパイラ
  • メソッドキャッシュの改善
  • etc.

Pattern Matching なんかは気になる新機能ですね。
APIを叩いて、レスポンスのJSONの中身によって処理を分岐する場合などに便利そうなので使ってみたいです。

REPLのマルチライン対応も開発時に重宝するのではないでしょうか。

さて、以前Rubocop Performance の速度比較について書きました。

tech.actindi.net

その中で、String、Array、Hashについてバージョンごとの生成速度の比較を行いました。 当時、Ruby 2.7 はまだ preview1 だったのですが、 Ruby 2.7.0 が正式リリースされたので改めて計測してみました。

計測について

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

次のRubyバージョンで計測しました1

  • 2.3.8
  • 2.4.9
  • 2.5.7
  • 2.6.5
  • 2.7.0

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

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

では順に見ていきます。

計測結果

プログラム中でよく作成する次の3つのリテラル生成の速度を比べます。

  • String
  • Array
  • Hash

String

まずはストリング。 次のコードで計測しました。

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.9', '2.5.7', '2.6.5', '2.7.0']

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

  x.prelude <<~RUBY
    def string_literal
      'string literal'
    end
  RUBY

  x.report %{ string_literal }
end

結果は次の通り。

f:id:HeRo:20191227085640p:plain
ストリング リテラルの生成

preview1 でも Ruby 2.7 は遅くなっていましたが、リリース版も変わらなかったようです。

Array

続いて配列です。
次のコードで計測しました。

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.9', '2.5.7', '2.6.5', '2.7.0']

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

  x.prelude <<~RUBY
    def array_literal
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
    end
  RUBY

  x.report %{ array_literal }
end

結果は次のとおりです。

f:id:HeRo:20191227085716p:plain
配列リテラル生成

Ruby 2.6 で劇的に速度アップしていますが、Ruby 2.7では少し遅くなったようです。

Hash

最後はハッシュです。
計測コードは次のとおりです。

require 'benchmark_driver'

output = :gruff
versions = ['2.3.8', '2.4.9', '2.5.7', '2.6.5', '2.7.0']

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

  x.prelude <<~RUBY
    def hash_literal
      { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, j: 0 }
    end
  RUBY

  x.report %{ hash_literal }
end

結果は次の通り。

f:id:HeRo:20191227085746p:plain
ハッシュリテラル生成

バージョンが大きいほど速くなってますね。
これはRuby 2.7が最速です。preview1 のときより Ruby 2.6 との差が広がったように思います。

まとめ

結果的には preview1 とあんまり変わらなかったです。

こんな小さなプログラムでは Ruby 2.7 のパフォーマンスチューニングの成果を確認することはできないのかな。
でも、Rails のようにずっと稼働し続け、もっと大きなプログラムではパフォーマンスアップが期待できるのかなー。

来年はいよいよ Ruby 3 のリリースが予定されています。
Ruby 2.7 はその準備となるバージョンのようなのでアップデートを計画しないとなー。

最後に

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

では、皆さん良いお年を。


  1. Ruby 2.7 以外も最新バージョンにアップデートしています。

Rubyどっちが速い?

morishitaです。

先日、Rubocop Performance の速度比較について3回に分けて書きました。

tech.actindi.net

tech.actindi.net

tech.actindi.net

どんな言語でも多かれ少なかれあることですが、Rubyでも同じ結果を得るのに複数の実装方法があり、読みやすさ、わかりやすさ、文字数・行数の多少、実行速度などの点でそれぞれ良し悪しがあるなぁ。とやってみて改めて思いました。

メンテナンス性の観点からは書きやすい、読みやすい、わかりやすいコードを書けばいいと思います。
ただ、ユーザにとってより良いサービスの提供を考えると速さは正義、ちょっとでも速い実装方法を選択したいものです。

で、上記のエントリを書きながら、これとこれはどっちが速いんだろうと思ったいくつかを計測してみました。

計測について

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

計測コードでは文字列、配列、ハッシュなどは定数にして使い回すようにしています。
論点にしているポイントだけをなるべく計測するため、これらの生成コストを計測に含めないようにするためです。

単に比較対象同士を計測するだけでなく複数のRubyのバージョンで計測しています。
一応、Rubocopのときと同様、次のRubyバージョンで計測しました。

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

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

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

では順に見ていきます。

String#tr関連

String#trってあんまり使ったことなかったのですが、使い方によっては文字種の変換などできるので面白いなと思って。

全角 −> 半角変換 NKF vs String#tr

まずは全角英字を半角に変換する処理。
ユーザ入力の正規化などで使う場面もあるかと。

NKF を使う場合と比べてみました。
計測コードは次のとおりです。

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
    require 'nkf'
    NKF_OPTS = '-Z1 -w -W'
    HANKAKU = 'a-z'
    ZENKAKU = 'a-z'

    STR = 'abcdefghijklmnopqrstuvwxyz'

    def use_nkf
      NKF.nkf(NKF_OPTS, STR)
    end

    def use_tr
      STR.tr(ZENKAKU, HANKAKU)
    end
  RUBY

  x.report %{ use_nkf }
  x.report %{ use_tr }
end

で結果が次の通り。

f:id:HeRo:20190809083919p:plain
NKF vs String#tr

あーやっぱ NKF の方が速いですね。
こんな処理用のツールですし。

小文字 −> 大文字変換 String#upcase vs String#tr

続いて、英字の小文字−>大文字変換。
String#upcase と比べてみました。

計測コードは次の通り。

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 = 'abcdefghijklmnopqrstuvwxyz'
    AZ_DOWN = 'a-z'
    AZ_UP = 'A-Z'

    def use_upcase
      STR.upcase
    end

    def use_tr
      STR.tr(AZ_DOWN, AZ_UP)
    end
  RUBY

  x.report %{ use_upcase }
  x.report %{ use_tr }
end

結果は次の様になりました。

f:id:HeRo:20190809084134p:plain
String#upcase vs String#tr

String#upcaseの方が圧倒的に速いですね。
まー、素直に専用メソッド使いましょう。

String#trでもできるんだけど、専用メソッドなりクラスがあるならそっちがやったほうがいいですね。何したいかも明確になるでしょうし。

ループ .times.map vs range.map

times と Range、決まった回数繰り返すどちらもよく使われるかなと思います。
どちらが速いかをmap を使って配列を生成する次のコードで比較しました。

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 use_times
      100.times.map{ |i| i }
    end

    def use_range
      (0...100).map{ |i| i }
    end
  RUBY

  x.report %{ use_times }
  x.report %{ use_range }
end

結果は次のとおりですが、Rangeの方が僅かに速いですかね。

f:id:HeRo:20190809084238p:plain
time vs range

文字列連結

文字列の連結の方法はいくつかありますが、次を比較しました。

  • String#+
  • StringIO
  • String#<<
  • String#concat
  • [String].join

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

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 = %w(
      あ い う え お
      か き く け こ
      さ し す せ そ
      た ち つ て と
      な に ぬ ね の
      は ひ ふ へ ほ
      ま み む め も
      や ゆ よ
    )

    def string_plus
      s = ''
      STR.each do |str|
        s += str
      end
      s
    end

    def string_io
      s = StringIO.new
      STR.each do |str|
        s.write str
      end
      s.string
    end

    def string_push
      s = ''
      STR.each do |str|
        s << str
      end
      s
    end

    def string_concat
      s = ''
      STR.each do |str|
        s.concat str
      end
      s
    end

    def string_array_join
      s = []
      STR.each do |str|
        s.push str
      end
      s.join
    end
  RUBY

  x.report %{ string_plus }
  x.report %{ string_io }
  x.report %{ string_push }
  x.report %{ string_concat }
  x.report %{ string_array_join }
end

結果は次の様になりました。

f:id:HeRo:20190809084312p:plain
文字列の連結

最速は String#<< ですね。
String#<<String#concat って同じと思っていたのに差があるのかぁ。

&:メソッド と ブロック

Array#map(&:to_s)の様に省略するのとブロックを渡して処理する書き方があると思います。

結果は同じでも速度差があるのか次のコードで計測してみました。

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
    RANGE = (1..100)
    def use_amp
      RANGE.map(&:to_s)
    end
    def use_block
      RANGE.map{ |i| i.to_s }
    end
  RUBY

  x.report %{ use_amp }
  x.report %{ use_block }
end

結果は次のとおりです。

f:id:HeRo:20190809233724p:plain
&:メソッド vs ブロック

結果は省略する書き方の方が少し速いですね。
見た目も簡潔だし、省略していきましょう。

バージョン間の速度の違い

バージョンによっても速度の違いが大きいものがあるなとRubocopのときも思いました。
実装方法間ではなくバージョン間の速度差を見てみます。

リテラル生成

プログラム中でよく作成する次の3つのリテラル生成の速度を比べます。

  • String
  • Array
  • Hash

Rubocop Performanceを測ってみた。後編のおまけでも紹介したのですが、1つのグラフに入れて差がわかりにくくなってしまったので、個別にグラフを作成しました。

String

まずはストリング。 次のコードで計測しました。

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 string_literal
      'string literal'
    end
  RUBY

  x.report %{ string_literal }
end

結果は次の通り。

f:id:HeRo:20190809084403p:plain
ストリング リテラルの生成

あれ? なんか緩やかに遅くなってるような…。
Ruby 2.7はこれから最適化されるのかな。

Array

続いて配列です。

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

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 array_literal
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
    end
  RUBY

  x.report %{ array_literal }
end

結果は次のとおりです。

f:id:HeRo:20190809084448p:plain
配列リテラル生成

Ruby 2.6 で劇的に速度アップしています。 Ruby 2.5以下を使っているならさっさとアップデートしたほうが良いですね。

Hash

最後はハッシュです。

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 hash_literal
      { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, j: 0 }
    end
  RUBY

  x.report %{ hash_literal }
end

結果は次の通り。

f:id:HeRo:20190809084527p:plain
ハッシュリテラル生成

バージョンが大きいほど速くなってますね。

Splat展開

Splat展開とはメソッド引数とかで配列の前に*をつけて要素を展開するやつですね。

配列を展開してまた同じ配列を作るなんて、実用性皆無なコードですが次のコードで計測しました。

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 = %w(
      あ い う え お
      か き く け こ
      さ し す せ そ
      た ち つ て と
      な に ぬ ね の
      は ひ ふ へ ほ
      ま み む め も
      や ゆ よ
    )

    def splat
      [*STR]
    end
  RUBY

  x.report %{ splat }
end

結果は次の通り。

f:id:HeRo:20190809084555p:plain
Splat展開

これも Ruby 2.6で大幅に速くなっています。

まとめ

書き方一つで結構差がつくのが面白いですね。 小さなプログラムでは気にしなくていいかもしれませんが、Webアプリケーションは多くのリスクエスト並列で処理するので、速いコードを心がけたいものです。

あと、Rubyは最新バージョンを使うほうが速度面でもメリット大きいですね。

最後に

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

Rubocop Performanceを測ってみた。後編

morishitaです。

前々回、前回から続くrubocop-performanceの指摘事項について盲従せずに確認してみるシリーズの最終回です。

前編、中編はこちらです。

tech.actindi.net

tech.actindi.net

計測について

計測には 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によるグラフで示しますが、グラフが長いほうが高速ということです。

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

では順に見ていきます。

18. Performance/RegexpMatch

Ruby 2.4 で追加された String#match?Regexp#match?Symbol#match?matchより速いので使いましょうという Cop ですね。
戻り値であるMatchDataを使わず正規表現にマッチしているかどうかだけ見るなら、match?を使った方がいいよってことですね。

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

# rubocop-performance Performance/RegexpMatch

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
    def do_something(arg = nil)
      1 + 1
    end

    X = 'regex-match'
    RE = /re/

    def bad_sample1
      if X =~ RE
        do_something
      end
    end

    def bad_sample2
      if X !~ RE
        do_something
      end
    end

    def bad_sample3
      if X.match(RE)
        do_something
      end
    end

    def bad_sample4
      if RE === X
        do_something
      end
    end

    def good_sample1
      if X.match?(RE)
        do_something
      end
    end

    def good_sample2
      if !X.match?(RE)
        do_something
      end
    end

    def good_sample3
      if X =~ RE
        do_something(Regexp.last_match)
      end
    end

    def good_sample4
      if X.match(RE)
        do_something($~)
      end
    end

    def good_sample5
      if RE === X
        do_something($~)
      end
    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 }
  x.report %{ good_sample3 }
  x.report %{ good_sample4 }
  x.report %{ good_sample5 }
end

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

f:id:HeRo:20190718085253p:plain
Performance/RegexpMatchの計測結果

match?を使うgood_sample1good_sample2の結果が突出しています。
good_sample1 より good_sample2 が速いのは、if文の中が実行されていないためです。

good_sample3good_sample4good_sample5match? を使ってなくて速くないですが、正規表現のマッチ結果を後の処理で使っているならOKですよっていう例ですね。

19. Performance/ReverseEach

reverse.each の代わりに reverse_each を使いましょうという Cop です。

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

# rubocop-performance Performance/ReverseEach

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, 2, 3, 4, 5, 6, 7, 8, 9, 10]

    def bad_sample
      ARRAY.reverse.each
    end

    def good_sample
      ARRAY.reverse_each
    end
  RUBY

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

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

f:id:HeRo:20190718085335p:plain
Performance/ReverseEachの計測結果

なるほど。reverse_eachを使ったほうがパフォーマンスがいいですね。
reverse_each、使っていきましょう。

20. Performance/Size

ArrayHash の大きさを求める場合、count より size を使いましょうという Cop です。sizelength のエイリアスなので、どちらを使っても同じです1

計測に利用したコードは次のとおりです。

# rubocop-performance Performance/Size

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, 2, 3]
    HASH = {a: 1, b: 2, c: 3}

    def bad_sample1
      ARRAY.count
    end

    def bad_sample2
      HASH.count
    end

    def good_sample1
      ARRAY.size
    end

    def good_sample2
      HASH.size
    end

    def good_sample3
      ARRAY.count { |e| e > 2 }
    end

    def good_sample4
      HASH.count { |k, v| v > 2 }
    end
  RUBY

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

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

f:id:HeRo:20190718085440p:plain
Performance/Sizeの計測結果

good_sample1(Array#size) と good_sample2(Hash#size) を見ると効果は大きいですね。 特にbad_sample2(Hash#count) と good_sample2(Hash#size) を比べると、Hash#count は使っちゃダメだろうって思いますね。

ただし、countには使いみちがあります。配列やハッシュの要素から条件を満たす要素を選択的に数えたい場合です。その例が good_sample3good_sample4 です。
前編の Performance/Count で見ましたが、Array#select{}.size よりも Array#count{} の方がパフォーマンスが良いので、この場合には count を使うほうがいいでしょう。
この条件を満たすものだけ数える機能のため、countでは要素をループで数えるので遅いのですね。

22. Performance/StringReplacement

文字列の中の1文字を他の文字で置き換えるなら、gsub ではなく trdelete を使おうという Cop です。

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

# rubocop-performance Performance/StringReplacement

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 = 'abc'
    STR_WS = 'a b c'

    def bad_sample1
      STR.gsub('b', 'd')
    end

    def bad_sample2
      STR.gsub('a', '')
    end

    def bad_sample3
      STR.gsub(/a/, 'd')
    end

    def bad_sample4
      'abc'.gsub!('a', 'd')
    end

    def good_sample1
      STR.gsub(/.*/, 'a')
    end

    def good_sample2
      STR.gsub(/a+/, 'd')
    end

    def good_sample3
      STR.tr('b', 'd')
    end

    def good_sample4
      STR_WS.delete(' ')
    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 }
  x.report %{ good_sample3 }
  x.report %{ good_sample4 }
end

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

f:id:HeRo:20190718085628p:plain
Performance/StringReplacementの計測結果

good_sample3good_sample4 の結果を見るとString#trString#delete の効果は大きいですね。 スペースの除去に String#gsub(' ', '') を使ってしまっていたこともある気がするので今後は String#delete(' ')を使うようにしたいと思います。 String#trの使いみちがいまいちわかりにくいかのですが、このメソッドは単に1文字を置換するものではありません。 'あかさたな'.tr("あ-ん", "ア-ン") => 'アカサタナ' の様なことができる使いようによっては便利なメソッドです2

good_sample1good_sample2gsub を使うべきケースです。単なる文字置換でなく正規表現にマッチした文字を置換しています。

23. Performance/TimesMap

決まった回数を繰り返して配列を作る時に、.times.map を使うより、繰り返し数の大きさのArrayインスタンスを作ったほうがいいよという Cop です。

文字で説明してもわかりにくいので計測に使った次のコードを見てください。

# rubocop-performance Performance/TimesMap

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
      9.times.map do |i|
        i.to_s
      end
    end

    def good_sample
      Array.new(9) do |i|
        i.to_s
      end
    end
  RUBY

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

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

f:id:HeRo:20190718085752p:plain
Performance/TimesMapの計測結果

good_sample(Array.new) の方が確かにパフォーマンスがいいですね。

24. Performance/UnfreezeString

フリーズされた文字列をアンフリーズするためにString#dupString.new を使うよりも単項の +演算子を使うほうが速いですよという Cop です。

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

# rubocop-performance Performance/UnfreezeString

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
      ''.dup
    end

    def bad_sample2
      'something'.dup
    end

    def bad_sample3
      String.new
    end

    def bad_sample4
      String.new('')
    end

    def bad_sample5
      String.new('something')
    end

    def good_sample1
      +'something'
    end

    def good_sample2
      +''
    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:20190718085900p:plain
Performance/UnfreezeStringの計測結果

確かに、good_sample1good_sample2 のパフォーマンスはいいです。
ただ、フリーズされた文字列をアンフリーズするケースがあんまり思い当たりません。
将来的に文字列はデフォルトでフリーズされるという話もありますが、そうなれば便利なのでしょうか。

25. Performance/UriDefaultParser

最後は URI::Parser.new より URI::DEFAULT_PARSER を使おうという Cop です。
URLをパースして解析したい場合に使うのだと思います。
ただ、これらより URI.parse を使うことが多いではないでしょうか。なので、URI.parse も加えて計測してみました。そのコードを次に示します。

Rubocopのドキュメントでは単に、パーサーを取得するだけですが、実際にURLをパースしてみるコードで比較しました。

# rubocop-performance Performance/UriDefaultParser

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, runner: :time) do |x|
  x.rbenv *versions

  x.prelude <<~RUBY
    URL_STR = 'https://www.sample.com'

    def bad_sample
      URI::Parser.new.parse(URL_STR)
    end

    def good_sample
      URI::DEFAULT_PARSER.parse(URL_STR)
    end

    def exstra_sample
      URI.parse(URL_STR)
    end
  RUBY

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

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

f:id:HeRo:20190718090050p:plain
Performance/UriDefaultParserの計測結果

サンプルのコードはいずれもURLのパースの結果として URI::HTTPS のインスタンスを生成します。
bad_sample(URI::Parser.new)は話にならないですね。
私がよく使う exstra_sample(URI.parse)もまあ、速いのですが、good_sample(URI::DEFAULT_PARSER)は更に速いですね。

一通り見てみて

さて、これで一通り Rubocop Performance を実際に計測して、その結果を見てみました。 概ね指摘されたら従ったほうが良さそうだとわかりました。
弊社でRubyというともっぱらRailsのアプリケーション開発ですが、あるメソッド内の1行であってもそれが、別のメソッドのループ内で使われることもありますし、アクセス数がとても多いページの処理で使われていたりします。1回ごとのコストは微々たるものでも減らす努力をするとサーバの負荷軽減、ひいては快適なユーザ体験につながるのでパフォーマンスにも気を使ったコードを書くようにしたいと思います。

パフォーマンス改善に優位性がないどころか、かえって遅くなったものについては今後、各プロダクトでRubocopルールに反映するかを考えたいと思います。
Rails内での利用を想定して計測方法や操作対象の配列やハッシュなどの大きさを見直して検証し直す必要があるかもしれません。

Rubocopは開発を効率的にするためにつかうもので、便利でとても有用なツールです。 ただ、プロダクトの性質やチームの方針により何を「効率的」とするのかは開発速度だったり、読みやすくメンテしやすいことだったり様々だと思いますし、プロダクトやチームの成長段階とともに変わることもあると思います。
うまく活用して開発していきたいと思います。

おまけ

もう一つ、計測してみて思ったことがあります。
それはリテラルで文字列、配列、ハッシュを生成するのは結構なコストなのだなということです。
最初は計測コード中の配列やハッシュを各メソッド内で生成していました。でも、badgood の結果にほとんど差が出ない Cop があり、試しに定数に変えてみると差が際立ちました。これらの生成コストが支配的であったということですね。それで今回の計測に使ったコードでは極力リテラルでオブジェクトの生成をしないように変更しました。

加えてRubyバージョンが上がるに連れリテラルの生成が高速化されているということを感じました。
で、実際に次のコードで文字列、配列、ハッシュの生成を計測してみました。

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
    URL_STR = 'https://www.sample.com'

    def string_literal
      'string literal'
    end

    def array_literal
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
    end

    def hash_literal
      { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, j: 0 }
    end
  RUBY

  x.report %{ string_literal }
  x.report %{ array_literal }
  x.report %{ hash_literal }
end

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

f:id:HeRo:20190718090721p:plain
文字列、配列、ハッシュのリテラル生成の計測結果

StringRuby 2.5 以降、若干ですが改善しています。
ArrayRuby 2.6 で大幅な改善がなされています。
Hashはバージョンを経るごとにだんだん改善されています。

Rubyのバージョンは新しいものほど着実にパフォーマンスアップされていますね。

最後に

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

Rubocop Performanceを測ってみた。中編

morishitaです。

前回のエントリーの続き、rubocop-performanceの指摘事項について盲従せずに確認してみるシリーズの2回目です。

前編はこちら。

tech.actindi.net

計測について

計測には 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によるグラフで示しますが、グラフが長いほうが高速ということです。

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

では順に見ていきます。

10. Performance/FixedSize

最初からなんなんですが、固定の値を求めるのはやめようってことで当たり前なので計測は割愛します。

ドキュメントの悪い例を見ると、文字列や配列、ハッシュのリテラルに対してsizeメソッドやcountメソッドを使っていますが、それはやめましょう。コードを書いた時点でわかっている値です。

一方、変数に入った文字列や配列、ハッシュ、あるいは一部の値が splat展開される配列やハッシュでsizeメソッドやcountメソッドを使うのは良い例となっています。変数の中身は実行時に変わるので、OKということですね。

11. Performance/FlatMap

flatten(引数あり)よりもflat_mapflatten(引数なし)を使いましょうという Cop です。

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

# rubocop-performance Performance/FlatMap

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, 2, 3, 4]

    def bad_sample1
      ARRAY.map { |e| [e, e] }.flatten(1)
    end

    def bad_sample2
      ARRAY.collect { |e| [e, e] }.flatten(1)
    end

    def good_sample1
      ARRAY.flat_map { |e| [e, e] }
    end

    def good_sample2
      ARRAY.map { |e| [e, e] }.flatten
    end

    def good_sample3
      ARRAY.collect { |e| [e, e] }.flatten
    end
  RUBY

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

計測結果は次のとおり。

f:id:HeRo:20190717085631p:plain
Performance/FlatMapの計測結果

うーん、good_sample1(flat_map)は効果ありと思うのですが、flattenは逆に遅くなってますね。 flat_mapを使っていきましょう。

12. Performance/InefficientHashSearch

ハッシュがあるキーや値を持つかどうかを調べるにはHash#keys.include?Hash#values.include?よりも Hash#key?Hash#value? を使いましょうという Cop です。

ちょっと例が多いので、キーと値で計測を分けました。

Hash#key?Hash#has_key?

まずはキーの計測コードから。

# rubocop-performance Performance/InefficientHashSearch

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

    HASH = { a: 1, b: 2 }

    def bad_sample_key1
      HASH.keys.include?(:a)
    end

    def bad_sample_key2
      HASH.keys.include?(:z)
    end

    def bad_sample_key3
      h = { a: 1, b: 2 }; h.keys.include?(100)
    end

    def good_sample_key1
      HASH.key?(:a)

    end

    def good_sample_key2
      HASH.has_key?(:z)
    end

    def good_sample_key3
      h = { a: 1, b: 2 }; h.key?(100)
    end
  RUBY

  x.report %{ bad_sample_key1 }
  x.report %{ bad_sample_key2 }
  x.report %{ bad_sample_key3 }
  x.report %{ good_sample_key1 }
  x.report %{ good_sample_key2 }
  x.report %{ good_sample_key3 }
end

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

f:id:HeRo:20190717085857p:plain
Performance/InefficientHashSearch(key)の計測結果

good_sample_key1(Hash#key?)とgood_sample_key2(Hash#has_key?)のパフォーマンスの良さが際立ちますね。素直にRubocopの指摘に従ったほうがいいでしょう。

bad_sample_key3good_sample_key3の遅さが目立ちますがこれはメソッド内でハッシュを生成してしまっているからだと思います。ドキュメントにあったので入れましたが、他の例と比べて必要性が薄いので外しても良かったかもしれません。

Hash#value?Hash#has_value?

そして値についての計測コードは次のとおりです。

# rubocop-performance Performance/InefficientHashSearch

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
    HASH = { a: 1, b: 2 }

    def bad_sample_value1
      HASH.values.include?(2)
    end

    def bad_sample_value2
      HASH.values.include?('garbage')
    end

    def bad_sample_value3
      h = { a: 1, b: 2 }; h.values.include?(nil)
    end

    def good_sample_value1
      HASH.value?(2)
    end

    def good_sample_value2
      HASH.has_value?('garbage')
    end

    def good_sample_value3
      h = { a: 1, b: 2 }; h.value?(nil)
    end
  RUBY

  x.report %{ bad_sample_value1 }
  x.report %{ bad_sample_value2 }
  x.report %{ bad_sample_value3 }
  x.report %{ good_sample_value1 }
  x.report %{ good_sample_value2 }
  x.report %{ good_sample_value3 }
end

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

f:id:HeRo:20190717085939p:plain
Performance/InefficientHashSearch(value)の計測結果

bad_sample_value1に比べてgood_sample_value1(Hash#value?)のパフォーマンスはいいです。
一方、bad_sample_value2に対して good_sample_value2(has_value?)はあんまり効果が見られません。
使うならば、Hash#value?を使うのがいいでしょう。
そのHash#value?にしてもRuby 2.3, 2.4では逆に遅くなっています。Rubyのアップデートについていくのも大事ですね。

13. Performance/OpenStruct

OpenStructは要素を動的に追加・削除できる手軽な構造体を提供するクラスです。
このOpenStructを使うのやめましょうという Cop です。

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

# rubocop-performance Performance/OpenStruct

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
    require 'ostruct'
    class BadClass
      def my_method
        OpenStruct.new(my_key1: 'my_value1', my_key2: 'my_value2')
      end
    end

    class GoodClass
      MyStruct = Struct.new(:my_key1, :my_key2)
      def my_method
        MyStruct.new('my_value1', 'my_value2')
      end
    end

    def bad_sample
      BadClass.new.my_method
    end

    def good_sample
      GoodClass.new.my_method
    end
  RUBY

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

計測結果は次の様になります。

f:id:HeRo:20190717090052p:plain
Performance/OpenStructの計測結果

結果は一目瞭然ですね。 OpenStructは便利ですが、パフォーマンスにシビアなコードでは使わないほうが良さそうです。

14. Performance/RangeInclude

Range#include? の代わりにRange#cover?を使いましょうという Cop です。 Range#include?は Range 内の値を1つづつ===でチェックしますが、Range#cover?は始点と終点を<=>で比較するだけです。ほとんどの場合はRange#cover?で十分でしょうというのがこの Cop の説明です。

ただし、処理の内容が異なる以上、結果が異なるケースがあるので注意が必要です。 例えば次の結果は異なるので注意が必要です。

('a'..'z').include?('yellow') # => false
('a'..'z').cover?('yellow')   # => true

一括置換するのは危険ですよってことですね。

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

# rubocop-performance Performance/RangeInclude

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
    RANGE = ('a'..'z')

    def bad_sample
      RANGE.include?('b')
    end

    def good_sample
      RANGE.cover?('b')
    end
  RUBY

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

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

f:id:HeRo:20190717090130p:plain
Performance/RangeIncludeの計測結果

注意は必要ですが、使える場合には Range#cover? を積極的に使っていったほうが良さそうですね。

15. Performance/RedundantBlockCall

メソッドでブロックを受け取る場合、ブロック引数&blockを取って block.call を呼ぶよりもyeildがパフォーマンス的に有利ですよっていう Cop です。

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

# rubocop-performance Performance/RedundantBlockCall

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_method(&block)
      block.call
    end

    def bad_another(&func)
      func.call 1, 2, 3
    end

    def good_method
      yield
    end

    def good_another
      yield 1, 2, 3
    end

    def bad_sample1
      bad_method { 1 + 2 }
    end

    def bad_sample2
      bad_another { |a, b, c | a + b + c }
    end

    def good_sample1
      good_method { 1 + 2 }
    end

    def good_sample2
      good_another { |a, b, c | a + b + c }
    end
  RUBY

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

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

f:id:HeRo:20190717090221p:plain
Performance/RedundantBlockCallの計測結果

Ruby 2.6 以降では &block引数 + block.call もパフォーマンス改善されていますが、yeild には及びません。

ただ、yeildだと、メソッドシグネチャ見ただけでブロックを取れることがわかりにくいので、行数が多い大きなメソッドで使うと見通しが悪くなるかもなと思います。 まあ、それ以前にRubocopの Metrics系の Cop にメソッドが大きすぎると指摘されるでしょうけど。

16. Performance/RedundantMatch

Regexp#matchより=~の方が高性能だよっていう Cop です。が、Regexp#match=~ で置き換えられる場面というのは match? で事足りる場合がほとんどでなのではないでしょうか1。割愛します。

17. Performance/RedundantMerge

Hash#merge! よりも Hash#[]= の方が高性能なので使いましょうねという Cop です。

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

# rubocop-performance Performance/RedundantMerge

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
      { x: 10, y: 20, z: 30 }.merge!(a: 1)
    end

    def bad_sample2
      { x: 10, y: 20, z: 30 }.merge!({'key' => 'value'})
    end

    def bad_sample3
      { x: 10, y: 20, z: 30 }.merge!(a: 1, b: 2)
    end

    def good_sample1
      hash = { x: 10, y: 20, z: 30 }
      hash[:a] = 1
    end

    def good_sample2
      hash = { x: 10, y: 20, z: 30 }
      hash['key'] = 'value'
    end

    def good_sample3
      hash = { x: 10, y: 20, z: 30 }
      hash[:a] = 1
      hash[:b] = 2
    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:20190717090301p:plain
Performance/RedundantMergeの計測結果

結果はHash#[]= の方が速いですが、マージするハッシュが大きいとコードが見にくくなります。 Rubocopのデフォルト設定ではキーの数が2つ以下だと指摘するようになっています。 キーが多いハッシュはHash#merge!を使ってコードをすっきりさせようってことですかね。

Ruby 2.6、2.7でグラフが伸びているのはハッシュリテラル生成の性能が上がっているからですかね。

まだ続きます。

さて、Rubocop Perfomanceにはもう少し Cop が残っていますが、本エントリではここまで。次回に続けます。

tech.actindi.net

最後に

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


  1. match? に関するCop は Performance/RegexpMatch があります。次回、後編で紹介します。