React+Reduxによる状態管理とフロントエンドの技術的負債 ─ 長く継続するサービスのアプリケーション設計
遷移なく表示コンテンツを変更できるシングルページアプリケーションでは、ページの状態管理が重要になります。現在はReactによるUI構築とReduxによる状態管理を選択しているChatworkは、jQueryなどの技術的負債と共存しながら、フロントエンド設計の見直しを重ねてきました。クライアントサイド・アーキテクトの火村智彦(@eielh)さんと、エンジニア採用広報の高瀬和之(@guvalif)さんによる解説です。
クラウド型ビジネスチャットツール「Chatwork」は、2011年3月にローンチされて10年以上にわたり開発を継続してきました。このように長く続くサービスがユーザーに価値を提供し続けるには、時間経過による変化に適応するため設計の見直しが必要になります。
しかし、設計を見直すことで技術的負債が生じます。技術的負債は新機能開発やバグ修正を難しくするため、可能な限り素早く返済することが理想ですが、すぐにすべてを返済するかは状況にもよります。返済には時間がかかり、その時間をプロダクトの価値を高めるために使うこともできるからです。
本稿では、長く継続している大規模なサービスが技術的負債と共存する実例として、Chatworkのフロントエンドを取り上げます。そして、UI構築における設計の見直しや、それに伴う変化とどのように向き合っているのかを紹介します。なお、筆者個人の見解をいくらか含んでいることにご留意ください。
- 長く開発と運用を続けるサービスが直面する変化
- 技術の進化による変化をReactの新機能から見る
- Chatworkにおけるフロントエンド設計
- UI構築に関わる設計の見直し: jQuery → React
- 状態管理に関わる見直し: プリミティブ → DDD → Redux
- どのように変化と向き合うか? 世代の管理
- 振る舞いを維持しつつ実装を置き換えるには
- 未来の理想の設計を予測する
長く開発と運用を続けるサービスが直面する変化
長期にわたって運用と開発を継続するサービスの設計とは、変化に対応していくことだと考えます。変化するユーザーやビジネスの状況に合わせて、サービスも変化していく必要があるためです。サービスが変化するには、サービスを実現するアプリケーションも変化する必要があります。分かりやすく極端な言い方をするならば、作っているアプリケーションは変化して別のものになるため、新規のアプリケーションを設計するようなことになります。もちろん実際には新しいアプリケーションを作るわけではなく、既存のアプリケーションを修正することがほとんどでしょう。
10年継続しているサービスの10年前を想像してみましょう。アプリケーションを開発した動機は何だったのでしょうか。身近な課題を解決するアプリケーションを思いつき、開発してみたら思いのほかニーズがあったからサービスにしたのかもしれません。野心を持って世の中を変えるために開発を開始し、プロダクトを購入してくれるユーザーを探しながらピボットを繰り返してきたのかもしれません。どちらにしても、10年にわたって事業計画を見直し続け、実現可能な計画へと育て上げてきたはずです。うまくいくのか分からなかったサービスも、ある程度は成功したサービスに変化しているはずです。
時間の経過を考える際に、エンジニアとして外せないのは、技術の進化です。10年前から今まで、技術が進化していることは言うまでもありません。新しい技術は、古い技術が抱える問題を解決するために登場します。それは生産性を向上させるかもしれません。今まで解決できなかった問題が、解決される可能性もあります。もしかしたら競合の後発サービスは進化した技術を採用し、より高い生産性を持ち、ユーザーに与える価値も大きく、そうなれば追い抜かれてしまうかもしれません。
開発と運用を長期に継続するアプリケーションで、ユーザーの要求やビジネスの状況、さらに進化する技術を考慮した設計を、すべて最初から完璧に作り上げることはおそらく不可能でしょう。少なくとも筆者にはできる気がしません。ニーズやビジネスの変化を予想し、的中させなければならないからです。だからこそ状況を定期的に確認し、それにあわせて設計を見直す必要があります。
アプリケーションのあるべき姿と現在の状態の差
ここで技術的負債について考えます。技術的負債の定義には諸説ありますが、筆者は「あるべき姿と現在の状態との差」という定義が、技術的負債を短く的確に言い表していると考えます。現在の状態からあるべき姿に修正するには、時間が必要であり、コストを支払う必要があります。
あるべき姿を目指さないのであれば、必要となる時間を短縮でき、払うべきコストを払わずに済みます。しかし、いつかはあるべき姿にしたいのであれば、そのときにコストを支払う必要があります。負債とはいずれ支払う点で、マイナスの財産に等しいのです。この考え方であれば、修正をしないで技術的負債を抱えるという選択により、想定より早くリリースできることもあります。
このように技術的負債といえば、リリースを優先するため設計された内容を妥協したり、より良い方法があるにも関わらず短時間で実装できる方法を選択したりすることで生じるという認識があるかもしれません。一方で、時間の経過とともに生じる技術的負債も存在します。
前節で、時間の経過に合わせて設計を見直す必要がある、という話をしました。つまり、アプリケーションの「あるべき姿」となる理想の設計は、時間とともに変化します。より良い方法も、時間とともに変化する可能性があります。理想の設計が変わるということは、技術的負債を返済しても、すぐ次の技術的負債が生じるということでもあります。これは、技術的負債をすべて返済することはできないことを示しています。
また、負債の返済に必要なコストが変化することもあるでしょう。例えばニーズが変化して、技術的負債を抱えていた部分がそもそも不要になったとしたら、不要な部分を削除するだけで負債を返済できます。このように将来的に踏み倒せる負債なのか、それともアプリケーションの根幹をなすため、時間とともに発生する利子の方が大きいのか、そういった検討の余地もあります。
つまり、技術的負債は返済すべきというより、管理すべきものです。負債を一時的に抱えることで、予定より早くユーザーへ価値を提供でき、いずれまとめて効率よく返済可能なのであれば、その方法を正しく活用すべきです。負債らしさをより感じられるかと思います。
時間経過による設計の見直しと技術的負債の発生
継続して運用するアプリケーションでは、さまざまな変化により設計の見直しが必要になります。設計を見直すと新しい「あるべき姿」が浮かび上がり、それまで理想と信じてきた世界が虚構だったことに気がつくこともあるでしょう。そして、あるべき姿と現在の差分が明確になり、技術的負債が見つかります。
例えば価値検証のフェーズで、アプリケーションの市場規模が想定より大きかったと気付くかもしれません。想定したユーザー数を超えると、期待していたパフォーマンスでサービスを提供できないかもしれません。そもそも始めから最終的なユーザー数を想定して開発していたなら、開発スピードやコストが見合わないでしょう。これが、事業フェーズの変化で発生する技術的負債です。
また、ReactやVueのような宣言的UIライブラリが登場するより前から開発されているアプリケーションでは、こういったライブラリの登場を予想できたでしょうか。仮にjQueryでシングルページアプリケーションを作成しているときに、宣言的UIライブラリを利用せず、そのままjQueryで開発を続けたいでしょうか。場合によっては「イエス」と答えることがあるかもしれませんが、多くは置き換えたいと考えるのではないでしょうか。これが、技術の進化によって発生する技術的負債です。
こういった技術的負債を無視してしまうと、アプリケーションの価値まで見失う可能性があります。設計は定期的に見直す必要があり、新しい技術的負債とは向き合い続けなければなりません。その向き合い方も、アプリケーションの状況に左右されます。どれくらいの規模のアプリケーションなのか、事業がどういったフェーズなのかによって、その選択は変わるでしょう。
技術の進化による変化をReactの新機能から見る
技術の進化を考慮しなければならない事例として、Reactの歴史を振り返ってみましょう。Reactは、フロントエンドでアプリケーションのUIを実装するJavaScriptのライブラリで、オープンソースとして公開されたのは9年前の2013年5月です。
Reactが解決したのは、理解しやすく見通しのよいUIを記述可能にしたことです。宣言的UIと呼ばれますが、完成形のUIイメージを宣言的に記述できるようになりました。実はReact登場以前でもUIを宣言的に記述はできましたが、パフォーマンスに問題がありました。宣言的に記述した範囲全体を描画し直す必要があり、大規模なアプリケーションでは実用に耐えない状態でした。Reactは変化する範囲を事前に検知し、必要な部分だけ描画することでこれを解決しました。
Hooks APIの導入
その後、React自体も変化を続けます。話題性が高かったのは、2019年2月にリリースされた16.8.0のHooks APIでしょう。アプリケーションとして実現できることはこれまでと変わりませんが、記述方法に大きな変化がありました。筆者の感想にすぎませんが、クラスコンポーネントと比較すると、Hooksに対してロジックを切り離すイメージがしやすく、多くの人がReactをよりViewらしく活用できるようになったと推察します。
Reactは、コンポーネントという単位でパーツを構築し、パーツ同士を組み合わせてプログラミングします。Hooks APIの登場以前は、コンポーネント自体が状態を持つ場合、JavaScriptのクラス構文を利用する必要がありました。Hooks APIは、実現できることに変化はありませんが、状態を持つコンポーネント関数を使った記述方法を提供しました。
Hooks APIと同様な思想は、それ以前にもRecomposeのようなサードパーティのライブラリに存在しました。Recomposeは高階関数の考え方を元にした "高階コンポーネント" と呼ばれる方法を使うものでした。高階コンポーネントでは、コンポーネントを関数に渡すと拡張されたコンポーネントが戻り値として返ります。
実際のところ、考え方として類似性はありますが、Recomposeでは高階コンポーネントは採用されていません。筆者の印象にすぎませんが、高階コンポーネントは読み解くのに慣れていないと、難易度が高いです。公式に提供されたHooks APIは、Reactの利用者にとってシンプルで、より筋のよいコードへと導いてくれます。
Concurrent Rendererのインパクト
直近の変更では、2022年3月リリースの18.0.0で備わったConcurrent Rendererのインパクトが大きいでしょう。今後の新機能の基盤にすぎませんが、Reactが描画する際に途中で一時停止し、後で再開することが可能になりました。その結果、重い描画が必要な状況でも、ユーザーへ素早く応答を返すことが可能になります。
Reactの2つのバージョンについて取り上げましたが、これらの進化を約10年前の2013年の時点で予測できたでしょうか。少なくとも筆者が振り返る限りでは、まったく予測できたとは思えません。
Chatworkにおけるフロントエンド設計
技術的負債と向き合う方法は、アプリケーションによって異なります。アプリケーション設計の妥当性を判断するには、そのアプリケーション特有の背景を理解しなければなりません。ここでは本稿で必要となる情報に絞って、Chatworkの状況をお話しします。
Chatworkは2011年3月にリリースされ、本稿執筆時点で11年を迎えたサービスです。今となっては普通のことですが、ページを再読み込みすることなく画面がリアルタイムに更新され、メッセージのやり取りができるシングルページアプリケーション構成を、11年前から実現していました。これを実現するには、サーバーからイベントを送信するPush型APIを備える必要があります。具体的には、WebSocket、Server Sent Events、Cometなどの技術が必要です。
シングルページアプリケーションにおいては、必然的にUX要件が課される場合も多いでしょう。Chatworkの場合も例外ではなく、UX要件に伴うロジックが至る所に存在しており、アプリケーションの設計を難しいものにしています。
Chatworkに固有の話ではありませんが、フロントエンドアプリケーションの特徴として、状態管理に自由度があります。バックエンドアプリケーションでは状態をデータベースに保存して管理するため、構造を変えるにはALTER TABLEやマイグレーションが必要になります。フロントエンドの場合、サーバーサイドから受け取る情報はAPI層を通じて任意に変換できます。また、バックエンドに比べてデータ容量の制限もあるため、そもそもすべてのデータではなく必要最低限のデータを持つだけの場合もあるでしょう。
UI構築と状態管理の方法に見られる設計の変遷
ここから、Chatworkのフロントエンドにおける設計の見直しの歴史と、それによって生じた技術的負債について深堀りします。
Chatworkのフロントエンドでは、設計の見直しによって3つの世代が存在します。
- 2011年~ JavaScript / jQuery / プリミティブ(以下、jQuery世代)
- 2017年~ TypeScript / React / DDD(以下、DDD世代)
- 2020年~ TypeScript / React / Redux(以下、Redux世代)
名前から想像できるように、この世代は主とするUI構築と状態管理の方法を組み合わせて区切られています。なおかつこの3世代は、同一のコードベース上で共存しています。
現在の目指す「あるべき姿」はRedux世代です。しかし、DDD世代における設計見直しの際に生じた技術的負債を返済するより前にReduxを採用しており、技術的負債の層が積み上がっています。
一定の技術的負債を抱えながら開発を続ける
現在、Chatworkのフロントエンドのコードベースはおよそ16万行あります。また、認知的複雑度(Cognitive Complexity)は、およそ9,000という規模です。特にjQuery世代の複雑度は高く、残行数3万に対して、認知的複雑度はおよそ5,000です。
この規模のコードベースにおいて、設計の見直しによる技術的負債の返済にかかる時間は、測りきれていません。そのため修正にあたっては、新機能開発や機能改善のタイミングに合わせて適宜置き換える方法を選択しています。コードを大規模に変更修正しなくとも価値提供を継続できているということは、一定の技術的負債を抱えながら開発を続けているということでもあります。
ただし、この技術的負債は設計の選択によって未来のあるべき姿が具体化し、可視化されたものにすぎません。設計を見直さなければ間違った目的に進み続けるだけなので、あくまで可視化された技術的負債を直ちに返済する・しないという選択の話です。
この選択により、問題をいくつか抱えることになります。一度にすべての技術的負債を返済する(≒ビッグバンリリースする)選択に比べて、変更修正が必要なときに複数の設計のコードベースと向き合わなければならないため、改修が難しくなります。新規メンバーは古い世代のコードベースを理解する必要があります。
また、利用しているライブラリがサポート終了するなどの外的要因があれば、機能開発を停止して、時間的制限のある中で技術的負債の返済を強要されるリスクも生じます。
このように長期継続するアプリケーションはコードベースが肥大化し、設計の技術的負債が積み重なります。技術的負債は開発速度を鈍化させるかもしれませんが、事業が存続するためには仕方ないことでもあります。技術的負債が戦略的に活用されており、返済計画が立っていれば問題はないと言えるでしょう。
UI構築に関わる設計の見直し: jQuery → React
前節で紹介した通り、ChatworkではjQueryを使ってUIを構築していました。現在は、新規に構築するUIではReactを用いていますが、jQueryによるUIも現存しています。
jQueryはUI構築というより、DOMを操作するユーティリティという色が強いライブラリです。UIを構築する際には、jQueryによる直接的なDOM操作を用います。ReactはjQueryとは違って、宣言的にUIを構築するためのライブラリです。そのためよりシンプルにUIを構築でき、開発期間は短く、保守性も高くなります。
Reactを採用した背景は、jQueryを使ったUI構築に限界があったためです。さらに、Chatworkのフロントエンドでは過去にAngularJS構成へ置き換えるプロジェクトも進められていましたが、プロジェクトマネジメントとUX要件の観点で中止されています。この際、アプリケーションをまるごと置き換えることの難しさや、開発期間中に機能開発が停止することの大変さも経験しています。
フロントエンドに詳しくない方のために補足すると、Reactは仮想DOMという中間表現を経由することで変更すべきDOMを検知し、DOM操作を最小限にします。jQueryでも宣言的にUIを構築できますが、前述のようにパフォーマンスがよいものとは言えません。もちろん書きやすさもReactに軍配が上がります。jQueryでは状態変更時に宣言的ではなく命令的に記述することでパフォーマンスチューニングできますが、見通しが悪くなりやすく、保守性や生産性が下がってしまいます。
Chatworkのようなシングルページアプリケーションを構築するときには、UI構築にjQueryを採用する理由として、ユーザーやステークホルダーの要求が関係することはまれでしょう。もしかするとReactが登場したばかりの時期で納期が短い開発であれば、ノウハウがあるjQueryを採用することはあったかもしれませんが。
また、どちらかを選ぶだけが選択ではありません。Reactによる宣言的なUI構築のアプローチを選ぶとチューニングに限界があるため、一部分だけDOMを直接操作したいときにjQueryを部分的に利用する可能性は否定できません。ただ実際には、jQueryではなく素のJavaScriptで対処するという選択肢もあるでしょう。jQuery自体がgzip圧縮で30kBほどあり、Reactも(コア機能を合算すると)40kB程度になるため、ちょっと無視しづらいサイズです。ここでも要求に応じた選択が生じることになります。
このように設計の選択はアプリケーションが置かれた状況に左右されます。絶対的な正解はなく、トレードオフを見抜いて選択することになります。
Reactの採用について検討すべき直近の事情
せっかくなので、Reactを採用するという選択を見つめ直してみましょう。
前述のように2022年3月にリリースされたReactバージョン18からは、同時に複数の状態を扱うConcurrent Rendererを備えています。この仕組みによって、優先的に描画するものとそうでないものを別々に管理できるようになりました。この機能は応答性の高いUIを作ることを可能にします。
例えばChatworkでは、ユーザーの所属チャットルームが多い場合に、チャットルームの切り替えはそれなりに重い処理になります。見た目だけでも緩和するため、いったん切り替え前の状態を表示し、その後で実際にチャットルームを切り替えますが、このときマウスクリックの応答が実はあまりよくありません。こういった場面で、Concurrent Rendererの効果がはっきり出るのではないかと考えています。本稿執筆時点ではまだ検証できていませんが、この点でChatworkにとってReact 18はメリットがありそうです。
ReactのようなUI構築ライブラリは他にもあります。最近では、Reactと違って仮想DOMを持たずに、効率的なDOM管理をする宣言的UIライブラリも登場しています。代表的なのは「仮想DOMは純粋なオーバーヘッド」と宣言したSvelteでしょう。実際、仮想DOMを用いるより直接DOMを操作するほうが効率的なのは間違いありません。
しかし、Concurrent RendererによってReactは複数の状態を持つため、仮想DOMも複数の状態を持つことになるのではないでしょうか。これを実際のDOMでやろうとすると複数の実際のDOMが必要になる可能性がありそうです。仮想DOMのオーバーヘッドが気になる場合は、より深堀りが必要でしょう。
なお、現在のChatworkの設計はReactに強く依存しています。仮にUI構築ライブラリの選択を見直すと、大きな修正が必要になります。大きな変更なしでライブラリを切り替えられるように抽象化し、交換可能性を高めていくという選択肢もあるかもしれません。
懸念点は、各ライブラリ固有の機能を使えなくなる可能性があることです。抽象化によるオーバーヘッドが発生し、パフォーマンスを低下させる可能性もあります。現時点では必要なさそうな観点ですが、リスクをはらむものであり、技術的負債をコントロールするために定期的に見直したいと考えています。
コラム:設計判断とチームの多様性
利用するライブラリに対する理解度が低いと、曖昧な設計判断しかできません。エンジニアはより良い設計判断をするために、利用するライブラリの理解度を日々高めるべきです。また、さまざまな選択肢を検討するためにも、利用しない他のライブラリについての理解も深めなければ有効な判断ができません。
そのすべてを1人で対応するのは難しいです。第三者の比較記事を参考にしてもよいでしょうが、作成するアプリケーションの要求を考慮しなければならないため、参考にしても鵜呑みにはできません。より有効な設計判断には、深い知識と広い知識の両面が求められます。そこで筆者が期待しているのは、チームメンバーのスキルの多様性です。
チームにスキルの多様性がある場合、1人ではなくチームで設計判断を行うことで多角的な判断ができます。そのためチームの多様性を高めることは設計判断に役立つと信じて、日頃からチームメンバーが試していない技術を自分が試したり、自分とは違う技術を試すメンバーの背中を押したりすることをおすすめしています。
状態管理に関わる見直し: プリミティブ → DDD → Redux
Chatworkでの状態管理の事情は、UI構築ライブラリと比べるとより複雑です。3世代ともすべて違う手法で管理されているためです。jQuery世代は、JavaScriptのプリミティブなObjectとArrayで状態管理されています。IDをキーとした辞書型としてObjectを使い、順序が必要なエンティティはArrayによる管理もされています。
jQuery世代の状態管理では、型定義も一部でしかされていません。もともとany
のままで許容されており、修正機会があれば合わせて修正する方針をとったからです。また、TypeScriptのコンパイラオプションであるstrict
を有効にする際に、@ts-expect-error
を用いて型エラーを無効化している部分もあります。
DDD世代の状態管理
DDD世代では、DDD(ドメイン駆動設計)の戦術として登場するプラクティスを用いて、状態管理されています。Repositoryパターンを用いて、ユビキタス言語を元にエンティティや値オブジェクトのクラスを定義して管理しています。DDD世代を経てモジュールの責務が明確化するとともに、型定義による安全性が得られました。
しかし、DDD世代にもいくつか課題があります。これはクラスを用いた実装そのものに起因しています。クラスを利用する場合、Reactの差分検出処理を抑えるためには独自の実装が必要になりがちです。Reactの差分検出処理では、親で差分がない場合に子の差分検出処理を省略でき、DOMの更新処理もなくなるためレンダリング負荷を下げることができます。
ただし、その最適化はイミュータブルなオブジェクトを使う前提であり、オブジェクトの参照が変化しているかどうかを確認することで行われています。クラスを用いる場合、自然に実装するとミュータブルな実装になりがちです。そのためオブジェクトの参照が変化せず、プロパティの値が等しいかを愚直に確認するShallow Equalityで代用することが多いです。
比較コストが増加することに加えて、比較処理のデフォルト実装が使えず、自前での実装が必要になります。比較処理を自前で実装すると変更に弱くなることがあります。プロパティが増加したときに、修正漏れをしてしまうと期待した動作をしなくなることがあります。
また、DDD世代では独自フレームワークを形成しており、学習コストが高いにもかかわらず、社内でしか役に立たない知識にもなりがちです。もちろん無価値ということではなく、DDDの知識は別の場面で役立つことは補足しておきます。
その他の課題として、Reactへの描画依頼を明示的に行う必要がありました。通常のReactであれば、状態を変更すれば自動で再描画がスケジュールされますが、DDD世代では手動で再描画しなければなりません。
Reduxを採用したメリット
前項で挙げた課題を解決する方法として選んだのがReduxであり、独自フレームワークから脱却し、より一般的なやり方に合わせようという選択をしました。Reduxは小さくシンプルなライブラリです。gzip圧縮されたファイルで4.4kB程度しかありません。もちろん、Reduxでは明示的な再描画も必要ありません。
2022年にもなってReduxかと思われるかもしれません。近年のフロントエンドでは、APIクライアントが持つキャッシュ機構に状態(≒Read Model)を管理することで開発コストを低減したスタイルが人気です。しかし、一般的にフロントエンドが必要とするようなRead Modelは非正規形な場合が多いです。サーバーサイドの要求も加味すると、CQRS(Command Query Responsibility Segregation)を検討することもあるでしょう。
Chatworkは2011年から開発が継続しており、なおかつ当初からシングルページアプリケーションとして構築しています。これにより、サーバーサイドも同時に大きく変更することは現実的ではありません。また非機能要件として、Chatworkにおいてはリアルタイム性が重要視されるため、このような構成を採用するかについては慎重な検討が必要となります。
話を戻しますが、2020年当時であれば一般的なやり方はReduxだけに限らず、Context APIで状態を管理することや、MobXなどを用いることも考えられたでしょう。
これらと比較したReduxの大きな利点は、イミュータブルなオブジェクト指向を、関数型プログラミングの考え方に則ってシンプルな機能群で実現できることです。これを見るために、クラスとReduxそれぞれによってオブジェクト指向がどのように実現できるかを比較してみます。
クラス | Redux | |
---|---|---|
メソッド呼び出し | instance.method(...params); |
store.dispatch({ type: 'method', payload: params, }); |
状態遷移 | // newState を事前計算 this.state = newState; |
const newState =
reducer(state, action);
|
Getter | get value() { return selector(this.state); } |
const value =
selector(state);
|
これにより、DDD世代に際してクラス化された各種モジュールは、自然な形でRedux世代へと交代可能です。DDDの戦術的設計は技術的負債となりましたが、モデリングの考え方は世代を超えて有効であることが示されました。また、ReduxにおけるActionの仕組みを用いることで、jQuery世代のコードとの接続も疎結合なまま行うことができます。かくしてjQuery世代→DDD世代→Redux世代と経て、技術的負債に対する利子は一定量が返却されることとなりました。
少し視野の広い話にもなりますが、Reduxの仕組み自体がCQRSとの類似を持つことも見逃せません。いったんフロントエンドでCQRSの枠組みを実現しておき、徐々にGraphQLなども交えながらロジックをサービス全体に分散させる戦術もとり得るでしょう。この際、バックエンドとフロントエンドの知見を双方持ち寄ることができ、より柔軟な設計の議論も望めるでしょう。
コラム:設計とアーキテクチャ
アーキテクチャを決めるという活動は、ふと立ち止まってみると設計の判断そのものです。しかし、設計は英語ではデザイン(design)であり、アーキテクチャ(architecture)ではありません。本稿では「アーキテクチャ」という言葉を意図的に避け、「設計」と言うようにしています。アーキテクチャは重要な設計判断の集まりを指す言葉だと筆者は考えています。そのため、アーキテクチャという言葉が与える印象は強いです。
アーキテクチャは何にでも存在します。ライブラリのアーキテクチャ、フレームワークのアーキテクチャ、アプリケーションのアーキテクチャ、クラウドアーキテクチャなどです。ソフトウェアから飛び出してエンタープライズアーキテクチャも存在します。アーキテクチャという言葉が出た際は、文脈を明らかにしたうえで、設計の判断を積み重ねることを心がけたいものです。
どのように変化と向き合うか? 世代の管理
Chatworkのフロントエンドには大きな設計の見直しがありましたが、一度に修正ができず、今でも古い設計判断で開発したコードが存在しています。本章と次の章では、設計の見直しによる技術的負債とどのように向き合っているかをお話しします。
前述した通り、Chatworkのフロントエンドには3つの世代があります。この世代ごとに分離することで保守性を高めたり、技術的負債の定量化をしやすくしたりできます。分離方法としては大きく2つあり、ディレクトリ構造による分離と、依存関係による分離です。
なお、本稿では一般的な解説をしていますので、より具体的な手法に興味があれば開発ブログに掲載した記事も合わせてご参照ください。
参考: 自前アーキテクチャなコードを Redux 構成に書き換えているお話
ディレクトリ構造によるコード世代の分離
まずは、ディレクトリ構造による分離です。設計の世代ごとに、ディレクトリ構造を分けることができます。これにより、ディレクトリ構造に応じた検証ツールを適用して、世代ごとの検証もできます。
例えば、JavaScriptにはESLintというLintツールがあります。ESLintでは、子ディレクトリに設定ファイルを置くことで、子ディレクトリにのみ適用される設定を追加できます。この機能を活用すると、世代ごとに独自のLintルールを付与できます。
参考: configuration-files - Cascading and Hierarchy - ESLint
また、コード行数計測ツールではディレクトリ構造を再帰的に検索してくれるので、ディレクトリごとの集計もできます。さらに多くのツールでは、計測対象とするディレクトリを指定できます。参考までに、SCC(Sloc Cloc and Code)のREADME.md
に類似ツールが記載されていたので紹介しておきます。
参考: Background - Sloc Cloc and Code (scc) - v3.0.0 - GitHub
ディレクトリ構造は、アプリケーションを実行しなくても現れる設計上の構造です。明確な意図がない場合もありますが、ディレクトリ構造はモジュール境界を定義していると言ってよいでしょう。
モジュールとは、クラスや関数、レイヤー、パッケージなどのプログラミング上の要素を複数持った構造です。JavaScriptのプロジェクトであれば、package.json
によって定義されるパッケージモジュールとしても定義できます。もちろん単にディレクトリ内でファイルを分割することでも、モジュールを表現していると言えるでしょう。
Chatworkのフロントエンドでは、さまざまなディレクトリがありますが、簡略化すると以下のような構造になっています。
src ├── features │ ├── feature-xxx # Redux世代の領域ごとのコード │ └── feature-yyy # Redux世代の領域ごとのコード └── old ├── ddd-application # DDD世代のビューに関するロジック ├── ddd-domain # DDD世代の状態に関するロジック ├── jquery-xxx # jQuery世代のビューに関するコード └── jquery-yyy # jQuery世代のビューに関するコード
これは、先ほど紹介したLintツールやコード計測のメリットを受けづらいディレクトリ構造です。設計を見直すと技術的負債に見えますが、old
ディレクトリはいずれなくなるものとすると、返済不要な負債です。とはいえ、世代の移行に際して旧世代のファイルの移動が必要になるため、置き換える際には変更差分が大きくなったり、変更を追従するためにひと手間かかったりするという欠点もあります。
このように設計の選択は時に間違うことや、メリットばかりではなくトレードオフもあります。
依存関係によるコード世代の分離
依存関係の基本は、安定したものに依存し、不安定なものには依存しないということです。この関係性を維持することは、技術的負債を予測するうえでも重要です。古いソースコードはいずれ技術的負債となる可能性が高いため、影響を受けにくいよう新しいコードが古いコードに依存することは避けたいです。
安定している・不安定であるということや、新しい・古いというのは相対的なものです。故に、依存関係を考える基準としてはやや心許ない部分もあります。依存関係を考える際には、責務に応じたモジュールの境界分割も意識したいです。とりわけ、モジュール同士がインタフェースを通じてしか相互に依存しないようにすると、依存関係が破綻する可能性を大きく下げることができるでしょう。
インタフェースを通じてのみ相互依存することは、技術的負債の層に対しても有効に働きます。Chatworkにおいても、ACL(Anti-Corruption Layer)をjQuery世代と他の世代との間に設けることで、依存関係の破綻を防止しています。より具体的な手法に興味があれば、開発ブログに掲載した記事も合わせてご参照ください。
参考: jQuery時代のアーキテクチャをReact化するために大切なACL層のお話
責務に関する鋭い洞察は、日々の観察や、モデリングの積み重ねを通じて獲得するものです。ドメインエキスパートとの対話や、さまざまなOSS実装を研究することはもちろん、既存のものを当たり前とせず疑い、アンラーニングし続ける姿勢も大事になるでしょう。
振る舞いを維持しつつ実装を置き換えるには
振る舞いを維持して実装を置き換えるといえば、E2Eテストが最初に思い浮かぶかもしれません。今回はE2Eテストではなく、品質の維持と開発パフォーマンスを支えているプラクティスとして、DevOpsの能力とされる「継続的デプロイ」と「トランクベース開発」を中心に紹介します。また、各種置き換えに関連するテクニックも合わせて紹介したいと思います。
メインブランチの開発者へ向けた継続的デプロイ
継続的デプロイとは、変更した内容を可能な限り素早く本番環境へデプロイする試みです。継続的インテグレーションでさまざまな確認をされ、コードレビューを経た変更内容は、メインブランチに取り込まれると数分でその変更を利用するユーザーがいる状態になります。
似た言葉である継続的デリバリでは、継続的デプロイとは違いユーザーへのデプロイは直ちにされませんが、リリース次第いつでも変更内容をユーザーへ届けられる状態を達成します。
Chatworkのフロントエンドでは、メインブランチがマージされたタイミングで社内の一部ユーザーに向けてデプロイされるようになっています。また、そのデプロイ内容はそのままユーザーへリリースできます。つまり、一部ユーザーには継続的デプロイを行い、ユーザー全体には継続的デリバリをしています。
継続的デプロイを行うと、変更した内容を直ちに利用できるため、迅速にフィードバックを得ることができます。動作に関わる問題はコードが取り込まれてから短い時間で発見され、なおかつ変更のサイズが小さいため修正箇所も特定しやすくなります。
もちろんリスクもあって、社内コミュニケーションにもChatworkを使っているため、問題が起きると業務を阻害する恐れがあります。一方で、こういったリスクを避けるために、よりリスクが小さくなるようプロセスを改善しようとする作用も期待できます。
ただし、継続的デプロイは総合力が試されるプラクティスであり、テストの自動化、継続的インテグレーション、デプロイの自動化などの実現が必須です。また、直ちにデプロイされるため自動テスト以外の動作確認も重要となるでしょう。
リスクが大きくリターンも大きい継続的デプロイですが、フロントエンドの場合は比較的実践しやすいと筆者は考えています。その理由として、状態管理を段階的に置き換えができていることからも見て取れるように、フロントエンドが持つ状態の多くはバックエンドによって担保されています。故に、もともと大きい事故につながりにくいのです。
また、デプロイを行ってもアセットの再読み込みがなければ反映されないため、必ずしも直ちに全体へ影響が出ることもありません。
本番環境を用いたアーティファクトの検証
継続的デプロイのリスクを緩和するため、Chatworkにはその負担を軽減する仕組みがあります。フロントエンドへの変更レビューは、本番環境のバックエンドを使って動作確認できるようになっています。マージしてから本番環境で確認するのではなく、レビュー時にも本番環境で確認できるため、少なくとも最低限動作することは自信を持ってデプロイできます。
仕組みとしては、継続的デプロイを実現する仕組みの中で行われています。プルリクエスト時のコミットIDを用いてアーティファクトを一意に生成し、クエリパラメータで読み込みするアプリケーションを切り替えるという単純なものです。
これらの本番に近い環境で動作させる仕組みはアプリケーションの品質を高め、開発パフォーマンスの向上に一役買っています。
Feature Togglesを用いたフィーチャーブランチの早期マージ
GitFlowやGitHub Flowのようにフィーチャーブランチを運用する場合、メインブランチとの差分はやがて大きくなります。大きな変更は、与える影響を大きくし、レビューを難しくし、デプロイする不安を生みます。また、大きな変更はコンフリクトも引き起こしやすいです。このときに同時に複数のブランチが存在すれば、それらすべてとコンフリクトしていると言えます。
これを防ぐためには、フィーチャーブランチが完成する前から、メインブランチに取り込みをする必要があります。このときに役立つのがFeature Togglesです。Feature Togglesでは次のブログ記事が有名です。
参考: Feature Toggles (aka Feature Flags) - martinfowler.com
Chatworkにおいても、この記事の中で「Release Toggles」に分類されるものを導入しています。これは簡単に言うと、未完成のフィーチャーを有効と無効を切り替えられるようにして組み込み、完成するまでは無効状態にしてメインブランチに取り込むことを指します。
単純な例を以下に用意しました。
const featureToggles = { enabledNewFeatureOnA: false, enabledNewFeatureOnB: false, }; function aFeature(enabledNewFeatureOnA) { // 新機能が有効か無効かに関わらず同一の処理 // 新機能の有効 or 無効を、Feature Togglesで分岐 if (enabledNewFeatureOnA) { newFeature(); } } aFeature(featureToggles.enabledNewFeatureOnA);
featureToggles
がFeature Togglesです。機能が完成すればトグルをtrue
に変更し、落ち着いたところでトグル自体を削除します。
自動テストで新機能が有効な場合と無効な場合を切り分けて実行するために、依存を注入(DI)することが望ましいです。動的であればミュータブルなオブジェクトで保持するか、ReduxのようなGlobal Stateに保持してもよいでしょう。その場合は強い依存が発生しますが、DIする必要はなくなります。
もし直接依存するのであれば単体テストや統合テストを行う粒度より大きい範囲で依存するのがよいでしょう。こうすることで未完成のブランチであってもメインブランチに取り込むことができます。
注意点としては、Feature Togglesの分岐による実行時の差異が大きい場合は、継続的デプロイのリスク低減にはならないことです。これを緩和する方法は一部のユーザーに対してより早い段階から新機能を有効にするという方法がありますが、こちらも乖離が大きくなるため、変更のバッチサイズを小さくできないかを別途検討するべきでしょう。
部分的にReactを導入する方法
最後に、かなりHowに寄った話ですが、部分的にReactを導入する方法を紹介します。最も単純なアプローチは、Reactを使いたい場所に対して都度ReactDOM.render(React 18以降はReactDOMClient.createRoot
)を使用することです。React化自体は、jQueryで行っていることを確認し、同じDOMを構築するようにコンポーネントを作成するだけです。このときに、React化したものを表示するかどうかは前述のFeature Togglesも活用して、表示の切り替えを行います。
しかし、単純なアプローチではいくつか不便な点が見つかったので、ReactDOM.render
を使う方法からReactDOM.createPortal
を使うように現在は変更しています。単純なアプローチでは、Reactのレンダリングツリーが複数できてしまいます。React開発者ツールでプロファイルを取る場合、レンダリングツリーごとにしか取得できないため、全体としてボトルネックがどこにあるか把握しにくくなります。また、Context APIを利用する場合にはすべてのレンダリングツリーに組み込む必要もあります。ReactDOM.createPortal
を使って同一の親コンポーネント内でマウントを行うことで、これらの問題を回避できます。
部分的にReactを導入する中では、イベントハンドラの多重登録によるトラブルも起きました。React化する範囲内にjQueryで実装された内容を維持する場合、コンポーネントのマウント後にjQueryを使ってDOMを構築し、割り当てるといったことを行います。このときに、内部のDOMが構築済みで構築処理がスキップされた場合、イベントハンドラだけが多重登録されてしまうのです。
jQueryによるDOMの構築処理はuseEffect
やcomponentDidUpdate
のようなライフサイクルメソッド内に存在します。つまり、何も考えずに実装するとコンポーネントが更新されるたびに呼ばれて、イベントハンドラを多重登録することにつながってしまいます。レンダリングのたびに必ず多重登録が発生するならまだ問題を発見しやすいのですが、たいてい依存する値に変化がない限り呼び出さないため、特定の操作をしたときにのみ発生する分かりづらいバグとなり得ます。
レガシーフロントエンドへ部分的にReactを導入する際には、気をつけたい観点です。
未来の理想の設計を予測する
Chatworkでは、Reactの採用や状態管理の変更により生じた技術的負債を完全には返済できていません。その対策として、ひとつは古いコードと新しいコードを分離することで技術的負債を明確にしています。
また、クラスベースの状態管理からの移行容易性や、jQueryとの疎結合を保った接続容易性を考慮してReduxを採用しています。Reduxはシンプルな部品を集め、UIと状態を分離できます。そして、ReduxはCQRSとの類似も持つため、バックエンドで用いられる要素技術との知見の流用や、お互いの学習コストを下げる可能性が十分にあります。
理想の「あるべき姿」は時間とともに変化する
このような設計判断は、アプリケーションに対する要求や置かれた状況に基づいて行われます。答えはアプリケーションによって千差万別です。また、長く継続しているサービスは時間の経過とともに要求や状況が変化するため、設計は定期的に見直すべきです。もし見直しを行わない場合は、変化したユーザーの要求を解決できなかったり、新しい技術で参入してきた競争相手に追い越されてしまったりするリスクがあります。
見方を変えると、理想の設計は時間とともに変化します。技術的負債は、理想の「あるべき姿」と現在の状態との差であり、つまり設計の技術的負債は、時間が経過するだけでも変化します。これは理想が変化したことによる負債であり、実装時の問題ではありません。このように突如明確になる負債を、時間をかけて返済していく必要があります。
では、技術的負債はいつ返済すべきでしょうか。技術的負債をため込むと大きな問題を生み出します。不確実性が大きくなり、変更がさらに難しくなります。なるべく早く返すに越したことはありません。自動化できるのが理想ですが、できるとは限りません。手動で対処する必要があれば、もちろん時間がかかります。時間がかかるということは、その間に新たな技術的負債が生じることになります。
技術的負債と距離を保って有効活用する
とはいえ、技術的負債は問題ばかりではありません。完璧ではありませんが、ユーザーへの価値提供ができなくなるものでもありません。価値提供を阻害するものでないならば、意図を持って返済を後回しにできます。有効活用できるのです。
技術的負債を有効活用することをより意識するため、筆者は将来のあるべき姿と現実の差として技術的負債を捉えています。未来のことを完全には予測できません。アプリケーションは、常に具現化されない技術的負債を抱えているのです。その不確実性の中で確実なことは何でしょう? 予測できることは何でしょう? 変わりやすいものと変わりにくいものを見抜く必要があるでしょう。
つまり技術的負債をすべて返済することより、どのように距離を保つかが重要だと筆者は考えます。
それは未来の理想の設計を予測し、予測したところを目指し、小さな変更で正しさを確認することです。この予測精度をより高めるためには、ビジネスの方向性、技術のトレンド、ユーザーの要求と向き合うことが必要になります。また、矛盾するかもしれませんが、これらを深追いし過ぎないことも重要です。時には安定したものを利用するのも良い選択です。
最後に、正しい場所へアプリケーションをたどり着かせるために、この記事がみなさんの設計ライフに役立てば幸いです。
編集:中薗 昴
制作:はてな編集部