patorashのブログ

方向性はまだない

exists?で起きるN+1問題に対処するためにSetを使った話

課題:データを弾くためにN+1問題が発生していた

数年前に実装した、CSVデータをDBにインポートするためのプログラムがありました。 単にインポートするだけならいいのですが、除外リストに登録済のデータは弾いてほしい、という要望があり、そのように実装していました。

以下、ダミーのプログラムです。モデル名やCSVの内容は変えてあります。

require 'csv'

csv = CSV.read('list.csv', { headers: true, return_headers: false, skip_blanks: true, encoding: 'UTF-8' })
import_data = csv.reject do |row|
  name = row['名前']
  age = row['年齢']
  hobby = row['趣味']

  # 除外リストに存在するデータは弾く
  IgnoreUser.exists?(name: name, age: age, hobby: hobby)
end

users = import_data.map do
  name = row['名前']
  age = row['年齢']
  hobby = row['趣味']
  User.new(name: name, age: age, hobby: hobby)
end
User.import(users) # activerecord-importでバルクインサートする

しかし、この実装だと、CSVファイルの行数だけIgnoreUser.exists?が実行されてしまいます。が、それも仕方ないかな…と考えていました。

EffectiveRuby読書会で学んだことを活かす

ここで、5〜7月で実施していたEffectiveRuby読書会で学んだことで、このN+1問題を解決する方法を思いつきました。 それが、StructとSetを使う方法です。

Structは、データクラスを作るのを簡単にするための組み込みライブラリで、SetはRubyで集合を扱うための標準ライブラリです。

class Struct (Ruby 3.0.0 リファレンスマニュアル)

library set (Ruby 3.0.0 リファレンスマニュアル)

Structで定義すると、サブクラスが作られます。そのサブクラスのオブジェクトの配列をSetに入れるというアイデアです。Setは内部記憶としてHashを使うため、include?メソッドを使ったときの探索がO(1)になるという特長があります。ちなみにArrayでinclude?を呼ぶと、線形探索なので最悪の場合は計算量がO(N)になり、Nが大きくなればなるほど遅くなります。

Structは設定される値が全部同じだと、==がtrueになります。

IgnoreUserStruct = Struct.new(:name, :age, :hobby)
a = IgnoreUserStruct.new("a", 1, "a")
b = IgnoreUserStruct.new("a", 1, "a")
a == b # => true

Setを使って修正する

そこで、修正したコードがこちら。

require 'csv'
require 'set'

# 構造体を定義
IgnoreUserStruct = Struct.new(:name, :age, :hobby)

# 除外リストのデータを構造体の集合にする
# DBへのアクセスはこの箇所のみ
ignore_users = IgnoreUser.find_each.map { |ignore_user|
  IgnoreUserStruct.new(ignore_user.name, ignore_user.age, ignore_user.hobby)
}.to_set

csv = CSV.read('list.csv', { headers: true, return_headers: false, skip_blanks: true, encoding: 'UTF-8' })
import_data = csv.reject do |row|
  name = row['名前']
  age = row['年齢']
  hobby = row['趣味']

  # 除外リストに存在するデータは弾く
  # 集合に対して除外データを確認するのでDBへのアクセスは起きない
  ignore_users.include?(IgnoreUserStruct.new(name, age, hobby))
end

# 以下略

これで、N+1問題をやっつけることができました。めでたしめでたし!

ベンチマークを取る

N+1問題は解決したものの、実際にはどれくらい速くなったのかが気になります。 そこで、benchmark-ipsを使ってベンチマークを取りました。

前提

  • ignore_usersテーブルには1万件のデータを登録済み
  • CSVファイルの行数は1万行

ignore_usersテーブルへのデータ投入は、PostgreSQLのgenerate_series関数を使いました。

bin/rails dbconsolepsqlを開きます。そこから、以下のクエリを流し込んで1万件のデータを生成しました。

truncate ignore_users;
insert into ignore_users(name, age, hobby, created_at, updated_at)
select
  format('名前%s', i), 
  i,
  format('趣味%s', i),
  clock_timestamp(),
  clock_timestamp()
from
  generate_series(1, 10000) as i;

これで準備はできました。

ベンチマークプログラム

rails runnerで実行できるように、script/benchmark.rbにファイルを作りました。 bin/rails runner script/benchmark.rb で実行します。

また、上記ではfind_each.mapで除外リストのモデルから構造体を生成していましたが、これpluckでやればモデルの生成処理が不要になるから更に速くなるんじゃないか?と考えて、それを追加してます。

  • exists?
  • Array#include?
  • Set#include?(model version)
  • Set#include?(pluck version)

なお、このベンチマークプログラムもダミーです(モデル名とかCSVの内容とかは変えてあります)

require 'csv'
require 'set'

# 構造体を定義
IgnoreUserStruct = Struct.new(:name, :age, :hobby)

Benchmark.ips do |x|
  csv = CSV.read('list.csv', { headers: true, return_headers: false, skip_blanks: true, encoding: 'UTF-8' })
  
  x.report('exists?') do
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      IgnoreUser.exists?(name: name, age: age, hobby: hobby)
    end
  end

  x.report('Array<Struct>.include?') do
    ignore_users = IgnoreUser.find_each.map do |ignore_user|
      IgnoreUserStruct.new(ignore_user.name, ignore_user.age, ignore_user.hobby)
    end
    
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      ignore_users.include?(IgnoreUserStruct.new(name, age, hobby)
    end
  end

  x.report('Set<Struct>.include? model version') do
    ignore_users = IgnoreUser.find_each.map { |ignore_user|
      IgnoreUserStruct.new(ignore_user.name, ignore_user.age, ignore_user.hobby)
    }.to_set
    
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      ignore_users.include?(IgnoreUserStruct.new(name, age, hobby)
    end
  end

  x.report('Set<Struct>.include? pluck version') do
    ignore_users = IgnoreUser.in_batches.flat_map { |records|
      records.pluck(:name, :age, :hobby).map { |data| IgnoreUserStruct.new(*data) }
    }.to_set
    
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      ignore_users.include?(IgnoreUserStruct.new(name, age, hobby)
    end
  end
  x.compare!
end

ベンチマーク結果

以下が、実行結果です。

bin/rails runner script/benchmark.rb
Warming up --------------------------------------
             exists?     1.000  i/100ms
Array<Struct>.include?
                         1.000  i/100ms
Set<Struct>.include?     1.000  i/100ms
Set<Struct>.include? pluck version
                         1.000  i/100ms
Calculating -------------------------------------
             exists?      0.035  (± 0.0%) i/s -      1.000  in  28.891907s
Array<Struct>.include?
                          0.057  (± 0.0%) i/s -      1.000  in  17.419491s
Set<Struct>.include?      2.386  (± 0.0%) i/s -     12.000  in   5.045444s
Set<Struct>.include? pluck version
                          3.810  (± 0.0%) i/s -     20.000  in   5.252635s

Comparison:
Set<Struct>.include? pluck version:        3.8 i/s
Set<Struct>.include? model_version:        2.4 i/s - 1.60x  (± 0.00) slower
Array<Struct>.include?:        0.1 i/s - 66.36x  (± 0.00) slower
             exists?:        0.0 i/s - 110.07x  (± 0.00) slower

予想通り、DBからデータを取得した際にIgnoreUserモデルを生成しない分、Setを使ったpluckバージョンが最も速いという結果になりました。 Array#include?はexists?よりも速いとはいえ、66倍も遅い結果に。 exists?を使った結果は110倍も遅い結果になりました。

SetとStructを使うと超速くなりますね!

データが増えたらどうなる?

とはいえ、ignore_usersテーブルに1万件程度なので、これが10万件とか100万件とかになってきたらexists?のほうが速くなるんじゃないのー?と思って、それもベンチマークとってみました。

10万件の場合

  • ignore_usersテーブルには10万件のデータを登録済み
  • CSVファイルの行数は1万行(変わらず)

以下、結果です。

bin/rails runner script/benchmark.rb
Warming up --------------------------------------
             exists?     1.000  i/100ms
Array<Struct>.include?
                         1.000  i/100ms
Set<Struct>.include? model version
                         1.000  i/100ms
Set<Struct>.include? pluck version
                         1.000  i/100ms
Calculating -------------------------------------
             exists?      0.010  (± 0.0%) i/s -      1.000  in 102.770187s
Array<Struct>.include?
                          0.005  (± 0.0%) i/s -      1.000  in 216.871624s
Set<Struct>.include? model version
                          0.262  (± 0.0%) i/s -      2.000  in   7.638904s
Set<Struct>.include? pluck version
                          0.436  (± 0.0%) i/s -      3.000  in   6.878924s

Comparison:
Set<Struct>.include? pluck version:        0.4 i/s
Set<Struct>.include? model version:        0.3 i/s - 1.67x  (± 0.00) slower
             exists?:        0.0 i/s - 44.83x  (± 0.00) slower
Array<Struct>.include?:        0.0 i/s - 94.61x  (± 0.00) slower

1位は変わらず、Setを使ったpluckバージョンが最も速いという結果になりました。 exists?を使った結果は44倍なので、多少縮まりましたが、全然遅いです。 Array#include?は遂にexists?よりも遅くなり、94倍という結果になりました。

100万件の場合

  • ignore_usersテーブルには100万件のデータを登録済み
  • CSVファイルの行数は1万行(変わらず)
  • Array#include?は遅くなりすぎて耐えられないのでベンチマークから外す
  • Setのモデルバージョンはやめてpluckのみにする

以下、結果です。

bin/rails runner script/benchmark.rb
Warming up --------------------------------------
             exists?     1.000  i/100ms
Set<Struct>.include? pluck version
                         1.000  i/100ms
Calculating -------------------------------------
             exists?      0.002  (± 0.0%) i/s -      1.000  in 402.106370s
Set<Struct>.include? pluck version
                          0.043  (± 0.0%) i/s -      1.000  in  23.170393s

Comparison:
Set<Struct>.include? pluck version:        0.0 i/s
             exists?:        0.0 i/s - 17.35x  (± 0.00) slower

100万件でも、Setを使ったpluckバージョンのほうが速いという結果になりました。 exists?を使った結果は17倍なので、だいぶ縮まりました。

しかし、Setを使うバージョンは100万件のデータをRubyのオブジェクトとしてメモリに持っている状態なので、メモリ使用量が気になりますね。その点でいえば、exists?はメモリはあまり使わなくて済むでしょうけど、遅いは遅いですね…。

なんとなくでいいから、どれくらいメモリ使ってるかなーとMacアクティビティモニタを眺めていたら、100万件の場合は420MBくらいが上限だったのでまぁそれくらいならまだSetを使ってもいいのかな?という気がします。なんといってもまだまだ17倍も速いので。

まとめ

exists?で起きるN+1問題はSet + Structでかなり対応できそうです! Structを作るには、pluckを使うと速く、簡潔になります。

EffectiveRubyを読んでよかった!

プリンシプル オブ プログラミング読書会を始めた

EffectiveRuby読書会が先週終わったので、今週からプリンシプル オブ プログラミング読書会を始めました。どういう本を読むのかは、チームメンバー3名で候補を出し合ってたのですが、これはパートナーさんが上げてくれた本で、「3年目までに身につけたい内容」ということで、ちょうど後輩氏にぴったりだし、自分も読んだことがないから良さそうだなと思って決めた次第です。

第一回では、本の内容についてと、前提、原則の途中まで進んだのだけれど、いろんな本に載っている内容を上手にまとめられている印象でした。沢山の本を読む手間が省けそうではあるし、節ごとに参考書籍の名前が載っているので、深堀したくなればその本を読めばいいというのも良さそう。

上ではAmazonで紹介していますが、今回は秀和システムの定額制読み放題の10xEngというサービスを使ってみました。

10x-eng.com

秀和システムの結構な量の本が月額990円で読み放題になっていて素晴らしい。もちろんプリンシプル オブ プログラミングもその対象。そして結構高額な本が多いし、システム系の本だけじゃなく、ビジネス書籍もラインナップに含まれています。ただし、本が探しにくい…。ジャンル毎に分けて合ってほしいところです。

とりあえず2週間無料でお試しできるようなので、他の本も読んでいってみようと思います。

RailsでElasticsearchを使う際のレスポンスはidだけ返せばよかった

表題の通りですが、今まで運用していて全然気づかなかったことでした。

Elasticsearchにクエリを投げると、ヒットした際にレスポンスがめっちゃ長くてdevelopment.logが見づらくなってました。 ログをよく見ると、レスポンスからrecordsメソッドを呼ぶと、ヒットしたデータのIDの配列でデータベースから再取得していたので、「あれ?これってもしかしてElasticsearchからのレスポンスってIDだけでいいんじゃない?」と思ったので、早速実験してみました。

ちなみに、これがその処理。

elasticsearch-rails/active_record.rb at v7.1.1 · elastic/elasticsearch-rails · GitHub

elasticsearch-dslでクエリを組み立てる

Postモデルがあるとして、Elasticsearchに既にデータ登録されているとします。 elasticsearch-dslを使って、sourceメソッドで取得フィールドを指定します。

definition = Elasticsearch::DSL::Search::Search.new
definition.query do
  # 検索条件を設定
end

# sourceメソッドで取得するフィールドを配列で指定する
definition.source([:id])

# ElasticsearchでヒットしたデータのIDから、データベースに取りに行く
posts = Post.__elasticsearch__.search(definition.to_hash).records

こうすると、ElasticsearchからのレスポンスはIDのみとなり、通信量が激減しました!😀はいえ、問題は、ちゃんとRailsで動くかどうかです。

テストの実行結果

担当プロジェクトのElasticsearch関連のテストを実行してみたのですが(見せられませんが…)、recordsメソッドでデータベースデータを取得し直しているので、テストも全て通って問題なし!しかもElasticsearchのレスポンスが減ったからか、テストの速度も若干改善しました🚀

Elasticsearchからのレスポンス自体を活用するには?

あんまりちゃんと調べてないけど、たしかrecordsメソッドではなく、resultsメソッドを使えばいいはず…。しかし、その際にはこの方法だとidしかないので、微妙です。でも、データベースへの問い合わせがないぶん、早いはず。ActiveRecordのモデルは使えないと思うけど。

elasticsearch-rails/response.rb at v7.1.1 · elastic/elasticsearch-rails · GitHub

画面で動作確認

本番環境と同等のマスタデータを搭載して、developmentモードで画面から検索をかけてみたところ、レスポンスが1秒近く改善しました。😯めちゃくちゃ効果あるやん…。1秒ではなかったです…。たまたま遅い時を引いてしまったぽかった。でもまぁ50ms〜150msくらい速くなりました。

まとめ

RailsでElasticsearchを使っている場合にrecordsメソッドを使っている場合は、Elasticsearchからのレスポンスはidだけあればいいので、idのみで取得しても問題なさそう。むしろ通信量が減って速度改善するしよさそう。

6月になってやっていること

先月のやつは、これ。

patorash.hatenablog.com

特に5月から代わり映えしていないのだけれど、継続して取り組んでいることは以下の通り。

  • EffectiveRuby読書会
  • モブレビュー
  • スプリントプランニング
  • レビューをする曜日を決める等

主に変更点はないのだが、他に取り組んだことを上げるとすると、以下の通り。 (あくまで私が率いるチームの、です)

  • 開発チームのルールを明文化した。開発チームのメンバーに求める資質なども。
  • 後輩氏とペアでRundeckのジョブを作成した。
  • プルリクのマージはプルリクを作った人が行うことにした。

開発チームのルールを明文化した

これは、まぁうちのチームに人が増えることは滅多にないのだけれど、オンボーディング的な資料にもなるし、自分たちでも確認しやすくするためにやった。暗黙的なルールよりは、オープンであるほうがいい。

求める資質については、ざっくりとは「自律的に動けるようになろう」と言いたかったのだけれど、それってどういうことか?を私の言葉で箇条書きしといた。

チームのメンバーに求めるもの

  • ミスを隠さない・ごまかさないこと
    • ミスは誰にでもあり得ること。チームでリカバリし、対策を練りましょう。
  • 問題を抱え込まないこと
    • 小さな問題だったら対処できるけど、問題が大きくなってからだと難しくなります。チームで早めに対処できるように動きましょう
  • 汚い言葉を使わないこと
    • その言葉を使った人自身のイメージが悪くなるだけ。良いことはありません。
  • わからないことをそのままにしないこと
    • 質問する、調査する、わかったことを共有する
  • 業務で得られた知見は情報共有すること
    • ドキュメント化する
      • 他のチームでも役に立つ情報かもしれません
      • なにより、自分のためになります
    • 汎用的な内容はブログ等で発信してもよいでしょう
  • 確認・確認・確認!
    • たとえ、相手に投げたボールが戻ってこなくても、返信することをうっかり忘れてしまっていることがあるので、適宜、再度確認をお願いしましょう
  • 自分で課題を見つけること
    • 言われたことだけをやるのではなく、自分自身で課題を探し、よりよくしていきましょう。

これらをまとめた言葉でいうと、 『自律的に動けるようになろう』 となります。

こんな感じ。特に、汚い言葉を使わないってのはすごく重視している。論理的に正しくても言い方がよくないと感情的な不和が起きてしまう。

後輩氏とペアでRundeckのジョブを作成した

これは、スモール・リーダーシップで書かれていた「一人でやらない」を実践した。無論、一人でやったほうが早いのだが、滅多にやらないことこそ、一人でやらずに後輩氏とやることで、後輩氏にとってのブラックボックスを減らすことができたと思う。今後、Rundeckのジョブを作ったり修正することが発生しても、後輩氏に頼むことができるようになった。

プルリクのマージはプルリクを作った人が行うことにした

これは、上司の id:tech-kazuhisa が、うちのTeamsに id:Songmu さんのGitのワークフローの記事の話題を投稿していて、それに感銘を受けたのですぐに採用した。まんまだが、私のレビューが通ればOKみたいな感じになっていたからである。

songmu.jp

以下は、開発チームのルールからの抜粋。

コードの管理はチームの責任であることが大前提ではあります。 しかし、○○さんがOK出したのでヨシ!というような人に依存する形はよくないというところと、 自分のコードがちゃんとプロダクトに反映されていくという当事者意識を持ってほしいからです。

以下は、そんむーさんのブログから抜粋した内容になります。

レビューとマージ

  • 社内プロダクト開発においてはpull requestを出した当人がマージボタンを押す
    • これは意見があると思うが個人的にはこだわりのスタイル
    • 「自分が押したマージが本番に出ていく」という体験をしてもらう
    • コードベースへの当人のオーナーシップの醸成
  • レビューを通して開発した当人が安心してマージボタンを押せる状況を作り出す
  • 「リードエンジニアにレビューしてもらってマージしてもらう」はアンチパターン
    • 暗黙の権威が生まれ、勾配も拡大する一方となる
    • レビューしてもらえたから大丈夫だろう、と担当者が油断する
    • レビュワーの好みにコード全体が偏る
    • さらにはレビュワーが過剰に直させる、みたいなことも起きがち
    • →コードベースが「リードエンジニアのコード」から脱却できない

6月のまとめ

基本的によかったことを継続している。EffectiveRuby読書会は月曜日に2時間くらいやっているので結構進んでいて、あと2回くらいで終わりそう。コードレビュー時の視点として、知っておいてよさそうなことが結構あったし、標準ライブラリの便利な使い方とかも知ることができたので結構得るものが多かった。

集中できる時間が増えたので、メンバーを育成しつつも、成果を出しやすくなってきた。

GraphQLの検索先をActiveRecordからElasticsearchに変更する

この記事はQiitaにも書いていますが、一応ブログにも同様のものを載せておきます。

qiita.com

graphql-rubyはリソースの参照元のデフォルトがActiveRecordなので、それをElasticsearchに変更したかったのですが、ライブラリがなかったので自分で実装しました。

環境について

  • Ruby 2.7.3
  • Rails 6.0.3.4
  • Gem
    • graphql 1.12.12
    • elasticsearch-model 7.1.1

実装方法について

GraphQLのページネーションは Relay-Style Cursor Pagination が主流でしょう。その他、kaminariなどを使ったページネーションの実装もQiitaの記事で紹介されていました。

https://qiita.com/nobuo_hirai/items/f9e34d8572a82283538b

こちらの記事にあるように、社内で使うだけとかであれば、kaminariのページネーションを使うのもありだと思うのですが、最終的に公開を目指しているAPIなので、素直にRelay-Style Cursor Paginationを使えるようにしました。

カスタムコネクションを作る

graphql-rubyは、デフォルトで様々なリソース用のコネクションクラスを準備しています。ActiveRecordだけでなく、Sequel、MongoDB、配列などをサポートしています。しかし、Elasticsearchはありません。そのため、独自にカスタムコネクションを作らなければなりません。

カスタムコネクションの作り方のサンプルは、公式のカスタムコネクションのページにざっくりとした作り方が書いてあります。

https://graphql-ruby.org/pagination/custom_connections

当初は、これを見ながら実装しようかと思ったのですが、やはりざっくりとしか書いてないので、なかなかわかりませんでした。そこで、graphql-rubyのpaginationsディレクトリのソースコードを読みながら進めることにしました。

https://github.com/rmosolgo/graphql-ruby/tree/master/lib/graphql/pagination

ActiveRecordRelationConnectionというクラスがあるのですが、それはRelationConnectionクラスを継承していたので、当初はRelationConnectionを継承して進めようとしましたが、よくわからなかったので、Connectionクラスを継承元とし、RelationConnectionで定義されているメソッドを全て再実装していきました。

結果的には、RelationConnectionで実装されているメソッドのままでよいものが大多数だったので、継承元をRelationConnectionに変更しましたが、処理の流れは掴めました。

それで、作成したクラスがこちらです。

module Connections
  class ElasticsearchRelationConnection < GraphQL::Pagination::RelationConnection

    def nodes
      @nodes ||= limited_nodes.records.to_a
    end
    # Rubocopにload_nodesメソッドが不要と言われた
    # しかし、継承元のRelationConnectionで呼ばれているのでnodesメソッドのエイリアスにしておく
    # また、元々private methodだったので変更しておく
    alias_method :load_nodes, :nodes
    private :load_nodes

    # GraphQL::Pagination::RelationConnectionの実装を改修
    # `@paged_node_offset`にオフセットが入っているので、2重で足さないようにした。
    def cursor_for(item)
      load_nodes
      # index in nodes + existing offset + 1 (because it's offset, not index)
      # offset = @nodes.index(item) + 1 + (@paged_nodes_offset || 0) + (relation_offset(items) || 0)
      offset = @nodes.index(item) + 1 + (@paged_nodes_offset || 0)
      encode(offset.to_s)
    end

    private

      # @param [Elasticsearch::Model::Response::Response]
      # @param [Integer] size LimitSize
      # @return [Boolean] sizeよりも残りが大きければtrueを返す
      def relation_larger_than(relation, size)
        initial_offset = relation_offset(relation)
        relation_count(relation) > initial_offset + size
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer] オフセットの値
      def relation_offset(relation)
        relation.search.definition.fetch(:from, 0)
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer, nil] 取得数
      def relation_limit(relation)
        relation.search.definition[:size]
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer] 総ヒット数
      def relation_count(relation)
        relation.results.total
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [ActiveRecord::Relation]
      def null_relation(relation)
        relation.records.none
      end

      def limited_nodes
        super()
      rescue ArgumentError => _e
        # カーソルの先頭より前の要素を取得しようとするとArgumentErrorになったため、
        # 例外を補足して空のActiveRecord::Relationを返すようにした
        ApplicationRecord.none
      end
  end
end

これを使えるようにします。先ほど作ったコネクションを使えるように登録します。

class MySchema < GraphQL::Schema
  connections.add(Elasticsearch::Model::Response::Response, Connections::ElasticsearchRelationConnection)
  # 省略
end

そして、これを使ったスキーマを定義します。UserモデルのElasticsearchのスキーマ定義は省略します…。

module Types
  class QueryType < Types::BaseObject
    field :users, Objects::User.connection_type, null: false do
      argument :keyword, String, required: false # 検索キーワード
    end

    # **argsにすることで、graphqlのページング条件などを一手に引き受けさせる
    def users(keyword: nil, **args)
      query = Elasticsearch::DSL::Search::Search.new
      query.query do
        bool do
          if keyword.present?
            must do
              simple_query_string do
                query keyword
                fields ['keyword_search_field']
                default_operator :and
              end
            end
          end
        end
      end

      es_response = User.__elasticsearch__.search(query.to_hash)
      # 先ほど作ったコネクションで返す
      Connections::ElasticsearchRelationConnection.new(
        es_response,
        first: args[:first],
        last: args[:last],
        before: args[:before],
        after: args[:after],
      )
    end
  end
end

これで、Elasticsearchに対してGraphQLで検索させることができるようになりました。

実際にクエリを書いてみます。

query {
  users(keyword: "山田", first: 3) {
    edges {
      cursor
      node {
        id
        name
      }
    }
    pageInfo {
      startCursor
      endCursor
      hasNextPage
      hasPreviousPage
    }
  }
}

先ほどのクエリを実行した結果です(公開用にデータは適当に修正しています)。 pageInfoにカーソルの値や、前後のページの有無が返っています。

{
  "data": {
    "users": {
      "edges": [
        {
          "cursor": "MQ",
          "node": {
            "id": "34",
            "name": "山田 孝夫"
          }
        },
        {
          "cursor": "Mg",
          "node": {
            "id": "76",
            "name": "山田 孝之"
          }
        },
        {
          "cursor": "Mw",
          "node": {
            "id": "55",
            "name": "山田 太郎"
          }
        }
      ],
      "pageInfo": {
        "startCursor": "MQ",
        "endCursor": "Mw",
        "hasNextPage": true,
        "hasPreviousPage": false
      }
    }
  }
}

まとめ

  • graphql-rubyはデフォルトで様々なコネクションクラスを持っている
  • その他のリソースで検索させたい場合などはGraphQL::Pagination::Connectionクラスを継承して作ることができる
  • graphql-rubyでElasticsearchを使いつつ、Relay-Style Cursor Paginationを実現したければ、カスタムコネクションを作る必要がある
  • 上記に載せたElasticsearchRelationConnectionのコードが、それである。

Selenium WebDriverからCupriteへの移行は難しい件

この記事を読んで、SeleniumからCupriteに移行できたらテストがまた速くなるんじゃないか?と思ってここ数日動作検証してるのですが、思った以上にうまくいかないので、一旦移行を諦めようかなという気持ちになっています。

techracho.bpsinc.jp

とはいえ、今までやってきたことを何もメモに残さないのも勿体ないので、とりあえずそれらは今後のために残しておこうかなと。

バージョン情報

この記事の執筆時点での私の環境です。開発環境はDockerにしています。

  • Mac Catalina
  • Ruby 2.7.3
  • Rails 6.0.3.7
  • Capybara 3.35.3
  • Cuprite 0.13

Cupriteとは?

CupriteはCapybaraのドライバーで、Chromeを直接操作するCDPプロトコルを使えるFerrumを使ってブラウザを操作します。

cuprite.rubycdp.com

Selenium経由じゃない分、速いという話です。

設定類については、一番最初に貼った記事に説明があるので、そこらへんはここでは言及しません。

Cupriteへの移行に躓いた点

JavaScriptの実行方法がselenium-webdriverと違う

JavaScriptの実行方法が違ったため、wait_for_ajaxなどが動かなくなりました。Ferrumを使ってbrowserからevaluateメソッドやexecuteメソッドを使います。

# selenium-webdriver
page.evaluate_script('jQuery.active').zero?

# cuprite
page.driver.browser.evaluate('jQuery.active').zero?

まぁこの辺りは一括置換で対応できるので序の口です。

click_linkとclick_buttonがよくコケる

click_linkとclick_buttonが結構な頻度で落ちました。原因はfixed指定している要素の下に対象のリンクやボタンがあるからと言われました。いや、selenium-webdriverのときはそうならなかったし…。うちのプロジェクトでfixed指定している要素ってのはつまり、bootstrapのナビゲーションバーとかです。あと検索結果の件数表示とか。Cupriteよ、fixedな要素の下に行くまでスクロールすな!!という気持ちです。

仕方ないのでモンキーパッチをあてました。

def click_link(target)
  failed ||= 0
  super(target)
rescue Capybara::Cuprite::MouseEventFailed
  if failed.zero?
    # 固定ナビバーが重なってクリックできないケースがあるので、その際はナビバー分を移動して再度実行するようにした
    scroll_x = page.driver.browser.evaluate('window.scrollX')
    page.driver.scroll_to(0, scroll_x - 60)
  else
    # それ以外の場合に押せない場合は、検索結果のフッター要素が重なっているとみなして、最後までスクロールさせる
    page.driver.scroll_to(0, 9999)
  end
  failed += 1
  retry if failed < 3
end

これで、大体はクリックできるようになりました(が、例外拾ってるからコスト大きそう)

入力結果が反映されない

fill_inで入力してすぐにclick_buttonでsubmitしてるんですが、なぜかfill_inで入力した内容が反映されません。sleepかませるとうまく動いたのですが、そんなことしていたら速くなるわけがありません…。本末転倒です。それに何個fill_inするところがあるというのか…。

調べたら、:slowmoというオプションがあることがわかりました。コマンドを送る前に指定した秒数だけ遅延させるようです。試しに、0.1を指定したら、まともに動き始めました。

Capybara.register_driver(:cuprite) do |app|
  Capybara::Cuprite::Driver.new(
    app,
    **{
      window_size: [1200, 800],
      browser_options: remote_chrome ? {"no-sandbox" => nil} : {},
      process_timeout: 10,
      inspector: true,
      headless: !ENV['HEADLESS'].in?(%w(n 0 no false)),
      pending_connection_errors: false,
      slowmo: 0.1, # <= これ!
      timeout: 15, # <= これもないと割りかし落ちる…
    }.merge(remote_options)
  )
end

しかし、毎回の処理で0.1秒遅延させることになるので、こんなのしていたら相当遅くなります…。

現状でベンチマークを取る

結構ボリュームのあるシステムテストselenium-webdriverとcupriteでローカルで実行してみました。 cupriteは、上記の設定をしたままです。

結果、cupriteは10分。selenium-webdriverは6分という結果となり、圧倒的にselenium-webdriverのほうが速いということに…😅

これは別にcupriteが遅いということではなく、私のcupriteの設定が悪いという話ではありますが、じゃあ適切な設定の落とし所はどういう設定なのか?を手探りで探さなければならないしそれは辛い。

まとめ

2021年6月時点では、ちょっとcupriteにサクッと移行できるわけでもなさそうだし、しかもテストも速くなるための調整が難しそうなので、移行へのモチベーションが上がりませんでした。 しかし、テスト高速化はコスト削減にも繋がるので、引き続き、時々調査してみようと思っています。

「いやいや、そんなのしなくてもこうすればCupriteでイケるよ!!」という情報をご存知の方は是非とも教えてください!お願いします。

ジュニアNISAするしと思って投資の本を読んだ

ジュニアNISAの口座を開設して、震えながら現金を突っ込んでおきました。なんせ、突っ込んだお金は子供が大きくなるまで引き出せないのです。でもまだ何も買ってません。日経平均3万近いので、今年中に調整があって2万前後になるに違いない!!と思いながら、下がるタイミングが来るのを待っています。それまでに、どういう株を買っておくのがいいのかな?と色々と考えておりました。

完全に株主優待を貰える株に絞って選択していこうと思っていたのですが、AmazonKindleのセールで以下の本が出ていたので、買ってみました。今回はこれの感想です。

割安だからと飛びつくと痛い目に合う

はい、もう経験済みです。めっちゃ経験済みです。

かなり高かった株が下がってきて、値頃感あるし、上がるだろうからと買ってみると、全然上がらずそのままダダ下がりになってしまう件。いい会社かどうかの判断はちゃんとしましょうねってことです。じゃあどうやっていい会社かどうかを判断するのか?っていうのが、この本の内容です。

いい会社の条件

あんま書きすぎると本の内容になってしまうので詳しくは書かないけれど、ざっくり書くと、ずっと利益の出ている会社です。ずっと配当が出ている会社です。なんか当たり前のようで、当たり前ではないというか、当たり前のことができないのが初心者なんだろうなと読みながら思った。

著者は優秀な会社をスクリーニングして見つけられるツールを作って提供している会社の方なので、ポジショントークは多いけれど、著名な投資家はどういうところを見て投資しているか、その著名な投資家ですら、こういうところでミスをしたことがあるという話もあったりして面白い。

株で一発当てようぜ!みたいな感じではない。まぁそれは投資ではなく、投機ですね…。

指標として何をみるか

PERやPBRとか色々あるけれど、どういう指標を見ればいいかは業種による、という気づきがあった。自分が投資しようとしていた業界はPBRのほうが重要なんだなとかがわかったのはよかったです。

全体を通して

書いてある式とかはなかなか頭に入ってこなかったんだけれど、定量的に会社の価値を比べることで投資対象として適格かどうか判断するというのはあんまりできてなかったので、もっとしっかりと確認しようと思いました。雰囲気で株買ってたらあかんな…。

まぁ買ってしまうことはあるだろうけれど、どういう商売をやっているのか、PERとかはちゃんと見た上で判断していこうと思います。