DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

【Ruby 3.2 新機能】るりまの Data クラスリファレンス作成 PR をレビューした

こんにちは。にくといいます。github だと niku です。2022 年 12 月に DIGGLE に入社しました。

さて Ruby 3.2 からは Data というクラスが使えるようになりました。 Ruby の日本語リファレンスるりま*1(以下「るりま」)にも class Data (Ruby 3.2 リファレンスマニュアル) というページができていますね。すごい!

「るりま」は GitHub の Pull Request を用いてメンテナンスされています。今回 Data のリファレンスも

github.com

で作られた PR が元になっています。 この PR のレビュアーとして私も参加したので、その時の様子も交えて「るりま」のドキュメントのメンテナンスの雰囲気をお伝えしたいと思います。

Data クラス

この記事を楽しむために、まずは Data クラスというのがどういうものなのかを大まかに説明します。 Data クラスは、Strcut クラスに似たインターフェースを持っています。

~ ()
irb
irb(main):001:0> SMeasure = Struct.new(:amount, :unit)
=> SMeasure
irb(main):002:0> DMeasure = Data.define(:amount, :unit)
=> DMeasure
irb(main):003:0> s_one_km = SMeasure.new(1, :km)
=> #<struct SMeasure amount=1, unit=:km>
irb(main):004:0> d_one_km = DMeasure.new(1, :km)
=> #<data DMeasure amount=1, unit=:km>

一方で Struct クラスは値を書き換えることができるのに対し、Data クラスは値を書き換えることができません。 Struct クラスは構造体クラスと呼ばれる概念を表すものに対し、Data クラスは値オブジェクトと呼ばれる概念を表すもので、値オブジェクトというものの性質の一つに「変わらない(不変)」があるためです。

irb(main):005:0> s_one_km.amount = 2
=> 2
irb(main):006:0> s_one_km
=> #<struct SMeasure amount=2, unit=:km>
irb(main):007:0> d_one_km.amount = 2
(irb):7:in `<main>': undefined method `amount=' for #<data DMeasure amount=1, unit=:km> (NoMethodError)
  from /Users/niku/.asdf/installs/ruby/3.2.1/lib/ruby/gems/3.2.0/gems/irb-1.6.2/exe/irb:11:in `<top (required)>'
    from /Users/niku/.asdf/installs/ruby/3.2.1/bin/irb:25:in `load'
  from /Users/niku/.asdf/installs/ruby/3.2.1/bin/irb:25:in `<main>'

ちょっと横道にそれますが「プログラムでできないことが増える」ことを導入するのに、それが嬉しい理由というのはあるんでしょうかね?私は以下の記事で覚えたことを大事にしていて「できないことが増える」のは便利なことだと感じています。

上が力が強く、下へ行くほど力が弱くなります。力が強いと何でもできてしまうので、コードの意図が不明瞭となり、また間違いが入り込みやすくなります。力が弱いとできることは限られるのでコードの意図は明確となり、間違いが入りにくくなります。 「目的に合う一番力の弱い手段を使う」のがよいプログラムを書くための大原則です。たとえば、草を刈るのにチェーンソーは使うべきではありません。もちろん草も切れますが、大切な植木も傷つけてしまうかもしれませんから。

Haskellの文法(再帰編) - あどけない話

「るりま」 Data クラス PR との出会い

私は Ruby 3.0 から実験的に導入された並列処理機能 Ractor https://techlife.cookpad.com/entry/2020/12/26/131858 に興味を持っていて、Ractor 間のデータのやりとりには値の変わらないオブジェクトがあると便利であることを、Erlang や Elixir といった他のプログラミング言語を通じて知っていました。Data 導入の issue https://bugs.ruby-lang.org/issues/16122 か PR https://github.com/ruby/ruby/pull/6353 をみてから、Ruby に導入できるのを心待ちにしていました。

Ruby の英語リファレンス である ruby-doc には Ruby 3.2 リリース (2022/12/25) 時点から Data クラスのリファレンス https://ruby-doc.org/3.2.0/Data.html がありました。一方で、日本語のリファレンスはその時点では存在していませんでした。kakutani さんが Ruby3.2.0 の新機能のほうの Data のリファレンスマニュアルがあってほしいという issue を rurema に立てた https://github.com/rurema/doctree/issues/2764 のをみて、私は「るりま」でリファレンスを書いたことがなかったので、そのうちやってみようと思っていました。

2023/1/7 に時間がとれたので、Data クラスのリファレンスを作ろうとしました。念のため「るりま」の PR 一覧を眺めてみると、なんと前日の 2023/1/6 に Data クラスを「るりま」に載せるための PR が kyanagi さんによって作られていました。そこで PR を書くのではなく、PR のレビューで貢献することとしました。

そのときの私の喜びのコメントです

欲しいなあと思っていたので PR があって嬉しく思いました✨ぜひマージされてほしい。 通りすがりですが、助けになればと思い一通りの変更をみました。コメントを残させてください。

https://github.com/rurema/doctree/pull/2777#pullrequestreview-1239634811

PR レビューの様子

結局、他の方のレビューも含めて、PR が作られたのは 2023/1/7 マージされたのは 2023/2/9 と Data クラスの記事作成には約一ヶ月かかったようです。私がレビューしたのは 2 回くらい、軽微なものを含めると数回といったところで、PR の出だしから完成度が高かったので、やりとりの回数はそれほど多くなく完成となりました。

「構造体」という言葉は Struct とは強く結びついていますが、Data とはそうでもなさそうに感じます。 別の言い回しだとさらに読者が理解しやすかったりしないでしょうか。

https://github.com/rurema/doctree/pull/2777#discussion_r1063972590

API は Struct に似ているので、Struct のリファレンスを元にしてくださったのかなと想像しています。Struct には構造体という言葉が似合うのですが、私は Data のリファレンスには似合わないと思ったので、コメントで問い掛けていました。

この後に記載されているサンプルは全てエラーになっているようです。また私の手元にある Ruby3.2 で試しても同じ結果になりました

https://github.com/rurema/doctree/pull/2777#discussion_r1063974100

例示されているサンプルを実行してみても、エラーになるので、どういうことかよくわからず相談したコメントです。結果としてこのふるまいは正しく、狙いのある API なのでした。どういうことなのかは完成系の https://docs.ruby-lang.org/ja/3.2/class/Data.html#S_--5B--5D 「new に渡す引数の数がメンバの数より多い場合は new でエラーになります。new に渡す引数の数がメンバの数より少ない場合は new ではエラーにならず、そのまま initialize に渡されます。ユーザが initialize のオーバーライドを通して、少ない引数のときの適切な振舞いを実装可能とするためです。」のあたりをご覧ください。例も含めて kyanagi さんがたいへん伝わりやすくしてくれました。

https://docs.ruby-lang.org/en/3.2/Data.html#method-c-define だと、るりまの定義とは異なり(略)こちらは単に記法の違いによるものになるでしょうか?

https://docs.ruby-lang.org/en/3.2/Data.html#method-c-define にある、 引数を取らずに Data.define() と定義して、パターンマッチングに使う便利なやり方はるりまにも記載があると嬉しそうです。

https://github.com/rurema/doctree/pull/2777#discussion_r1063975629

先行して実装されていた ruby-doc の Data と照らしあわせながらコメントしていました。この質問の回答で知ったのですが kyanagi さんは私が参照していた ruby-doc のリファレンス訂正 PR https://github.com/ruby/ruby/pull/7038 も作られていました。素敵です。

細かいですが、Object#hash の定義をみると、hash が必ず満たさなければいけないのは「メンバの値を eql? で比較して同じなら、hash の値も同じでなければならない」ということで、「メンバの値を eql? で比較して異なるときに hash の値が異ならなければならない」ではなさそうに思います。

https://github.com/rurema/doctree/pull/2777#discussion_r1063978482

Object#hashObject#eql? は異なるメソッドですけれどいい感じに協調するように実装しないと望ましい動作が得られない性質を持っているので、リファレンスの表現も(書くなら)気をつかいますよね。これは Ruby 特有ではなくて Java などでもそういうものですよね。結局この表現は省くことになりました。実装詳細に踏み込まないのは穏当な判断かなと感じました。 参考までに Java の場合のドキュメントも貼っておきます

常に、このオブジェクトに対するequalsの比較で使用される情報が変更されていなければ、hashCodeメソッドは常に同じ整数を返す必要があります

https://docs.oracle.com/javase/jp/17/docs/api/java.base/java/lang/Object.html#hashCode()

「るりま」 PR レビューの感想

現状「るりま」に深くかかわっているわけではない第三者の私がふらっときてレビューさせてもらっても特に困ることはありませんでした。「るりま」をメンテされているみなさん、kyanagi さん、親切にしてくださってありがとうございます。

私はレビューするときに強く確信を持っている場合をのぞくと、だいたい「私には状況がaのように見えていて、その場合私ならxと書くところyと書かれていて違いを感じました。ここの意図を詳しく教えていただけますか?」といった問い掛け形式でレビューしていることを再発見しました。PR の書き手が見ている状況と読み手が認知している状況が異なることはよくあるので、認知している状況を自ら開示するのは、すれ違いによってやりとりがややこしくなるのを防ぐために重要ですね。

DIGGLE は RubyKaigi2023 スポンサーになりました

DIGGLE は今年開催される RubyKaigi2023 の Platinum スポンサー になりました。よろしくおねがいします! RubyKaigi 現地でブースも出展し、私も居ますので、遊びにいらしてください。 この記事のことや、Ractor や、よいレビュー、その他について雑談しましょう〜

*1:たぶん びー ふぁれんす にゅあるの略

DBとDWHの技術選定調査について

こんにちは。DIGGLEのエンジニアのchikugoです。

はじめに

弊社プロダクトのDIGGLEの成長に伴い、先々も見据えてパフォーマンス改善を行う必要性が生じました。そのパフォーマンス改善の一貫で、DBやDWHの技術選定を行っています。今回はDBやDWHの技術選定の際に行った、一次調査についてお話できればと思います。

パフォーマンス改善の要件としては特定処理で数倍程度の速度改善が必要で、参照だけでなく追加など更新系の速度改善も求められます。 既存DBがAurora PosgreSQLになりますので、それに比べての改善効果や改善・移行コストが主な選定基準となります。

DB選定

Azure Cosmos DB for PostgreSQL vs AlloyDB で比較を行います。

Azure Cosmos DB for PostgreSQL

citusdataをMicrosoftが買収後、Azure Cosmosとしてリリースされました。マルチテナント型のSaasに最適と謳われています。弊社DIGGLEもマルチテナント型のSaasなのでプロダクト形態としてはマッチします。

citusdataの利用実績としてはSmartHRさんの事例をご存知の方もいるかもしれません。
つらくないマルチテナンシーを求めて: 全て見せます! SmartHR データベース移行プロジェクトの裏側 / builderscon 2018 - Speaker Deck

citusdataの方ではクエリを20倍から300倍 (またはそれ以上) 高速化可能と謳われており、この性能アピールは魅力的です。

Azure Cosmos DB for PostgreSQL のアーキテクチャの概要としては以下のような概念図となります。

概念図

  • コーディネーターノード
    • 分散テーブルのメタデータを格納し、分散計画を担当します
  • ワーカーノード
    • 実際のデータが格納されます
    • 分散テーブルが複数のワーカーノードに分散されて格納されます
    • ノード数を指定してスケーリング可能です

こちらは分散テーブルという概念があり、性能担保する為には以下のようなSQLで分散テーブルに対して分散列を指定してやる必要があります。

SELECT create_distributed_table(
    'table_name',
    'distribution_column');

また性能劣化させない為に分散テーブルの参照には分散列の指定(Where句)が必要になります。

こういった特徴により、Azure Cosmos DB for PostgreSQLはベンダーロックイン要素が多少有り、移行コストはそれなりにあると思われます。

料金については以下で算出可能です。
価格 - Azure Cosmos DB | Microsoft Azure

1ノード辺り、8vCPUs、64 GiBメモリで ¥130,139/月 の料金となっています。
(2023/2/21現在)

AlloyDB

AlloyDBはGoogleが昨年(2022)リリースしたカラム型エンジンを備えたDBになります。トランザクションは4倍、分析クエリは最大100倍高速になると謳われており、こちらの性能アピールも魅力的です。

AlloyDB のアーキテクチャの概要としては以下のような概念図となります。

概念図

  • Cluster
    • ルートリソースになります
  • Primary instance
    • クラスター内のデータベースの読み取り/書き込み接続ポイントを提供します
  • Read pool instance
    • クラスター内のデータベースの読み取り接続ポイントを提供します
    • ノード数を指定してスケーリング可能です
    • 参照系性能が必要でなければRead pool instanceがなくても動作します

AlloyDBはPostgreSQLとの完全互換が謳われており、Aurora PostgreSQLからの移行コストは低そうです。

ただAlloyDBの売りの一つであるカラム型エンジンを有効にした場合は、以下のようにjson型などはサポートされなくなるようなので注意が必要です。
About the AlloyDB columnar engine  |  AlloyDB for PostgreSQL  |  Google Cloud

カラム型エンジンについては以下で詳しく紹介されていますので、興味がある方は参照してみてください。
AlloyDB for PostgreSQL の仕組み: カラム型エンジン | Google Cloud 公式ブログ

料金についてはvCPUsやメモリによって決まっています。
料金  |  AlloyDB for PostgreSQL  |  Google Cloud

月単位で1vCPUが$61.7434、メモリ1GBが$10.4682 の料金になっています。
(2023/2/21現在)

DWH選定

Redshift vs SnowFlake vs BigQuery で比較を行います。

DWHのそれぞれの性能面の比較については今回は一旦おいています。その理由としては以下の記事で紹介されているようにそれぞれのベンダー間でベンチマーク戦争が行われているのと、利用のユースケースによって性能が変わってくると思われるためです。
Redshift vs. BigQuery - 選択ガイド | Integrate.io

DB選定にも言えることですが、弊社プロダクトのユースケースに沿った性能検証は別途行います。

またDWHのコスト比較についてもSnowFlakeやBigQueryは従量課金で算出しずらく、ある所ではRedshiftが安いと言われていたり、ある所ではBigQueryが安いと言われていたりするので、こちらも今回は比較対象外にしています。

Redshift

Redshiftは以下の特徴があります。

  • フェデレーテッドクエリを使用するとRedshiftからAurora PostreSQLのデータが参照可能
  • Aurora PostreSQL側からもリンクテーブルでRedshitを参照可能
  • 月額制の通常プランと従量課金のサーバーレスプランがある
  • PostgreSQL8.xに基づいて作られている

他のDWHにいえることだと思いますが、更新系はそこまで早くないのとデータ量が少ないと検索速度の優位性は小さくなります。逆にデータ量が多くなれば爆速が期待できます。

また同時実行についても同時実行増加オプションはありますが、デフォルトの同時実行推奨数が15とそちら方面も強くありません。

尚、Redshift Serverlessについては同時実行スケーリングがデフォルトで含まれています。

SnowFlake

SnowFlakeの特徴は以下になります。

  • PostgreSQLとの互換性はない
  • 従量課金制
  • WebUIは洗練されている
  • データはAmazon S3に保存
  • リンクテーブルは使えない

SnowFlakeはAurora PostgreSQLでのリンクテーブルが使えないのと、PostgreSQLとの互換性はないことから移行コストは高そうです。

また、弊社ではRubyがメイン言語で使われているのですが、SnowflakeではRuby用のネイティブコネクタが用意されていないのでそちらはマイナス要因となります。
Snowflake Community

BigQuery

BigQueryの特徴は以下になります。

  • PostgreSQLとの互換性はない
  • 従量課金制
  • GCP関連のDBサービスならほぼリアルタイムにレプリケーションできると謳われている
  • 内部的なサーバーリソースの振り分けはBigQueryが自動でやってくれ、DWHの中ではよりマネージドなサービスに分類される
  • リンクテーブルは使えない

BigQueryもAurora PostgreSQLからの移行コストは高そうですが、AlloyDBを選定した場合にはほぼリアルタイムにレプリケーションできると謳われているのは魅力的です。

おわりに

今回はDBとDWHの技術選定調査についてお話ししました。

今回の調査の結果、弊社開発チームとしてはAlloyDBとRedshift Serverlessに対して、弊社プロダクトのユースケースに沿った性能検証を行うこととなりました。

理由としてはAlloyDBは移行コストが低そうで性能改善が見込まれること。Redshift Serverlessも性能改善が見込まれることと、フェデレーテッドクエリなどが利用できる所から移行コストが比較的低そうだというのが主な理由となります。

弊社プロダクトのユースケースに沿った性能検証については現在行っているところですので、また機会があれば記事にしたいと思います。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

プロダクトマネジメント、時代の変化

DIGGLEのベルマです。DIGGLEではプロダクトマネジメントチームとして6月に組織化しました。立ち上げにあたってどのようにプロダクトマネジメントしていくべきかを考えるため、過去の文献や資料をリサーチしました。
今回のブログでは、リサーチを自分の考察も含めてまとめたものを下記にアウトプットすることにしました。

 

DISCLAIMER: 本記事は元が英語であるため、一部ニュアンスが正しく翻訳できていない場合があります

デジタル化の始まり、SaaSが台頭する前の時代

この頃の特徴
  • エンジニアのリソースが少なく、地理的にアクセスしにくい

  • コンピュータサイエンスに特化した教育・研究を行っている国が少ない

                             areppim AG, 2008, https://stats.areppim.com/stats/stats_unitopnbrxengx09.htm

  • 初期開発コストが高い(特に、サーバーの調達コストが高い)

                            2009年10月14日.https://www.rbbtoday.com/article/2009/10/14/62995.html 

  • 大規模な手動テストの実施やバージョン管理システムの未発達のため、開発の所要期間が長い

  • サーバーは物理的で、クラウドは一部の国でしか利用できず、技術スタックも柔軟性に欠ける

  • 製品はパッケージ商品であり、すべての機能が一緒に開発され、ユーザーからのフィードバックは最後にあり、カスタマーサポートが製品の成功の中心でない

  • マーケットはグローバルが対象であり、参入企業は少ない

 

            Scott Brinker, https://chiefmartec.com/2020/04/marketing-technology-landscape-2020-martech-5000/ 

 

この時代のプロダクトマネジメント

 

 

 

以前は2種類のコミュニケーションが行われていました。

マーケット・コミュニケーション

  1. 競合分析、社内SWOT
  2. 市場の展望、それに基づく
    1. インパクトマップ
    2. ステークホルダーマップ
  3. ユーザーのニーズ
    1. ユーザー要件
    2. ユーザージャーニーマップ
  4. 営業コミュニケーションをリードするためのプレイブックを作成する
    1. 営業パイプラインをベースに、お客様にサービスを提供するための契約書を作成する
    2. 営業はCRM、オンライン広告、パイプライン管理ツールを使用する
  5. マーケターは以下の情報を収集する
    1. 上記の情報をもとに、お客様へのセールスピッチを行い
    2. カスタマーサポートは、マーケティングや製品コミュニケーションに問題がある場合、以下の方法で修正します
  6. お客様のオンボーディング
    1. 機能のリリースをサポートする
    2. 既存顧客からのバグ、問題、要求の収集
  7. このコミュニケーションに関わるすべての人はプロフィットセンターである
  8. このコミュニケーションに参加する全員が、お客様(マーケット)と向き合います

プロダクトコミュニケーション

  1. CTOが率いるプロダクトチーム
    1. エンジニアが多いチーム構成
    2. 要件は技術的なもの
    3. CTOとシニアエンジニアは、営業やマーケティングから要件を聞き、迅速かつ効率的に生産することを目的としています
    4. 製品管理ツールはない
  2. DEVチームはCTOが率いる
    1. チームは製品ごとにチームまたは部門に分かれています
    2. プロダクトをまたいで人が動くが、タイムラグが長い
    3. DEVの要件は、シニアエンジニアからチームに伝えられ、実行される
    4. DEVは課題管理ツール、Github、Wikiを使って日々制作している
  3. QAチームはシニアエンジニアが中心
    1. CTOが設定したリリースの基準に合致しているかどうかを確認する
    2. 常にスコープクリープに対抗する
    3. チェック不足でリリースの妨げになったり、品質を低下させたりしている
    4. エクセルシートで行われる
  4. このコミュニケーションに参加する全員がコストセンターである
  5. このコミュニケーションに参加する全員が、顧客と向き合えていないことが多い

デジタル時代、SaaSの時代

特徴
  • エンジニアリングリソースが豊富で、地理的な問題が業務に影響しない

   


                              

                                                       ARWU (Academic Ranking of World Universities), 08/30/2019

  • 開発の初期費用が安い、サーバー、採用、マーケティングがすべてクラウド上にある

  • CI/CDによる自動テストやデプロイの自動化、バージョン管理システムの進化により、現在では開発プロセスは高度にコラボレーションできるようになりました

  • 開発プラットフォームはすべてデジタルで、プラグアンドプレイスタイル。多くの競合他社からオンラインのクラウドベースのサーバーを複数選択でき、技術スタックは市場の需要に追従する

  • プロダクトスタイルはアプリ重視で、各アプリはエンドユーザーの1つの「ジョブ」を解決することに焦点を当てる。カスタマージャーニーを通して常にお客様からのフィードバックを求める。カスタマーサクセスは、プロダクトを通じたカスタマージャーニーの実現を目指して行動する

  • 競争は広範囲に及び、常に進化しローカルである。アプリは簡単に複製でき、ニッチ市場を獲得するために展開できる

 

画像元

この時代のプロダクトマネジメント

                   

 

  1. 社内全員が同じコミュニケーションチャネルで、同じ方向に向かって話している

  2. PdMは製品チームの一員ではありませんが、すべての部門のフィードバックと市場洞察を促進するために

    1. アイデア

    2. ストーリーからサブタスクに

    3. エピックは、ストーリーを組み合わせて大きな画にしたもの

    4. エピックを各機能に落とし込む

  3. PdMは、そのタスクを管理するための独自のツールを持っている

    1. PdMはマーケティング-セールス-CSのマーケティング・サイクルと連動しています

    2. PdMはDEV-QAと連携し、生産サイクルを回す

  4. どのメンバーも収益の創出を担う

    1. DEVとQAは、ユーザーデータとユーザー行動からアイデアを生み出し、ユーザーエクスペリエンスに付加価値を与えることもできます。

    2. DEVは常にシステムパフォーマンスを管理し、それが顧客維持、ひいてはMRR/ARRに影響を与える。

  5. どのメンバーも様々な情報を元にマーケットと対峙する

    1. DEVとQAは、マーケット側からの情報をすべて把握し、プロダクトの議論に積極的に参加します

* 実際の部門役割の定義は、各社で異なります。例:呼び方や分解されることもある。PdM/PMM、商品企画、事業開発など。

 

過去と現在の対比



                          これまで

                       現在

email

チャットツール

paid feedback

instant free feedback

高価なマーケティング

低価格なマーケティング

競合比較は難しい

競合比較サイトが無料でみられる

ストーリー 引き継ぎ時間が長く、面倒くさい

idea >> story >> epic >> feature

フィードバックは1つの大きなカテゴリー

既存顧客の声と潜在顧客の声を分離

全てのアプリケーションを内製化

3rd partyによる積極的な自動化



その結果、このような変化が生まれました。

 

  1. より迅速で効果的なコミュニケーション*を実現します。

  2. マーケットの変化に機敏に対応するプロダクトマネジメント

    1. CS/Salesからのインプットが重要に

      1. 既存顧客とのベータテスト

    1. マーケットベンチマーク

    2. マーケットに合わせた機能

  3. 最小限のアプリをプロダクトスイートとして開発することができる

    1. 市場シェアと普及率の向上

    1. サブスクリプションモデルに移行できる価格が目立つ

 

* コミュニケーションに深みがない、つまり返信は早いけれども、積極的なコミュニケーションに欠けることもあります。

 

この記事で使用している用語について
  • SaaS - サービスとしてのソフトウェア
  • テスト/QA - 顧客にリリースする前にソフトウェアをテストすること
  • リポジトリ - 一連のソースコードと、それらのファイルに対する変更の履歴
  • Git - リポジトリの分散バージョンコントロールのためのソフトウェア
  • バージョン管理システム - ソフトウェアチームがソースコードの変更を長期的に管理するためのソフトウェアツール。
  • デプロイ - ソフトウェアシステムを一般顧客が使用できるようにするためのすべての活動。
  • 技術スタック - ソフトウェアを構築し実行するために使用する技術の組み合わせ。
  • ユーザージャーニーマップ - ソフトウェアを利用するユーザの流れを視覚的に表現した図
  • CTO - 最高技術責任者
  • 課題管理ツール - チケットトラッキングとアジャイルプロジェクトマネジメントを可能にする課題追跡製品
  • スコープクリープ - プロジェクト開始後に、プロジェクトのスコープが変更されることを指します。
  • コストセンター - 利益を直接的に増やさないが、それでも組織のコストがかかる組織内の部門や機能
  • ジョブ - 人々がなぜそのような行動をとるのかを理解するための、ジョブ理論というフレームワークにおける用語
  • ハイパーローカル - 明確に定義されたコミュニティとその住民に向けられた主要な焦点
  • Ideas-Story-Epic-Feature - エンドユーザーの視点で書かれた、ソフトウェアシステムの機能に関する非形式的な自然言語による記述
  • PdM - プロダクトマネージメントまたはプロダクトマネージャー
  • MRR/ARR - 月間定期収入/年間定期収入

 

DIGGLEの考える開発効率の上げ方

こんにちは。DIGGLEエンジニアリングマネージャーのzakkyです。 先月は埼玉県民の日(11/14)で学校が休みだったので、有給を取って家族写真を撮りに行ってきました。娘も息子も可愛かったです。

ということで、開発生産性 Advent Calendar 2022の23日目の記事となります。

前回のブログ投稿から約半年立ちましたが、その間に役職が変わりまして、エンジニアリングマネージャーになりました。 今回は、マネージャーになって最初の記事という事で、技術的な話からは少し離れて、DIGGLEの開発効率向上施策について書きたいと思います。

そもそも開発効率ってどうやったら向上するんだろう

開発効率向上と一言で言っても色々な視点や施策があるかと思います。

ざっと思いつくだけでも以下があるかと思います。

  • CI/CDの導入・高速化
  • IaC化
  • よく使うコマンドをaliasとして展開
  • 自動生成系のツールの利用(無ければ作成)

上記はDIGGLE内で実施している一例なのですが、今回は先ほどお伝えした通り、上記のような技術的な話からは少し離れ、開発チームとして行っている開発効率向上施策にフォーカスして書いていきたいと思います。

差し込み作業の発生頻度を減らす

皆さんも経験があるかと思いますが、今の作業に差し込みで新しい作業が発生するとガクッと作業効率が落ちることがあると思います。 他の業種に比べてエンジニアは特にここの部分が顕著で、いかに作業に集中してもらえるかが肝になると考えています。

弊社では、そんなエンジニアの効率を下げる差し込み対応を、スクラムマスターに集約することで、他のメンバーが作業に集中できる状況を作るようにしています。

スクラムマスターには負荷になってしまいますが、トータルとしてチームの生産性は向上することとなります。 また、余談となりますが、スクラムマスターを輪番にすることで、各メンバーの自律化を促す施策も行っています。 こちらについて、詳しくは下記の記事で語っておりますので参照ください。

www.wantedly.com

自律したフルスタックエンジニアで構成する

弊社エンジニアはバックエンドやフロントエンドの垣根なく全員フルスタックに開発を行っています。 また、全員が仕様の確認&調整、CSなど他チームとの連携なども含めて自律して行うことができるので、開発要件1件につきエンジニア1人を割り振れば事足りることが多いです。(開発工数次第で複数人で開発することもあります)

これにより、作業工程間での連携で引継ぎ工数が掛かってしまったり、バックエンド(またはフロントエンド)側のタスクだけが積みあがって滞留してしまうといった事を防げます。

また、自律したエンジニアで構成されたチームとすることで、一般的にメンバーが増えれば増えるほど増加していくマネジメントコストの増加を抑えることができます。 そしてマネージャーはマネジメントに追われることなく、開発へリソースを回すことができるようになります。

実際、今回マネージャーとなりましたが、マネジメントに関する工数はほぼ掛かっておりません。優秀なエンジニアに囲まれて嬉しい限りです。

チューター制度を採用することにより素早い立ち上がりをサポートする

せっかく新しいエンジニアの方に入社いただいても、実際に開発できるようになるまでに時間が掛かってしまっては意味がありません。 ということで、弊社では新規参画エンジニアの方にはチューターを付けて、立ち上げをサポートする仕組みがあります。

チューターには、業務内容の説明や、各作業方法の伝達など細かなサポートをお願いしています。 どのようにサポートするかについては、それぞれのエンジニアの裁量にお任せしているので、チューターとなるエンジニアごとに多少の違いはありますが、だいたい1カ月ほどで上記の自律したフルスタックエンジニアになれるようにサポートをお願いしています。

もちろん個人差もありますし、1カ月で「はいおしまい」ということではなく、チーム全体として新規参画エンジニアを受け入れる土壌を作る。ということに重きを置いています。

開発効率向上施策が打てる土壌を持つ

日々の開発の中で、「開発者として実施したいけど、目先の作業に押し流されてできないこと」があると、それが積み重なって負債となり、開発効率を低下させる要因となります。

例えば、以下のようなものがあるかと思います。

  • ライブラリやミドルウェアを最新バージョンに上げたい
  • CIが遅いから高速化したい
  • リファクタリングがしたい
    • unusedなコードがあるから消したい
    • 冗長なロジックを見つけたから共通化したい など

弊社では、上記のような開発効率向上施策に全体工数の何%を使用して良いかを事前に経営層と相談し、工数を確保しておく仕組みを取っています。(およそ10~20%程度)

これによりプランニング時に、「次のSprintではRailsのバージョンを上げたい」であったり、「CIを高速化しないと作業効率が悪すぎるから今やってしまおう」といった事が行いやすくなっています。

※無論、予定していた機能リリースが遅延したりしては元も子もないので、杓子定規で測るのではなく、都度都度柔軟に対応しています。

メンテナンスする資料をむやみに増やさない

README、新規参画者向けのキャッチアップ資料、各種議事録など、ドキュメント化は率先して行っています。

ただ、エンジニアの本分はコードを書くことですので、資料を作成する際には必ず以下を確認するようにしています。

  • なぜ必要なのか
  • 用途は何なのか
  • 似たような資料、もしくは代替しうる資料は他に存在しないか
  • メンテナンスし続けることができるか(※議事録や調査資料など揮発性の高い資料は除く)

特に「メンテナンスし続けることができるか」が重要だと思っています。 作ってはみたけど、メンテナンスせずに放置してしまうと、新規参画者へ間違った情報を与えてしまったり、逆に悪影響を及ぼすことすらありますし、メンテナンスするトリガーが明確か、工数を確保できるかも考えていくと、そこまで重要ではない資料は作成しない方向へ進むことが多いです。

そして、作ることに決めた資料は、チームとしてメンテナンスする意識を持ち、メンテナンスコストを認識・許容するようにしています。

環境面(PCや開発環境)でストレスを与えない

ディスプレイが小さかったり、PCのスペックが低かったりすると開発効率は当然落ちます。 また、開発環境(OSやIDEなど)を制限されることで慣れない環境を余儀なくされるケースもあります。 私も前職や前々職では貸与PCのスペックが低かったり、ディスプレイが貸与されなかったりと不便を感じたこともありました。

弊社では、ハイスペックPCの貸与はもちろん、OSやIDEの縛りもありませんので自分の好きなように開発環境を作ってパフォーマンスを出して貰うことが可能です。 実際、私はwindows + WSLで開発していますし、MacやUbuntuを使って開発しているメンバーもいます。

さいごに

上記のような開発効率向上施策は、その場その場で我々が考えて作り上げたものになります。そのため、「これが最適解である」ということではなく、あくまで現時点(2022年末時点)での形となります。 そして、今後も色々と改善が行われて変わっていくものだと思います。

また良いものが見つかったらご紹介させていただきますので続報お待ちください。

We're hiring!

DIGGLEでは、現在の仕組みを改善してより良いものを一緒に作っていけるメンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

Ruby on Rails で Google API を利用するための承認フローを実装する

こんにちは!DIGGLE エンジニアの miyakawa です。
この記事は Ruby on Rails Advent Calendar 2022 の22日目の記事です。

qiita.com

はじめに

弊社のプロダクト DIGGLE には、Google ドライブ や Google スプレッドシートからデータをインポートする機能があり、この機能を実現するために Google Drive API を利用しています。

Google Drive API 等の Google API を利用して各種 Google サービス上のユーザーのデータを取得・操作する場合、OAuthによってアクセストークンを発行・利用する必要があります。

↓の画像のやつです

今回は Ruby on Rails のアプリにこちらの OAuth 承認フローを組み込む方法についてお話しします。

※この記事では触れないこと

  • OAuth について
  • Google API 個別の利用方法
  • Google OAuth の検証・公開の手順

ステップ1: OAuth 同意画面の設定

まず Google Cloud の Web コンソールから OAuth 同意画面の設定を行います。
OAuth 同意画面とはユーザーが OAuth の承認をする際に表示される画面のことを指します。

下記のURLからアプリ情報等を入力していきます。

https://console.cloud.google.com/apis/credentials/consent

ひとまずは開発環境向けなので適当に項目を埋めていって問題ありません。
(本番環境向けに公開する際には正しく情報を入れる必要があります。利用する OAuth スコープによっては Google によるアプリの検証が必要になります。)

ステップ2: OAuth クライアント ID の作成

OAuth 同意画面の設定が終わったら下記 URL から OAuth クライアント ID を作成します。

https://console.cloud.google.com/apis/credentials

アプリケーションの種類は「ウェブ アプリケーション」を選択し、承認済みのリダイレクト URI に次のステップで作成する OAuth 用 Controller の callback アクションの URI を入力します。

OAuth クライアント ID を作成するとクライアント ID とクライアントシークレットが発行されるので控えておきます。

ステップ3: Rails アプリに OAuth 承認機能を実装する

※ 既に Rails アプリの土台があることを前提にしています。

gem のインストール

今回は Google 公式の OAuth クライアントライブラリである googleauth gem を使用します。

github.com

余談

googleauth gem では signet という汎用的な OAuth 2.0 クライアントライブラリを利用しています。
もし Google 以外のサービス向けに OAuth のクライアント実装が必要な場合はこちらの利用を検討してみてください。

OAuth アクセストークンの Model 作成

今回の実装では、取得した OAuth アクセストークン(アクセストークン、リフレッシュトークン)を RDB(PostgreSQL)に保存します。

マイグレーションと Model は下記のようになります。

class CreateGoogleOauthTokens < ActiveRecord::Migration[7.0]
  def change
    create_table :google_oauth_tokens do |t|
      t.references :user, null: false, foreign_key: true
      t.string :access_token, null: false
      t.string :refresh_token, null: false
      t.timestamps
    end
  end
end
class GoogleOauthToken < ApplicationRecord
  belongs_to :user

  validates :access_token, presence: true
  validates :refresh_token, presence: true
end

※ 上記コードは説明用のため省略していますが、実際は access_tokenrefresh_token は暗号化して保存するように実装するべきです。

Token Store の作成

Token Store とは googleauth gem における OAuth アクセストークンの保存場所です。先の通り、今回は RDB です。

googleauth gem では以下二つの Token Store が実装されていますが、RDB 向けの実装は用意されていません。

  • Google::Auth::Stores::FileTokenStore
  • Google::Auth::Stores::RedisTokenStore

そのため、自アプリの RDB 向けの Token Store を実装します。

require 'googleauth/token_store'

class DBTokenStore < Google::Auth::TokenStore
  def load id
    token = GoogleOauthToken.find_by(user_id: id)
    return nil if token.nil?
    JSON.dump({
      access_token: token.access_token,
      refresh_token: token.refresh_token
    })
  end

  def store id, token
    token_hash = JSON.parse(token)
    token = GoogleOauthToken.find_or_initialize_by(user_id: id)
    token.update!(
      access_token: token_hash['access_token'],
      refresh_token: token_hash['refresh_token'],
    )
  end

  def delete id
    token = GoogleOauthToken.find_by(user_id: id)
    token&.destroy!
  end
end

内容はシンプルで、loadstoredelete の各メソッドに DB への保存、更新、削除の処理を定義したものとなっています。

OAuth 用 Controller の作成

最後に Controller の作成です。

class GoogleOauthController < ApplicationController
  before_action :set_authorizer

  def authorize
    credentials = authorizer.get_credentials(current_user.id)
    if credentials.nil?
      redirect_to authorizer.get_authorization_url(request: request)
    else
      redirect_back fallback_location: root_path
    end
  end

  def callback
    cred, = authorizer.handle_auth_callback(current_user.id, request)
    redirect_to root_path
  rescue Signet::AuthorizationError
    render :authorization_error
  end

  private

  def set_authorizer
    # クライアント ID とクライアントシークレットを環境変数で渡しておく
    client_id = Google::Auth::ClientId.new(ENV.fetch('GOOGLE_OAUTH_CLIENT_ID'), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET'))
    # Google API の OAuth スコープのリスト。例として Google Drive の読み込み専用スコープを指定。
    scopes = ['https://www.googleapis.com/auth/drive.readonly']
    token_store = DBTokenStore.new
    # callback アクションの URL
    callback_url = 'http://localhost:3000/google_oauth/callback'

    @authorizer = Google::Auth::WebUserAuthorizer.new(client_id, scopes, token_store, callback_url)
  end
end

routes.rb の設定もお忘れなく。

...
resources :google_oauth, only: [] do
  collection do
    get :authorize
    get :callback
  end
end
...

※ callback アクションの URL が、ステップ2の 承認済みのリダイレクト URI に設定したものと異なる場合はリダイレクト時にエラーとなるため再度設定を確認しておいてください。

これで、authorize アクションのURLにアクセスすることで Google OAuth の認可プロセスを行うことが可能になります。

コードの説明

まず authorizer についてですが、これによってアクセストークンの取得・保存、認証先リダイレクトURLの作成などを行うことができます。
そして、今回利用している Google::Auth::WebUserAuthorizer は authorizer の Rack アプリケーション向けアダプタです。

@authorizer = Google::Auth::WebUserAuthorizer.new(client_id, scopes, token_store, callback_url)


authorize アクションは、Google OAuth の認証/認可の画面へリダイレクトする役割をもちます。

callback アクションは、ユーザーが Google OAuth の認可を終えた後のリダイレクト先であり、リクエストに付与された認証情報を Token Store に保存します。

(参考)Google API の OAuth スコープ

Google API のサービス個別に OAuth のスコープが用意されており、下記ページでスコープの一覧を確認できます。

OAuth 2.0 Scopes for Google APIs  |  Authorization  |  Google Developers

ステップ4: アクセストークンを利用して Google API を使う

アクセストークンを取得できたら、それを利用して Google API を使うことができます。

以下は Google 製の Google ドライブ用クライアントライブラリ google-apis-drive_v3 を使う例です。

require 'google/apis/drive_v3'

drive = Google::Apis::DriveV3::DriveService.new
# 先述の @authorizer を利用して認証情報をセット
drive.authorization = @authorizer.get_credentials(current_user.id)

# ドライブトップ上のファイルを取得
files = drive.list_files()
files.items.each do |file|
  puts file.title
end

おわりに

今回は Ruby on Rails のアプリに Google の OAuth 承認フローを実装する方法についてお話ししました。

他にも関連するトピックとして、Drive API の小話や Google OAuth 利用のためのアプリの検証の方法などあるので、また機会があればお話しできればと思います。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

React Testing Libraryのrerenderを薄くラップすることで使い勝手を向上させた話

こんにちは。DIGGLE エンジニアの伊藤です。
ソフトウェアテスト Advent Calendar 2022の 20 日目の記事です。 Advent Calendar 初参加になります。

普段はフロントエンドを中心に業務を行なっています。
今回は DIGGLE で実施されているフロントエンドの単体テスト、 特にコンポーネントのテストで使っているフレームワークの使い勝手を向上させたお話をさせていただきます。

はじめに

DIGGLE ではフロントエンドで React を使っており、 テストフレームワークとして下記を利用しています。

Jest でモデルなどの React を介さない部分のテストを行い、React Testing Library を使って React コンポーネントのスナップショットテストを実施しています。React コンポーネントのテストで React Testing Library を使うか Enzyme を使うかといった違いはあるかもしれませんが、一般的によくある構成だと思います。

上記2つのフレームワークを用いることで DIGGLE でテストしたい項目を概ね満たすことができたのですが、 state を外部からコンポーネントに渡すテストを書こうとした際に上手く書けない問題がありました。

stateを外部からコンポーネントへ渡せない問題

例えば、React Testing Library を使って下記の様な Dropdown のコンポーネントをテストした際、

import { render } from '@testing-library/react';

---

it('Dropdownをクリックした時のテスト', async () => {
  const [value, setValue] = React.useState();
  render(<TestDropdown value={value} onClick={(e, v) => setValue(v)} option={...}/>);

// onClickした時のvalueの変化をテストしたい
});

としてuserEventでクリックイベントを発火させても value に値が反映されませんでした。
React Testing Library での関数は React の関数ではないので、React のフックを使おうとすると怒られるということで、 考えてみれば当然の話でした。
(Storybook では useState を使えたため、React Testing Library でも使えるのでは?と勘違いしたことが原因ですが、 おかげで Storybook と React Testing Library のスタンスの違いを感じることができました。)

上記の問題解消のためには、Jest + React Testing Library 構成の中で state を外部から props で渡すコンポーネントに関して「useState をモックしたい」という話になるのですが、

結論から先に書いてしまうと React Testing Library の rerender を活用することで問題を解決しました。

解決にあたって

解決にあたって候補になった方法は下記の2点でした

  1. rerender*1 を使って値を更新して擬似的に useState を表現する
  2. @testing-library/react-hooks というライブラリを使う

どちらの方法も甲乙つけがたいのですが、最終的には「1. rerender を使って値を更新して擬似的に useState を表現する」を採用することにしました。

「2. @testing-library/react-hooks というライブラリを使う」を採用しなかった理由は、

  • @testing-library/react-hooks はカスタムフックのテストに対する解決方法という色が強そう
    • DIGGLE ではカスタムフックの利用が活発でなく、導入の旨味が薄い
      • 展開のコストも考えると、急いで入れる必要はなさそう
  • React v18.0 対応を見てみると、 @testing-library/react-hooks の ver がサッと上がらない様子だった
    • 急いで入れて旨味を感じられず、管理の手間が増えるということになりかねない
  • 「1. rerender を使って値を更新して擬似的に useState を表現する」を実施する場合は薄くラップする必要があるとわかっていたため、「2. @testing-library/react-hooks というライブラリを使う」は必要になったタイミングでラッパーの中で切り替えれば良い

と考えたためです。

ただ、「1. rerender を使って値を更新して擬似的に useState を表現する」の方法にも問題はあり、 なにもせずに利用しようとするとほぼ同じ記述を 2 回することになってしまいます。

「rerender を使って値を更新して擬似的に useState を表現する」の問題点

愚直にテストを記述すると下記の形になります。( it 関数の中身だけを記載しています)

let rerender = _.noop;

const handleClick = jest.fn((value) => {
  rerender(<TestDropdown value={value} onClick={(e, v) => handleClick(v)} />);
});

const { rerender as TLRerender } = render(
  <TestDropdown value={value} onClick={(e, v) => handleClick(v)} />
);

rerender = TLRerender;

// onClickした時のvalueの変化をテスト

rerender を活用することにより、onClick が発火した際にvを与えながら再描画をhandleClick経由で行える様になります。

「useState をモックしたい」は上記で満たすことができたものの、TestDropdown をほぼ同じ形で2回書かないといけないことがわかると思います。

今回の様なシンプルな props ならまだ耐えられますが、 大量に props を渡す必要のあるコンポーネントに対するテストや onClick、onBlur など複数イベントに rerender の関数をセットしたい際に記述が煩雑になりそうなことがわかります。

問題点の解消 - rerender を薄くラップする

タイトルの通り、rerender を薄くラップすることで同じ記述を 2 回しなければならない問題を解決しました。

用意したラッパーは下記になります。

import { render as TLRender } from '@testing-library/react';
import _ from 'lodash';

export default class RerenderHelper {
  #rerender = _.noop;

  #render = _.noop;

  #firstRenderValue = null;

  constructor(render = _.noop, firstRenderValue = null) {
    this.#render = render; // render関数
    this.#firstRenderValue = firstRenderValue; // 初期描画時に利用されるvalueの値
  }

  // 再描画
  rerenderComponent = jest.fn((value) => {
    const r = this.#render(value, (v = value) => this.rerenderComponent(v));
    this.#rerender(r);
  });

  // 初期描画
  firstRender() {
    const { rerender } = TLRender(
      this.#render(this.#firstRenderValue, (v = this.#firstRenderValue) =>
        this.rerenderComponent(v)
      )
    );
    this.#rerender = rerender;
  }
}

やっていることはシンプルで、 同じ記述をしている部分を #render として持たせ、初期描画と再描画処理でそれぞれ呼び出しています。

また、#render を (value, setValue) => {} という形の関数で渡せる様にすることで、 使い勝手を useState に寄せています。

使い方は下記になります。

import Rerender from 'helpers/rerender';

---

it('Dropdownをクリックした時のテスト', async () => {
  const r = new Rerender((value, setValue) => {
    return (
      <TestDropdown value={value} onChange={(v) => setValue(v)} option={...}/>
    );
  });
  r.firstRender();

  // onClickした時のvalueの変化をテスト
});

上記ラッパーでできることは限られているものの、カスタムフックを使っていない環境であれば問題ないと思います。

まとめ

今回は DIGGLE のフロントエンドテストの環境を、フレームワークの一機能をラップすることで向上させた事例について紹介しました。

フロントエンドに限らずテストではさまざまなモックがされるかと思います。
モックする箇所や基準は様々だと思いますが、必要な部分に絞って慎重にモックを適用していく一例として今回の事例が参考になれば幸いです。

モックの濫用を防ぐ意味でも、カスタムフックの活用が活発になるまでのつなぎとして役割を果たしてくれると思います。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

*1:rerender自体の説明は公式にあるためここでは割愛させていただきます https://testing-library.com/docs/react-testing-library/api/#rerender

RailsのJOIN方法の違いでソートしたときに意図した結果を取得できてなかった話

こんにちは。DIGGLEのエンジニアのhondaです。
Ruby on Rails Advent Calendar 2022の12日目の記事です。
Advent Calendar初参加です。

はじめに

Railsで開発している方にはN+1問題というのはおなじみだと思います。(説明は割愛します)
そのためincludesやeager_load, joinsなどのN+1問題を起こさないためのメソッドに関する理解は必須だと思います。
自分もそれぞれの意味を把握して使い分ける程度にはわかっているつもりだったのですが、その理解が甘かったのでソートした時に意図した結果を取得できていませんでした。 今回はそのときの失敗談と解決した方法をお話します。

状況設定

今回は説明のために簡略化した↓のテーブル定義を使います。 データベースはpostgresqlを使用しています。

ER

要件はitemsをユーザーにリスト形式で表示します。
このときユーザーは任意のitem_tag_fieldsを指定してitem_tagsのvalueでソートした結果を取得できます。
なお、item_tagsのtag_field_idとitem_idに対してunique_indexを貼っています。 ユーザーに見せるインターフェースは以下のようなイメージになります。

item list

ID name tag_field_A(ソート可能) tag_field_B(ソート可能)
1 item_name_A value_A_1 value_B_3
2 item_name_B value_A_2 value_B_2
3 item_name_C value_A_3 value_B_1

以下では↑のようなレコードがDBに保存されていることを想定します。

どのような失敗をしたのか

例えばtag_field_id = 1でソートしたいとしたとき以下のような処理を書いたとします。 簡略化のためにtag_field_idのパラメータ化やsanitizeなどの処理は省略します。

items = Item.eager_load(item_tags: :item_tag_field).order(Arel.sql(<<~SQL))
  CASE item_tags.item_tag_field_id
  WHEN 1 THEN item_tags.value
  ELSE NULL
  END
SQL

> items.first.name # => item_name_A
> items.last.name # => item_name_C
> items.count # => 3

この結果だけ見ると上手くいっているように見えなくもないですが、実は問題をはらんでいます。
eager_loadにしているのはorder by句に指定しているitem_tagsテーブルとのjoinを確実に行うことを意図して書いていました。
変更前はincludesで書かれていたのもあってeager_loadにしておけば変なことにはならないだろうという漠然とした考えもありました。

# 変更前のコード
Item.includes(item_tags: :item_tag_field).order(:id)

一旦は問題がないと思ってしまった...

実はこのコードを書いた段階では自分はitems.countの部分は正しく動かないだろうと思っていました。
理由は発行されるSQLにありました。 実際にSQLを見てもらうとわかりやすいと思います。
items.to_sqlでSQLの文字列を出力して整形してカラム名をわかりやすく変更し、実行した結果が↓のものになります。

SELECT "items"."id" AS item_id,
       "items"."name" AS item_name,
       "item_tags"."id" AS item_tag_id,
       "item_tags"."item_tag_field_id" AS item_tag_field_id,
       "item_tags"."value" AS value,
       "item_tag_fields"."name" AS item_tag_field_name
FROM "items"
LEFT OUTER JOIN "item_tags" ON "item_tags"."item_id" = "items"."id"
LEFT OUTER JOIN "item_tag_fields" ON "item_tag_fields"."id" = "item_tags"."item_tag_field_id"
ORDER BY CASE item_tags.item_tag_field_id
             WHEN 1 THEN item_tags.value
             ELSE NULL
         END
;
 item_id |  item_name  | item_tag_id | item_tag_field_id |   value   | item_tag_field_name 
---------+-------------+-------------+-------------------+-----------+---------------------
       1 | item_name_A |           1 |                 1 | value_A_1 | tag_field_A
       2 | item_name_B |           3 |                 1 | value_A_2 | tag_field_A
       3 | item_name_C |           5 |                 1 | value_A_3 | tag_field_A
       2 | item_name_B |           4 |                 2 | value_B_2 | tag_field_B
       3 | item_name_C |           6 |                 2 | value_B_1 | tag_field_B
       1 | item_name_A |           2 |                 2 | value_B_3 | tag_field_B
(6 rows)

ご覧の通り6件の結果が出力されています。 items : item_tags は 1:Nの関係にあるのでjoinするときに取得件数に影響がでます。 なので、自分としてはitems.count == 6になるのではないかと思っていました。 しかし、実際には↓のような処理がおこなわれました。

# countした時に別のsqlが発行される
> items.count
SELECT COUNT(DISTINCT "items"."id") FROM "items" LEFT OUTER JOIN "item_tags" ON "item_tags"."item_id" = "items"."id" LEFT OUTER JOIN "item_tag_fields" ON "item_tag_fields"."id" = "item_tags"."item_tag_field_id"
=> 3

joinしたテーブルが1:N関係にあるときはCOUNT(DISTINCT items.id)しているので重複レコード分はカウントしないようになっています。
これを見て「ああ、Railsがよしなにやってくれるんだな」と思い手元でテストした感じも動いてそうだったのでこの書き方で問題ないと思っていました。

正しく値を取得できないときがある

しかし、このコードはページング処理を行うときに問題が発生します。
kaminariを使って2件づつ取得する処理をおこなうとします

# 1ページ目は問題なさそうに見える
page1 = items.page(1).per(2)
page1.pluck(:id) # => [1, 2]
page1.current_page # =>1
page1.total_pages # => 2
page1.total_count # => 3

# 2ページ目にレコードが2件ある!?
page2 = items.page(2).per(2)
page2.pluck(:id) # => [2, 3]
 
# 3ページ目!?
page3 = items.page(3).per(2)
page3.pluck(:id) # => [1, 3]

ご覧のとおりページネーション(ORDER BYとLIMT OFFSET)を使うと意図していないレコードが取れてしまいます。 自分でテストした段階では入れていたデータがよくなかったのか、この問題に気づけませんでした...

解決策

このバグは開発終盤の方に見つかったのもあってか、お恥ずかしい話、自分はいい修正方法が思いついていませんでした。
しかし、弊社のハイパーエンジニアことokazakiさんにこのことを相談したところ、 order byするときは必要なレコードに絞ってjoinすれば問題ないという至極真っ当なアドバイスをもらいました。(なぜ気づかなかったのか...)

また、関連テーブル先のデータはeager_loadで取得するのではなくpreloadで取得するようにしてORDER BYを含むSQLを発行する際に余計なテーブルとjoinしないようにしました。
例の如く簡略化のためにtag_field_idのパラメータ化やsanitizeなどの処理は省略します。

join_clause = <<~SQL
  LEFT OUTER JOIN item_tags ON items.id = item_tags.item_id
  AND item_tags.item_tag_field_id = 1
SQL

items = Item.joins(join_clause).preload(item_tags: :item_tag_field).order('item_tags.value')

items.to_sql 
=begin
SELECT "items".*
FROM "items"
LEFT OUTER JOIN item_tags ON items.id = item_tags.item_id
AND item_tags.item_tag_field_id = 1
ORDER BY item_tags.value
=end

# ページネーションも問題なし

page1 = items.page(1).per(2)
page1.pluck(:id) # => [1, 2]

page2 = items.page(2).per(2)
page2.pluck(:id) # => [3]

page3 = items.page(3).per(2)
page3.pluck(:id) # => []

これで上手くソートもページネーションもできました。よかったよかった。
普段ActiveRecord::Relationを使っていると意外と頑張ってやってくれるので大変重宝しているのですが、 1:N関係にあるレコードでページネーションするときは注意が必要というお話でした。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net