こんにちは。ウォンテッドリーでバックエンドエンジニアをしている太田です。普段はWantedly Hireという採用管理システムを開発しています。今回は、その開発を通じて設計の見通しが良くなったカレンダー連携の改善について紹介します。
目次
はじめに
何をしたか
Wantedly Hireでは、当初Google Calendarとの連携機能を提供していました。ここに新しくOutlook Calendarとの連携を追加するにあたり、HireからGoogle CalendarやOutlook CalendarのAPIを呼び出す部分に、共通インターフェースを定義しました。Google CalendarとOutlook Calendarそれぞれのクライアントは、この共通インターフェースを実装するアダプタでラップし、差し替え可能にしています。
なぜ抽象化したか
Google Calendarへのアクセスは、もともとモデルやサービスの各所に直接実装されていました。ここにそのままOutlook Calendarを加えれば、同じ分散をもう一系統増やしてしまいます。これを避けるため、カレンダーへのアクセスに共通インターフェースを挟みました。
共通インターフェースを挟むことで、Google Calendar向けに実装した処理をOutlook Calendarでも流用でき、実装コストを抑えられます。さらにアダプタとして実装したため、対応するカレンダーを増やしても同じ仕組みに組み込むだけで済み、追加のたびにコストが膨らみにくくなります。
この記事で扱うこと
この記事では、モジュール間の依存をどう抽象化するかを、カレンダー連携を題材に扱います。
抽象化のねらいは二つあります。一つは、カレンダーサービスそのものを抽象化し、実装を差し替えられるようにすることです。もう一つは、サービスとやり取りする予定などのデータを抽象化し、その定義の変化を内側に閉じ込めることです。
記事では、この二つをどう設計したか、そしてどこまでを共通インターフェースに含め、どこを含めなかったかを説明します。
既存の設計のままでは何が問題か
Outlook Calendarに対応するにあたって、既存の設計には変更しにくい点が二つありました。
モデルやサービスに直接埋め込まれたカレンダーアクセス
予定の取得や作成、更新、削除のたびに、呼び出し側からGoogle Calendarのクライアントを直接呼び出していました。その結果、よく似たカレンダー操作が、複数のモデルやサービスに重複して分散していました。
クライアントを変えるたびに広がる手動テスト
クライアントを一つ変えるだけでも、手動で確認する範囲が広がっていました。予定の取得や更新が、日程調整のロジックの内部にも埋め込まれていたためです。
日程調整は、選考官と候補者の空き時間を照らし合わせて候補日を算出するなど、条件が多く複雑な処理です。カレンダーアクセスを変えたときの影響範囲を、ここで見極めにくくなっていました。
共通インターフェースの設計
依存の付け替え
設計の方針は、呼び出し側がカレンダーサービスを直接参照しないようにすることです。モデルやサービスは、Google CalendarやOutlook Calendarのクライアントではなく、共通インターフェースだけに依存します。その共通インターフェースの内側で、Google Calendar用とOutlook Calendar用の実装に分かれます。
抽象レイヤーを挟まない場合と挟む場合を比べると、依存の矢印が共通インターフェースに集まることがわかります。
抽象基底クラスとアダプタ
共通インターフェースは、取得や作成、更新、削除といった操作を宣言する抽象基底クラスとして定義しました。Google CalendarとOutlook Calendarは、それぞれこの抽象基底クラスを実装したアダプタとして用意します。共通インターフェースに各サービスの実装を合わせるこの形は、アダプタパターン(Adapterパターン)と呼ばれます。
この抽象基底クラスでは、各操作を実装していなければエラーを投げるようにしました。アダプタ側で実装を忘れても、テストでエラーとなり検知できる仕組みです。
アダプタを選ぶファクトリ
呼び出し側が、Google CalendarとOutlook Calendarのどちらのアダプタを使うかを毎回判断するのは望ましくありません。そこで、連携状態に応じて適切なアダプタを返すファクトリパターン(Factoryパターン)を用意しました。呼び出し側はファクトリからアダプタを取得するだけで、どちらの実装かを意識する必要がなくなります。
共通化したメソッドの粒度
共通インターフェースに揃えたのは、予定の取得や作成、更新、削除と、予定表の取得です。一方、システム内に保存している予定表レコードの作成や更新、削除は、共通インターフェースには含めず、別の仕組みとして残しました。共通化しなかった範囲とその理由は、後述します。
共通インターフェースへの移行はどう進んだか
Google Calendar専用の処理を共通インターフェースに置き換える
移行の出発点は、すでにあったGoogle Calendar向けの処理です。これらはGoogle Calendar専用に書かれていたため、共通インターフェース経由の呼び出しに置き換えました。すべてを一度に置き換えると影響が大きいため、操作ごとに段階的に進めました。
どの操作から置き換えたか
最初に置き換えたのは、予定や予定表を取得する読み取り系です。入力が単純なためです。サービスごとの違いも、共通の値オブジェクト(各サービスの予定を同じ形で扱うオブジェクト)への変換で吸収でき、置き換えが容易でした。
一方、予定を作成や更新する書き込み系は、サービスごとにインターフェースの構造が大きく異なります。細かい規約があり、単純な変換では済まず、何をどう共通化するかの調査から始める必要がありました。
そこで、書き込み系の中で最もインターフェースが小さい削除から着手し、書き込みに必要な基盤を整えたうえで、作成や更新の共通化へ進みました。
過剰な責務の切り出し
共通インターフェースへの移行に際して、カレンダー操作以外の責務の置き場所も整理しました。Google Workspaceの管理とカレンダー管理を兼ねていたサービスからは、カレンダー操作をアダプタへ切り出しました。元のサービスは、Workspace連携に責務を絞りました。
モデルには、プレゼンテーション層向けの処理に特化した実装も混在していたため、これをプレゼンテーション層とサービスクラスに分けました。
抽象化で何が変わったか
新しいカレンダーを追加するとき
Outlook Calendarは、共通インターフェースを実装するだけで、Google Calendar向けに作成した処理にそのまま適用できました。呼び出し側はファクトリからアダプタを受け取るため、カレンダーサービスごとに実装を分岐させる必要がありません。サービスごとのAPIの差異も、アダプタの内側に隠蔽されます。たとえばOutlook Calendarでは、同じ予定でもどの予定表から取得するかでIDが変わります。ただし、このIDの違いは呼び出し側には現れません。
日程調整のロジックやUIも、カレンダーの違いを意識せずに動作します。今後カレンダーを追加するときも、アダプタを一つ実装すれば足ります。
あわせて、カレンダーアクセスがアダプタに集約されたため、変更の影響範囲もアダプタの内側に収まります。クライアントを変えても、日程調整を含む広い範囲を手動で確認する必要はなくなりました。
予定の表し方の違いの吸収
予定そのものの表し方は、たとえば終日予定かどうかの表現のように、サービスごとに異なります。以前はフロントエンドで判定していたため、その違いを吸収しきれていませんでした。そこで、この区別を共通の値オブジェクトのフラグとして持たせ、その判定をアダプタが予定を生成する際の責務としました。呼び出し側は、サービスの違いを意識せずフラグを参照するだけで済みます。
この設計により、後から日程調整に関わる変更が生じたときも、予定の値オブジェクトの内側で吸収できました。たとえば、「特定のキーワードを含む予定は、予定ありではなく空き時間として扱う」機能を追加した際は、このキーワード判定を予定の値オブジェクトに持たせています。日程調整のロジックは予定が空きかどうかを参照するだけでよく、変更せずに済みました。
どこを共通インターフェースに含めなかったか
個別実装のまま残した認証と接続
認証や接続の仕組みは、Google CalendarとOutlook Calendarで異なります。こうした差は、各サービスのクライアントが吸収しています。ただし、Google Workspace(Google Calendarを含むサービス群)の認証は、Googleログインによるサインアップなど、カレンダーと無関係な処理でも利用されます。こうした認証は、カレンダーの共通インターフェースには取り込まず、従来の位置に残しています。
共通化しなかった内部のレコード管理
システム内のデータベースには、予定表レコードや予定レコードを保存しています。これらのレコードの作成や更新、削除は、共通インターフェースに含めていません。外部との通信と内部のレコード管理では責務が異なり、モジュールを分離しておきたかったためです。ただし、これらのレコードは多くの箇所から参照されるため、必要なフィールドだけは、カレンダーサービスによらず同じ名前で扱えるようにしました。
共通のエラー型に変換しきれなかったライブラリのエラー
各サービスのライブラリは、それぞれ独自のエラーを投げます。これらを、対応する共通のエラー型に変換しています。ただし、ライブラリが投げるエラー型をすべて把握することはできません。把握しきれないものは、総称的なエラー型として扱います。
抽象化する範囲をどう線引きしたか
今回の抽象化は、すべてを共通化したわけではありません。変更すると影響範囲が大きく、かつ変更頻度も高い部分を最優先にしました。この部分はカプセル化の効果が最も大きいため、責務を分割し、共通インターフェースとして定義しました。
作業は、インターフェースの導入を妨げる実装を先に修正してから、インターフェースを導入する順で進めました。一方、日程調整のような複雑で変更を避けたい部分は、抽象化によって変更の影響から保護する側に位置づけています。
結論
抽象化の成果
カレンダーへのアクセスを共通インターフェースで抽象化し、Google CalendarとOutlook Calendarのクライアントを差し替え可能なアダプタでラップしました。Outlook Calendarは既存の処理にそのまま適用でき、今後カレンダーを追加するときも労力を抑えられます。あわせて予定を値オブジェクトに抽象化したことで、後から加わる変更も内側に閉じ込められるようになりました。
抽象化の範囲の決め方
一方で、認証や内部のレコード管理は、費用対効果に見合わないため、今回の共通化から外しました。