こちらはTimee Advent Calendar 2023の13日目の記事です。
タイミーでバックエンドエンジニアをしている @Juju_62q です。
記事内でワーカーさんや事業者さんに関して敬称を省略させていただきます。
タイミーは雇用者である事業者に求人を投稿してもらい、労働者であるワーカーが求人を選ぶという形でマッチングを実現しています。ワーカーが求人を選ぶためにはなんらかの形でワーカーが自分にあった求人を見つけられる必要があります。検索はワーカーが求人を見つけるために最もよく使われる経路です。今回はそんな検索機能において今後の開発をスムーズにするためのリファクタリングを実施した話を紹介します。
背景
タイミーの検索は以下の機能です。
ワーカーの状況や希望に合わせて求人を表示しています。求人はサーバーサイドで取得、絞り込みを行っています。この絞り込みはFilterというクラスが一手に担っていました。Filterクラスでは具体的に以下の処理を行なっています。
- 該当日の全ての求人を取得する
- ワーカーや事業者の設定した絞り込み条件やタイミーのルールに合わせて絞り込みを行う
前述した通り検索はマッチングが最も生まれている動線です。そのために機能追加の圧力も非常に大きく、さまざまな機能が追加されていきました。結果としてFilterクラスは見通しが悪くなっていきました。最大では16の絞り込み処理が1つのクラスで行われていました。
また、このFilterクラスですが利用箇所が検索だけであればいいものの、タイミーのルールに合わせた絞り込みを行なっていたために検索以外にもお気に入り求人やお仕事リクエストなどさまざまな機能に利用されています。
ただでさえ問題を起こした時に大きくなってしまう検索機能です。コードの見通しが悪く、影響範囲も不透明となると変更するのもどんどん億劫になってきます。大切な機能であるからこそチームとして改修をした方が今後も含めて開発が早くなるだろうと考えてリファクタリングに踏み切りました。
リファクタリングの切り口
見通しが悪いのは絞り込み処理を行なっているFilterクラスなので、Filterクラスに絞ってリファクタリングを検討します。Filterクラスがどんな処理を担っているのかをチーム内で話しました。
取得部分を一旦置いておくと、Filterクラスの絞り込みには大きく以下の2種類があるとわかりました。
- ワーカーが設定した検索条件による絞り込み(10処理)
- 良いマッチングを生むためにタイミーが実施している可視性の制御(7処理)
また、Filterクラスを使っている箇所を眺めてみても検索では1, 2の両方を利用しているものの、それ以外の箇所では2の「良いマッチングを生むためにタイミーが実施している可視性の制御」しか利用していませんでした。
これを踏まえて3つの要素を満たすようなリファクタリングをすることにしました。
- 「ワーカーが設定した検索条件による絞り込み」と「良いマッチングを生むためにタイミーが実施している可視性の制御」の2つに処理が分かれること
- 今後の開発で処理の要素を既存処理に影響を与えずに3つ以上にできること
- 利用するクラスが適用するロジックを選択できること
なぜこれらの要素を選択したかを解説します。まず ”「ワーカーが設定した検索条件による絞り込み」と「良いマッチングを生むためにタイミーが実施している可視性の制御」の2つに処理が分かれること”です。まず、ロジックを精査する前にFilterクラスの見通しが悪いということは単一責任の原則に反していると考えていました。実際に話し合ったところ2つの責務が見えてきたので責務が単一になるように分割することを考えます。
次にその他2つに関してはオープンクローズドの原則を考えたものです。タイミーのフィルタリングロジックはまだまだ発展途上で、今後どんどん拡張される可能性があります。その時に簡単に処理を追加・削除できる状況を作ることを考えると重要であると考えました。
実施したリファクタリング
結論から行くと、以下のようなbefore-afterとなっています。
Offeringは求人を表現しているモデルで、Userはワーカーです。
before
クラス図
Filterクラスの利用例
offerings = ::Offering::Filter .new(current_user, search_params) .result
after
クラス図
Filterクラスの利用例
offerings = ::Offering::Filter .new(conditions: [ ::Offering::Filter::SearchCondition.new(search_params), ::Offering::Filter::Viewable.new(current_user) ]) .result
よくみるとわかるのですが、ストラテジパターンを用いてリファクタリングをすることとなりました。SearchConditionクラスが「ワーカーが設定した検索条件による絞り込み」を担い、Viewableクラスが「良いマッチングを生むためにタイミーが実施している可視性の制御」を担っています。
この変更によって元々考えていた要素は以下のように達成されました。
- 「ワーカーが設定した検索条件による絞り込み」と「良いマッチングを生むためにタイミーが実施している可視性の制御」の2つに処理が分かれること → SearchConditionとViewableの2つに分かれた
- 今後の開発で処理の要素を既存処理に影響を与えずに3つ以上にできること → Conditionのインターフェースを持ったクラスは追加可能、Conditionを追加しても他のクラスに影響はない
- 利用するクラスが適用するロジックを選択できること → 引数でConditionを選択することで制御が可能
クラスや省略したコードだけ見るとかなり複雑に見えますが、まとまりごとで制御できるので全体としてはすっきりしたと思います。
結果と所感
今回のリファクタリングによって検索にかなり変更を加えやすくなりました。実際にリファクタリング後に検索を3度ほど触っていますがかなり機能の追加はしやすくなりました。早くロジックが大きくして、いろんなConditionが見つかると良いなと思っています。
ここまでの話で特に話していませんでしたが、このリファクタリングはスクラム開発の中でプロダクトゴールを最速で達成する一環として実施しています。品質が開発速度に好影響を与えた例としてとても感慨深かったことを覚えています。
ところで、Rubyでストラテジパターンを実現する時って皆さんどうしていますか?今回は以下のような抽象クラスのようなものを作成しました。ただ、今回の用途で継承を利用するのはToo muchであると思います。何かいい方法があれば教えていただけると嬉しいです。
# frozen_string_literal: true class Offering class Filter class Condition # 一定の検索条件で絞り込むために利用される。 # Offering::Filterクラスで利用するinterfaceとなる。 # # @param [Offering::ActiveRecord_Relation] offerings # @return [Offering::ActiveRecord_Relation] def apply(offerings) raise NotImplementedError, "You must implement #{self.class}##{__method__}" end end end end
終わりに
今回はタイミーの検索を司るFilterクラスをリファクタリングした話を紹介しました。重要な機能には仕様がどんどん追加されます。開発がしにくいと感じた際には責務や基本的な原則に立ち返ってリファクタリングしてみるのもよいかなと思います。今後もユーザーへの価値提供をしながら技術品質をどんどん高めていきたいと思います。