Mackerel のフロントエンド "React化" プロジェクトを支える技術と設計

こんにちは, Mackerel 開発チーム アプリケーションエンジニアの id:susisu です.

現在 Mackerel では, Web コンソール画面の開発に使用しているフレームワークを, これまで使用してきた AngularJS から React へ移行することを中心とした, フロントエンド開発の刷新プロジェクトを行っています. このプロジェクトの立ち上げについては以前 Hatena Engineer Seminar発表しましたが, そこでは時間の都合もあり, 技術的側面についてはあまり深く掘り下げることは出来ませんでした. ということでこの記事では, より技術的な面にフォーカスしてプロジェクトの内容をご紹介できればと思います.

"React化" プロジェクトについて

Mackerel の開発は 2014 年ごろから始まりましたが, フロントエンドのフレームワークとしては当初から AngularJS (バージョン 1 系) を使用してきました. しかしながら現在 AngularJS の開発は LTS 期間に突入しており, セキュリティパッチやバグフィックスのみ更新が行われ, またそれも 2021 年 6 月に終了することが決定しています. したがって今後も継続して Mackerel を開発・提供していくためには, フレームワークの移行が不可欠です.

またこのことに加えて, Mackerel のフロントエンドの設計やコードには長年の開発によって負債が溜まっており, これを返済しないことにはフレームワークの移行が難しくなるだけでなく, フレームワーク移行後のさらなる開発を妨げてしまったり, また外部的な品質を保つことも困難になる恐れがありました.

こういった事情を受けて, 昨年より "React化" プロジェクトとして, AngularJS から React への移行を中心としたフロントエンド開発の刷新プロジェクトを行っています. 現在はフロントエンドのコード全体のうち, 行数にして 40% 弱がこのプロジェクトによって書き換えられたものになっています.

f:id:susisu:20200615221110p:plain
行数ベースの書き換え割合 (%) の推移. Mackerel にメトリックとして投稿し可視化しています.

以下ではプロジェクトの開始以前に Mackerel のフロントエンド開発が抱えてきたいくつかの課題について, それぞれどのような技術や設計で解決を試みているかをご紹介します.

課題 1. レガシー化したAngularJS

先に述べたとおり, AngularJS はすでに開発の停止が決定しているレガシーなフレームワークであり, アプリケーションの開発を継続するためには他のフレームワークへと乗り換える必要があります.

移行先のフレームワークは最終的には React を採用することになりましたが, この選定においては, 主に以下のような観点で検討や検証を行いました.

  • 移行するにあたって機能は十分か?
  • 段階的に移行していくことは可能か?
  • AngularJS の LTS 期間の終了までに移行することが可能か?

この中でも, 段階的に移行できるかという点は特に重要視しました. もし段階的な移行, つまり AngularJS と連携しつつ, 部品ごとに置き換えていくといった作戦がとれない場合, 全ての部品を移行先のフレームワークで新たに書き起こした後, 一度にまとめてリリースするという形で移行を進めることになってしまいます. こういったビッグバンリリースは不具合の発生などのリスクがとても大きいですし, また開発工数の観点でも, 並行して行われる新規機能の開発や既存機能の改善に追従するためには, 二重に手間がかかることになってしまいます.

React への移行については react2angular というライブラリを利用することで, React のコンポーネントを AngularJS のコンポーネントに変換して画面に組み込むことで, 段階的に移行を進めていくことができます. コンポーネントの変換はいたって簡単で, 以下のように AngularJS の bindings (や DI パラメータ) を, React コンポーネント側では props として受け取ることが出来ます.

import React from "react";
import { react2angular } from "react2angular";

const Greeting: React.FC<Readonly<{ name: string }>> = ({ name }) => (
  <p>Hello, ${name}!</p>
);

angular.module("myModule")
  .component("greeting", react2angular(Greeting, ["name"]));
<greeting
  name="$ctrl.name"
  ng-if="$ctrl.name !== undefined"
></greeting>

実際にはこれに加えて, AngularJS 側の変数の初期化サイクルと協調するため, 上の例の Greeting のような AngularJS 側から利用されるコンポーネントをラップする高階コンポーネントを定義し, AngularJS の DI を通していくつかパラメータを受け取り, それらを React コンポーネント側では Context を経由して利用可能にする, といったこともしています.

段階的に移行できるかの他に, AngularJS には LTS 期間の終了という「締め切り」が存在することも考慮する必要があります. たとえば上記のような部分的な移行を進める上で, 一部を置き換えるために既存のコードにも大きく手を入れる必要があると, 移行をスムーズに進めることができず, この締め切りに間に合わなくなる可能性が大きくなります. これはスクリプトに限らず, コンポーネントに適用されるスタイルシートなどにおいても同様のことが言えます.

このことを考慮して, Redux や CSS modules / CSS in JS など, それぞれ既存のコードから開発パラダイムを大きく変えると思われるライブラリ・仕組みの導入については, ひとまず見送ることとしました. ただしあくまで「ひとまず」であって, 将来にわたって導入を完全に諦めたということではありません. 詳しくは後述しますが, 後からこういったものが導入される可能性を考慮しつつ, それを設計にも反映させています.

課題 2. 緩いTypeScript

続いては開発言語についてです. Mackerel の開発の初期は素の JavaScript を使用していましたが, 途中から TypeScript へと切り替え, 現在に至っています. もちろん TypeScript を使用すること自体は良い選択ではあるのですが, 歴史的経緯や AngularJS との相性もあり, 型による安全性を犠牲にした緩い設定となっていることが課題となっていました.

具体例を挙げてみます. 以下のコードは Mackerel のフロントエンドのコードによく見られるパターンを単純化して切り出したものです.

/**
 * ユーザー情報を表示するコンポーネントのコントローラー
 */
class UserInfoCtrl implements ng.IController {
  userId: string;
  userName: string;
  
  constructor(private apiClient: ApiClient) {}
  
  $onInit(): void {
    // サーバーからユーザー情報を取得
    this.apiClient.getUser(this.userId).then((user: any) => {
      this.userName = user.name;
    });
  }
}

// コンポーネントを定義
angular.module("myModule")
  .component("userInfo", {
    templateUrl: "templates/userInfo.html",
    bindings: { userId: "<" },
    controller: ["apiClient", UserInfoCtrl],
  });

このコード, あるいはこのコードが許容される TypeScript の設定にはいくつかの問題があります.

まずは TypeScript の設定が strict mode でなく, 特に strictNullChecks が有効になっていないことが, 開発中に混乱を生む大きな原因となっていました. 例えば上記のコードの場合,

  • userId は AngularJS の binding により設定されるため, $onInit() の時点では利用可能になっているが, constructor() が呼び出された時点では undefined である
  • userName は非同期に取得されるため, 取得が完了するまでは undefined になっている

といったように, プロパティがそれぞれ初期化されるタイミングまでは undefined である一方で, そのことが型の上で表現されない (表現しても見た目が変わる以上の効果はない) ため, どのプロパティが初期化済みなのか常に気を配る必要があります. またこういったプロパティを初期化よりも前に使ってしまうことで実行時エラーとなってしまう, という事故も実際に発生したこともありました. そして残念ながら TypeScript にはファイルごとに strict mode かどうかを切り替える手段が存在しないために段階的に改善することが難しく, 改善もほとんど試みられてきませんでした.

次に問題なのは any の利用です. 開発の途中で JavaScript から TypeScript へ移行したため, 一時的に any を使うことは妥当ではあるのですが, そこから先に進めることが十分にできていませんでした. 特に any の利用の傾向としては, 上記の user のように, TypeScript への移行時にフロントエンドのコードだけでは推論できなかった, サーバーサイドから与えられるデータの型に対して使われていることが多く, どういったデータなのかを知るためにはサーバーサイドのコードを確認する必要があるなど, 開発において大きな負担となっていました. これについては各開発者が余裕のあるときに型の整備をしてはいたものの, そういった草の根的な活動では全体を改善するには至っていませんでした. さらには TypeScript の設定で noImplicitAny が有効になっていないこともあり, 気づかないうちに新規に any を導入してしまいかねない状況でもありました.

こういった問題を解決するため, 新規に書くコードについては TypeScript の設定を分離して型検査を厳しくしつつ, さらに any の利用などについて一定の制限を設けることで, より安全に開発が行えるようにしていくことにしました.

まずは TypeScript の設定の分離ですが, 上述のとおり TypeScript コンパイラにはファイルごとに設定を変更するといった機能はありません. またこれは TypeScript に限らず, その他の様々なツールにおいても, 設定の分離には同様の困難が伴います. そこで考えた作戦として, Yarn workspaces を使用し, 新規に書かれるコードの所属するディレクトリ / パッケージごと既存のコードから分けてしまうという方法をとることにしました. Yarn workspaces は monorepo を実現するための仕組みですが, これによって既存のコードの側に影響を与えないまま, 新規に書かれるコードについて様々な設定や依存を分離し, TypeScript については strict mode を有効にすることで, 型検査の恩恵をより強く受けられるようになりました. 加えてこのとき「既存のコードが新規コードに依存する」と依存の向きを決めることで, 逆に新規のコードが安全性の低い既存コードの上に立つことで全体の安全性が揺らいでしまう, ということを回避するようにました.

次に any の利用などに対する制限ですが, これには noImplicitAny のような TypeScript の設定に加えて, ESLint とそのプラグイン typescript-eslint を使用することで, 安全性の低いコードに対して警告を出すようにしています. 既存のコードに対しても ESLint は使われていたのですが, 新規コードに対しては明示的な any の使用を原則禁止するなど, より安全性を重視する・厳格にベストプラクティスに従うような設定にしました. この設定の分離についても, Yarn workspaces でパッケージが別れていることにより, 非常に容易に実現できています.

課題 3. 漠然としたアーキテクチャ

フレームワークや言語だけでなく, アプリケーションのコードにも, そのアーキテクチャが漠然としていることによる課題がありました.

プロジェクト開始以前のアーキテクチャは以下の図のようになっていました.

レガシーなアーキテクチャ

典型的には AngularJS コンポーネントが直接サーバーに対してリクエストを行い, 表示するためのデータの取得や, ユーザー操作に基づいてデータの更新を行います. 加えてフロントエンドでグローバルな状態管理を行いたいといった事情がある場合は, 別途サービスを用意し, コンポーネントはそれを購読したり操作したりするという形をとります.

このアーキテクチャは決して何らかの意図をもって設計されたものではなく, 既に書かれたコードから逆算された, 大まかに共通しているパターンを切り取ったものです. そしてそれゆえ, コードを書く際に設計に対して明確な指針を与えてくれるようなものではなく, 実際に書かれたコードには様々な問題が現れていました.

まず明らかだったのはコンポーネントの役割の肥大化です. 上記のアーキテクチャでは処理を記述できるような場所としてはサービスまたはコンポーネントが規定されていますが, 典型的にはサービスはあまり使用されず, コンポーネントには

  • サーバーへのリクエスト (インフラ層)
  • ドメインモデルに基づく処理とそれらの調停 (ドメイン層やアプリケーション層)
  • 画面への表示 (プレゼンテーション層)

といった, あらゆる責務が集中してしまい, 結果として可読性やメンテナンス性が低くなってしまっていました.

またコンポーネントは実際には HTML を拡張したテンプレートと TypeScript で書かれたコントローラーの 2 つの部分からなりますが, これらの関係性も曖昧になっており, 例えばテンプレート内に単純な表示に関わるロジックの範囲を超えた記述があり, これまた可読性が下がっているといったこともしばしばありました.

こういった問題を解決するため, コンポーネントに集中してしまった責務を適切に分離できるよう, 新たにアーキテクチャを規定しました. 以下はそのアーキテクチャ (右側) と, 上記のレガシーなアーキテクチャ (左側) との関係を表した全体図です.

新しいアーキテクチャとレガシーなアーキテクチャの関係

まずはアプリケーション層を主に担う部分としてサービス (右側中央) を定義し, コンポーネントは直接サーバーに対してリクエストを発行したりするのではなく, 必ずこのサービスを経由してデータの取得や更新を行うようにしました. これによりコンポーネントは画面への表示という単一の役割に集中することができるようになります.

またサーバーへのリクエストを担う部分として API クライアント (右側上部) を定義し, サービスはこれを呼び出すこととしました. これはインフラ層を分離すると同時に, 長期間の開発によってサーバーの提供する API に溜まった負債 (例えばエンドポイントごとにレスポンスに一貫性がなくなっている) を吸収するための腐敗防止層としての役割も兼ねています.

このようなサービスを使ってアプリケーション層を定義したのは, フレームワーク移行中に一時的に AngularJS コンポーネントからこのサービスを利用する, というケースを容易に実現させるためでもあります. 新規に定義したサービスは元々 AngularJS 側にあったサービスと基本的には全く同じ仕組みとなっているため, AngularJS コンポーネントから利用するために必要な仕組みの上でのギャップはほとんどありません.

続いてコンポーネントの設計です. ここには Model-View-Presenter アーキテクチャを適用することとしました.

MVP アーキテクチャ

ここで Model は上記のサービスや API クライアントからなる部分で, また View は通常の React コンポーネント, Presenter はカスタムフックを使って記述しています.

このようにコンポーネントを View と Presenter に分離しつつ, View は純粋に表示に関わるロジックのみを持ち, Presenter はそのために Model から受け取ったデータを変換する部分であると明確に定義することで, それぞれの可読性やテスタビリティが向上しました. またそれに加えてデータの流れる向きが規定されることで, Presenter は Model と View の間の調停を見通しよく行えるようになりました.

さらに, Model と Presenter は直接結合するのではなく, 間にもうひとつカスタムフックを使った層を配置しています.

Model と Presenter の結合

これは複数の Presenter から同じ機能を利用するためでもあるのですが, それだけではなく, 例えば Redux のようなライブラリを導入するなどして将来的に Model をリファクタリングしたくなった際に, この層で差異を吸収することで Presenter や View に及ぶ影響を最小限にできるといった意図もあります.

と, ここまで現在使用しているアーキテクチャを紹介してきましたが, これらは決して最初から完成していたものではなく, あくまで開発を行いながら, 使い勝手や, 次に説明するテスタビリティなどの面からのフィードバックを受けて, 徐々に組み立て・改良していったものです. 今後も必要があれば更新していくことでしょう. アーキテクチャもコードと同様に, またコードの規模の成長に対応できるよう, リファクタリングのサイクルを回すことが重要だと考えています.

課題 4. 過疎化したテスト

最後にテストについてです. Mackerel のフロントエンド開発において, テストを書くことは軽視されてきた, あるいは難しそうといった印象から避けられてきており, その結果ごく限られた場所だけにしかテストが書かれていないといった状態でした. しかしながら, 複雑な機能を開発する際にはテストを活用して開発したいという要望もあり, また既存機能のリファクタリングや依存ライブラリの更新を行う際に, 手動での動作確認に時間がかかったり, また変更が影響する箇所を見つけるためにはある種の勘のようなものが必要なことが開発経験が浅い場合にハードルとなっているなど, テストを書けるようにすること, そして実際に書いていくことは明らかに必要とされていました.

このようにテストが書かれて来なかった背景の一つとしては, 上記の漠然としたアーキテクチャがあると考えています. 既に述べたとおり, 従来のアーキテクチャではコンポーネントに様々な責務が集中して肥大化しまっていたため, これをテストすることは単一の機能のテストと比べると幾分難しくなってしまいます.

またテストが書かれないことで, どうテストを書けばよいかといった知識が蓄積されず, 結果として新しくコードを書く際にも, テストの書き方がわからないので書けない / 書かない, といった悪循環になっている面も見られました (私もそうしてテストを書かなかった一人です...).

こういった状況を打開するため, レガシーなコードを書き換えていくにあたって, 特にプロジェクトの初期の段階でいくつかのことに注意を払うようにしました.

まずはテスタビリティの高い設計を行うことです. これは先に述べたアーキテクチャと表裏一体で, 各部分の関心事を単一にしつつ, また抽象化を適切に用いてそれぞれの間を疎結合にすることで, 単体テストが行いやすくなります. 具体的にはアーキテクチャの各部分に対して, 以下のようなライブラリや設計を用いています.

  • API クライアント: リクエストを発行するバックエンドには axios を使っており, テストでは axios-mock-adapter を使ってモックしています.
  • サービス: API クライアントには実装ではなくそのインターフェースに依存するようにし, テストではモック実装で差し替えています.
  • Presenter: react-hooks-testing-library を使って単体テストを行っています. サービスを扱うためのカスタムフックは React の Context を使って差し替えられるようになっており, テストではこれらに対してモック実装を与えます.
  • View: react-testing-library を使って単体テストを行っています. コンポーネントの外部への依存はできるだけ props に寄せることでテストをシンプルにしつつ, 利便性のために props を経由していない言語の切り替えや相対時刻の表示などについては, こちらも Context を使ってダミーの実装や時刻を与えられるようにしています.

次に, あらゆる部分についてテストを欠かさず書くことです. こうすることの利点は, ひとつはテスト対象となるものの設計や, 全体のアーキテクチャに対してフィードバックを与えられることです. もしテストを書くことが難しかった場合, 設計になんらかの問題がある可能性があります.

もうひとつの利点は, 様々な状況に対してテストをどう書けばよいか, 参考となり実際に動いている例を用意できることです. プロジェクトに関わるメンバーは必ずしもフロントエンド開発のプロフェッショナルではありませんので, TypeScript / React のコードに対するテストの書き方も浸透しているわけではありませんでした. メンバーがテストを書きたいときはいつでも書けるようにするという基盤整備の一環として, これは有意義であったと考えています.

最後に気をつけた点としては, テストを書くことの負担を大きくしすぎないことです. これはもちろんテスタビリティを高めておくというのも一つなのですが, それに加えてテストユーティリティを整備したりスナップショットテストを活用することで, テストを書くことに対するハードルが必要以上に上がらないように気をつけました.

このように整備を進めた甲斐もあってか, 現在は新規に書かれるコードのほぼ全てに, 当たり前にテストコードが書かれるようになっていおり, さらにはリファクタリングを安心して行えるといった効果も既に出ています.

まとめ

ここまで Mackerel のフロントエンド開発が抱えてきた 4 つの課題に対して, 現在 "React化" プロジェクトの中でどのように解決を試みているかを紹介しました. 大まかにまとめると以下の通りです.

  • 課題 1. レガシー化したAngularJS
    • react2angular を用いた部分的・段階的なコンポーネントの置き換え
    • 「締め切り」までに移行を完了させるための技術選定
  • 課題 2. 緩いTypeScript
    • Yarn workspaces を用いたコード・設定の分離
    • TypeScript の strict mode と ESLint を活用した安全性の向上
  • 課題 3. 漠然としたアーキテクチャ
    • 明確に責務を分離したアーキテクチャの規定
  • 課題 4. 過疎化したテスト
    • 責務を分離したアーキテクチャによるテスタビリティの向上
    • 実例となるテストコードの記述

こういった技術と設計, またそれを理解してくれるチームメンバーの助けもあり, プロジェクトは現在順調に進行しています.

はてなでは一緒に技術・設計を活用し, 健全な開発を目指すメンバーを募集しています.