esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

Rubyで任意の文字列を削除するメソッドの速度を比較してみた

はじめに

こんにちは、wai-doiです。

Railsアプリケーションを開発していて、文字列の削除をするコードを書くことがあると思います。 例えば以下のようなコードです。

zip_code = '123-4567'
zip_code.gsub(/-/, '')
#=> 1234567

この例では String#gsub を使いましたが、他のメソッドでも同様のことを実現することができます。 私はそのたびに、どの書き方が良いのか迷っていました。そこで今回は速度の観点でどのメソッドを使うのがよいのかを測定してみました。

実行環境

速度の測定には、簡単に処理速度を計測することができる benchmark-ips を用いました。

github.com

今回の実行環境です。

  • Ruby (3.0.0)
  • Active Support (6.1.3.1)
  • benchmark-ips (2.8.4)

測定その1

郵便番号からハイフン - 1 文字を削除するときの速度を測定します。以下の観点を比較してみることにしました。

  • 比較するメソッドはString#tr, String#delete, String#sub, String#gsub, String#remove の 5 種類。
  • 非破壊的と破壊的な変更をするメソッドはどちらが速いのか。
  • 置換対象の引数に文字列でも正規表現でもとることができるメソッドはどちらが速いのか。

郵便番号を表す文字列から - 1文字を削除する Ruby のコードを実行します。String#remove を使うため Active Support を require しています。

require 'active_support/core_ext/string'
require 'benchmark/ips'

Benchmark.ips do |x|
  x.report('String#tr') { '123-4567'.tr('-', '') }
  x.report('String#tr!') { '123-4567'.tr!('-', '') }

  x.report('String#delete') { '123-4567'.delete('-') }
  x.report('String#delete!') { '123-4567'.delete!('-') }

  x.report('String#sub (string)') { '123-4567'.sub('-', '') }
  x.report('String#sub (regexp)') { '123-4567'.sub(/-/, '') }

  x.report('String#sub! (string)') { '123-4567'.sub!('-', '') }
  x.report('String#sub! (regexp)') { '123-4567'.sub!(/-/, '') }

  x.report('String#gsub (string)') { '123-4567'.gsub('-', '') }
  x.report('String#gsub (regexp)') { '123-4567'.gsub(/-/, '') }

  x.report('String#gsub! (string)') { '123-4567'.gsub!('-', '') }
  x.report('String#gsub! (regexp)') { '123-4567'.gsub!(/-/, '') }

  x.report('String#remove (string)') { '123-4567'.remove('-') }
  x.report('String#remove (regexp)') { '123-4567'.remove(/-/) }

  x.report('String#remove! (string)') { '123-4567'.remove!('-') }
  x.report('String#remove! (regexp)') { '123-4567'.remove!(/-/) }

  x.compare!
end

benchmark-ips の出力は以下になりました。

Warming up --------------------------------------
           String#tr   402.521k i/100ms
          String#tr!   434.153k i/100ms
       String#delete   453.843k i/100ms
      String#delete!   519.036k i/100ms
 String#sub (string)   245.331k i/100ms
 String#sub (regexp)   240.213k i/100ms
String#sub! (string)   262.920k i/100ms
String#sub! (regexp)   255.543k i/100ms
String#gsub (string)   120.926k i/100ms
String#gsub (regexp)    63.566k i/100ms
String#gsub! (string)
                       117.623k i/100ms
String#gsub! (regexp)
                        63.364k i/100ms
String#remove (string)
                        56.069k i/100ms
String#remove (regexp)
                        37.356k i/100ms
String#remove! (string)
                        67.638k i/100ms
String#remove! (regexp)
                        42.655k i/100ms
Calculating -------------------------------------
           String#tr      3.933M (± 2.4%) i/s -     19.724M in   5.018301s
          String#tr!      4.370M (± 1.6%) i/s -     22.142M in   5.067534s
       String#delete      4.514M (± 1.9%) i/s -     22.692M in   5.028565s
      String#delete!      5.081M (± 2.0%) i/s -     25.433M in   5.007540s
 String#sub (string)      2.419M (± 1.6%) i/s -     12.267M in   5.071963s
 String#sub (regexp)      2.385M (± 3.1%) i/s -     12.011M in   5.041188s
String#sub! (string)      2.600M (± 3.5%) i/s -     13.146M in   5.062776s
String#sub! (regexp)      2.539M (± 1.9%) i/s -     12.777M in   5.035136s
String#gsub (string)      1.186M (± 2.6%) i/s -      5.925M in   5.001154s
String#gsub (regexp)    630.950k (± 3.6%) i/s -      3.178M in   5.044408s
String#gsub! (string)
                          1.074M (± 4.8%) i/s -      5.411M in   5.050392s
String#gsub! (regexp)
                        528.834k (± 1.7%) i/s -      2.661M in   5.033851s
String#remove (string)
                        487.230k (± 2.8%) i/s -      2.467M in   5.067225s
String#remove (regexp)
                        349.516k (± 4.1%) i/s -      1.756M in   5.032162s
String#remove! (string)
                        677.874k (± 1.7%) i/s -      3.450M in   5.090182s
String#remove! (regexp)
                        427.461k (± 1.9%) i/s -      2.175M in   5.091096s

Comparison:
      String#delete!:  5080887.5 i/s
       String#delete:  4514317.0 i/s - 1.13x  (± 0.00) slower
          String#tr!:  4370478.5 i/s - 1.16x  (± 0.00) slower
           String#tr:  3932740.8 i/s - 1.29x  (± 0.00) slower
String#sub! (string):  2600367.7 i/s - 1.95x  (± 0.00) slower
String#sub! (regexp):  2538594.2 i/s - 2.00x  (± 0.00) slower
 String#sub (string):  2419134.9 i/s - 2.10x  (± 0.00) slower
 String#sub (regexp):  2385105.6 i/s - 2.13x  (± 0.00) slower
String#gsub (string):  1185649.3 i/s - 4.29x  (± 0.00) slower
String#gsub! (string):  1073853.4 i/s - 4.73x  (± 0.00) slower
String#remove! (string):   677873.7 i/s - 7.50x  (± 0.00) slower
String#gsub (regexp):   630949.8 i/s - 8.05x  (± 0.00) slower
String#gsub! (regexp):   528833.6 i/s - 9.61x  (± 0.00) slower
String#remove (string):   487230.2 i/s - 10.43x  (± 0.00) slower
String#remove! (regexp):   427461.0 i/s - 11.89x  (± 0.00) slower
String#remove (regexp):   349516.3 i/s - 14.54x  (± 0.00) slower

測定その 1 の考察

メソッドを速い順に並べると String#delete, String#tr, String#sub, String#gsub, String#remove の順番でした。用途がより限定的なメソッドは速く、より汎用的なメソッドは遅いという結果となりました。便利な String#gsub をいつも使うのではなく、用途に合わせて適切なメソッドを使うことが良いということですね。

非破壊的と破壊的メソッドについては、常にどちらの方が速いかというのは今回の測定ではわかりませんでした。破壊的メソッドの方がオブジェクトを新たに作らないため速いのではないかと予想していましたが、速度のために破壊的メソッドを使うメリットはそれほど無いのかもしれません。

引数に文字列を与えるか正規表現を与えるかですが、常に文字列を与えたときの方が少しだけ速いことがわかりました。それは正規表現のマッチに少し時間がかかるためと考えられます。置換対象の引数が文字列で十分なときは文字列を使うのが良いですね。

また、とても学びになったのは String#remove がとても遅かったということです。Rails で開発しているなら String#remove がせっかく使えるから使おうと私はいままで考えていましたが、考え直す必要があるなと思いました。実装を見たところ、中で String#gsub! を呼び出しているだけなので gsub より遅いのは納得ですね。

測定その2

次に、レシーバーが長い文字列の場合の速度を測定してみました。

先ほどの郵便番号に対して1 万字の長さの文字列をくっつけた文字列をレシーバーにして、削除のメソッドを実行してみました 。比較する観点は、測定その 1 と同じにしています。

Benchmark.ips do |x|
  x.report('String#tr') { ('a' * 10000 + '123-4567').tr('-', '') }
  x.report('String#tr!') { ('a' * 10000 + '123-4567').tr!('-', '') }

  x.report('String#delete') { ('a' * 10000 + '123-4567').delete('-') }
  x.report('String#delete!') { ('a' * 10000 + '123-4567').delete!('-') }

  x.report('String#sub (string)') { ('a' * 10000 + '123-4567').sub('-', '') }
  x.report('String#sub (regexp)') { ('a' * 10000 + '123-4567').sub(/-/, '') }

  x.report('String#sub! (string)') { ('a' * 10000 + '123-4567').sub!('-', '') }
  x.report('String#sub! (regexp)') { ('a' * 10000 + '123-4567').sub!(/-/, '') }

  x.report('String#gsub (string)') { ('a' * 10000 + '123-4567').gsub('-', '') }
  x.report('String#gsub (regexp)') { ('a' * 10000 + '123-4567').gsub(/-/, '') }

  x.report('String#gsub! (string)') { ('a' * 10000 + '123-4567').gsub!('-', '') }
  x.report('String#gsub! (regexp)') { ('a' * 10000 + '123-4567').gsub!(/-/, '') }

  x.report('String#remove (string)') { ('a' * 10000 + '123-4567').remove('-') }
  x.report('String#remove (regexp)') { ('a' * 10000 + '123-4567').remove(/-/) }

  x.report('String#remove! (string)') { ('a' * 10000 + '123-4567').remove!('-') }
  x.report('String#remove! (regexp)') { ('a' * 10000 + '123-4567').remove!(/-/) }

  x.compare!
end
Warming up --------------------------------------
           String#tr     4.700k i/100ms
          String#tr!     5.329k i/100ms
       String#delete     4.399k i/100ms
      String#delete!     5.182k i/100ms
 String#sub (string)    15.998k i/100ms
 String#sub (regexp)     8.603k i/100ms
String#sub! (string)    14.971k i/100ms
String#sub! (regexp)     8.500k i/100ms
String#gsub (string)    11.075k i/100ms
String#gsub (regexp)     5.322k i/100ms
String#gsub! (string)
                        13.001k i/100ms
String#gsub! (regexp)
                         5.132k i/100ms
String#remove (string)
                        10.418k i/100ms
String#remove (regexp)
                         4.718k i/100ms
String#remove! (string)
                        12.948k i/100ms
String#remove! (regexp)
                         5.079k i/100ms
Calculating -------------------------------------
           String#tr     47.354k (± 3.1%) i/s -    239.700k in   5.066950s
          String#tr!     50.061k (±10.0%) i/s -    250.463k in   5.078335s
       String#delete     45.918k (± 6.5%) i/s -    228.748k in   5.005509s
      String#delete!     51.088k (± 5.2%) i/s -    259.100k in   5.088284s
 String#sub (string)    160.722k (±14.8%) i/s -    799.900k in   5.071508s
 String#sub (regexp)     83.337k (± 7.9%) i/s -    421.547k in   5.090275s
String#sub! (string)    149.517k (±11.8%) i/s -    748.550k in   5.070937s
String#sub! (regexp)     83.269k (± 5.1%) i/s -    416.500k in   5.015623s
String#gsub (string)    140.134k (±10.0%) i/s -    697.725k in   5.024230s
String#gsub (regexp)     55.118k (± 3.4%) i/s -    276.744k in   5.026910s
String#gsub! (string)
                        137.273k (± 8.2%) i/s -    689.053k in   5.051651s
String#gsub! (regexp)
                         54.317k (± 6.9%) i/s -    271.996k in   5.036091s
String#remove (string)
                         99.842k (± 6.8%) i/s -    500.064k in   5.031706s
String#remove (regexp)
                         45.780k (± 9.3%) i/s -    231.182k in   5.114578s
String#remove! (string)
                        128.251k (± 8.8%) i/s -    647.400k in   5.085621s
String#remove! (regexp)
                         53.039k (± 3.1%) i/s -    269.187k in   5.080040s

Comparison:
 String#sub (string):   160722.0 i/s
String#sub! (string):   149517.0 i/s - same-ish: difference falls within error
String#gsub (string):   140134.1 i/s - same-ish: difference falls within error
String#gsub! (string):   137273.0 i/s - same-ish: difference falls within error
String#remove! (string):   128250.9 i/s - same-ish: difference falls within error
String#remove (string):    99842.5 i/s - 1.61x  (± 0.00) slower
 String#sub (regexp):    83337.3 i/s - 1.93x  (± 0.00) slower
String#sub! (regexp):    83269.4 i/s - 1.93x  (± 0.00) slower
String#gsub (regexp):    55118.3 i/s - 2.92x  (± 0.00) slower
String#gsub! (regexp):    54317.2 i/s - 2.96x  (± 0.00) slower
String#remove! (regexp):    53039.1 i/s - 3.03x  (± 0.00) slower
      String#delete!:    51087.8 i/s - 3.15x  (± 0.00) slower
          String#tr!:    50060.5 i/s - 3.21x  (± 0.00) slower
           String#tr:    47353.9 i/s - 3.39x  (± 0.00) slower
       String#delete:    45917.8 i/s - 3.50x  (± 0.00) slower
String#remove (regexp):    45779.8 i/s - 3.51x  (± 0.00) slower

測定その 2 の考察

結果は測定その 1 から大きく変わり、長い文字列がレシーバーのときは String#deleteString#tr はとても遅くなってしまうことが分かりました。逆に String#sub, String#gsub, String#remove の方が速くなっていました。

また、String#sub, String#gsub, String#remove において引数が正規表現の場合がとても遅くなってしまうことがわかりました。それは、文字列が長いと正規表現のマッチ処理のステップ数が多くなるため速度に影響してしまったと思われます。

まとめ

今回は任意の文字列を削除するメソッドの速度を測定してみました。今回の知見から速度の面と用途の面で適切なメソッドを選んでいきましょう。

RDBMS付属のツールで大規模データをインポートする

こんにちわ、swamp09です。

先日Railsプロジェクトで遭遇した大規模データのインポートについてお話しします。

ある外部システムから大規模なデータの連携をファイルで行うことになり、ファイルからの大規模データのインポートについていくつか方法を試しました。 大規模データは、月次で連携され毎回データをインポートします。 例として、その大規模データはユーザーデータで CSV 形式で受け取るとして、users.csv を毎月ファイルで受け取りインポートすると思ってください。

最初にやったのは、取り込む CSV ファイルを分割し並列で読み込む方法です。CSVファイルのサイズが1GB以上あり、データ数が100万件を超えていたので、ファイルを一度に読み込みデータをインポートするのはメモリへの負荷が大きすぎました。 ファイルの分割はsplitコマンドを使用し、10万件ずつのファイルに分割しました。 gem の Parallel を使用し、だいたい下記のような形になりました。 Parallel.map を使用するとデフォルトでCPUコア数から並列数を決定してくれます。https://github.com/grosser/parallel/blob/v1.20.1/lib/parallel/processor_count.rb#L8

def import(file_path)
  file_split(file_path)
  split_files = Dir.glob(SPLITTED_FILES_PATH + '*')
  
  Parallel.map(split_files) do |csv_file|
    csv = CSV.read(csv_file)
    User.insert_all(csv)
    File.delete(csv_file)
  end
end

def file_split(file_path)
 system("split -l 100000 -d --additional-suffix=.csv #{file_path} #{SPLITTED_FILES_PATH}splitted_", exception: true)
end

手元の開発環境で試して、約30分でインポートが終わりました。 こちらを試そうとしたのですが、実行する予定のサーバーのマシンパワーに余力がない懸念があり、もう少し良い方法がないか検討することにしました。

そんな時、チームメンバーから MySQL だとファイルの一括インポートできるツールがあるけど、それと似たようなツールあるだろうか、といった話があがりました。プロジェクトで使っていた RDBMS が Oracle だったのですが、調べてみると SQL*Loader というファイルからの一括インポートのツールがあったので試してみることにしました。 SQL*Loader では、制御ファイルを作ってインポートを行います。

users.csv のデータの例としてこのようなデータが入っているとします。

user_id name prefecture birth_day last_sign_in_at
1 田中 太郎 東京都 2000/06/01 2020/01/30
2 山田 次郎 神奈川県 2000/10/01 2020/03/30

上記のデータから、必要なものが user_idnameaddress だけであるとして、3つだけ抽出します。 制御ファイルを下記のように作成します。

LOAD DATA CHARACTERSET JA16SJIS
INFILE 'users.csv'
INTO TABLE users
APPEND
FIELDS TERMINATED BY ','
(
  user_id INTEGER EXTERNAL,
  name CHAR,
  prefecture CHAR,
  birth_day FILLER,
  last_sign_in_at FILLER
)

SQL*Loader で処理すると、ファイルの分割処理も必要なくなり、処理時間も格段に短くなりました。 スクリプトを自作していた時は約30分ぐらいかかっていた処理時間が SQL*Loader では約3分で終わったので、圧倒的スピードで終わるようになりました。

まとめ

Active Record を使って良い感じに速くデータをインポートする方法を考えなければ、と思っていたところRDBMSの付属のツールを使えば一気に解決しました。 他の RDBMS では、MySQL は mysqlimport があり、PostgreSQL では COPY があるようです。

大量のデータをファイルからインポートするケースはあまり多くないかもしれませんが、なにかの参考になれば幸いです。

Rails / OSS パッチ会オンライン 2021年5月のお知らせ

2021年5月の Rails / OSS パッチ会を 5月27日(木)にオンライン開催します。

この会をひとことでいうと、日頃のお仕事で使っている Rails をはじめとする OSS への upstream にパッチを送る会です。

会には Ruby と Rails のコミッターである顧問の a_matsuda もいますので、例えば Rails に送るパッチのネタがあるけれど、パッチを送るに適しているかの判断やパッチを送る流れが悩ましいときなど a_matsuda に相談して足がかりにするなどできます。

開催時間は 17:00-19:00 となりますがご都合のあう方はぜひご参加下さい。Zoom あたりのテレビ会議システムを使います。

当日の招待 URL は Idobata の esminc/rails ルームで共有する予定です。

idobata.io

特に募集ページなど設けませんが、上記理由から Idobata のアカウントが必要になると思います。

以下、前回の活動が関わる成果です。

koic: rubygems.org

github.com

osyo-manga: doctree

github.com

yahonda: ruby-plsql

github.com github.com github.com


開催日程の決まった RubyKaigi Takeout 2021 や、最近の Ruby / Rails まわりの動向に関する話題などあるかもしれません。

その他の開催方針については以下の Gist に記していますので、ご参照ください。

Reboot Rails/OSS meetup online · GitHub

浮動小数点数のバイナリ表現を10進数表記へ変換してみる

こんにちは、星野源です。すみません取り乱しました、はたけやまです。

最近は趣味でCPUを自作しています。自作のCPUを浮動小数点演算に対応させるためにオープンソースのFPUコア(浮動小数点演算装置)の使い方を調べていたのですが、FPUとやりとりするデータが浮動小数点数のビット列なので、返ってきた結果が正しいのか正しくないのかがパッと見で分からなくて生きるのが辛い...( 浮動小数点数の 0x3F800000 が10進数表記でいくつか分かります?答えは 1.0です)

そこで、浮動小数点数のビット列を10進数表記へ変換するスクリプトを書いてみました。

f:id:htkymtks:20210520234418p:plain (↑ FPUとのデータのやりとり)

浮動小数点数の構造

変換スクリプトを書く前に浮動小数点数について軽く説明を。「浮動小数点数」は小数点を含む数値をビット列にエンコードする表現方式で、現在では「IEEE 754」として標準化されたものが広く使われています。IEEE 754で定義されている浮動小数点数には32ビットの単精度浮動小数点数(single)や64ビットの倍精度浮動小数点数(double)などがあります。

32ビットの単精度浮動小数点数は以下のような内部構造を持ちます。

  • 符号部(sign)1ビット
  • 指数部(exponent)8ビット
  • 仮数部(fraction)23ビット

f:id:htkymtks:20210520235020p:plain ( File:Float example.svg - Wikimedia Commons )

手作業で浮動小数点数を求める

浮動小数点数への理解を深めるため、10進数の「-118.625」を例にして符号部、指数部、仮数部に入る値を計算してみます。

(1) 符号部

  • 符号部には、正の数の場合は0が、負の数の場合は1が入る
  • -118.625は負の数なので、「符号部 = 1」となる
  • (符号を求めた後は、-118.625 ではなく118.625 で後続の計算を行う)

(2) 10進数を2進数に変換する

  • 118.625を2進数に変換します
  • まずは整数部の118と小数部の0.625に分ける
  • 整数部
    • 118 = (64 * 1) + (32 * 1) + (16 * 1) + (8 * 0) + (4 * 1) + (2 * 1) + (1 * 0)
    • 10進数の「118」を2進数にすると「1110110」となる
    • 整数部の2進数表記 = 1110110
  • 小数部
    • 0.625 = (0.5 * 1) + (0.25 * 0) + (0.125 * 1)
    • 10進数の「0.625」を2進数にすると「0.101」となる
    • 小数部の2進数表記 = 0.101
  • 整数部 + 小数部
    • 1110110 + 0.101 = 1110110.101
  • 118.625の2進数表記 = 1110110.101

(3) 求めた2進数を指数表記へ変換

  • 1110110.101 = 1.110110101 * (2 ^ 6)
  • 「仮数 = 1.110110101」「指数 = 6」となる

(4) 指数部

(5) 仮数部

  • (3) で求めた仮数1.110110101のうち、仮数の先頭は常に「1.」となるので省略し、仮数部23ビットに足りない分はうしろに0を詰めて、「仮数部 = 11011010100000000000000」となる

(6) 符号 + 指数部 + 仮数部

  • 符号「1」+ 指数部「10000101」+ 仮数部「11011010100000000000000」
  • 「-118.625(10進数)」の32ビット単精度浮動小数点数の表記は「11000010111011010100000000000000」となる

以下のサイトを使って、計算した浮動小数点数の答え合わせしてみましょう。

手作業で求めた計算結果が「11000010111011010100000000000000」で、シミュレータで計算した結果が「11000010111011010100000000000000」。どうやら合ってそうです。

「浮動小数点数のバイナリ表現→10進数」変換スクリプト

32ビットの単精度浮動小数点数への理解が深まったので、さっそくスクリプトを書いてみます。

# bin2float.rb

# 32ビット単精度浮動小数点数の正式名称は「binary32」
class Binary32
  def initialize(bin32_str)
    # セパレータ `_` を取り除く
    str = bin32_str.gsub(/_/, '')

    if str.size == 8
      # 16進数表記の場合は2進数表記へ変換
      str = "%032b" % str.to_i(16)
    end

    raise 'Invalid Binary32 string' unless str.size == 32
    
    # 0バイト目は符号(+ or -)
    @sign = str[0]
    # 1〜8バイト目は指数部
    @exp = Exponent8.new(str[1..8])
    # 9〜32バイト目は仮数部
    @fract = Fraction23.new(str[9..])
  end

  def to_f
    # ゼロ
    return 0 if @exp.to_i == 0 && @fract.to_f == 0
    # 非正規数
    return Float::MIN if @exp.to_i == 0 && @fract.to_f != 0
    # 無限大
    return Float::INFINITY if @exp.to_i == 255 && @fract.to_f == 0
    # NaN
    return Float::NAN if @exp.to_i == 255 && @fract.to_f != 0

    # 符号が0ならプラスの値、1ならマイナス
    sign = @sign == "0" ? 1 : -1
    # 仮数部を求める際に省略した1.0を戻す
    fract = @fract.to_f + 1
    # 指数部を求める際に足したバイアス(127)を引く
    exp = @exp.to_i - 127
    
    sign * fract * (2 ** exp)
  end
end

# 指数部の8bit
class Exponent8
  def initialize(exp_str)
    @exp = exp_str
  end

  def to_i
    @exp.to_i(2)
  end
end

# 仮数部の23bit
class Fraction23
  def initialize(fract_str)
    @fract = fract_str
  end

  def to_f
    # (b0 * 0.5) + (b1 * 0.25) + (b2 * 0.125) + (b3 * 0.0625) + ...
    @fract.split(//).map(&:to_i).zip(1..23).map {|b, n|
      b * (2 ** (n * -1)).to_f
    }.sum
  end
end

p Binary32.new(ARGV[0]).to_f

使い方はこんな感じ。先ほど手で計算した「-118.625」も正しく計算できてそうです。

# 正の数
$ ruby bin2float.rb 01000010111011010100000000000000
118.625

# 負の数
$ ruby bin2float.rb 11000010111011010100000000000000  
-118.625

# ゼロ
$ ruby bin2float.rb 00000000000000000000000000000000
0

# 非正規化数(0ではないけれどBinary32で表現できないぐらい小さい値)
$ ruby bin2float.rb 00000000010000000000000000000000
2.2250738585072014e-308(Float::MINが返る)

# 無限大
$ ruby bin2float.rb 01111111100000000000000000000000
Infinity

# NaN
$ ruby bin2float.rb 01111111100000000000000000000001
NaN

# アンダーバーで区切ってもOK
$ ruby bin2float.rb 0_10000101_11011010100000000000000
118.625

# 16進数表記もOK
$ ruby bin2float.rb 42ED4000
118.625

突然のワンライナー

「わーい、できたー」と今回書いたスクリプトをTwitterへ投稿したところ id:udzura によりワンライナーに!!!

packとunpackに向き合ってみた

Rubyでバイナリアンを目指すためにpackとunpackに向き合った結果、僕にもワンライナーできたよー!!!ビッグエンディアンな単精度浮動小数点数としてunpackする必要があるのが罠でした。

gist1416977adf7d370eced64d22e0c273d2

参考

RuboCop オリジナル Cop 活用のオススメ

こんにちは、夜な夜なフォーチュンタワーに登っている nsgc です。

複数人でプロダクトやサービスを作る際に可読性や保守性を向上させるため、 コーディング規約を用意してフォーマットを統一したり、あきらかに不要な記述を静的解析ツールでチェックすることはよくあるかと思います。

Ruby でプログラミングしている場合、そういった Formatter/Linter としては RuboCop が有名ですが、 RuboCop 標準で用意されているルールだけではもの足りず、プロジェクトのコンテキストに特化した独自ルールが欲しい時がありませんか?

そんな時には、オリジナルの Cop の作成をオススメしたい!

tapp をチェックする Cop を作ってみよう

突然ですが tapp というツールをご存知でしょうか? tapp はメソッドチェーンの途中でもオブジェクトを表示できる優れものなのですが、プロダクトコードには入れたくありません。

コードレビューの時に「デバッグ用途の tapp が残ってますよ」 という指摘をしますが、本来はその前に CI で検知してほしいものです。そこで、今回 "tapp が呼ばれているか検知する" 独自 Cop を用意してみましょう。

まずは、Cop クラスの作成です。 RuboCop::Cop::Base を継承したクラスにコールバックを定義し、その中で問題になる条件がないかチェックし、最終的には警告メッセージを出すためのメソッドを呼びだします。

下記例では、メソッド呼び出し時に呼ばれる on_send コールバックを定義し、警告表示のための add_offense を呼んでいます。 RESTRICT_ON_SEND 定数に指定することで on_send で呼ばれる対象を tapp のみに限定し、add_offense で表示するメッセージを MSG 定数で設定しています。

# frozen_string_literal: true

module RuboCop
  module Cop
    module InternalAffairs
      #
      # # bad (ok during development)
      # # using tapp
      # def some_method
      #  do_something.tapp
      # end
      #
      # # good
      # def some_method
      #  do_something
      # end
      #
      class Tapp < Base
        RESTRICT_ON_SEND = %i(tapp).freeze
        MSG = 'Remove debugger'.freeze

        def on_send(node)
          add_offense(node)
        end
      end
    end
  end
end

Cop クラスが用意できたら、次にそのクラスを .rubocop.yml で読み込み、他の Cop 同様に有効にすると使えます。

require:
  - ./lib/rubocop/cop/internal_affairs/tapp

InternalAffairs/Tapp:
  Enabled: true

ここまで読んでどうでしょう?単純なメソッドコールの有り無し位ならとても簡単に作れそうですね。

もう少しリッチな構文チェックをしたい場合やオートコレクトを用意したい場合は公式で用意されている記事 が丁寧に書かれていますし、既存の Cop と近い Cop を作りたい場合は既にある実装 がリファレンスとして参考になります。

さいごに

私が参加しているプロジェクトでは、 特定のロールの利用者が操作時の証跡をデータベース上に残す必要があり、 controller でその証跡レコーディング用のメソッドが呼ばれているかを Cop でチェックしています。

また、Time::DATE_FORMATS で独自のフォーマットを用意しているのですが、それを用いないで書かれた場合にアラートをあげる Cop もいます。

チームのコードレビューで機械的に検出できるものを何度も指摘をしているなら、適応する Cop がないか探してみたり、なければ、自分たちで Cop を用意してチェックを任せ、人間にしかできないレビューに注力していきましょう!

価値創造契約10周年

アジャイル事業部で価値創造契約を担当している平田です。

価値創造契約という新しいサービスを発表して、また、最初のソフトウェアリリースから今年で10年になります。 価値創造契約についてはこちらから。(現在は新規受付は停止しています) https://agile.esm.co.jp/services/value_creating_contract/

詳細は上記のページに書いていますが、特徴を説明すると、初期費用0円とした上で、利用している期間だけ月額利用料をいただき、解約は自由というサービスモデルです。 これを発表した当時は、まだ今ほどアジャイル開発が受け入れられていなかった時期でもあり、「任せてくれたら大丈夫」という自信と覚悟を見せるためのご提案でした。 (そう思うと、請負契約ではなく、リスクをお客様側が持つ形での契約が一般的になっている現在は、隔世の感がありますね。)

価値創造契約はビジネスとして拡大はしなかったものの、ご契約いただいているシステムはいずれも長生きしており、継続的にメンテナンスをしながら使い続けていただいています。 一番長いものはリリースから10年になり、未だにリプレースすることもなく、運用を続けています。

ビジネスとして拡大しなかったのは、いくつも要因が重なったことが理由ですが、その理由のひとつに、我々が継続的に「保守」をやっていく体制をうまく作れなかったというものがあります。 通常、我々が取り組んでいるアジャイルなやり方では、ソフトウェアが動き続ける間、開発チームが継続的に追加開発や不具合修正を続けていきます。 一方、この価値創造契約のモデルでは、リリース後にメンテナンスは行うものの、開発チームを維持し続けるだけの費用をいただいていないため、チームは解散します。チームが解散してしまうことにより、知識の貯蔵庫が失われてしまうのが問題なのではないかと仮説を立てています。

今のところ、価値創造契約というサービスそのものを拡大していく予定はありませんが、上記のような課題を踏まえて、これまで通りの準委任契約での開発に引き続き取り組んでいくことはもちろんのこと、さらにその先をいくような契約形態やサービスを試行していきたいです。

ActiveRecord::LogSubscriber を使って追加でログを出力する

どうも muryoimpl です。

先日 ActiveRecord::LobSubscriber を使ってログ出力に手を加えたので、その意図と実装例をご紹介したいと思います。

ActiveRecord::LogSubscriber とは

ActiveRecord で発行されたクエリをログに出力する役割を担ったクラスです。ログレベルを debug とした場合に、ActiveRecord のクエリを発行した際に実行時間や SQL 文が出力されますが、それはこのクラスが活躍しているおかげです。

ActiveSupport::Notifications の仕組みを使ってログに記録する ActiveSupport::LogSubscriber を継承したクラスとなっていて、ActiveSupport::LogSubscriber の API ドキュメントをみると、ActiveRecord::LogSubscriber を使った例が記載されています。

ActiveRecord::LogSubscriber を継承した独自のクラスを作り、sql メソッドを上書き実装することで、既存のクエリのログ出力に加え、独自のログ出力を追加することができます。

なぜ ActiveRecord::LogSubscriber を使ったのか

今回私は、特定のテーブルのクエリが発行されているかどうか、発行されたときにどのくらいの時間がかかっているのかを確認したいがために、ActiveRecord::LogSubscriber を継承した独自クラスを作成しました。

Rails アプリケーションのログレベル全体を下げるとログの量が爆発的に増えてしまい、大量のSQLにより欲しい情報が埋もれてしまって探しにくくなってしまう問題があり、条件を指定してログ出力できないか?ということで、ActiveRecord::LogSubscriber の出番となったわけです。

この後記載する実装例でも出てきますが、SQL文やクエリに渡されたパラメータが payload として渡されてくるので、これらを条件にして出力する/しないの切り替えを柔軟に設定ができるのではないかと思います。

ActiveRecord::LogSubscriber 自体には、クエリが実装されているソースがどこかを出力するメソッドも実装されているので、あるテーブルへのクエリがどこから出力されるかを調べることもできるでしょう。

テーブルの特定の属性が更新された場合のみSQL文を出力する実装例

以下の環境で動作確認をしています。

  • Ruby on Rails: 6.1.3.1
  • Ruby: 2.7.3

ActiveRecord::LogSubscriber#sql の元の実装 や、ActiveRecord::LogSubscriber の API ドキュメント を参考にして実装しています。

今回は Profile モデルの memo 属性が更新された場合に、ログレベル info で SQL を出力するようにしています。ファイルは config/initializers 以下に配置しています。

class ProfileLogSubscriber < ActiveRecord::LogSubscriber
  def sql(event)
    self.class.runtime += event.duration
    payload = event.payload

    # debug レベルでログが二重に出力されないようにしている。
    # また、自分で実装したクエリ以外のSQLが出力対象とならないようにしている。
    return if logger.debug? || IGNORE_PAYLOAD_NAMES.include?(payload[:name])

    sql_str = payload[:sql]
    # profiles と memo が両方含まれているクエリのみを対象とする
    return if !(/profiles/i.match?(sql_str)) || !(/memo/i.match?(sql_str))

    name  = "#{payload[:name]} (#{event.duration.round(1)}ms)"
    name  = "CACHE #{name}" if payload[:cached]

    binds = type_casted_binds(payload[:type_casted_binds])
    # 親クラスの実装は debug で出力されるようになっているが
    # 今回は info で出力する
    info("#{name}  #{color(sql_str, sql_color(sql_str), true)}; #{binds}")
  end
end

# AcitveSupport::Notifications の"active_record" の namespace に
# ProfileLogSubscriber#sql を登録する
ProfileLogSubscriber.attach_to :active_record

event.payload は Hash で、実行されたSQL文(:sql)、SQLの種類を示す名前(:name)、SQL に bind する値(:bind_casted_binds) 等の情報が含まれています。これらを使って、出力するかどうかの判定と、ログに出力する文字列を作成しています。

ちなみに payload{:name] ですが、ActiveRecrod::Relation#explain を呼び出したときは "EXPLAIN"、テーブル情報等スキーマの情報を取得するSQLが発行された場合は "SCHEMA" になります。その他には、transaction を開始/終了した場合は "TRANSACTION"、定義したテーブルのレコードを操作した場合は "<モデル名> <操作名>" (例: Profile Load)になります。

以下は、前述のコードを config/initializers ディレクトリに配置し、development 環境のログレベルを info に設定した上で、bin/rails console を実行してクエリを発行した様子の画像です。profiles テーブルの memo を更新したときのみ SQL が出力されているのがわかると思います。

f:id:muryoimpl:20210426112809p:plain
rails console での実行例

さいごに

ActiveRecord::LogSubscriber を使って、従来のログ出力に加えて、特定のテーブルや属性に更新があったときのみログ出力する例を紹介しました。

特定の条件のときだけログを追加で出力したいといった場合に、Rails 本体のログ出力機能を書き換えることなく使えるため便利です。実行されたSQLの確認やチューニング、呼び出し元を特定する場面等で使えるので、こういう機能があったな、と覚えておくと活用できる場面に出会うかもしれません。