MailVaultのリファクタリング: モノリスからモジュールへ
MailVaultは急速に育ってきました。ここ数週間で、キーボードショートカット、統合受信トレイ、メールのテンプレート、送信者の傾向、自動クリーンアップのルール、送信の取り消し、フォルダへの移動、そしてMicrosoft Graph APIのトランスポート層をまるごと出荷しました。機能は次々に着地し、ユーザーは喜んでくれました — そしてコードベースは、その重みできしみ始めていました。
中心となる状態のストア — mailStore.jsという1つのZustandのファイル — は4,300行を超えるまで膨らんでいました。アカウントの管理、メールの読み込み、キャッシュ、検索、スレッド化、Graph APIの分岐、CONDSTOREの差分同期、一括操作など、すべてをここで扱っていました。Rustのバックエンドのmain.rsも同じ道をたどっていました。SettingsPageやEmailViewerのようなコンポーネントは、JSXの一枚岩の壁に育っていました。動いてはいましたが、何かを新しく足すには迷路を進むことになっていました。
リファクタリングの時期でした — 何かが壊れていたからではなく、新しい機能を出荷する速度が落ち始めていたからです。この記事では、何をしたか、なぜしたか、そしてどこへ向かっているかを書きます。
巨大なストアが1つあることの問題
Zustandは状態の管理に向いています。しかし1つのストアのファイルが数千行を超えると、現実的な問題が出てきます。どの機能も同じファイルに触れ、マージの衝突が日常になり、何が何に依存しているのかを追いにくくなります。私たちのmailStoreでは、メールの読み込みの処理とGraph APIの通信のコードが交互に並び、LRUキャッシュの管理が検索の状態の隣に置かれ、スレッド化のアルゴリズムがアカウントの切り替えと混ざっていました。
Rustの側でも同じことが起きていました。main.rsには、Maildirのパスの補助関数、キーチェーンの操作、キャッシュの管理、添付ファイルの処理、通知の処理、システムのユーティリティが入っていました — すべて1つのファイルにです。IMAPのモジュールでは、接続のプールの管理がプロトコルの水準の操作と絡み合っていました。
2ストリームのメール読み込み
v2.2.0でのいちばん大きなアーキテクチャの変更は、アカウントやフォルダを切り替えたときにメールがどう読み込まれるかの再設計でした。以前のやり方では、6つの重なり合う段階が壊れやすい順序で走っていました。キャッシュを確認し、キャッシュのデータを表示し、IMAPに接続し、ヘッダーを取得し、結果を統合し、UIを更新する、という流れです。素早くアカウントを切り替えると、これらの段階が入り混じり、違うアカウントのメールが一瞬表示されることがありました — 「メールが空になる」不具合です。
新しい設計は、すっきりした2ストリームのモデルです。アカウントを有効にすると、独立した2つのストリームが並行して走ります。
ストリーム1(ローカル)は、キャッシュ済みのヘッダーとディスクに保存されたメールを読み込みます。これはほぼ一瞬です — headers.jsonのキャッシュと、ディスク上のすべての.emlをMIME解析する必要をなくす新しいlocal-index.jsonにより、画面はミリ秒の単位で埋まります。
ストリーム2(サーバー)はIMAP(Microsoftのアカウントの場合はGraph API)に接続し、現在のサーバーの状態を取得して、変更をローカルの表示に統合します。新しいメールが現れ、削除されたものは消え、既読と未読のフラグが更新されます — いずれも、すでに画面にあるものをちらつかせたり置き換えたりすることなく行われます。
どちらのストリームもAbortControllerで包まれています。どちらかが終わる前にアカウントを切り替えれば、進行中の作業はきれいに取り消されます。競合状態も、古いデータも、「空のメール」もありません。
モジュールの切り出し
リファクタリングの最初の段階では、ストアやmain.rsの中にいる必要のないコードを外に出すことに集中しました。
JavaScript側で切り出したのは次のものです。
Graphの設定 — Microsoft Graphのフォルダ名の対応付け(Inbox → INBOX、Sent Items → Sentなど)は、コードベースの3か所に重複していました。今はgraphConfig.jsという1つのモジュールにまとまっています。
LRUキャッシュ — アカウントのキャッシュ(即座に切り替えられるよう最大8アカウント分のメールの状態を保持)と本文のキャッシュ(メモリの上限を設定できるメールの内容)は、共通のLRUの実装を持つ専用のキャッシュのモジュールに切り出しました。
検索 — 検索の状態と処理は、ほとんど独立しているにもかかわらず、主となるストアに絡みついていました。これを外に出したことで、ストアも検索のコードも追いやすくなりました。
性能のトレース — 読み込みのコードのあちこちに、その場しのぎのconsole.timeの呼び出しが散らばっていました。これらは、オンとオフを切り替えられる構造化されたperfTraceのユーティリティに置き換え、性能の調査をずっと扱いやすくしました。
コンポーネントの分解
2つのReactのコンポーネントが、あまりに多くを抱えていました。SettingsPageとEmailViewerです。
SettingsPageは、すべての設定のタブを1つのファイルで描画していました — アカウント、外観、キーボードショートカット、通知、テンプレート、自動クリーンアップなどです。これをタブごとのコンポーネントに分け、それぞれをsettings/のサブディレクトリに置きました。親のコンポーネントは、タブの切り替えと、選ばれているタブの描画だけを担当します。
EmailViewerは、1通のメールの表示、スレッドの会話の表示、添付ファイルの描画、DKIMの検証のバッジ、送信者の情報、返信と転送の操作まで抱えるようになっていました — すべて1つのコンポーネントの中にです。ここからThreadView、EmailHeader、AttachmentBarを、email/のサブディレクトリにある役割の絞られたコンポーネントとして切り出しました。
結果として、ファイルは小さくなり、たどりやすく、テストしやすく、関係のないところを壊さずに手を入れやすくなりました。
5段階の計画
v2.2.0で出荷したのは始まりであって、終わりではありません。少しずつ進めている5段階のリファクタリングの計画を立てました。
フェーズ1: 共通の土台 — 共通の定数とユーティリティ(Graphの設定、Rustの定数、性能のトレース)を切り出す。完了しました。
フェーズ2: Rustのバックエンド — main.rsを役割の絞られたモジュールに分割する。Maildirの操作、キーチェーンへのアクセス、キャッシュの管理、添付ファイルの処理、通知、システムのユーティリティです。IMAPのモジュールは、プロトコルの水準の操作と接続のプールの管理に分けます。
フェーズ3: ストアの分解 — 専用のuseSearchStoreとuseThreadStoreのストアを作り、Graph APIの通信、キャッシュの管理、メールの読み込みをサービスのモジュールへ切り出す。目標は、mailStoreを4,300行から2,000行ほどに縮めることです。
フェーズ4: コンポーネントの切り出し — 大きなコンポーネントの分解を続ける。AccountModalは、サーバーの検出とOAuthの設定のサブコンポーネントに分けます。大きすぎるコンポーネントが残っていれば、同じように扱います。
フェーズ5: 後片付け — 使われていないコードを削り、重複したユーティリティ(複数の場所にあったhasValidCredentialsなど)をまとめ、データベースのユーティリティのモジュールを役割ごとのファイルに分ける。
後退させないリファクタリング
どんなリファクタリングでも、いちばん難しいのは何も壊れないようにすることです。MailVaultは人のメールを扱います — アカウントの切り替えで違うメッセージが出るようになったり、読み込みの競合状態が再発したりすれば、それは本当の問題です。
私たちのやり方は、一度に1つの境界だけリファクタリングし、すべてが変わらず動くことを確かめてから、次の境界に移るというものです。4つの層にまたがる600件を超える自動テストが安全網になりますが、各段階のあとには手作業のテストもします — アカウントを素早く切り替える、読み込みの途中でオフラインにする、17,000通のメールボックスで試す、Graph APIのアカウントと通常のIMAPのアカウントの両方が正しく動くことを確かめる、といったことです。
ストアの構成についても、意識的な判断をしました。すべてを小さなストアに分けきる(それ自体が調整の問題を生みます)のではなく、私たちは折衷的なやり方を選びました。本当に独立している関心事(検索とスレッド化)には独立したストアを与え、密に結びついているアカウントとメールの状態はmailStoreにまとめたままにする、というものです。目指すのは純粋さではなく、分かりやすさです。
ユーザーにとっての利点
リファクタリングは、設計上ユーザーには見えません — アプリの見た目も使い心地も、まったく同じであるべきです。しかし効果は実在します。
アカウントの切り替えが速くなります — 2つのストリームの構成により、アカウントを切り替えるとほぼ即座にメールが見え、サーバーとの同期は背後で静かに進みます。
読み込みの乱れがなくなります — アカウントを素早く切り替えたときに出ることのあった「空のメール」の不具合は、以前の逐次的な読み込みのコードにあったほかの競合状態とともに、なくなりました。
機能の開発が速くなります — これが一番大きい点です。コードベースが整理されていれば、新しい機能をより速く作って出せます。予定している「すべてダウンロード」の機能、スケジュールによる自動のバックアップ、そのほかよく要望される改善は、コードが扱いやすくなったぶん早く届きます。
私たちは、長く続くものとしてMailVaultを作っています。メールは何十年も前からある技術で、現代的な道具立てからあまり手をかけられておらず、既存のバックアップの手段は技術者向けすぎるか、扱いにくすぎるかのどちらかです。コードベースを持続可能にすることが、アプリが良くなり続けるための方法です — 今月だけでなく、長い目で見ても。