はじめに
こんにちは! Yahoo!知恵袋の津村です。去年の11月からYahoo!知恵袋のフロントエンドシステムのリアーキテクトに取り組んでいます。この記事では、これまで抱えていた技術的な問題と、それらをどう解決したかについて説明します。この結果、開発効率向上やレビュー時間短縮などの効果がありました。
Yahoo!知恵袋は利用者登録者数5,200万人、質問総数2億8,000万件、回答総数は6億5,000万件以上(2024年4月3日現在)ある、日本最大級のQ&Aサイトです。2004年からサービスを開始し、今年20周年を迎えます。
Yahoo!知恵袋はモバイルクライアント、バックエンド、フロントエンドの3つのシステムで構成されています。今回対象とするフロントエンドシステムは、ブラウザやYahoo! JAPANアプリからアクセスされる際のWebページを提供するシステムです。
Yahoo!知恵袋のフロントエンドシステム
現在、Yahoo!知恵袋のフロントエンドシステムは以下のような構成になっています。約6年前にPHPからJavaScriptに移行し、Express.jsやReact.jsを利用してシステムを構築しています。Next.jsは現在利用せず、BFF(Backends For Frontends)は存在していません。フロントエンドシステムから直接(サービス内外の)APIをたたいてデータを取得しています。
次にYahoo!知恵袋のページが表示されるまでにフロントエンドシステムで行われる一連の処理について説明します。
- ユーザーがYahoo!知恵袋のページを訪問すると、controllerが呼び出される
- controllerからservice(APIごとに存在する)を呼び、serviceからAPIに対してリクエストし、データを取得する
- serviceから受け取ったデータをcontrollerに返却する
- controllerは取得したデータを元に、viewmodelを利用して、Webページの表示に必要なデータを生成する
- そのデータをReact Componentに渡して、React Componentを元にWebページの表示に必要なHTMLを作成し返却する
今回のリアーキテクトでは上図の赤線で囲われているcontrollerのみを対象としています。理由としては、controllerには、ルーティング、各層との連携、静的なバリデーション、データの加工、主要なビジネスロジックのほぼ 全部が存在するため、処理が複雑であり、事故の原因となることが多く、開発効率・品質・保守性への影響が大きかったからです。
将来的にはcontrollerで行う処理を減らし、代わりにapplicationService層などの層を新たに作成することで、controllerにあるルーティング・静的なバリデーション・レスポンスの返却以外の処理を適切な層に移行することを目指しています。
対象範囲
全体のリアーキテクトを行う上で、まず他のメンバーがまねできるようなモデルケースとなるような実装が必要であったため、1つのcontrollerに絞ってリアーキテクトを行いました。今後、他の箇所にも同様のアーキテクチャを適用していく予定です。
今回は、そのモデルケースとして質問詳細ページのcontroller(detail controller)をリアーキテクトしました。質問詳細ページは質問に対する回答やリアクション、ランキング、回答への返信等様さまざまな情報が存在するページです。detail controllerを選んだ理由は、機能が一番多く、それに伴いコード量も多いので、モデルケースとして最も適していると考えたためです。他には、一番リアーキテクトを行った結果が実感やすいという理由もあります。結果が実感できれば、他の箇所にも同様のアーキテクチャを適用していく話にもつながりやすいと考えました。
Yahoo!知恵袋のフロントエンドシステムが抱える問題
将来的には、applicationService層に処理を移 行することも踏まえつつ、今回のリアーキテクトでは以下の問題の解決に取り組みました。
問題1: ロジックの集中と重複が多く発生していて、保守性が低くバグが起きやすい
controllerには、ルーティング、静的なバリデーション以外に、各層との連携やデータの加工、主要なビジネスロジックのほぼ全部が存在しています。そのため、追加修正を行いづらく、いろいろなケースのユニットテストを書くことも難しいため、保守性が低くバグが起きやすい状況になっていました。また、微妙に異なるロジックがcontrollerごとで存在しているため、意図せぬバグが発生していました。
問題2: 型がないため、実行時になって初めて発覚するエラーが多い
JavaScriptを利用しているため、実行時になって初めて型エラーが多々発生していました。例えば、mapメソッドをarrayに対して呼んでいるつもりが、実際にはundefinedに対して呼んでしまっていて、実行時になってエラーが発生していました。また、型がないため、関数の引数や返り値の型が分かりづらく、コードの理解が難しい状況になっていました。
解決策
問題1への対処: 重複したロジックを排除しつつ、ロジックの集中を緩和
以下のようなアーキテクチャを導入することで、controllerはルーティング・静的なバリデーション・レスポンスの返却のみを行うようにし、controllerへのロジックの集中を緩和することを目指しました。それを実現するために、以下の2つの方法を考え、1を選択しました。
- controllerからmodelとutilityに処理を移動させてからapplicationServiceを作成し、applicationServiceからmodelとutilityを呼び出す
- controllerからapplicationServiceに処理を移動させてからmodelとutilityを作成し、applicationServiceからmodelとutilityを呼び出す
理由としては、1を選択した場合、controllerに処理が存在する期間が2と比べて長いため、既存のコード(問題2の解決策で説明しますが、detail.js(既存)とdetail.ts(新規)が存在しました)と見比べやすく、追いつき作業が行いやすいからです。今回は1ヶ月という短い期間でリアーキテクトとTypeScript化を行う必要があったため、1を選択しました。1を行うことで、controllerから処理を逃がし、これから追加する予定のapplicationService層への移行を行いやすくしました。これにより、重複したロジックを排除しつつ、ロジックの集中を緩和させました。
model
計算結果を持続的に保持する必要がある場合や、データを分かりやすい単位で保持する場合に用います(振る舞いを含むこともあります)。例えば、質問に関するデータ(id, title, content)をまとめたい場合にquestionModelを用意します。単一のフィールド から算出可能な値はフィールドとして設定しないようにしています。また、値はimmutableにしていて、オブジェクトのフィールドはreadonlyにしています。modelはmodel間の関係性を表すこともあります。
utility
繰り返しいろいろな箇所で利用するような関数だけのモジュールです。modelとは異なり、まとまった単位でデータをフィールドに保持し続ける必要がない(statelessな)場合に利用します。例えば、ユーザーがどのようなデバイスでアクセスしているかを判定する関数などが該当します。utilityはmodel間の関係性を表すこともあります。例えば、ユーザーが閲覧できるカテゴリーを取得する関数などが該当します。
問題2への対処: TypeScript化を行うことで、実行時エラーを減らす
TypeScript化を行うことで、実行時になって初めて発覚するエラーを減らしました。また、以下のESLint ruleの追加を行い、オブジェクトのプロパティの上書きを行えないようにしたり、関数の戻り値の型を必ず指定するようにすることで、コードの理解をしやすくし、意図せず関数の戻り値の型が変わってしまうことを防ぎました。
- @typescript-eslint/prefer-readonly(外部サイト)
- @typescript-eslint/prefer-readonly-parameter-types(外部サイト)
- @typescript-eslint/explicit-function-return-type(外部サイト)
- type-festのReadonlyDeep(外部サイ ト)
TypeScript化を行う上で、以下の1-3の進め方を考え、その中から、1番開発に時間がかからなさそうな1を選択しました。
- detail.jsをコピーし、detail.tsを作成する
- もともとあったdetail.jsをdetail.ts(anyまみれ)で上書きする
- 空のtsファイルを作成し、そこに新たなcontrollerのコードを移動させていく
detail.jsは一番変更頻度が高いファイルで、リアーキテクトを行う期間にも並列で開発が進んでいくという背景がありました。もし2を採用した場合、リアーキテクトを自分が行い、他の開発者がdetail.jsを更新していくと、大きなコンフリクトが度々発生してしまいます。このコンフリクトの解消を行っていると、開発速度が落ちてしまうため、新たにdetail.jsからdetail.tsをコピーして、detail.tsを変更してリアーキテクトを行うという方法を採用しました。また、もともとあったユニットテストを動かして、常にcontrollerの出力がこれまでと変わっていないことを確認する必要があったために、3は採用しませんでした。もしユニットテストの移行を行う時間が確保できれば、この方法が良かったと思いますが、現在detailのユニットテストは26,000行あり、今回の記事内で述べている作業全体で使える作業期間も1カ月程度しかなかったため、一番時間がかからなさそうな1を採用しました。
TypeScript化は以下の手順で行いました。detail.jsは質問詳細ページのcontrollerが記述されているファイルです。
- detail.jsをコピーしてdetail.tsを作成
- // ts-nocheckをdetail.tsの最初の行に追加
- 型を追加する(importしているjsファイルにはd.tsを用意する)
- // ts-nocheckを削除
- 型がつけられていないものには、TODOコメント付きでany型をつける
- 処理をmodelやutilityに移動しつつ、型を追加する
- detail.jsの開発は日々進んでいくので、定期的にdetail.tsに反映する
おわりに
今回は、Yahoo!知恵袋のフロントエンドシステムのリアーキテクトについて説明しました。リアーキテクトを行うことで、controllerの責務を明確にし、保守性を向上させることを目指しました。また、TypeScript化を行うことで、実行時になって初めて発覚するエラーを減らし、コードの理解をしやすくしました。
今回学んだこととして、以下のようなことがありました。
- ユニットテストがないと、そもそもリアーキテクトを行うことが難しい
- 常にcontrollerの出力がこれまでと変わっていないことの確認を行うことが重要
- 少しずつmodelとutilityに切り出して型をつけていくことで作業が進めやすい
- オブジェクトのプロパティの上書きを行っている箇所はどういう値が入っているかが分かりにくい
- JSDocを書いておくと何を行う関数・クラスなのかが後から見直しても分かりやすい
- JavaScriptで書いているコードのJSDocに型を書いておくと型をつける作業が楽
- APIとのやりとりを行っている層から型付けはスタートしたほうがやりやすい。今回はそうする時間がなかったため、どういう型変換が行われるか目で追っていく必要があり、大変だった
また、今回のリアーキテクトを行った結果、以下のような効果がありました。
- 実行時エラーが起きづらくなり開発効率向上
- 重複している処理を共通化したことで、開発効率が上がった
- detail controllerのスリム化により、開発効率向上やユニットテストが書きやすくなった
- 型があることによる可読性が向上した
- レビューにかかる時間が短縮された
今後は、以下を行えたら良いなと考えています。
- 質問詳細ページのcontrollerのユニットテストのコードのリファクタリングを行い、保守性を向上させる
- applicationService層を作成し、controllerから処理を逃がす
- 他のcontrollerにも同様のアーキテクチャを適用する
- 他のJavaScriptで書かれているコードをTypeScript化する(更新頻度の高いコードから順に)