freeeの開発情報ポータルサイト

Bundleの3年間をライブラリで振り返る

こんにちは。freee株式会社でBundleの開発を行っている kouhei です。 この記事は freee Developers Advent Calendar 2023 の23日目の記事です。

Bundleは、サービス提供からそろそろ3年が経とうとしているサービスで、もともとはfreeeにグループジョインしたWhy株式会社が提供していたサービスです。 私自身もまた、グループジョインに伴いfreeeに入社し、freeeで引き続きBundleの開発に携わっています。

Bundleのサービス提供開始からfreeeにグループジョインするまでの数年は、実はフルタイムで働くエンジニアは私だけという状況でした。私は、サービスローンチ後はひたすらその時その時の開発にフルコミットしていたため、過去を振り返る時間を取れていませんでした。 しかし、ようやく最近はその余裕も出てきたため、この記事では、サービス提供開始から今までを振り返り、私がサービスの成長に寄与したと思う使ってよかったライブラリと、考えが甘く導入後に利用をやめる事になったライブラリをいくつか紹介したいと思います。

Bundleとは

本題の前に、簡単にBundleというサービスについて紹介させてください。 BundleはSaaSのアカウントの管理を効率化するためのサービスです。主に情報システム部の人が使うことを想定していますが、SaaSのアカウントを管理している人であれば、情報システム部以外の人でも使うことのできるサービスになります。

Bundleのトップページのスクリーンショット
Bundleのトップページのスクリーンショット

例えば業務で10個のSaaSを利用していて、来月10人のメンバーが入社するという状況を想像してください。この場合、情報システム部の人は合計で100個のアカウントを発行する必要があります。基幹システムなどのアカウントは自動で発行することが可能だったりするかもしれませんが、利用しているSaaSのいくつかは、結局入社ぎりぎりだったり、入社後に(新入社員から依頼を受けて)アカウントを手動で発行することもあると思います。

Bundle導入前のアカウント発行に苦しむ情シスの図
Bundle導入前のアカウント発行に苦しむ情シスの図

Bundleは人事マスタのSaaSと連携して自動で新入社員情報を取得し、その情報をもとに各SaaSのアカウントを自動的に発行することができます。 さらに、入社時だけではなく退職時も、人事マスタ上で退職になったことを検知して、それぞれのSaaSのアカウントを削除したり、不要なアカウントが残っていることを検知することができるようなサービスです。Bundleを導入すると、これ誰のアカウントだっけ?や、知らないうちに使っていないアカウント分のSaaS料金が課金されていた〜!?という問題も解決できます。

Bundle導入後にアカウント管理の苦しみから開放されている図
Bundle導入後にアカウント管理の苦しみから開放されている図

バックエンド編

ridgepole

そんなBundleのバックエンドはRuby on Railsで動いています。 Ruby on Railsであるのは、私が一番慣れ親しんだウェブアプリケーションフレームワークだったということに尽きるので詳細は割愛しますが、その中で使っているgemの一つにridgepoleというgemがあります。

ridgepoleはデータベースのスキーマを管理するgemです。

Railsだと通常、DBに変更を加えるために、その変更を記述したマイグレーションファイルを作成します。 これだとDBの変更が多くなると、マイグレーションファイルの量も多くなりますし*1、変更の大小に関わらずファイルを作成する必要があるのですが、そもそも、DBへ変更を加える度に新しいファイルを作成する必要があるというのが大変です。

一方で、ridgepoleはDBの変更の度にマイグレーションファイルは作らず、常に1つのファイルに期待するDBの状態を記述していきます。 以下のような、Railsのマイグレーションと良く似た内容のコードをSchemafileという名前で保存してマイグレーションを実行すると、usersテーブルが作成されます。もし既にusersテーブルが存在する場合は、不足しているカラムやインデックスのみ追加するマイグレーションが実行されます。

create_table :users, id: :uuid, default: nil do |t|
  t.string :name, limit: 100, null: false
  t.citext :email, limit: 200, null: false
  t.timestamps

  t.index [:email], unique: true
end

ridgepoleを使うことで都度マイグレーションファイルを作成していく手間から開放されました。プロダクトの方針が変わったり新しい機能を作る際は、得てしてデータベースの中身も大きく変わるものですが、Schemafileの中身を期待したものに変更すれば良く、ストレス無く開発を進められたので、私が入れてよかったと思うライブラリの筆頭です。

activerecord-bitemporal

一方で、導入したことが失敗だったなというgemもあります。 それが、activerecord-bitemporalです。*2

Bundleは当初、至高の人事データベース*3を目指していました。 従業員の入社のタイミングで自動でSaaSにアカウントを作成しに行くために、SaaSが要求するアカウントの属性情報を網羅的に持っておく必要があるためです。

SaaS Aは名前と生年月日を、SaaS Bは名前と部署をアカウント作成に必要とする場合、Bundleは名前と生年月日と部署を持っている必要があるという説明の図
SaaS Aは名前と生年月日を、SaaS Bは名前と部署をアカウント作成に必要とする場合、Bundleは名前と生年月日と部署を持っている必要があるという説明の図

また、SaaSにアカウントを作成するというプロダクトの性質上、なぜSaaSにアカウントが作られたのかという監査ログも保持しておく必要があります。 つまり、「人事データベースとしての正しい情報」と「監査ログとしてアカウントを作成した時点でBundlleに登録されていた従業員情報」の2つを持っておく必要がありました。

例えば、Bundle上では開発部に所属という情報だったので、BundleはSaaSに開発部としてアカウントを発行したが、実はマーケティング部配属だったため、後ほどBundleの人事データベースをマーケティング部に書き換えたというケースがあります。 この場合、SaaSのアカウントは開発部として発行されているわけで、それは人事情報を書き換える以前、アカウント発行時にBundleでは従業員は開発部に所属していることになっていたからということが分かる証跡も保持しておきたいのです。

DB上の時間軸と現実の時間軸との2軸で情報を管理する必要があるという図
DB上の時間軸と現実の時間軸との2軸で情報を管理する必要があるという図

これを表すことのできるデータモデルがBiTemporalデータモデルで、それを実現するgemがactiverecord-bitemporalでした。

しかし、Bundleの開発が進むにつれて、過去の時点での情報にまで正しさを要求する人事データベースの要件は薄れ、アカウントを発行した時点でBundleに保存されていた情報が何だったのかを保持できていれば良いという要件のみが残りました。 Bundleの従業員情報は複数の人事マスタを統合した独自の統合マスタなため、Bundleの従業員情報を構成する元データは、引き続きそれぞれの人事マスタのSaaSでも管理されているはずです。そのため、Bundleでは過去の人事データを参照できなくても、正しいデータがSaaS側にあるので問題無かったのです。

特に何もなければBiTemporalデータモデルのままで、activerecord-bitemporalを使い続けるということもできましたが、activerecord-bitemporalが発展途上なgemだったことや、データが変更されるたびにレコードが2つ追加されていくこと、新メンバーの認知負荷が高いこと、他のライブラリとの兼ね合いなどいろいろと問題もあったため、利用をやめるという判断を行いました。

このactiverecord-bitemporalを剥がす作業は、前述したridgepoleを利用していたお陰で*4そこまで工数をかけずに行うことができました。この件は、要件に対する過度な最適化だったという反省もあり、特にデータ構造は気軽に変更できないので、悩んだら、あるいはプロダクトの初期は、なるべくデータ構造がシンプルな状態を維持することが大切という良い教訓になりました。

フロントエンド編

Relay

フロントエンドの話に移りたいと思います。 BundleはフロントエンドでReact、TypeScirptを使っています。また、バックエンドとフロントエンドとのデータのやりとりにはGraphQLを採用しています。 Rest APIと違ってエンドポイントが1つなので、APIの追加のたびにエンドポイントを追加しなくても良いことや、GraphQLは、フロントエンドのクライアントが必要なデータを柔軟に選択できるため、バックエンドの変更なしにアプリケーションの挙動を変更できます*5。これが、フロントエンド開発のスピードに大きく貢献したと思っています。

GraphQLのクライアントとしては、ApolloとRelayが有名ですが、BundleではRelayを採用しています。RelayはFacebookが開発したGraphQLのクライアントライブラリであり、Reactも同様にFacebookが開発したため、Reactとの親和性が高い特徴があります。

GraphQLはフロントエンドが柔軟にデータを取得できる一方で、どのフィールドがどこで利用されているのかが分かりにくくなるという課題があります。この課題に、RelayではFragment Colocationという手法で対処します。Fragment Colocationでは、あるコンポーネントで利用するGraphQLのフィールドはそのコンポーネントにGraphQLのfragmentとして記述します。

// RamenPrice.tsx (孫)
function RamenPrice({ ramenRef }: Props) {
  const ramen = useFragment(
    graphql`
      fragment RamenPrice_ramen on Ramen {
        # このコンポーネントで利用するフィールドのみ記述する。
        # もしもコンポーネントで利用していないフィールドの記述があったら何も考えずに消してしまって良い。
        price
      }
    `,
    ramenRef
  );

  return <div>{ramen.price}</div>;
}

子コンポーネントは自身のコンポーネントで使うデータをfragmentとして定義し、親コンポーネントは、子コンポーネントのfragmentを自身が発行するfragmentに含めます。

// RamenList.tsx (親)
function RamenList({ shopRef }: Props) {
  const shop = useFragment(
    graphql`
      fragment RamenList_shop on Shop {
        ramens {
          # このコンポーネントで利用するフィールドのみ記述する。
          # 加えて、RamenListItemを呼び出しているので、RamenListItemのfragmentを自身のfragmentに含める。
          id
          ...RamenListItem_ramen
        }
      }
    `,
    shopRef
  );

  return (
    <div>
      {shop.ramens.map((ramen) => (
        <RamenListItem key={ramen.id} ramenRef={ramen} />
      ))}
    </div>
  );
}

// RamenListItem.tsx (子)
function RamenListItem({ ramenRef }: Props) {
  const ramen = useFragment(
    graphql`
      fragment RamenListItem_ramen on Ramen {
        # RamenPriceを呼び出しているので、RamenPriceのfragmentを自身のfragmentに含める。
        ...RamenPrice_ramen
      }
    `,
    ramenRef
  );

  return <RamenPrice ramenRef={ramen} />;
}

Query(GraphQLのデータ取得のリクエスト)は各コンポーネントで発行するのではなく、基本的に最上位の親のコンポーネントで発行します。

// Shop.tsx (最上位の親)
function Shop() {
  const data = useLazyLoadQuery(
    graphql`
      query ShopQuery($id: ID!) {
        shop: node(id: $id) {
          ... on Shop {
            # ShopListを呼び出しているので、ShopListのfragmentを自身のqueryに含める。
            ...RamenList_shop
          }
        }
      }
    `,
    { id: 1 },
    { fetchPolicy: "store-or-network" }
  );

  return <RamenList shopRef={data.shop} />;
}

通常のレンダリングで利用するデータ以外に、コールバックの関数などで利用するデータもFragment Colocationで表現することができます。詳細な説明をしようとするととても長くなってしまうので、詳しくは公式のチュートリアルを見てもらえたらと思います。

このFragment Colocationのアプローチは、ApolloでもGraphQL Code Generatorといったライブラリを使用することで実現できます。しかし、Relayはこの機能がオフィシャルに提供されており、むしろ規約としてそれに則ってコンポーネントやGraphQLのクエリを記述することを要求されます*6。これにより、保守性や開発の容易さが向上します。編集しているコンポーネントと、そのコンポーネントの直接の親子だけに意識を向ければ他のことは考えないで良いため、プロジェクトが大きくなってもそれによってフロントエンドの開発の複雑性が増すということがありませんでした。

Polaris

フロントエンドにおいて一番失敗したことは、UIコンポーネントライブラリの導入です。 Bundleでは、Shopifyがメインで開発を行っているPolarisというUIコンポーネントライブラリを導入しました。 後述しますが、Polarisが悪いというわけではなく、Bundleにおいてはおそらく、どのUIコンポーネントライブラリを導入しようとしても上手くは行かなかったと思います。

BundleはスタイルをEmotionというCSS-in-JSを利用して記述しており、react-modalなどのライブラリの利用はありましたが、基本的にはほぼすべてのコンポーネントを独自に書いてきました。コンポーネントのスタイルの記述については特に不自由していませんでしたが、チームのメンバーのスキルセットだったりa11yを意識しだすと、何らかUIコンポーネントライブラリを使ったほうが良いかもと思うようになりました。

Bundleは一覧表示を多用するため、一覧表示のためのTableコンポーネントに対する機能が充実しているライブラリを探しました。そしてPolarisというライブラリを発見し、インターフェースの気持ち良さや、PolarisのテーマカラーがBundleのテーマカラーと似ているということも後押しし、まずは、新規に開発する奥まった画面で導入してみることにしました。

導入はかなり大変でした。Bundleはフォントサイズを1remが16pxとしてスタイルを組んでいましたが、Polarisのデザインシステムはそうではなく、コンポーネントは期待した大きさになりませんでした。それ以外にも発生したいくつかの問題をなんとかクリアして、作られた一覧表示のコンポーネントは、最終的にはいくつかの画面で利用されましたが、これでは無い感があり早々にPolarisの導入自体を中断しました。 ごく一部の画面にしか導入していないPolarisのコンポーネントですが、剥がすのには数ヶ月の時間を要しました。

「これでは無い感」はいったい何だったのでしょうか。結局、求めていたのは、ボタンを押したらPortalでPopoverが開くことだったり、Escを押したらPopoverが閉じることだったり、Popoverが開いたらフォーカスがPopoverにあたりフォーカスリングが出るというスタイル以外の挙動であり、スタイルは自身でCSSを書けば解決するのでライブラリに求めてはいませんでした。実は、こういったスタイル以外の機能を提供するUIコンポーネントライブラリが存在していて、それらをヘッドレスUIコンポーネントと言います。私の場合、スタイルは自分で書けば良いと思っているのに、そのスタイルまでライブラリに依存しようとしたことが、これでは無いと感じた原因だったのだと思います。

BundleはPolarisの導入挫折と時を同じくして、ヘッドレスUIコンポーネントのreact-ariaというライブラリを導入して、機能はreact-ariaに、スタイルは独自に書くということでコンポーネントを開発しています。今のところ、これは上手く行っていると思っています。

感想

今日紹介したのはほんの一例ですが、この3年を振り返ってみると、もっと早くお客様に価値を届けられたなというポイントがいくつもあり、反省するばかりです。

利用をやめることになったライブラリは、Bundleの要件に合わなかっただけで、他のケースではとてもパワフルで良いライブラリだと思います。 activerecord-bitemporalはBiTemporalデータモデルをRailsで実現するとなったらやはり真っ先に選択肢に上がりますし、Polarisのコンポーネントのインターフェースやデザインシステムの哲学などは、ヘッドレスUIコンポーネントを使って独自にコンポーネントを作るときに今でも参考にしています*7

Bundleは、ridgepoleやRelayなどをはじめ、その他いろいろなライブラリに開発を助けられています。 そのため、ライブラリを入れることを嫌厭したり、ライブラリを入れないということが正解ではなく、入れるときは今一度本当に必要か考えたり、剥がしやすくする工夫をしたりすることが大切だと感じました。

出てきた反省を活かして、これからも引き続きお客様に最速で価値を届けられるように頑張っていきたいと思います!

最後に

12月23日は私の誕生日です。 この記事を書いている32歳の私は、32歳の終わりに1つ詩を書いて、最近話題のSuno AIに曲を作ってもらいました! 聞いてください、「23時の32歳」。

app.suno.ai

...はい、場が温まったところで、明日のAdvent Calendarの紹介です! 明日はfreeeのセキュリティ戦士tdtdsさんの記事です、お楽しみに〜!

*1:https://github.com/jalkoby/squasher など、過去のマイグレーションファイルを束ねてくれるgemがあったりします

*2:決して、activerecord-bitemporalを否定する気持ちはなく、むしろこういうRailsらしい(難しいことを簡単にできるようにする)ライブラリはとても好きで、自分もプルリクエスト(https://github.com/kufu/activerecord-bitemporal/pull/83)を送ったりしていました

*3:https://speakerdeck.com/f440/implementing-command-history-and-temporal-access?slide=11から引用

*4:ridgepoleに加え、映画マトリックス レザレクションズでも見られたキアヌ・リーブスばりの華麗なるマイグレーションテクニックで

*5:もちろん前提として、予めクライアントが必要とするGraphQLのフィールドがバックエンドで定義されている必要はあります

*6:おそらくこれが巷でRelayが難しいと言われる所以でしょう

*7:ちなみに、今でもCSS カスタムプロパティにPolarisを使っていた残滓を見ることができます