LIFULL Creators Blog

「株式会社LIFULL(ライフル)」の社員によるブログです。

9 年を超えて開発が続く LIFULL HOME'S の Web フロントエンド開発環境の改善

技術開発部の相馬です。好きな UI フレームワークは Svelte です。

私が現在所属しているグループでは、弊社のメイン事業である LIFULL HOME'S における開発効率の改善などを行っています。

今回は、LIFULL HOME'S の Web フロントエンド(以降はフロントエンドと表記します)開発環境を、Node.js の資産を用いて近代化した話(以降は近代化と表記します)をご紹介したいと思います。

目次

はじめに

LIFULL HOME'S は弊社でもっとも開発が盛んなアプリケーション(Repository)です。

Commit 数や Contributor 数は恐らく弊社でもっとも多く、また開発の歴史も非常に長い(9年超)です。

アプリケーションの基本構成は PHP(Symfony + Twig) + jQuery で、サーバーサイドでテンプレートを組み立て HTML を構築し、クライアントサイドでは jQuery を使って補助的に DOM の操作などを行うような形です。

近代化を行うにあたり、当時の課題点としては大きく 2 種類ありました。

  • CSS/JS の minify/bundle は 初回の HTTP リクエスト時に PHP 側からランタイムで生成される
    • 初回リクエストのレスポンスが非常に遅くなってしまう
    • CSS/JS の依存関係の解決(ファイルの読み込み)は Twig 上で行なっており、JS などを単体で見た場合に依存関係が分かりづらい
  • CSS/JS は開発者が書いたコードがそのまま(minify/bundle を除く)ブラウザーへと出荷される
    • CSS
      • vendor prefix 手作業がつらい
      • URL 関数などで参照する画像のバージョン管理(cache-buster の手動付与)がつらい
      • Hex などのデザイン周りの値が毎回ハードコーディングでつらく、たまに間違っててつらい
    • JS
      • 変数スコープがつらい
      • 3rd party ライブラリの取り扱いが煩わしい
      • JS のみで静的な依存解決ができない

近代化として取り組んだ内容

前述した課題を解決するため、実際に取り組んだ内容としては以下の通りです。

Sass の導入

前述していた CSS に対する課題は、Sass を導入することでほぼ全て解決できましたが、画像などの cache-buster のみ、ライブラリだけではどうにもならなかったため、対象サーバに内包している画像については Sass のビルドの事前に MD5 を計算した結果をファイルへと出力しておき、Sass の JavaScript API を使って CSS の URL 関数をオーバーライドし、MD5 の計算結果のあるものは Hash を付与して URL 関数を組み立てるということを Sass の build 内で行うことで解決しました。

sass-lang.com

この対応によって、既存のコードを修正することなく、また開発者のメンタルモデルも据え置きでハッピーだねという作戦です。関数名を別の名前(URL_USE_CB のような)にして、CSS 側をリネームするかという議論もあったのですが、オーバーライドするデメリットがほぼ存在しないことと、別の関数に切り出した後の周知徹底/実装忘れの懸念を考慮した結果、URL を上書きするという結論に至りました。

また、テンプレート(Twig)側から参照される画像についても、Twig 上に cache-buster 用のカスタムタグを作成し、その中で参照される img 要素には PHP から例の MD5 の計算結果ファイルを参照し、自動で付与されるように実装を加えました。

input

{% cachebuster %}
<img src="/img/xxxx.png">
<img src="https://s3..../foo.png">
{% endcachebuster %}

output

<img src="/img/xxxx.png?v=xxxxxxxxxxxx">
<img src="https://s3..../foo.png">

また、開発補助として StyleLint/Autoprefixer も合わせて導入しています。

Rollup の導入

JavaScript の bundler には Rollup を選択しました。

選択した理由としては、将来的に module/nomodule を採用した build プロセスを検討していたため、実装当時では ESM 形式の output の選択肢を取ることができる唯一の bundler だったということと、plugin の書きやすさなどが大きな要因です。

philipwalton.com

実装したい機能が満たされているかの確認として、bundler の機能比較はこのサイトを参考にするのが良いと思います。

bundlers.tooling.report

また、弊社では長年 joo というライブラリを使ってクラス宣言のようなことを行い、その宣言単位でモジュールと呼んで開発を行なっていたのですが、そのモジュール間の依存関係の解決は global(window)の名前空間を頼りに行なっていました。

github.com

イメージです

// module/A.js
def().as('myapp.module.A').
it.provides({
    method: function() {
        ~~~~~~~
    }
});

// module/B.js
def().as('myapp.module.B').
it.inherits(window.myapp.module.A).
it.provides({
    method: function() {
        this._super();
        ~~~~~~~
    }
});

しかし、これらの JS ファイルの読み込みは Twig 側から行なっていたため、そのページ(ルーティング)から読み込まれる Twig をまず特定し、そこから辿るようにして JS が読み込まれているかどうかを判定するしかファイル特定の術がありませんでした。また、Twig 上で依存関係(JS の読み込み及びそれらの読み込み順番)を解決しているため、ページによっては依存するモジュールを読み込んでおらずランタイムでエラーになってしまうということが発生してしまっていました。

<!-- ./somepage/head/javascript.twig -->
{{ minify_js([
    "module/A.js",
    "module/B.js"
]) }}

<!-- ./somepage2/head/javascript.twig -->
{{ minify_js([
    "module/B.js"  <!-- 依存する A を読み込めていない -->
]) }}

この問題に対して、Twig 側で依存関係の解決をするのをやめ、ESM の import/export を使用して JS 上で静的に依存関係の解決を行えるようにしました。

Babel の導入

JavaScript のコンパイラーとして Babel を導入しました。

前述した joo などは Class シンタックスによって、this のスコープ問題で bind や this の再代入などを行なっている記述はアロー関数に書き換え可能です。Optional chaining や Nullish coalescing などが良い例ですが、コードの記述量に関してもモダンな構文を採用する方が少なくなる傾向にあり、コード全体として可読性の向上にも繋がると思います。

また、導入時には基本的にモダンシンタックスのダウングレードコンパイルの役割しか持たせませんでした。

というのも、Babel を利用する場合 @babel/preset-env を併用して browserlist などでサポートブラウザを指定し、未サポートのメソッドの利用があった場合に Babel が core-js などから polyfill コードを読み込むのが一般的なセットアップだと思われますが、この自動判定の仕組みには限界があり「該当するメソッド呼び出しがあった際に透過的に追加する」という問題が発生してしまいます。今回の一件で具体例を挙げると Array.prototype.find と jQuery の $.find を見分けることが出来ずに不要な polyfill コードが読み込まれてしまっていました。

この挙動によって、後述する自動テストに影響が出てしまうため @babel/preset-env のオプションで利用する polyfill は、エントリーとなるようなファイルで手動読み込みするような設定にしました。

@babel/preset-env · Babel

Babel 自体は Rollup の plugin として通すようにビルドを組みました。

PJ を進める上で立ちはだかる問題点

これらのツールを適応/導入すること自体はそこまで難易度の高いものではありません。

しかし今回取り組んだ環境には 9 年を超える"重み"が存在し、PJ を進めるには同時にこれらを解決する必要がありました。

全ファイルのコンパイル前後における動作担保問題

前提として、これらの導入/移行計画は全ファイルを一括で行う必要がありました。

CSS で約 700 ファイル、JS で約 1500 ファイルほど存在していたのですが、それぞれコンパイル前後でファイル内容に差分が発生することが分かり、リリースするためには「コンパイル前後で生じた差分によって現状の期待値とずれてしまうことがない」ということを全ページの全機能で確認する必要がありました。

正攻法でいくには圧倒的にテスト工数が足りないことが分かっていたため、ブラウザ上での動作確認は念頭に置いておらず、「コンパイル前後で構文上に差分がないこと」を確認するという方針を取りました。

最終的には AST の一致を測る自動テストで 99.9 % 以上を担保することができました。Babel によってどうしても変えられてしまう if 文表記がいくつかあったので、そこだけは等価性を証明できなかったため、手動での確認を行いました。

無事何事もなく済んだのですが、リリースのためにはこの「何事も起きないはず」ということを証明する必要があったというのが最初の小話です。

文字コード問題

歴史のあるサイトのため、中には UTF-8 ではないファイルがそれなりにありました。

基本的にコンパイルされ作成されるファイルは UTF-8 として吐き出されてしまうため、日本語などでコメントが記述されている箇所を特定し、事前に文字コードと内容を修正する必要がありました。

CSS ハックをパースできない問題

いわゆる CSS ハックな書き方は、仕様上正式な構文ではないため、Sass の parser がエラーとして扱ってしまいます。

これらは全て以前サポートしていた IE 向けの記述ばかりだったのですが、幸いなことに現在弊社では IE については 11 のみをサポートしているため、これらの記述は本来不要であったため、事前に手動で削除する対応を行いました。

デプロイサーバー(テスト)で本番デプロイできちゃう問題

以前の弊社のデプロイ方法は、Jenkins からリリーススクリプトを手動実行し、魔法のシェルスクリプトによって本番サーバへと展開されていました。(現在は k8s にのっているため当時のこのフローは存在しません。詳しくは下記リンク先の記事をご覧ください)

www.lifull.blog

その魔法のシェルが動いている環境が、見出しで言及しているデプロイサーバで、これが非常に厄介ものでした。

歴史的な理由により、いわゆるテスト環境内で閉じた状態で動作確認を行える環境がありませんでした。

デプロイサーバ(本番)とデプロイサーバ(テスト)の環境的な差分はほぼ存在せず、デプロイサーバ(本番)で動いているスクリプトをコメントアウト/修正して動作確認をする必要がありました。

デプロイの向き先をミスしてしまうと、テスト中のソースで本番サーバに展開されてしまう可能性があり、ここでの動作確認がこの PJ でもっとも精神的負荷のある局面でした。

PJ チーム一丸となり、円陣を組みながらシェルを叩いた(比喩表現)のが功を奏し、本番デプロイは免れ無事にテストは完了できました。

学び

今回の近代化を通して、いくつか学びがあったので最後にまとめさせていただきます。何かに役立てば幸いです。

npm ci の必要性

リリース当初、モジュール更新は npm i コマンドでセットアップしていたため、実行タイミングによっては package-lock.json に差分が生じてしまう構成になっていました。

開発に関しては、都度マージすればいいんじゃないくらいの温度感の話なのですが、ある日社内のステージング環境の一つが突然更新が途絶えたタイミングがあり、調査した結果「定期的に git pull をしてソースを最新化」しているような構成だったため、package-lock.json に差分が生じたタイミング移行の git pull が失敗してしまっていました。

前述したリリーススクリプトでは「git 上で差分が無いこと(ステージングに何も存在しないこと)」を確認するステップがあり、こちらも大慌てで修正した次第です。

package-lock.json から忠実に再現したい場合に npm ci の必要性を認識させられました。

npm modules の更新作業

開発タイミングやブランチのマージタイミングによって、作業環境の node_moudles が最新の状態ではないことはそれなりに発生すると思いますが、その状態でビルドなどを実行した場合は必須モジュールが存在しないためにエラーとなってしまいます。

この際 Node.js への理解などがある場合は各自で npm ci を再実行してもらえば良いのですが、多くの開発者が出入りしている Reposiroty では全員がそうとは限らず、エラーの問い合わせも何度か発生していました。

この問題に対して、node_modules の更新作業を自動化するスクリプトを、開発コマンドの前に発火させることで対応しました。

イメージ

    "predev": "node ./check-module-install.js"
    "dev": "run-p dev:*"

スクリプトの内容としては、package-lock.json の中身と node_modules 配下のモジュールのバージョン確認をするシンプルなスクリプトで、完全に一致していれば何もせず、差分があったタイミングで npm ci を別のプロセスで実行するような内容です。

これを Github Packages として公開し、社内の他のプロジェクトでも利用できるようにしました。

おわりに

今回の PJ のように歴史あるアプリケーションの改善やリファクタリングにおいては、技術的課題に対して導入/解決する技術選定の問題と、それらを取りまく開発環境などにまつわる技術的問題が重なるケースがあり、場合によっては後者が進行を鈍化させてしまうこともあると思います。

また、開発環境の改善をする際は「それらを利用する開発者」のことを第一に考え、導入チームのバイアスがかかり過ぎないよう全体として最善の選択になるように心がけています。

今回のプロジェクトは約 3 ヶ月の期間で開発 2 名 + QA 2 名という体制でしたが、障害なく完遂できました。サポートしてくれた優秀なメンバーや様々な調整/依頼を快く引き受けてくれた多部署のメンバーに恵まれたことも、今回のような部署を跨いだ大きめなプロジェクトを成功させるための大きな要因の一つだと思っています。

これからも、緩やかではありますが、着実な改善を続けていきたいです。