こんにちは、Speee エンジニアの中嶋(id:nyamadori)です。外装工事を希望するユーザと外装リフォーム会社のマッチングサービス「ヌリカエ」の開発を担当しています。今回は、ヌリカエ社内で運用している内製コールセンターシステム「TelTelBows」のリファクタリングの取り組みについてご紹介します。
Speee Advent Calendar 2018 の 4 日目の記事です。昨日は @hatappi さんの 福利厚生でSlackでアンケートをとることが出来るサービス『Anket』をつくりました でした。
背景
ヌリカエ
ヌリカエは、外装工事を希望するユーザを Web で集客し、マッチングした外装リフォーム会社を一括でユーザに紹介するサービスです。外装リフォーム会社紹介時には、社内スタッフが外装に関する症状などを電話でヒアリングします。
ヒアリングによってユーザの施工意欲を高めることができるため、外装リフォーム会社は集客工数をかけずに優良顧客を獲得でき、ユーザは複数社の相見積もりを通して安心を得ることができます。
外装リフォーム会社紹介後から工事成約に至るまでの間、社内スタッフがユーザフォローを行うことで、工事の状況を把握し、担当外装リフォーム会社への要望を吸い上げ、外装リフォーム会社に還元します。
内製コールセンターシステム「TelTelBows V2」
紹介時のヒアリング及び、外装リフォーム会社紹介後のユーザフォローは、内製コールセンターシステム「TelTelBows V2*1」によって実現しています。TelTelBows V2 は、問い合わせしたユーザを独自ルールで分類し、分類したリストごとに自動で電話を掛ける(架電する)ことができます。
以下は、TelTelBows V2 のユーザリスト画面です。左ペインが分類されたユーザのカテゴリです。カテゴリを切り替えると、カテゴリに含まれるユーザが右ペインのテーブルに表示されます。テーブル左上のフォームで、リストを更に絞り込むことができます。テーブル右上のボタンは、自動架電を開始するのに使います。[10件] ボタンを押すと、リストの上から 10 件を順番に架電できます。
TelTelBows V2 の利用技術
フロントエンドは React 製 SPAで、サーバサイドは Rails です。フロントからサーバに通信する API は JSON API Resources gem を用いて RESTful に記述しています。電話をかける仕組みは、Twilio によって実現しています。
課題
重要なビジネスルールが分かりづらい既存コード
業務アプリケーションは、いわばビジネスルールの集まりです。例えばヌリカエでは、不通だった案件(ユーザからの見積もり依頼を案件(Subject
)と呼ぶ)に再度架電する際は、前回の発信から一定時間経過していないと架電できないルールがあります。このルールは、ユーザ体験に関わる非常に重要なビジネスルールなので、TelTelBows のコードを見てビジネスルールがはっきり分かるように工夫する必要があります(もちろんテストコードは書きますが)。
しかし、TelTelBows の既存コードは、ビジネス上重要なクエリが無造作に置かれており、ビジネスルールが把握しづらい状態でした。
例えば、以下のようなクエリです。ignore_subject_ids=...
で、TelephoneCallHistory
モデルに記録された前回の発信から間もない案件 ID を取得し、それらの ID を .where.not(id: ignore_subject_ids)
で除外するようにしています。このような「ある条件を満たす子レコードを一つでも持つ親レコードは除く」類のクエリは、2つに分けて書けないといけないため、複雑化しがちです。
ignore_subject_ids = TelephoneCallHistory .where('called_at > ?', CALL_INTERVAL_TIME.ago) .distinct .pluck(:subject_id) Subject .includes(:conversions, :telephone_call_histories) .unintroduced .where.not(id: ignore_subject_ids)
上記の例だけを見れば、それほど問題はなさそうですが、このような複雑なクエリが、あらゆる場所に散らばることで、以下のような問題が生じます。
- 複雑なクエリ自体の問題
- 複雑なクエリの具体的な記述(
distinct
やpluck
など)に目が奪われ、抽象的なビジネスルールを掴むのが難しくなる - ビジネスルールの把握が困難になることで、実装漏れが起こりバグの温床になる
- 複雑なクエリの具体的な記述(
- あらゆる場所に散らばることの問題
- コードの重複によって、ある箇所のロジックの変更が他の箇所に反映されない
- ロジックの変更が他の箇所に反映されないことで、実装漏れが起こりバグの温床になる
Rails では、こうした問題の対処に Active Record スコープ(以下、スコープ)が有効です。スコープによって、複雑なクエリを抽象化できます。
TelTelBows のサーバサイドは、Rails で実装しているため、Active Record スコープ が利用できます。この記事では、上記の 1 番目の問題について扱い、複雑なクエリをスコープに切り出し、適切な命名をつける方法を、ヌリカエのドメインルールを通して紹介します。
取り組み
クエリをスコープに切り出す
まずは、重要なビジネスルールをわかりやすくするために、複雑で見通しの悪いクエリをモデルスコープに切り出します。以下は、ビジネス上重要なクエリが無造作に置かれたコード(再掲)です。
ignore_subject_ids = TelephoneCallHistory .where('called_at > ?', CALL_INTERVAL_TIME.ago) .distinct .pluck(:subject_id) Subject .includes(:conversions, :telephone_call_histories) .unintroduced .where.not(id: ignore_subject_ids)
上記コードの、ignore_subject_ids
の除外対象の案件 ID と .where.not(id: ignore_subject_ids)
がペアなので、以下のようにスコープに切り出します。
一定の呼び出し間隔まで待機した案件のみを取得したいので、waited_until_call_interval
という名前をつけてみました。
class Subject < ApplicationRecord scope :waited_until_call_interval, lambda { reject_ids = TelephoneCallHistory .where('called_at > ?', CALL_INTERVAL_TIME.ago) .pluck(:subject_id) where.not(id: reject_ids) } end
このスコープを使うように、もとのコードを書き換えます。
Subject .includes(:conversions, :telephone_call_histories) .unintroduced .waited_until_call_interval # 切り出したスコープを使う
コードがスッキリした上、このクエリがどのような性質の案件を取得するかが一目瞭然です。とても簡単ですね。 このようなスコープの切り出しを繰り返した結果、コードに含まれるビジネスルールが明快になりました。
後述するスコープの命名には気をつける必要はありますが、スコープに切り出し、それらをメソッドチェーンでつなげることで、どのような性質のレコードを取得するかが一目で分かるようになります。
議論
命名で大切なこと
切り出すクエリが決まったら、意図が伝わるような名前をつけます。
一番大切なのはスコープの意図が他の開発者に伝わることです。そのためには、コードを読む上で必要な情報を名前に込めることが大切です。
コードを読む上で必要な情報が名前に含まれていないと、以下のような問題が生じます*2。
- スコープの意図を勘違いして使った結果、意図しないレコードが得られてしまう
- 何度見ても名前がしっくり理解できないので、コードを読むたびスコープの定義を見に行く
このようなスコープに遭遇したら、スコープの命名を変えて Pull Request(PR)を出しましょう。Pull Request 上で開発メンバーと相談し、スコープのイメージを合わせていくことが大事だと思います*3。
スコープの命名に役立つ前置詞
前述の例で、呼び出し間隔まで待機した案件を取得するスコープに waited_until_call_interval
という名前をつけましたが、別の例を紹介します。
これは、案件が持つ紹介(Introduce
)*4 のうち、どれか一つでも課金除外申請中(applying_for_exemption
)*5なら、その案件を除くスコープです。課金除外申請中の紹介を含む案件と、そうでない案件では、ユーザとの応対内容を変える必要があるため、これも重要なビジネスルールです。
# Subject モデル内 scope :without_applying_for_exemption_for_any_introduces, lambda { where.not(id: Introduce.applying_for_exemption.pluck(:subject_id).uniq) }
このスコープの命名で便利なのが、without
for
until
などの前置詞です。名前に含まれる名詞(ドメイン言語)を修飾し、スコープの意図や得られるレコードを端的に表現できます。
この例では、for_any
と without
を使っており、for_any
で子レコードの数量(紹介のいずれか)を、without
で以降に記述された性質を満たすレコードがないことを表しています。
スコープの使い所
明確なルールはありませんが、意図がイメージできない複雑そうなクエリをスコープに切り出すのがいいと思います。前述の例のように複数に分けて書かざるを得ないクエリや、Redis など外部 DB に保存されたデータを検索キーに使うクエリは、意図が分かりづらくなりがちです。
まとめ
- 複雑なクエリをスコープに切り出し、適切な命名をつける方法を、ヌリカエのドメインルールを通して紹介しました
- 命名において一番大切なのは、スコープの意図が他の開発者に伝わることです。そのためには、コードを読む上で必要な情報を名前に込めることが大切です
without
for
until
などの前置詞は、スコープの命名において便利で、スコープの意図や得られるレコードを端的に表現できます
最後までご覧頂きありがとうございました!
*1:TelTelBows V2 は、TelTelBows V1 の改良バージョンです。V1 は、複数の ユーザ ID を画面上で指定して架電することは可能でしたが、ユーザの分類は、基本的にスプレッドシートによる手動運用にとどまっていました
*2:実際は、そうした情報量のない命名よりも、そもそもスコープに切り出せていないことのほうが多いように思います
*3:あなたが、そのドメインに触れて間もない場合は、単にそのスコープの名前を構成するドメイン言語にまだ馴染みが無いだけかもしれません
*4:案件をどの外装リフォーム会社に紹介したかを表すモデルで、課金ステータスを持つ
*5:課金ステータスの一つ。外装リフォーム会社が提出する課金除外申請(ヌリカエから紹介されたユーザを課金対象から除外する申請)の結果待ち状態