株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

JVM勉強会(運用編)を開催しました

こんにちは、SREの戸田です。本日は社内で開催したJVM勉強会(運用編)の一部を公開します。

JVM、使っていますか?弊社ではサーバサイドKotlinが活躍しているので、もちろん日常的にJVMが稼働しています。このためサービス運用の一貫で必要になる知識や関連ツールなどをSREないしプロダクトチームに共有することを目的として、この勉強会を開催しました。

図1 勉強会はGoogle Meetでオンライン開催しました

パフォーマンス・チューニング

サービスを開発していると、この処理をもっと高速化したい!ランニングコストを抑えてユーザ体験の向上に投資したい!というというシーンには多く遭遇しますよね。こうしたユーザが増えてサービスに負荷がかかるようになったことで生じた課題に対して迅速に打ち手が取れることは、とても重要です。

しかし焦ってはいけません。「このコードはめっちゃループしてるし遅そう!」「あの機能を入れたら手触り悪くなったから、あの機能が怪しい!」という勘で改善を始めてしまうと、本当に改善されたのかの検証が難しいことに加え、問題の根っこを見つけられない可能性も出てきます。まずは、計測から始めましょう。

scrapbox.io

今回は問題となった処理をメソッドとして切り出せることを期待して、Java Microbenchmark Harness (JMH)を紹介しました。またその利用事例として inputStream.readBytes().toString(Charsets.UTF_8)inputStream.bufferedReader(Charsets.UTF_8).readText() に変えることで高速化することの確認を紹介しました:

Benchmark                   Mode  Cnt       Score      Error  Units
MyBenchmark.buffered128k   thrpt   25  226168.261 ± 3085.163  ops/s
MyBenchmark.buffered512k   thrpt   25  226123.470 ± 1595.982  ops/s
MyBenchmark.readBytes128k  thrpt   25    5105.985 ±   86.458  ops/s
MyBenchmark.readBytes512k  thrpt   25    1258.793 ±   49.706  ops/s

また問題が特定できていない場合に使える手段として、JDK Misson Control(JMC)などで作成できるJDK Flight Recorder(JFR)ファイルの作成を紹介しました。JFRファイルはJVMの状態をひろく、また時間軸に沿って観察したデータを残せるため、どこに問題があるかわからない状態でも活用しやすい特徴があります。

図2 JFRファイルをJMCで見ればメモリの利用状況も可視化される

そこからヒープダンプを取得したりマイクロベンチマークにつなげることで、より問題を掘り下げて調査する事ができると考えています。

起動高速化

Cloud RunでKotlinアプリケーションを動かす際、サービスの起動速度は重要なポイントになります。コンテナの起動が遅いとデプロイやスケールアウトに影響があり、最悪の場合はすぐにお客様に届けたい修正がなかなか届かない原因になってしまいます。

この課題は多くのユーザで共有されているようで、Cloud Runの公式ドキュメントではAppCDSによる高速化を紹介しています。ただこの方法だとデプロイの前処理が増えるので、スケールアウトはともかく修正を迅速に届けたい場合にはあまり有効ではないかもしれません:

cloud.google.com

今回の勉強会ではこれに加えて、CRaCjlink、native-imageについて紹介しました。またそれぞれの特徴を踏まえて、いまの自社サービスに適切と思われるアプローチについて議論しました。

図3 起動高速化に加えてコードの動作高速化についても実例を交えて議論しました

質疑応答

最後に質疑応答の時間を取りました。

まず -Xms-XX:MinRAMPercentage のような似た設定ではどちらを使えば良いのか?という質問がありました。答えは -XX:MinRAMPercentage です。JDKにはエルゴノミクスという考え方があり、ざっくりと要求を伝えておけば自身を動的に最適化してくれます。そのためヒープのサイズを -Xms-Xmx でバイト単位で指定するのではなく、このくらいまでメモリを使っていいよとざっくり伝えて細かいチューニングを任せるようにします。

docs.cloudbees.com

またStacktraceを取得するのはコストが高いのではという質問もありました。実はJava 9でStackWalkerが実装されたことで、Stacktraceを取得するコストは安くなっています。その効果は高く、例えばlog4j2ではJava9以降ではこの新しいAPIを使ってメソッド呼び出し元を特定しています。

JVMと仲良くなってKotlinをもっと活用したい

今回のJVM勉強会(運用編)は良い盛り上がりを見せました。Kotlinを活用して顧客に価値提供をするためにも、こうした基盤部分の理解を深めることは大切です。今後もチーム一丸となって学ぶため、運用編に続き開発編も開催したいと考えています。

なおKotlinに関する活動については以下の記事でも紹介していますので、あわせてご覧いただけますと幸いです。

経営層が知るべき、目標と見積りの話について

img_tagert

こんにちは。ヘンリーCEOの逆瀬川です。

今日は、ソフトウェア見積りという本を社内輪読会で読んでいるのですが、この本のお陰で、目標と見積りに関するコミュニケーションが劇的に改善したお話をします。

ソフトウェア見積りはとても良い本なので、他にも紹介したいことがありますので、それは別の機会に。

皆さんは目標や見積りはどのような意味で使っていますか? デジタル大辞泉によると、目標とは、「 行動を進めるにあたって、実現・達成をめざす水準。」と定義されてます。

皆さんがイメージしていた内容ではないでしょうか?私たちもあまり認識がずれるとは思ってなかったので、社内であまり定義せずに使っていた結果、下記のような問題が発生していました。

  • 目標と必達目標が生まれ、どちらの話をしているのかがわからない
  • 目標の期日と品質がいつの間にか必達水準に変わってしまうため、見積りをそのまま目標としてしまう
  • 見積りのコミュニケーションをしていたはずが、いつのまにか目標にすり替わってしまう
  • 元々出したい期日が目標として決まっており、その目標を達成前提で計画する。(しかも、その計画が非現実的)
  • 開発スケジュールが遅れに遅れ、見積り不要論が発生する
  • 開発スケジュールが遅れに遅れ、逆に同じような見積りを何回もする

などなど

結果、目標に関してミスコミュニケーションが多発しており、目標とはどうあるべきだという議論をしつつ、結論が出ないままフラストレーションが溜まる日々でした。 そこで、ソフトウェア見積りという素晴らしい本に出会います。

“ターゲット“と“コミットメント“と"見積り"と

ソフトウェア見積りに、見積り、ターゲット(目標)、コミットメントについての説明がありますが、まさに求めていたものでした。 しかも、1章に求めていたものが!!

- 見積り : プロジェクトにかかる期間やコストを予測
- ターゲット : 実現したいビジネス上の目標を明文化したもの
- コミットメント : 定義された機能を、特定の品質レベルを確保しながら期日までに納品するという約束

「この日までに終わらせたい」が見積りを歪めていた

スタートアップたるもの、なるべく早く開発したいし、Over達成したいものです。ましてや、私は楽天出身です。

いつの間にか、見積りを出す時または見積りを受け取る時に、目標を伝えていました。

みなさんもこういう経験あるのではないしょうか?

"見積り"と"ターゲット"の分類が全てを救う

“ターゲット“と“コミットメント“と"見積り"を分けて、コミュニケーション取るようになったため、社内のコミュニケーションがだいぶ改善されています。

「XXXの機能ですが、見積りとしては3週間程度で終わりますが、ターゲットとしては、2週間で終わらせたいと思っています」

「年内のロードマップですが、見積りとしては、1.5ヶ月ほどはみ出る可能性が高いです。XXとYYが特にリスクが高いので、それを早めに解消して、再度見積りとターゲットを決めましょう!」

などなど、建設的な意見が生まれるようになりました!

ソフトウェア見積りは、今年一番読んで良かった本といっても過言でもないので、ぜひ、プロジェクト進行や見積りなどで困っている方は読んでみてください。

経営者の皆さん、「それいつ終わるの?」って聞いてないですか?

最後に、自戒も込めて。 経営者が「それいつ終わるの?」と聞くと、コミットメントのように聞こえます。

そのため、回答する人は固めな目標を出すケースが多いと思いますし、経営者自身も正しい見積りを聞ける機会を失ってます。 ターゲットと見積りとコミットメントを分けて聞くことで、正しくコミュニケーションが取れるので、ぜひ使ってみてほしいです。

一緒に仕事をする仲間を募集しています

ヘンリーでは、他にも社内勉強会を積極的に開催して、得た知識を事業推進にみんなで活かしています。 全ては、日本の持続可能な医療を実現するために。 困難で大変なことも多々ありますが、今後も高いターゲットを掲げて事業推進していきます! ぜひ、興味のある方は、採用サイトよりご連絡ください。

参考にした本

www.amazon.co.jp

はじめまして nabeo です

今年の6月にヘンリーに SRE として中途入社しました nabeo (id:nabeop) です。前職では toC 向けのコンテンツプラットフォームサービスと toB 向けのテクノロジーソリューションサービスなどを展開している会社で共通の基盤を開発運用している部署で SRE をやっていました。

ヘンリーに入ろうと思った理由

今年の初め頃から以下を軸にしてなんとなく転職活動を開始していました。

  • 自分にとって新しいチャレンジをしたい
  • エンジニアとしての成長ができる場に身を置き続けたい

エンジニアとしての成長はわりとこだわりが強くて、自分よりも若いエンジニアがメキメキ成長をしている様子を目の当たりにしていたので強い危機感をもっていました。そんな中で、カジュアル面談や面接を通じてヘンリーには優秀なエンジニアが在籍していそうだし、一緒に仕事をしたいと思えました。とくにカジュアル面談や面接では同じ SRE として活躍されている戸田さんといくつかの技術的なディスカッションをさせてもらいましたが、ディスカッション自体が楽しく一緒に働きながら組織を成長できそうだという印象をもてたことが大きかったです。

また、会社としても勢いがあるが、SRE 的な文脈ではまだまだ未整備なところがあって、SRE として技術分野だけでなく、文化などより広範囲なところで活躍できそうというイメージがあったことも魅力でした。とくに電子カルテやレセコンという医療分野をターゲットにしたプロダクトを開発/運用するにあたり SRE 的な知見をどのように導入して活用していくのかというのは入社前にはまったく想像がつかずとてもワクワクしたことを覚えています。

ヘンリーのオンボーディング

ヘンリーのオンボーディングでは「初日」「最初の1週間」「最初の1ヶ月」の3つの粒度でヘンリーが開発しているクラウド電子カルテ・レセコンシステムの Henry の概要や開発で必要となってくるドメイン知識だけでなく、ヘンリーの文化やプロダクトの機能などを効率よくキャッチアップできるようにタスクが組まれていてとても助かりました。実際にヘンリーに入るまではレセコン*1という言葉も聞いたことないし、病院やクリニックでどのような業務フローがあるかもわかっていない状態でしたが、必要な知識が効率よく学べるようにチェックリスト形式になっていたのでスムーズにキャッチアップできたと思います。

入社初日の TODO リストとして業務で使用するツールで実施するべき初期設定のリストがある。
入社初日の TODO リスト

また、実務でもこれまでに発生した障害や不具合の対応などをまとめて開発に及ぼした影響などが判別しやすい指標を考えるというタスクを渡されて、開発の進め方や Henry の大まかなシステム構成などを俯瞰して眺めることができました。とくに不慣れな医療という現場でどのようにシステムが使われているかを座学で学んだ医療ドメインの知識と照らし合わせることができたのでコスパが良いキャッチアップタスクだったなと思っています。

これから何をするのか

前述のとおり Henry はこれからも成長を続けていくプロダクトですが、SRE 的な視点だとまだまだ未整備なところがあります。当面はサービスの開発と安定性の維持にバランスよく、かつ、最大の速度を出せるように SRE 的な知見の導入が主なミッションになっていくのかなと思っています。

特に SRE として絶対に欲しい SLI や SLO もまだ十分に整備されている状態とは言えず、Henry のプロダクト特性に応じた SLI や SLO を策定し運用する必要があると考えています。

また、前職では自分自身のアウトプットの他にも組織としてのアウトプットを後押ししていましたが、ヘンリーでも何らかの形で他者のアウトプットを後押しすることで、ヘンリーのエンジニアについてたくさんの人に知って欲しいと思っています。

一緒に仕事をする仲間を募集しています

SRE 職にかぎらずヘンリーでは一緒に働く仲間を募集しています。これからも成長が見込める弊社で一緒に働いてみませんか?興味のある方は以下の採用サイトからぜひコンタクトしてみてください。

*1:レセプト(診療報酬明細書)を作成するときに使用するレセプトコンピューターのことです。

StorybookをReact以外のプロジェクトでも使いたい!

はじめまして。今月から株式会社ヘンリーのフロントエンドエンジニアをしている kobayang です。この記事では Storybook を React や Vue などの UI ライブラリを使っていないプロジェクトでも活用できるかも、という話をしていこうと思います。

Storybook

storybook.js.org

もはや説明不要な気がしますが、Storybook は UI を実装する上で便利な、UI プレビューのためのライブラリです。また、プレビューだけでなく、Jest から Story を呼び出すことで簡単にテストが書けたり、Chromatic と連携することで、VRT ができるようになったりと、開発の生産性をあげるだけでなく、プロダクトの安定性を上げるためにも重要なライブラリになっています。

そんな便利な Storybook なのですが、普段当たり前のように使っているがゆえに、Storybook がないと不安になる、ということに気づいてしまいました。その時に触っていたコードは、HTML のテンプレートエンジンで書かれていたため、プレビューできないのは仕方がないかなと思っていたのですが、そこで、あることを思い至りました。

HTML で動いているんだから Storybook で動かないはずはない、と。

Storybook for HTML

ここから本題です。先に結論を言ってしまえば、HTML を Storybook で動かすことができます。新規プロジェクトの場合は、Storybook 初期化時に、 —type html を指定することで、HTML を動かすための最小限のセットアップが行えます。

// pnpm の場合
pnpm dlx storybook@latest init --type html
// npm の場合
npx storybook@latest init --type html

Storybook v7 からは Vite でも動くようになったので、Vite or Webpack のどちらを使用するか、という選択が出ます。今回は Webpack を選択します。

初期化が完了すると、Storybook の設定と、stories フォルダにサンプル用のプロジェクトが追加されます。

Storybook の設定

.babelrc.json
.storybook/main.js
.storybook/preview.js
package.json

サンプルファイル

stories/Button.js
stories/Button.stories.js
stories/Header.js
stories/Header.stories.js
stories/Introduction.mdx
stories/Page.js
stories/Page.stories.js
stories/assets/code-brackets.svg
stories/assets/colors.svg
stories/assets/comments.svg
stories/assets/direction.svg
stories/assets/flow.svg
stories/assets/plugin.svg
stories/assets/repo.svg
stories/assets/stackalt.svg
stories/button.css
stories/header.css
stories/page.css

stories に出力されたサンプルファイルは邪魔なので全部消してしまいます。

さて、もう少し変更されたファイルを見てみましょう。まずは package.json から。

"devDependencies": {
  "@babel/preset-env": "^7.22.5",
  "@storybook/addon-essentials": "^7.0.20",
  "@storybook/addon-interactions": "^7.0.20",
  "@storybook/addon-links": "^7.0.20",
  "@storybook/blocks": "^7.0.20",
  "@storybook/html": "^7.0.20",
  "@storybook/html-webpack5": "^7.0.20",
  "@storybook/testing-library": "^0.0.14-next.2",
  "react": "^18.2.0",
  "react-dom": "^18.2.0",
  "storybook": "^7.0.20"
}

上記のパッケージが追加されました。 HTML を選択しているのに React が追加されているのが気になりますね。

今回、Storybook を動かすのに本当に必要なのは、以下のパッケージになります。

  • storybook
  • @storybook/html
  • @storybook/html-webpack5
  • @babel/preset-env

さて、ここで、 storybook/main.js をみてみると以下のような設定がされています。

framework: {
  name: "@storybook/html-webpack5",
  options: {},
}

Storybook v7 から導入された Framework によって隠蔽されてしまっていますが、どうやら、この設定によって、 HTML を Storybook が起動できるようになっていそうです。

HTML を Story として読み込んでみる

試しに、適当な index.html を作って Story に読み込ませてみましょう。

<h1>test</h1>
import html from "./index.html";

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉

ではこれはどうでしょう?Story に直接 HTML を書いてみます。

export default { title: "index" };
export const Preview = () => `<h1>test</h1>`;

これも無事に動きました 🎉

適当に Story を試しただけですが、このことから、ファイルを HTML として出力さえすれば Storybook で動かすことができることが分かりました。

index.html が読み込めているのは、 @storybook/html-webpack5 が HTML を Webpack のローダーとして設定しているためです。ということは、ローダーさえセットできれば、任意のプロジェクトを Storybook で動かすことができるはずです。

実際にやってみましょう。

テンプレートエンジンを Storybook で動かす

UI ライブラリではないプロジェクトを Storybook により動かせるようにしたいと思います。今回は例としてHandlebars というテンプレートエンジンを使います。詳細は割愛しますが、以下のように、テンプレートを記述することができます。

<h1>{{title}}</h1>

Handlebars.js によってテンプレートをコンパイルできます。コンパイルするためにパッケージをプロジェクトに追加します。

pnpm add handlebars

以下のようにコードを書くことで、 Handlebars で書かれたファイルを HTML に変換することができました。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });
// => <h1>Test</h1>

Story に直接書いてみる

コンパイルすることで HTML を生成することができるところまで分かったので、このコードを Story に記述してみます。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きそうですが、これは残念ながらエラーになってしまいました。

handlebars モジュールを読み込む際に、 Module not found: Error: Can't resolve 'fs' というエラーになります。本質的なエラーではないですが、Storybook を起動するために修正する必要があります。fs module を何らか mock してあげれば回避できそうです。

ググって見つかった stackoverflow の回答に従って、以下のように Storybook の main.jsconfigwebpackFinal を記述します。

webpackFinal: async (config) => {
  config.resolve.fallback.fs = false;
  return config;
},

これで、Storybook が起動しました 🎉

ファイルから Import する(その1)

上記の直接 Story に書いていくやり方は、さすがに古典的すぎるので、せめてファイルからインポートできるようにしたいです。というよりファイルからインポートできないと、プレビューとしての意義を果たせません。察しの良い方はすでにお分かりの通り、この辺りから Webpack のローダーをカスタマイズしていきます。

先ほど修正した webpackFinal のフィールドがありますが、ここから Storybook で動かす Webpack の設定を変更できるようになっています。

とりあえずファイルから読み込めるようにするために、raw-loader を使ってみましょう。 raw-loader は名前の通り、ファイルをテキストとして読み込むローダーです。

まず、 raw-loader をパッケージに追加します。

pnpm add -D raw-loader

.storybook/main.jswebpackFinal に、 .handlebars 拡張子のファイルに対して raw-loader を適用するようにセットしてみましょう。

webpackFinal: async (config) => {
  config.module?.rules?.push({
    test: /\.handlebars$/,
    loader: "raw-loader",
  });
  config.resolve.fallback.fs = false;
  return config;
},

次に、 index.handlebars を作成し、そこにテンプレートを記述します。

<h1>{{title}}</h1>

これで準備が整いました。先ほど書いた source の部分を index.handlebars から import するように変更してみましょう。

import Handlebars from "handlebars";
import source from "./index.handlebars";

const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きました 🎉

ファイルから Handlebars で書かれたテンプレートを Story に import できるようになりました。ここまでで必要最小限のプレビュー機能が達成できたことになります。

しかし、テンプレートが一つのファイルで収まることは稀でしょう。実際には、テンプレートは別のテンプレートに依存します。Handlebars においても、テンプレートから別のテンプレートを呼び出すことが可能です。

以下のように記述します。

<h1>{{title}}</h1>
{{>subtitle}}

subtitle.handlebars

<h2>{{subtitle}}</h2>

上記で記述した Story だと、 subtitle を読み込むことができないので、以下のようなエラーになってしまいます。

これを解決するには、 registerPartial を使って、コンパイルの前に、依存する Partial Template を登録しておく必要があります。

import Handlebars from "handlebars";
import source from "./index.handlebars";
import subtitle from "./subtitle.handlebars";

Handlebars.registerPartial("subtitle", subtitle);

const template = Handlebars.compile(source);
const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

これで、動くには動きますが、依存ファイルが増えるたびに Story にそれを追加しなければいけないのは何とも筋が悪いです。次はこれを何とかしましょう。

ファイルから import する(その2)

Handlebars には幸いにも Webpack のローダーが存在します。

github.com

このローダーを設定することで、先ほどの依存をいい感じに解決してくれるようになります。

設定してみましょう。

まずは、handlebars-loader をパッケージに追加します。

pnpm add -D handlebars-loader

Storybook の Webpack のローダーを変更します。

config.module?.rules?.push({
  test: /\.handlebars$/,
-  loader: "raw-loader",
+  loader: "handlebars-loader",
});

変更はこれだけです。この設定により、 Handlebars で書かれたテンプレートがローダーによって解決されるようになりました。

以下のような Story を記述すれば先ほどのテンプレートも動くようになります。

import template from "./index.handlebars";

const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉  これで、Handlebars によって記述された HTML をいい感じに Storybook で確認できるようになりますね!

まとめ

HTML を Storybook で動かす方法について簡単に紹介しました。また、Handlebars というテンプレートを例にとって、コンパイル可能であれば raw-loader を使ってテンプレートファイルをプレビューできること、さらに Webpack のローダーがあれば、より良い感じに Story を記述できることを示しました。

ぱっと思いつく適用例としては、 erbhaml の Webpack ローダーが存在していそうなので、Rails のプロジェクトにも Storybook が適用できるかもしれません。(注: 試してないので分かりません)

github.com

github.com

今回例に出した Handlebars は、Vite 用のローダーがなかったため、Vite では起動できませんでしたが、設定さえしてあげれば Vite でも同様なことができるはずです。

また、実際のプロジェクトは、より複雑だったり、依存するファイルがプログラムによって制御されていたりと、ローダーを直接使うことが難しかったりするかもしれません。

しかし、Webpack をいい感じに設定して HTML さえ出力できれば Storybook を使えることが分かってもらえたと思うので、もし、Storybook が入ってないプロジェクトがある場合には、ぜひ一度導入をチャレンジしてみて欲しいなと思います。

皆様がより良い Storybook ライフを送れますように。敬具。

参考

今回説明で使った成果物は以下のリポジトリにあります。

github.com

【Scala 3 macroがすごい】Compiletime API編

株式会社ヘンリーでメタプログラミングに没頭しているgiiitaです。

突然ですが皆さんはメタプログラミングに触れたことがあるでしょうか? プログラミング言語によって様々なメタプログラミングの機能があります。Javaやその派生言語では馴染み深い「リフレクション」はその代表例ですが、LispやRust, Scalaなどにはマクロと呼ばれる、compile前にコードを操作する仕組みがあります。 そんな中でも最近急激な変化を遂げている Scala3 のMacroがとんでもなくすごいんですが、なかなかまだ情報が出回っておらず、手を出すにはあまりにも敷居が高くなっているため、皆さんに面白さを知ってもらうべく紹介します。

マクロって何ができるの?

そもそもマクロに手を出しづらい背景として、何に使えるのか、いつ使うべきなのかよくわからないからというのが大きいのではないでしょうか? 現に私自身、手を出すまでイメージがつかず、わざわざ難解でExperimentalな機能 (Scala2系当時) を使ってまで何かをしたいというモチベーションがありませんでした。 とは言いつつも、今なら明確にこういうケースで使うべきですと言えるわけではないので難しいところですが、基本的には コードの変換自動生成 といったところが主な目的です。 そして何より、それら成果物を compilerによって静的に検査できる というのが旨味なわけです。

使ってみよう

今回はひとまず先日の3.3.0リリースを祝って、3.3.0でいろいろやっていきたいと思います。

Setup

まずはModule構成です。マクロを使用したmoduleは、利用するmoduleのbuild時にはbuildが完了していないといけないので、macroを定義するmoduleと呼び出すmoduleが必要です。 (確かこれもScala3で変更があったような気も...

build.sbt

lazy val Scala3_3  = "3.3.0"
scalaVersion in Scope.Global := Scala3_3

lazy val root = (project in file("."))
  .aggregate(
    macroModule,
    runtimeModule,
  )
lazy val macroModule = (project in file("macro-module"))
  .settings(
    libraryDependencies ++= {
      Seq(
        "org.scala-lang" %% "scala3-compiler" % scalaVersion.value,
      )
    }
  )
lazy val runtimeModule = (project in file("runtime-module"))
  .dependsOn(macroModule)

もはや何も特別な事はありません。 さて、何を作ろうか悩むところですが、わかりやすくここは誰もが通る道という事で、JsonParserを作っていきたいと思います。 String => JsonJson => String が必要なのでI/Fを切りましょう。この時、マクロから利用するものはmacro以下のmoduleに定義する必要があります。

trait Read[T] {
  def read(json: Json): T
}
trait Write[T] {
  def write(t: T): Json
}
trait Both[T] extends Read[T] with Write[T]

ここでは変異境界は無しでやっていきます。

trait Json extends Serializable {
  def nameOf(key: String): Json
}
case class JsonString(value: String) extends Json {
  override def toString: String = s""""$value""""
  override def nameOf(key: String): Json = JsonNull
}
case class JsonAny(value: String) extends Json {
  override def toString: String = value
  override def nameOf(key: String): Json = JsonNull
}
case class JsonArray(values: Seq[Json]) extends Json {
  override def toString: String = s"""[${values.mkString(",")}]"""
  override def nameOf(key: String): Json = JsonNull
}
case class JsonObject(values: Map[JsonString, Json]) extends Json {
  override def toString: String = s"""{${
    values.map { case (key, value) =>
      s"$key:$value"
    }.mkString(",")
  }}"""

  override def nameOf(key: String): Json = values(JsonString(key))
}
case object JsonNull extends Json {
  override def nameOf(key: String): Json = this
}

Jsonの型としては一旦こんなところでしょう。エスケープ処理は面倒なので考慮していません。 性能面では話にならないのであまり参考にしないでください。String to Jsonの処理もちゃんとやると結構面倒なのでイメージです。

さて、ここからいよいよマクロを書いていきますが、Scala3のマクロには2種類あります。

一つはお馴染み、AST(抽象構文木: Abstract Syntax Tree) を直接的に操作する方法です。 もう一つはScalaのコードがCompileされる前段のフェースでPrecompileされる、半マクロ的なものです。これは、scala3-libraryの scala.compiletime パッケージにAPIがあります。

Compiletime API

後者は近しいものがScala2にもありましたが、非常に強化されました。非常に簡単かつ、安全に使用できるAPIなので、出番も多いかもしれません。これはCompiletime APIと呼ばれています。 まずはこれを用いて、CaseClassのJson変換器を導出していきます。

// runtimeModule
object CodecGenerator {
  inline final def CaseClass[T]: Both[T] = InferCodecs.gen[T]
}

実行module側からinline functionを呼び出しています。 InferCodecs.gen は単なるinline関数であり、マクロ展開はされませんが、前述の通りPrecompileによってinline化されます。

// macroModule
object InferCodecs {
  inline def gen[A]: Both[A] = {
    summonFrom[Both[A]] {
      case given Both[A] => implicitly[Both[A]]
      case _: Mirror.ProductOf[A] => InferCodecs.derivedCodec[A]
      case _ => error("Cannot inferred.")
    }
  }

  inline def derivedCodec[A](using inline A: Mirror.ProductOf[A]): Both[A] =
    new Both[A] {
      override def write(value: A): Json = Writes.inferWrite[A].write(value)
      override def read(value: Json): A = Reads.inferRead[A].read(value)
    }

  trait ProductProjection {

    transparent inline def inferLabels[T <: Tuple]: List[String] = foldElementLabels[T]

    transparent inline def foldElementLabels[T <: Tuple]: List[String] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          constValue[t].asInstanceOf[String] :: foldElementLabels[ts]
      }
  }

  object Reads extends ProductProjection {

    inline def inferRead[A]: Read[A] = {
      summonFrom[Read[A]] {
        case x: Read[A] => x
        case _: Mirror.ProductOf[A] => Reads.derivedRead[A]
        case _ => error("Cannot inferred")
      }
    }

    transparent inline def derivedRead[A](using A: Mirror.ProductOf[A]): Read[A] =
      new Read[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def read(value: Json): A = {
          val buffer = new Array[Any](elemCount)
          elemSignature.foreach { case ((label, read), i) =>
            buffer(i) = {
              read.read(value.nameOf(label))
            }
          }
          A.fromProduct(
            new Product {
              override def canEqual(that: Any): Boolean = true

              override def productArity: Int = elemCount

              override def productElement(n: Int): Any =
                buffer(n)
            }
          )
        }
      }

    private inline def inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

    private inline def foldReads[T <: Tuple]: List[Read[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferRead[t] :: foldReads[ts]
      }
  }
  object Writes extends ProductProjection {
    inline def inferWrite[A]: Write[A] = {
      summonFrom[Write[A]] {
        case x: Write[A] => x
        case _: Mirror.ProductOf[A] => Writes.derivedWrite[A]
        case _ => error("Cannot inferred")
      }
    }

    inline def derivedWrite[A](using A: Mirror.ProductOf[A]): Write[A] =
      new Write[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemDecoders: List[Write[_]] =
          inferWrites[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemDecoders).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def write(t: A): Json = {
          val entries = t.asInstanceOf[Product].productIterator.toArray
          JsonObject(
            (0 until elemCount).map { i =>
              JsonString(elemLabels(i)) -> elemDecoders(i).asInstanceOf[Write[Any]].write(entries(i))
            }.toMap
          )
        }
      }

    private inline def inferWrites[T <: Tuple]: List[Write[_]] = foldWrites[T]

    private inline def foldWrites[T <: Tuple]: List[Write[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferWrite[t] :: foldWrites[ts]
      }
  }
}

複雑に見えますが、順に見ていきます。

InferCodecs.gen
  inline def gen[A]: Both[A] = {
    summonFrom[Both[A]] {
      case given Both[A] => implicitly[Both[A]]
      case _: Mirror.ProductOf[A] => InferCodecs.derivedCodec[A]
      case _ => error("Cannot inferred.")
    }
  }

summonFrom というのはcompiletime APIで、scala2における implicitly[T] に近いものです。 パターンマッチによって、 given Both[A] がimplicit scopeに見つかればそれを返して終わります。

case _: Mirror.ProductOf[A] もpackageこそ変わっていますが馴染み深いのではないでしょうか?要するにケースクラスであり、メンバの型情報が導出できれば、という分岐で、 given Both[A] は見つからないけど、型情報導出できそうなのでReadとWriteをそれぞれ導出するぜ!という処理です。

Reads.inferReads
    inline def inferRead[A]: Read[A] = {
      summonFrom[Read[A]] {
        case x: Read[A] => x
        case _: Mirror.ProductOf[A] => Reads.derivedRead[A]
        case _ => error("Cannot inferred")
      }
    }

これも一緒ですね、さっきのは Both[T] の導出だったのに対し、今回は Read[T] の導出になっているだけです。

Reads.derivedRead
    transparent inline def derivedRead[A](using A: Mirror.ProductOf[A]): Read[A] =
      new Read[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def read(value: Json): A = {
          val buffer = new Array[Any](elemCount)
          elemSignature.foreach { case ((label, read), i) =>
            buffer(i) = {
              read.read(value.nameOf(label))
            }
          }
          A.fromProduct(
            new Product {
              override def canEqual(that: Any): Boolean = true

              override def productArity: Int = elemCount

              override def productElement(n: Int): Any =
                buffer(n)
            }
          )
        }
      }    

    private inline def inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

    private inline def foldReads[T <: Tuple]: List[Read[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferRead[t] :: foldReads[ts]
      }

さて、いよいよcompiletime APIの本領発揮です。

inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

これはシグネチャから想像できる通り、任意のTupleのサブタイプ T の要素から Read[T] を導出してListで返しています。

inline erasedValue[T] これはまぁ見ての通りですが、 T <: Tupletypeunapply 的な操作である事は想像できます。 ※リファレンス

EmptyTuple か、 _: (t *: ts) かのパターンがあるという事は、 (A, B, C)(A *: (B, C))unapply されるという事ですね。そして A, B, C が順次導出されていきます。WritesもReadと同じですね。

        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

この辺はなんとなく想像しやすいと思います。型情報からラベル (変数名) とそれに対応する再起的に導出された Read[_]を持ち、 read(value: Json) が呼び出された際にはマッピングして各メンバをdeserializeしますよ、という事ですね。

さて、これでケースクラスのCodecは導出できるようになりました。後は末端のプリミティブ型のCodecが事前に定義されていて、implicit scopeに見つかれば動きそうです。 サンプルとして、末端がStringかIntであればいいとしましょう。

object Codecs {
  given Both[Int] with {
    override def read(json: Json): Int = json.toString.toInt
    override def write(t: Int): Json = JsonAny(t.toString)
  }

  given Both[String] with {
    override def read(json: Json): String = json.toString

    override def write(t: String): Json = JsonString(t)
  }
}

object Main extends App {
  export Codecs.given

  case class Root(child: Child)
  case class Child(str: String, num: Int)

  val codec = InferCodecs.gen[Root]
  val raw = codec.write(
      Root(
        Child("A", 1),
      )
  )
  println(raw)

  val value = codec.read(raw)
  println(value)

}

出力結果はこんな感じになりました。

{"child":{"str":"A","num":1}}
Root(Child("A",1))

雑な作りではありますが、雰囲気はつかめたのではないでしょうか? このサンプルでは、 given Both[T]Mirror.ProductOf[A] のいずれかしか処理していないので、間に Seq[T] などが入ってきてしまうとcompileできなくなります。 そして、マクロの呼び出し元はcompileの時点で異常に気付くことができるようになるわけです。

※本内容には、一部個人的な解釈を含んでいます。

最後に

面白い反面、マクロの運用にはなかなかリスクも伴います。 Scala3はまだまだガンガン開発が進んでいるので、3.0 ~ 3.3にかけて結構APIが変わっていたり、それだけならまだしもシグネチャのシンボルがこっそり変わったりして、OSSによってはScala3.xごとにsrcクラスパスを分けていたりします。 すでに、cross buildという観点では、同一のsrcクラスパスで各マイナーバージョンでのマクロmoduleのビルドはなかなか難しいかもしれません。

Scala2と比較して、構文literal直書き (scala.reflect.api.Quasiquotes) ができなくなった分マシにはなりましたが、結構めちゃくちゃなことをやっているところも少なくないので、適当なOSSのマクロ関数を読む事はあまりお勧めできません。 何より一番厳しいのは情報の少なさで、実装を読むか、公式 に聞く以外お手上げなんてことも... そんな中でも少しでも情報を集めている方は、私がメンテナンスしている DI framework のコードでも参考にして見てください。少しは助けになるかもしれません。

次回はもっと自由度の高いMacro APIについてお話ししようと思います。 Techオタクなあなた、是非私たちとお話しましょう!

jobs.henry-app.jp

プロダクト開発における認知負荷と戦う知見を共有するイベント「認知負荷バスターズ」を開催します!

こんにちは、リードエンジニアの @agatan です。

今回は、6/16に開催予定のイベント「認知負荷バスターズ」についてのお知らせです! このイベントは、株式会社タイミーとヘンリーが共同で開催するもので、我々プロダクト開発者にとって興味深いテーマ、「認知負荷」に焦点を当てています。

henry.connpass.com

認知負荷とは

認知負荷とは、一般的には情報処理能力を超える情報や要求によって生じる精神的な負荷を指します。

プロダクト開発に関わる全ての人々が、認知負荷と日々対峙していると言っても過言ではありません。 難解なコードベース、複雑な仕様、膨大なドメイン知識、etc...
認知負荷を高める要素は至る所に存在しています。 認知負荷との戦いは、プロダクト開発の根幹を成すものともいえるのではないでしょうか。

このイベントで話すこと

一方で、認知負荷との戦い方は、プロダクト・組織のフェーズや性質、辿ってきた歴史によって様々です。 このイベントでは、それぞれの抱える認知負荷にまつわる課題を共有し、それぞれの企業がどのように対処しているのかをLT形式で発表します。

タイミーとヘンリーは、それぞれ求人と医療という異なる業界でビジネスを展開していますが、共通して難易度の高いドメインを扱っています。(そして、みなさんのいる業界も!)

それぞれの開発チームが抱える課題には、共通点もあれば相違点もあるはずです。それらの経験を共有することで、登壇者だけでなく、参加者全員が新たな学びを得ることができるとよいなと思っています。

LT終了後は、懇親会も開催します。私たち登壇者と直接話す機会はもちろん、参加者同士で意見を交換したり、議論したりする時間も設けています。今回のイベントのテーマの性質上、懇親会での議論もなかなかに盛り上がるのではないかと楽しみにしています!(そのためのオフライン開催です!)

こんな人に参加してほしい!

  • 「認知負荷」について深く理解したい方
  • 自社の認知負荷軽減のための実践的な事例を探している方
  • タイミーやヘンリーのエンジニアと交流したい方

まとめと参加方法

以上、株式会社タイミーとヘンリーの共催イベント「認知負荷バスターズ 〜複雑なドメインをやっつけろ〜」のご案内でした。複雑なドメインに取り組む我々にとって、認知負荷の軽減は非常に重要な課題です。一緒にこの課題に立ち向かい、認知負荷をバスターしませんか?

興味を持っていただけた方は、ぜひイベントに参加してみてください!お待ちしています!

↓参加登録はこちらから

henry.connpass.com

フロントエンドをViteからNext.jsに書き換えた話 〜パフォーマンス編〜

こんにちは。4月にヘンリーに入社したSWE / アーキテクト / SETのsumirenです。
弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

今回、HenryのフロントエンドをReact + ViteからNext.jsに書き換えました。
この記事では、最初にNext.jsへの切り替えによってもたらされたユーザー体験の向上について説明します。次に、このユーザー体験の向上がどうして生じたのか、その背後にある技術的な要素をエンジニア向けに詳細に解説します。最後に、フロントエンドアーキテクチャに対する我々の長期的なビジョンについて述べます。

対象読者

  • エンジニアの方
  • ビジネスサイドの方
  • Henryを導入されている医療機関様の方

Next.js導入によるユーザー体験向上

私たちは数年前からHenryを開発しており、IT業界のトレンドに沿って技術をアップデートしています。
今回、業界のトレンドとしてNext.jsという技術が進化を遂げており、こちらで画面表示をリプレイスすることにしました。

以前にブログ記事でも紹介したとおり、ヘンリーでは様々なツールを用いてソフトウェアとエンジニアリングのステータスを定量化しています。 dev.henry.jp

医療機関様の環境での画面の性能は、以下のようになっていました。右下のFCP Distributionという箇所を見ると、200ms〜1秒程度のFCPがボリュームゾーンとなっていました。

ツール上で表示される画面表示のパフォーマンス

大部分が200msで済むならそこまで低い数値でないようにも思えるのですが、定性的にパフォーマンスを深掘りしていくと、ページをリロードしたときに半分以上の方が0.5秒〜2秒程度の待ち時間を感じていることが分かりました。

ツール上で表示される画面表示のパフォーマンス - 定性

この数値が高い(性能が低い)と、ユーザーがページを読み込み直すときに時間がかかり、ユーザー体験を悪化させます。例えば患者様の会計をするために急いでいるときに、Henryのページが高速で表示されなければ、医療事務の方々はストレスを感じることになります。

Next.js採用は、長期的なビジョンに基づいています(それについては主にエンジニアの方々向けに記事の最後のほうで述べます)。それに加えて、短期的なユーザー体験向上のメリットもあり、特に上記のパフォーマンスについても改善できると見込んで導入を決定しました。

デモ

以下2点のパフォーマンスが向上しています。

  • ヘッダーなどのレイアウトが表示されるまでの圧倒的に速くなっている
  • 最終的なデータ取得完了までの時間がやや速くなっている

上がNext.js版(新しい)、下がVite版(古い)の比較になっています。同時にページを読み込んでいます。 少し分かりづらいかと思うのですが、上記2点に注目して見ていただけると幸いです。

www.youtube.com

定量的なパフォーマンス比較

Next.js版のHenryは本番環境にリリースしたばかりで、まだ医療機関様の環境でのパフォーマンスは集計できていません。そのため、検証環境でのパフォーマンスの比較を記載します。
以下が、LighthouseというIT業界で最もスタンダードなツールでの計測結果です。

  • 総合パフォーマンス:56点82点
  • FCP(初期表示):4.0秒0.5秒
  • LCP(データ表示):5.4秒2.9秒
  • CLS(カクつき):0.050.001

FCPが圧倒的に速くなり、LCPもそれにつられてやや速くなっていることが分かります。 CLSというのはデータが表示されるにつれて画面がずれたりするカクつきのことで、これも改善しています。 その結果、総合パフォーマンスが56点から82点まで改善しました。toB バーティカルSaaSとしては先進的な数値ではないかと思います。

計測結果はツールによって異なるので、先述のダッシュボード上の数値と噛み合ってない部分もありますが、本番環境でも同様に高速化すると見込んでいます。

※3画面に対して複数回実施した平均点数を記載しています。
※キャッシュはオフで計測しています。

ユーザー体験がどう向上したか

上記の通り、Next.jsに書き換えたことで、ブラウザのリロード時や初回アクセス時のページ表示が速くなりました。

Henryにアクセスすると、ナビゲーションや画面のレイアウトなど、データ以外の部分が非常に速い速度で表示されます。 これにより、ユーザーが別のタブを見たい場合やデータを新規作成したい場合には、即座に操作を行えます。

また、ページの初期表示が速くなったことで、結果的に最終的なデータ取得までの時間も短くなっています。 加えて、データ以外のレイアウトが先に高速表示されるため、ユーザーは「画面上のこのあたりにデータが出そうだ」というのを事前に認識でき、データ表示後すぐに操作ができます。

ユーザー体験向上の図解
ユーザー体験向上の図解

FCP高速化の技術的な仕組み

この章では、主にエンジニアの方向けにNext.jsを導入するとFCPが高速化する理由を解説します。

元々のHenryは以下のようなアーキテクチャでした。

  • 素のReact + Vite
  • SSR

今回、上記からNext.jsに置き換えるだけで、自然とFCPが向上しています。 いくつか工夫しているポイントもあるため、併せて説明します。

以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。
また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。

まずはNext.jsとViteの基本的なアーキテクチャの違いを説明した後、高速化を後押しする要因である以下2点について説明します。

Next.jsとViteの基本的なアーキテクチャの違い

Vite版とNext.js版のヘンリーでページをリロードしたときの初回レスポンスを見比べてみます。

ViteとNext.jsの初回レスポンスの比較

左側がVite版です。Viteのような通常のSPA構成では、Reactコンポーネントレンダリングは全てブラウザで実行されます。
そのため、サーバーからのレスポンスは、Reactが書かれたJavaScriptを読み込むための<script>タグを含む最小限のhtmlであり、ほとんど何も表示されていません。
通常のSPA構成の経験がある方なら、<div id="root"></div>は馴染みがあるのではないでしょうか。

一方、右側がNext.js版です。Next.jsは、サーバーサイドでページをHTMLとJSに変換します。
そのため、サーバーからのレスポンスの時点で中身のあるHTMLが返されています。

次はChromeのPerformance Insightsで、ブラウザの処理の流れを見比べてみます。
以下のとおり、Next.js版のHenryでは初回のレスポンスでレイアウトの表示が始まっています。

Next.jsのPerformance Insights

一方、Vite版のHenryでは、初回のレスポンスでほとんど空のHTMLが返ってきた後、JSの読み込みがあり、その後JSの処理が行われています。
その結果、初回のレスポンスが返ってきてから、いくつものステップを挟まないと画面上には何も表示されていません。

ViteのPerformance Insights
ViteのPerformance Insights

このアーキテクチャの違いがNext.jsのFCPを高速化しています。
Vite版の場合は、ブラウザに初回レスポンスが来た後、JSの読み込み、ブラウザでのJS処理をして、ようやく画面を表示できます。
一方のNext.js版の場合は、ブラウザに初回レスポンスが来た時点で画面にレイアウトを表示できます。

この基本的なアーキテクチャの違いを踏まえたうえで、高速化を後押ししているNext.jsの2つの機能を見ていきます。
上記のPerformance Insightsの比較は処理の流れにフォーカスするためにキャッシュありで比較していましたが、この後はターンアラウンドタイムの最適化やトラフィック量に関わる話になるため、キャッシュを無効化したPerformance Insightsも見ていければと思います。

補足:工夫しているポイント

HenryはtoB SaaSであり、大部分の画面に認証がかかっています。認証には、クライアントサイドでFirebaseを利用しています。
Vite版のHenryでは、認証の状態が得られてから画面上に出すべきコンポーネントが決まりレンダリングしていました。

今回Next.jsに移行するにあたり、認証できている場合のコンポーネントレンダリングして返しています。これにより、Firebaseの処理待ちやコンポーネントの再レンダリングなしで画面のレイアウトが表示できます。
※データはあくまで認証後に取得するため、セキュリティ上の問題はありません。

const PageRoot: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // ...

  // Firebaseによる認証は副作用として行う
  useEffect(() => {
    if (currentUser.loading) {
      return;
    }
    if (requiredAuth(router.pathname) && !currentUser.membership) {
      setShowLoginPage(true);
      return;
    }
    setShowLoginPage(false);
  }, [currentUser, router]);

  // ...

  return (
    <div>
      {showLoginPage ? (
        // ...
      ) : (
        // サーバーサイドレンダリング時点では、認証できている前提でレンダリングしておく
        <RootLayout showGlobalNav>
          {children}
          <WindowsPresenter />
        </RootLayout>
      )}
    </div>
  );
};

プリレンダリング

前章で説明したとおり、Next.jsは初回のレスポンスさえ受け取れれば、データを必要としない画面の一部を表示できます。
しかし、単純にリクエストが来てからサーバーサイドでReactをHTMLに変換すると、ブラウザでのJS処理の代わりにサーバーでのJS処理に時間がかかり、初回のレスポンスが遅くなってしまいます。

そこで、Next.jsではビルド時に大部分のHTMLとJSを生成してキャッシュしています。これをプリレンダリングと言います。具体的には、動的なサーバーサイドでのデータ取得を含まないページがプリレンダリングの対象となります。
この機能により、Next.jsは初回リクエストに対するレスポンスのターンアラウンドタイムを最小限に押さえています。

キャッシュなしでのVite版HenryとNext.js版Henryの初回リクエストに対するレスポンス速度を比較してみます。

まず、Vite版のHenryは次のとおりです。
オレンジの部分はJSの読み込みで、かなり気になる長さになっていますが、これは次の章で解説するので一旦見なかったことにしてください。
ここで見たいのは青色の初回リクエストに対するHTMLのレスポンスで、15〜20ms程度に収まっています。これは、Viteでは画面表示にJSが必須となる代わりに、HTMLはほとんど空に近いもので、サーバーサイドでの処理も必要ない静的なものだからです。

Vite版 キャッシュなし時のHTMLのレスポンス速度

次に、Next.js版を見てみます。
一見Vite版より遅くなっていますが、すでにレンダリング済みのHTMLでファイルサイズで多少大きさがあるため、通信完了までの時間が伸びるのは自然です。
特筆すべきはレスポンスが始まるまでのリードタイムです。0msから数えると、わずか5ms程度でレスポンスが始まっているのが分かります。

Next.js版 キャッシュなし時のHTMLのレスポンス速度

何もデータフェッチを行わない動的フェッチ(getServerSideProps)を含むNext.jsのページをローカルで動かすと、このようになりました。Reactのレンダリングで30ms程度ロスしていることが分かります。

Next.js版 非プリレンダリング時のHTMLのレスポンス速度

これがプリレンダリングの威力です。サーバーサイドでReactレンダリングすることでかかってしまう数十msのリードタイムすらなくし、SPAアーキテクチャとの性能差を圧倒的なものにしています。

補足:工夫しているポイント

Vite版のHenryのデータフェッチは、Apollo Clientを用いたクライアントサイドフェッチでした。
今回、マイグレーションにあたり影響を最小限にするために、データフェッチ手段は変えずにNext.jsに書き換えました。そのため、ほぼすべての画面がプリレンダリングの対象となりました。

個人的には、Streaming SSRでない通常のサーバーサイドフェッチはFCPを落としてしまうため、プリレンダリング可能なクライアントサイドフェッチのほうが望ましいと考えています。

コード分割

加えて、Next.jsには、フロントエンドへのJS転送量も大きく軽減する仕組みもあります。
※正式な機能名がドキュメント上で見つけられなかったため、便宜上コード分割としています。

Viteの場合、基本的には全てのReactコンポーネントが1つの巨大なJSファイルにバンドルされます。Lazy Loadingにより個別のコンポーネントを動的に読み込むことは可能ですが、そうした工夫をしなければ全ページ分のコンポーネントの情報がクライアントサイドで読み込まれます。

先ほど少し目に留まったかと思いますが、改めてVite版のHenryのJS読み込み時のPerformance Insightsを見てみます。
キャッシュヒットなし時には1秒経っても読み込みが終わっていません。このindex.jsは、4.0MBもの大きさになっていました。かつ、前の章で説明したとおり、SPAアーキテクチャではこのJSが読み込みとJSのブラウザ側での処理をしないかぎり、画面には何も表示できません。

Vite版のキャッシュあり時となし時のindex.jsの読み込み比較

記事の最初にあるLighthouseの計測結果でVite版のHenryのFCPが4.0秒もかかる一番の要因は、このキャッシュヒットなし時のJSの読み込みです。

続いてNext.js版のHenryのキャッシュヒットなし時のJS読み込みを見てみます。
細かくて分かりづらいかもしれませんが、下記の図から300ms程度でJSが読み込まれていることが分かります。
このように高速なのには2つの理由があります。
1点目の理由は、Next.jsは細かくJSを分けて並列での読み込みを可能にしていることです。これはこの図からも分かりやすいかと思います。

Next.js版 キャッシュなし時のJS読み込み

より重要な2点目の理由は、Next.jsはサーバーサイドでページ単位でReactにレンダリングしているため、そのページに必要なJSだけを返せることです。
実際、上記の図で読み込まれているJSは、数は多く見えますが、1つ1つは10〜300kBほどで、合計すると(この画面では)1MB未満になっています。

以下はNext.jsのビルド実行時のログです。ビルド時点で、ページ単位のFirst Load JSが計算できています。
これによると、多くの画面が500kB〜1MBに収まっており、4.0MB以上を一括で読み込まなければいけないVite版のHenryに対して大きなアドバンテージがあります。
実際にはこれでもNext.js的には大きいということで、First Load JSのログが真っ赤に染まってしまっています。今後、よりFirst Load JSを削減できるように精進していきたいです。

Next.js ビルド時ログ

このように、Next.jsでは個別の最適化を行わずとも、JSを小分けにする仕組み・デフォルトでページに必要なJSしか返さない仕組みがあり、高速化とトラフィック削減が実現されます。

補足:工夫しているポイント

Next.jsでも、Vite同様、Lazy Loadingによる個別の最適化が可能です。
今回は、もともとルートに近いコンポーネントでインポートしていた大量のFloatingWindowなどのコンポーネントをLazy Loadingするようにしました。
まずはマイグレーションを完了させることが最優先のため、手早くできるボリュームゾーンのみを対応しましたが、それでもFirst Load JSを300kBほど削ることができました。

補足:JS読み込みとFCP

Next.jsにとって、このJS読み込みは、厳密にはFCPと直接関係がありません。
ここまでに説明してきたとおり、初期表示のためのHTMLはサーバーサイドでレンダリング済みだからからです。

それではこのJSは何に使うかというと、ざっくり言うとユーザーが画面を操作できるようにするために使います(ハイドレーションと言います)。
あくまで初回レスポンスで返ってくるHTMLで、clickなどのイベントは一切登録されていないため、ブラウザ側にReactコンポーネントを正しく紐付ける必要があるということです。

つまり、このJSの読み込みや処理が遅いと、画面は高速で表示されても、ユーザーはデータを作成したり画面遷移するといった操作ができないことになります。
FCPには直接関わらないものの、広い意味では初期表示の高速化に大きく関わるものです。

長期的なビジョン

今回、Next.jsを導入したことで自然とページロードが高速化しました。
しかし、実のところ、Next.jsを導入したのは、単に短期的なページ高速化のメリットを享受するためではありません。
むしろ、Next.jsを導入できた今、フロントエンドエンジニアリングの可能性がさらに広がったのだと思っています。

この章では、フロントエンドアーキテクチャに関して、どのような長期的なビジョンを持ってNext.jsを導入したのか解説します。
この章では、これまでの章に比べ、主観や推測、判断が多くなります。予めご理解いただければと思います。

フルスタックReactというトレンド

まず、最も長期的な観点として、フルスタックの波に乗ることで、トータルでより先進的なユーザー体験や開発者体験が実現できる可能性が高いと考えています。

背景として、フロントエンド界隈全体の流れとして、Web標準フルスタックに寄せていくことが開発生産性やパフォーマンスを高めることにつながるという動向があるように思います。
Reactチームもそうしたトレンドを推し進めている当事者であり、今後Reactを始めるうえではNext.jsやRemixを採用することを推奨しています。

React公式のスクリーンショット

この記事で解説してきたように、こうしたフルスタックフレームワークはサーバーサイドレンダリングにより初回レスポンスからHTMLを返せます。
また、最近はPOSTについてもServer ActionsやAction Functionのように、個別のinputから値を集めてJSONに詰める今までのやり方に比べ、よりWeb標準に近い形を模索しています。

一方、ViteのようなSPAアーキテクチャはというと、空のHTMLが返ってくることも、巨大なJS読み込みが発生することも、そのJSで画面全体を構築することも、全てがWeb標準フルスタックのトレンドとは真逆です。
そのため、少なくとも今後盛り上がりや進化を見せる可能性は低く、最悪の場合はエコシステムの豊富なライブラリやホスティングサービスとの統合が難しくなっていく懸念もあると判断しています。

こうしたことを踏まえ、フルスタックフレームワークの波に乗ることで、ますます革新的なユーザー体験や開発者体験が可能になり、未来が明るくなると判断しました。

ユーザー体験

より短期的な観点として、Next.jsを導入したことですぐに始められるアプリケーションレベルのアーキテクチャ施策もたくさんあります。

例えばApp Routerを有効化すれば、React Server Componentsを使ってJSバンドルサイズを削減できます。
Steaming SSRによりFCPを保ちながらGraphQLリクエストをサーバーサイドに集約するなどすれば、ページリロード時だけでなくクライアントサイドナビゲーションによる遷移時のパフォーマンスも向上することができる期待があります。

また、Server Componentsの導入と併せてstyled-componentsからゼロランタイムCSS in JSへの置き換えを行うことで、iPadスマートフォンでも高いINPを実現できる見込みもあります。
その他、細かい機能も含めると、フォントやイメージの最適化の活用などもあります。

開発生産性

加えて、開発者体験についても、Next.jsを導入したことで今後向上する見込みです。

Next.jsの規約に従うことで、ルーティングやバンドラの設定を自前で管理する必要がなくなり、アプリケーションに集中しやすくなります。 機能開発自体も、レイアウトやメタデータを簡単に共通化できるようになるなど、配管コードの量が削減できる見込みです

より積極的なアプローチとして、ソフトウェアのアーキテクチャを見直す余地も出てきます。
現状はフロントエンド - GraphQL BFF - gRPC Backendという構成ですが、例えばコンポーネントにServer Actionsを書いて直接gRPCを呼んだり、Server Componentsから直接gRPCを呼んでデータを取得するアーキテクチャに変更できます。
このようにすれば、Reactだけでフロントエンドの開発が完結し、より開発を加速できるかもしれません。

まとめ

HenryをViteからNext.jsに書き換えたことで、フロントエンドエンジニアリングの可能性が広がり、直近ではFCPも向上しました。
引き続きHenryフロントエンドアーキテクチャをよくして、ユーザー体験と開発生産性を高めていきたいと思います。

この記事は比較的一般論に近い解説記事となりました。実際、パフォーマンスに寄与しているのはほとんどNext.jsのビルトインの仕組みです。
一方、今回行ったViteからNext.jsへのマイグレーションプロセスには、より実用的な洞察がたくさん含まれていると考えています。
最も面白い点としては、今回Vite版を破壊せずに、ほとんどのコードを共通化した状態でNext.jsを導入し、開発を止めずにマイグレーションしたことが挙げられます。
こうしたマイグレーションを実現したプロセスやコードベース設計について、別途記事として公開したいと思いますので、よければそちらもご覧ください。

最後まで読んでいただき、ありがとうございました。

修正ログ

  • 2022/06/02 12:57
    • 記事の内容がVite自体の性能について述べるものと誤解を与えうる内容になっていたため、補足を追加しました。
      • 以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。

      • また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。