mizdra's blog

ぽよぐらみんぐ

Next.js の getServerSideProps を共通化する

Next.js で開発していると、複数のページの getServerSidePropsで同じようなコードを度々書くことになると思う。例えば 「GraphQL クライアントでクエリを fetch して、そのレスポンスをpagePropsに焼き込むロジック」であったり、「(マルチテナントアプリケーションにおいて) リクエストヘッダーからテナントを特定するロジック」であったり。こうした大部分のページで使われるロジックのコードは、何度も書かずに済むよう、何らかの共通化をしたくなる。

すでに色々な人が似たような動機でgetServerSidePropsを共通化する方法を紹介していると思う。それを参考にしながら id:mizdra も自分流の共通化方法を実践している。何度か Next.js アプリケーションを作っているうちに、自分の型のようなものが身についてきたので、それを紹介してみる。

はじめに結論

こう書いてます。

// lib/get-server-side-props.ts
import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
import { GetServerSideProps, GetServerSidePropsContext } from 'next';

type TenantName = 'Tenant-A' | 'Tenant-B' | 'Tenant-C';

// getServerSideProps の共通化とは関係ないので実装は省略し、型だけ定義しておく
declare function getTenantNameFromHostHeader(context: GetServerSidePropsContext): TenantName | undefined;
export declare function createApolloClient(
  cache: NormalizedCacheObject | undefined,
  tenantName: TenantName,
  context?: GetServerSidePropsContext,
): ApolloClient<NormalizedCacheObject>;

/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` に渡される context の型 */
type WithTenantOuterContext = GetServerSidePropsContext;
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` に渡される context の型 */
type WithTenantInnerContext = WithTenantOuterContext & { tenantName: TenantName };
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` から返すべき pageProps の型 */
type WithTenantInnerPageProps = {};
/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` から返すべき pageProps の型 */
export type WithTenantOuterPageProps = WithTenantInnerPageProps & { tenantName: TenantName };

/**
 * Host ヘッダーからテナント名を取得し、innerGSSP にテナント名を渡す HoF。
 * また、テナント名をコンポーネントから参照できるよう、pageProps に焼き込む。
 */
function withTenantGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithTenantInnerContext) => ReturnType<GetServerSideProps<P & WithTenantInnerPageProps>>,
): (context: WithTenantOuterContext) => ReturnType<GetServerSideProps<P & WithTenantOuterPageProps>> {
  return async (context) => {
    const tenantName = getTenantNameFromHostHeader(context);
    if (tenantName === undefined) throw new Error('Host ヘッダーからテナントの特定に失敗しました。');
    const innerResult = await innerGSSP({ ...context, tenantName });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        tenantName,
      },
    };
  };
}

type WithApolloOuterContext = GetServerSidePropsContext & { tenantName: TenantName };
type WithApolloInnerContext = WithApolloOuterContext & { apolloClient: ApolloClient<NormalizedCacheObject> };
type WithApolloInnerPageProps = {};
export type WithApolloOuterPageProps = WithApolloInnerPageProps & { initialApolloState: NormalizedCacheObject };

/**
 * Apollo クライアントを innerGSSP に渡しつつ、Apollo クライアントのキャッシュを pageProps に焼き込む HoF。
 * 焼きこまれたキャッシュは、_app.tsx で Apollo クライアントを初期化する際に使われる。
 */
function withApolloGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithApolloInnerContext) => ReturnType<GetServerSideProps<P & WithApolloInnerPageProps>>,
): (context: WithApolloOuterContext) => ReturnType<GetServerSideProps<P & WithApolloOuterPageProps>> {
  return async (context) => {
    const apolloClient = createApolloClient(undefined, context.tenantName, context);
    const innerResult = await innerGSSP({ ...context, apolloClient });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        initialApolloState: apolloClient.cache.extract(),
      },
    };
  };
}

export type WithGlobalOuterContext = GetServerSidePropsContext;
export type WithGlobalInnerContext = WithTenantInnerContext & WithApolloInnerContext;
export type WithGlobalInnerPageProps = WithTenantInnerPageProps & WithApolloInnerPageProps;
export type WithGlobalOuterPageProps = WithTenantOuterPageProps & WithApolloOuterPageProps;
/** よく使われる HoF を合成した HoF */
export function withGlobalGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithGlobalInnerContext) => ReturnType<GetServerSideProps<P & WithGlobalInnerPageProps>>,
): (context: WithGlobalOuterContext) => ReturnType<GetServerSideProps<P & WithGlobalOuterPageProps>> {
  return withTenantGSSP(withApolloGSSP(innerGSSP));
}
// pages/_app.tsx
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import { createApolloClient, WithTenantOuterPageProps, WithApolloOuterPageProps } from '../lib/get-server-side-props';

// withGlobalGSSP などで囲われていれば、OuterPageProps 相当のプロパティが pageProps に入っているはず。
// ただしページによっては withGlobalGSSP などで囲ってなかったりするので、 Partial<> で optional にする
type CustomAppProps = AppProps<Partial<WithTenantOuterPageProps & WithApolloOuterPageProps>>;

export default function App({ Component, pageProps }: CustomAppProps) {
  if (!pageProps.tenantName) throw new Error('pageProps.tenantName is required.');
  const apolloClient = createApolloClient(pageProps.initialApolloState, pageProps.tenantName);
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}
// pages/index.tsx
import { GetServerSideProps } from 'next';
import { withGlobalGSSP } from '../lib/get-server-side-props';
import { gql, useQuery } from '@apollo/client';

const QUERY = gql`
  query TopPage {
    hello
  }
`;

export default function Home() {
  const { loading, error, data } = useQuery(QUERY);
  return (
    <main>
      <p>top page.</p>
      <div>
        {loading && <p>loading...</p>}
        {error && <p>error: {error.message}</p>}
        {data && <p>data: {JSON.stringify(data)}</p>}
      </div>
    </main>
  );
}

export const getServerSideProps = withGlobalGSSP(async (context) => {
  await context.apolloClient.query({ query: QUERY });
  console.log(context.tenantName);
  return { props: {} };
}) satisfies GetServerSideProps;

以下のリポジトリにコード全文があります。

github.com

ポイントをかいつまんで紹介

  • それぞれのページの固有の getServerSideProps (GSSP) のロジックが実行される前後で共通のロジックを呼び出したいことがあるので、High-order Function (HoF) として共通化してます
    • 簡単に言うと関数を引数にとって、関数を返す関数のこと
    • (React Hooks 登場以前の) Redux 触ったことある人なら馴染みあると思う
  • innerGSSP がページの固有の GSSP で、それを HoF に渡すと、「共通ロジック前半部分の実行」=>「innerGSSP の実行」=>「共通ロジック後半部分の実行」みたいな順で実行されていく
    • 「共通ロジック後半部分」で得たデータを innerGSSP に渡したり、innerGSSP の返り値を「共通ロジック後半部分」で拡張してから返したりできる
  • よく使う HoF を合成した withGlobalGSSP という HoF を用意してる
    • export const getServerSideProps = withGlobalGSSP(async (context) => { ... }) satisfies GetServerSideProps; と書いて使う
  • Inner と Outer の型をちゃんと表現してるので、 HoF の適用順序を間違えたら型エラーが出るようになってる
    • HoF の適用順序が正しい時 (右) と間違えた時 (左) の比較
    • withApolloGSSPtenantName を要求するので、先に withTenantGSSP を呼ばないといけない

感想

  • withApolloGSSPwithGlobalGSSP などを使うページコンポーネント側はすごくスッキリする
    • けど HoF 実装側は大変なことになってる!!!
    • 何とかならないものか
  • 厳密に型付けするのを諦めて、もう少しナイーブにやったらメンテナンスしやすい形にできないかな
  • 皆さんはどうしてますか?

YAPC::Kyoto 2023 に参加してきた

登壇とかではなく、いち聴者として参加してきました。

yapcjapan.org

前日祭も参加していて、土日での京都滞在でした。

yapcjapan.connpass.com

僕と YAPC

YAPC への参加は去年の YAPC::Japan::Online 2022 に続いてとなり、YAPC::Kyoto 2023 で2回目です。オフラインの YAPC は初めてでした。

また、別の話として新卒入社のタイミングがコロナと重なっており、参加人数が数百人超える大きなカンファレンスに出たことがほとんどありませんでした (入社前のものを含めると HTML5 Conference 2018 と builderscon tokyo 2019 くらい?)。大きなカンファレンスへの参加が4年ぶりということで楽しみにしてました。

印象深かったセッション

moznion さんの廃墟の話が印象深かったです。出てくる話全部に思い当たる節があるというか、トークの引き込み力がすごかった。

よく面白い発表をなさっている方なのはご存知だったけど、まだ生でその発表を聞くことはできてなかったので、今回直接発表を聞けてよかったです。

docs.google.com

あと mackee さんのデプロイの話も印象深かったです。CGI 時代からどうデプロイの仕方が変遷してきたか、今と昔でどこがどう変わったのか、デモを交えながら丁寧に解説されていてすごく良かった。あと当時の技術的背景の紹介も挟あっていて、なぜその技術がそのタイミングで出てきたのかが納得できる形になっていたのも良かったです。僕のベストトークはこれです。

speakerdeck.com

他にも Hono の 2 セッションも良かったです。個人でカッコいいソフトウェアを作るのはすごく憧れますね。usualoma さんの Hono の内部のルーターの最適化の話が結構面白くて、RegExpRouter がどういうアイデアをもとに作られているかとか、あえて単純な方へと割り切りをしたことで他の最適化が活かせるようになったりとか、なるほどと思いながら聞いてました。

speakerdeck.com

speakerdeck.com

オフラインならではの出来事

セッションを聞いていると dankogai さんが観客席からちょくちょくツッコミを入れられていて、「これがあの Dan The Comment か!」となっていました。YAPC::Japan::Online 2022 のときはリモートゆえにツッコミ不能だったので、すごくオフラインイベントっぽさがありました。あと休憩時間になると廊下に無数にサークルが発生して皆ワイワイ話されていて、あれもオフラインイベント感 MAX でした。

builderscon 以来ぶりの人や、インターネットではよくお見かけするけど物理では面識のない人とかも結構いて、そういう方と挨拶できたのは良かったですね。「ブログいつも読んでます。」とお声がけいただくことも多くて嬉しかったです。

イベントの夜

前日祭があった夜に Hono Conference #1 に参加しました。Hono 使ったことなかったのですが、「使ったことない人もぜひ!」とのことだったので堂々と参加してきました。

初対面の方ばかりだったのですが、ずっと夢中になって話し込んでた!何してたか忘れつつあるけど、Hono contributor 同士の Hono の開発の話を聞いたり、chimame さんと情報共有や Prisma の話をしたりしていた気がする。あと uzulla さんとはオンライン越しに顔を合わせることは何度かあったけど、オフラインで会うのは初で、お会いできてよかった。むちゃくちゃ楽しかったです。

2日目の夜ははてなオフィスに人が集まってて盛り上がってた。結構色々な人とお話した記憶。yusukebe さんに「Hono の tRPC インテグレーション、最近推してますが実際のところどれくらいイケてるものなんですか」と聞いたら Hono でコードを書きながら Hono の RPC モードがどういうものだとか、その裏側はこうなっているとか解説してくださって、お互いにあーだこーだ言っててすごく盛り上がった。あれはよかった。こんなに Hono のこと話してるのに僕が Hono 使ってないの勿体ないので、何とかして普段使いの技術セットにねじ込みたい。

まとめ

面白いセッションもたくさん聞けて、オフラインイベントならではの楽しみも味わえて、めちゃめちゃ楽しかったです! イベントを運営してくださったスタッフの方々、それを支えてくださったスポンサーの方々に感謝です。次回の YAPC もぜひ参加したいです。

あと pastak さんには登壇してくださいとツッコまれてすみませんとなってたので、次回は登壇したい!! まずは Proposal を出すところから...

CPU シミュレータを用いて継続的ベンチマークを安定化させる

id:mizdra は eslint-interactive というツールをメンテナンスしています。このツールを使うと、多数の ESLint エラーを効率的に修正できます (詳しくは以前書いた記事を見てください)。

www.mizdra.net

eslint-interactive では「中規模〜大規模なコードベースであってもキビキビ動く」を大事にしてます。その一環として、eslint-interactive には CI (GitHub Actions) でベンチマークを取り、以前から大きく劣化していたら CI を fail させる仕組みがあります。

しかし CI で実行するためにノイズが大きく、よく誤検知が発生してました。そこで最近 CPU 命令数と CPU キャッシュのヒット率 をメトリクスにしたベンチマークへと移行しました。これにより、約 200% あったノイズが約 3.68% に減少し、安定してベンチマークを取れるようになりました。

非常にニッチな話なのですが、面白いかなと思い紹介してみます。

移行前のベンチマークについて

移行前のベンチマークでは、以下のような Node.js で書かれたスクリプトファイルを実行してベンチマークを取ってました。

github.com

スクリプトは lint 対象となるファイルをファイルシステム上に生成し、それに対して eslint-interactive を実行して lint エラーを修正する作りになってます。この修正に掛かる時間を performance.now で計測しbenchmark-action/github-action-benchmark というベンチマーク結果を視覚化する Actions に渡しています。

実際に視覚化されたベンチマーク結果は以下の URL から確認できます ([OBSOLETE BENCHMARK] というラベルが付いたものが、移行前のベンチマーク結果です)。

視覚化されたベンチマーク結果。commit ごとのメトリクスの変化が確認できる。

見ての通りメトリクスが波打っているのですが、ここでパフォーマンスの改善や劣化が起きている訳ではなく、ほとんど全てがノイズによるものです。いくつか前提条件の異なる数種類のベンチマーク結果があるのですが、その中には 200% のノイズ (最大値 / 最小値 * 100 - 100 から算出) を含むものもあります。

200% のノイズを含むベンチマーク結果。

eslint-interactive では 150% メトリクスが悪化した際に CI を fail させるようにしていたのですが、このノイズのせいで contributor から貰った PR で誤検知が発生して contributor を混乱させてしまったりと、よくトラブルを引き起こしてました。

ノイズが発生する理由とその大きさ

一般に、クラウド管理された CI サービスでは、物理マシンの上に仮想マシンが構築されていて、その仮想マシンの上で job を実行します。物理的なマシン 1 台を複数のユーザ・複数の job で共有するので、非常に大きなノイズが発生します。

実際に、Travis-CI で発生するノイズを調査した記事があり、これによると 50% のノイズが発生することが一般的だとされています (しかも 10,000% のノイズを持つ 4 つの外れ値を除外した上で...)。

bheisler.github.io

Note that there were four benchmark results in the cloud set with percentage differences greater than 10,000% which I’ve removed as outliers. Those were not included in the calculations above; if they were included the cloud numbers would be substantially worse. I opted to remove them after inspecting them and finding inconsistencies in those benchmark results which lead me to suspect that the logs were damaged. For example, one benchmark shows the time for each iteration increased by more than 200x but the throughput for the same benchmark appears to have increased slightly, rather than decreased as one would expect.

... many of the comparisons show shifts of +-2%, roughly similar to the noise observed in local benchmarks. However, differences of as much as 50% are fairly common with no change in the code at all, which makes it very difficult to know if a change in benchmarking results is due to a change in the true performance of the code being benchmarked, or if it is simply noise. Hence, unreliable.

あくまで Travis-CI の事例ですが、「ベンチマークの信頼性を損なう程度のノイズがある」ことはどの CI サービスにも言えることかと思います。

パッと思いつく解決策

パッと思いつく解決策は「ベンチマークの試行回数を増やし、その中央値をメトリクスとする」手法です。例えば、ベンチマークの job で10回ベンチマークスクリプトを回し、その中央値をメトリクスとします。しかし、その job が実行される仮想マシンの CPU がたまたまサーマルスロットリングを起こしていた場合、10回の計測値がすべて劣化して中央値もそれに引きづられてしまいます。複数の job に分けて計測してその影響を受けにくくすることもできますが、手間ですし、依然として一定のノイズがあります。

次に思いつく解決策は、自前の CI runners を用意して、そこでベンチマークを実行することです。GitHub Actions でいうところの self-hosted runners です。この手法なら物理的なマシンを専有できるので、かなりノイズを排除できますが、それを用意するための労力や維持費の問題が出てきます。

どちらも eslint-interactive には合わなさそうだったので、他の手段を検討することにしました。

CPU 命令数と CPU キャッシュのヒット率をメトリクスとする手法

なにか良い方法はないかと途方に暮れていたところ、まさに求めていたものはこれだ!という記事を見つけました。

pythonspeed.com

詳しい内容は記事を読んでもらえれば良いですが、いくつか要点を書くと...

  • CPU 命令数が増えたり、CPU キャッシュヒット率が低下すると、一般にパフォーマンスは劣化する
    • CPU 命令数と CPU キャッシュヒット率を見れば、パフォーマンスの変化がある程度わかる
    • 実行時間はブレやすい一方、実行された CPU 命令の数はブレにくい
    • よって壁時計時間や CPU 時間よりも一貫性のあるメトリクスが得られる
  • 「Valgrind」という仮想環境と、「Cachegrind」というCPU キャッシュプロファイラを用いる
    • この仮想環境上でベンチマーク対象のプログラムを実行し、CPU 命令数とキャッシュヒット率を計測する
    • キャッシュというのは L1 キャッシュや L2 キャッシュなどのこと
  • CPU キャッシュのコストがうまく反映されるよう、各層ごとのキャッシュヒット率を重み付けした 1 つの値で比較する
    • ハードウェアによって多少の重みが異なるが、(L1 + D1 ヒット数) + 5 × (LL ヒット数) + 35 × (RAM ヒット数) で十分
    • メトリクスが1つに結合されるので、比較もしやすい

実際に記事の中では、Python で書かれたプログラムを対象にこの手法を適用したところ、0.000001% 以内のノイズに収まったと紹介しています。

なんと sqlite でも長年同様の方法でベンチマークを取っているそうです。

Valgrind/Cachegrind を使った手法を試すには

Valgrind/Cachegrind を使ってCPU 命令数とキャッシュヒット率を計測し、キャッシュコストを考慮した単一のメトリクスを計算する...ところまで自動でやってくるスクリプトを、件の記事の筆者が作っています。手軽に試したいならこれを使うと良いと思います。より一貫性のある結果を得るために ASLR (アドレス空間配置のランダム化) を無効化する機能も入ってます。

github.com

Valgrind/Cachegrind を使った手法のデメリット

もちろん銀の弾丸ではないので、デメリットや注意点があります。

  • 他のソフトウェアとベンチマークメトリクスを比較する用途には使えない
    • CPU 時間を計測している訳ではないので
    • 「1つのソフトウェアが以前と比較して改善したか・劣化したか」の比較のみに使える
  • 遅い
    • 仮想環境で実行するので
    • ただしノイズを排除するために複数回実行する手間はなくなる
  • 実際にユーザが利用する CPU をエミュレート出来るわけではない
  • 命令ごとの実行コストの違いが反映されていない
    • 命令の種類によってコスト異なるはずだが、それが無視されてる
  • CPU 以外のハードウェアのシミュレートがされない
    • 例えばファイルシステムへの I/O はシミュレーターレートされない
    • そういったものがが支配的なプログラムでは、ノイズが大きくなるはず
  • 最新世代の CPU で実装されているような最適化がシミュレートされない
    • 少し古い世代の CPU の最適化がシミュレートされるようになってるため
    • 現実のパフォーマンスと多少ギャップがある
  • 他にも色々...

試してみた

eslint-interactive では、以前からパフォーマンスが改善したか劣化したかがわかれば良いのでマッチしそうな感じがします。そこで、実際にこの手法を試してみることにしました。

github.com

その結果、ノイズが 200% => 3.68% と大幅に減少しました :tada:

ベンチマーク結果のグラフにも、はっきりとその成果が現れています。

新しいベンチマークのグラフ。大きなノイズは見られない。

実際にパフォーマンスを劣化させる変更を仕込んで、CI が fail することも確認できました。きちんとパフォーマンスの劣化を検知できているようです。

今の所大きな外れ値も出ておらず、非常に安定しているように見えています。

試してみて気になったこと

試した上でいくつか気になったことも書いてみます。

  • 実行が遅い!
    • 直接実行すると 3 秒 (ref)、Valgrind/Cachegrind を経由すると 2分46秒 (ref)
    • 仮想環境で実行しているためと思われる...が遅すぎる気もする
  • ランダム性を排除するために、いくつかオプションを渡す必要がある
  • ノイズが思っていたよりも大きい
    • 記事の Python プログラムの事例では 10 回の試行で 0.000001% 以内に収まってる
    • しかし eslint-interactive では 30 回の試行で 3.68% のノイズがあった
    • node コマンドに渡すオプションが不十分で、ランダム性が十分に排除しきれてない?
      • V8 や Node.js に詳しい人が見たら何かわかるかも
      • 誰かわかりませんか!
    • eslint-interactive ではファイルシステムへの I/O が支配的なため?
      • インメモリな仮想的なファイルシステムへ書き込むようにしたら良い?
      • 試しにやってみた
      • 確かにファイルシステムへの I/O が原因っぽい
      • しかしインメモリなファイルシステムに fixture を用意する都合上、fixture を作成するコードも Valgrind/Cachegrind 上で実行する羽目になってる
        • CPU 命令数の総量が増える (21 billion instructions => 27 billion instructions) のでその分ノイズの変化量も小さくなる、というバイアスがある点には注意
        • 加えてベンチマークの実行時間が 3分 => 4分に増えた
      • ちょっと導入するか悩ましい

まとめ

  • クラウド管理の CI では、ベンチマークの信頼性を損なうのに十分なノイズがある
  • そうした環境でも安定的にベンチマークを取るには...
    • CPU 命令数とCPU キャッシュヒット率を見れば良い
      • Valgrind/Cachegrind を使う
    • ランタイムのランダム性を排除する
      • ASLR の無効化、seed の固定化など
  • Valgrind/Cachegrind のシミュレートは完璧ではない
    • 現実のパフォーマンスと多少のギャップがある
    • シミュレートされないリソースへのアクセスはノイズの原因となる

思ったよりもノイズを小さくできなかったですが、パフォーマンスの劣化を検知する用途としては十分そうです。これで不安定なベンチマークともおさらばできそうです。

合わせて読みたい


  • 2022/2/13追記
    • 「シミュレーター」が「シュミレーター」になっていたの直しました (全く気づかなかった…) (ブコメありがとうございます)

試行錯誤を邪魔しない開発環境

  • ある機能を実装する際、完成形のコードになるまでには、プログラムとして不正確な状態や、プロダクト品質ではない状態を経る
    • 静的型検査や lint rule に違反したコードが途中に挟まる
  • 型エラーや lint エラーは望ましくないので、できるだけ早くこうした情報を開発者に伝え、気付けるようにすると良い
    • CI でこうしたエラーを検知して、Pull Request をマージする前に気づけるようにするとか
    • エディタ上にエラーの情報を表示して、コーディング中に気づけるようにするとか
  • エラーを積極的に通知してくれるのはありがたいけど、やりすぎには注意するべき

なんとなくでも動いてくれたほうが嬉しい

  • 例えば lint エラーがあった際に、watch モードで起動しているビルドやテストの実行を止めて、lint エラー見つけたよーと教えてくれる開発環境がたまにあるけど...
    • 別にビルドやテストの実行は止める必要ないと思う
    • なんとなくでも動いて結果を教えてくれるほうが、試行錯誤しやすくて嬉しい
  • TypeScript なら(静的)型エラーを無視してトランスパイル...なんてこともできる
    • ts-loader に transpileOnly: true オプションを渡したり、swc のような (型検査なしで) トランスパイルだけするツールを使えばできる
      • 多少型のミスマッチがあっても実行できるところまでは動くし、問題があったら実行時エラー (TypeError など) が出るだけ
    • このアプローチはここ数年で当たり前になってきた気がする
      • 試行錯誤のしやすさのため...というよりは watch ビルドに掛かる時間を短縮するためというのが主目的
      • あと swc や esbuild のようなツールが普及したというのもありそう
    • 何だかんだで背後が動的型検査の JavaScript だからこそできる荒業だと思う

create-react-app のおせっかい機能

  • 「create-react-app」という React アプリケーションを作成するためのフレームワークがある
  • 主に React 初学者が React を使った開発方法を学ぶためにある
    • しかし面倒な環境構築が不要かつ、難しいビルド周りの設定を上手く隠蔽してくれるので、業務でも十分有効なフレームワークだと思う
    • id:mizdra も業務で使ってる
  • ただ、watch ビルド中に lint エラー or (静的)型エラーが検出された時の挙動が id:mizdra が期待しているものからズレててイマイチ
    • 以下のように lint エラーをブラウザの画面いっぱいにオーバーレイで表示してくる
    • ブラウザで開いているページ上にオーバーレイで lint エラーが表示されている様子
    • 一応ビルド自体はされていて、右上のバツボタンからオーバーレイを閉じればページを操作できる
      • とはいえビルドされ直されるたびにオーバーレイが出るのでわずらわしい
    • おそらくReact 初学者がメインユーザーなフレームワークゆえに、エラーを目立たせる作りになっているのだろう
      • しかし初学者でない人からすると、おせっかい機能だと思う
  • そういう感じなので、最近は TSC_COMPILE_ON_ERROR=true DISABLE_ESLINT_PLUGIN=true react-scripts start で起動してる

更に一歩踏み込んで考えてみる

  • 空の body を持つ関数を禁止する lint rule とかあるけど、ああいうのもわずらわしく感じる
    • () => {} から書き始めるので、大体空になって怒られる
    • 書いている途中で赤線が引かれて、「あれ何か間違った?」とびっくりすることが多い
    • こういうのも警告レベルを落としたり、無効にすると良い?
      • しかしキリがなくて大変そう
      • こういう細かいチューニングをするよりは他のことに時間を掛けたい
    • 発想は面白いと思う!
  • エディタ上で未 format のコードに赤線を引いてくる開発環境もあるけど、あれもわずらわしい
    • インデントの深さが間違ってたら真っ赤になる
      • スタイルガイドに合ってないだけで、コードの挙動は正しいのに…
    • エディタ上では警告せずに formatOnSave で勝手に format する & CI 上では警告する、くらいが丁度よい

書いてみて思ったけど、「静的型エラーを無視してトランスパイルする」とか「ブラウザにエラー内容をオーバーレイで表示しない」とか、Web フロントエンドならではの話題ばかりだった。

ネイティブモジュールに依存しない node-canvas 代替ライブラリを使う

Web フロントエンドにて、Canvas を使った View のテストを書きたいことがたまにあります。ブラウザであれば以下のようにして Canvas を利用できますが、テストが実行される Node.js ではそのような API は生えていません。

const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

そこで Node.js では、node-canvas という npm パッケージがよく使われます。これを使うと、Web Canvas API 互換な API を用いて、Node.js でも Canvas を利用できます。

import { createCanvas } from 'canvas';

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

余談ですが、Node.js に DOM API の互換実装を提供するライブラリ「jsdom」では、HTMLCanvasElementHTMLImageElement の実装に、この node-canvas を利用しています。

ネイティブモジュールの依存問題

そんな node-canvas ですが、実はネイティブモジュールに依存しています。そのため、いくつかのモジュールがマシンにインストールされていなければ実行できません。

一度マシンにインストールしてしまえば良いものではありますが、複数人で開発するようなプロジェクトで node-canvas に依存していると、新しくチームメンバーがやってくる度にインストール方法を案内する必要があります。加えて、CI 上でテストを走らせる際も CI 環境にインストールが必要になってきます。

ちょっとした手間ではあるのですが、面倒なのでなんとかして解消できると嬉しいです。

@napi-rs/canvas を使う

@napi-rs/canvas という、同じく Web Canvas API 互換な API を実装したライブラリがあります。このパッケージはネイティブモジュールに依存しない作りになっていて、ネイティブモジュールをインストールせずとも node-canvas と同等のことができます。

$ npm i -D @napi-rs/canvas
import { createCanvas } from '@napi-rs/canvas';
//                            ^^^^^^^^^^^^^^^
//                            ここが canvas から変わっただけ

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

jsdom と組み合わせるにはもう一手間必要

先程ちょっと触れましたが、jsdom からは const Canvas = require("canvas"); で node-canvas が参照されています。従って、jsdom の HTMLCanvasElement を呼び出すと node-canvas が使われてしまう…ということになってしまいます。

そこで id:mizdra@napi-rs/canvas を以下のようなコマンドでインストールしています。

$ npm i -D canvas@npm:@napi-rs/canvas

このコマンドを実行すると、package.json 上では以下のような依存関係の情報が書き込まれます。

{
  // ...
  "devDependencies": {
    // ...
    "canvas": "npm:@napi-rs/canvas@^0.1.34",
  }
}

これで require('canvas') というコードが require('@napi-rs/canvas') へと解決され、node-canvas の代わりに @napi-rs/canvas が利用されるようになります。jsdom の HTMLCanvasElement からも @napi-rs/canvas が使われるようになり、ネイティブモジュール無しでテストを実行できるようになります。

こことかここを見るとわかるのですが、実際には @napi-rs/canvasnode-canvas の API を完全に模倣している訳ではないので、もしかしたらうまく動かないこともあるかもしれません… とりあえず手元の HTMLCanvasElement を使ったテストケースは正常に動作していますが、凝ったテストを書いてると動かないとかあるかもです。

ある Web ページに関するリソースのうち、ブラウザにリークしているものを調べる

最近の Web フロントエンドの開発では、JavaScript/CSS ファイルを bundler (webpack, vite, ...) でバンドルして、それをブラウザに配信することが多いと思います。

例えば以下のようなコードを bundler でバンドルすると、react + react-dom/client + ./locales/ja-JP.json が結合された bundle.js というファイルが生成されます (出力されるファイル名は bundler によって異なるのでイメージです)。

import React from 'react';
import { createRoot } from 'react-dom/client';
import locale from './locales/ja-JP.json';

function App() {
  return <div>{locale['Hello World!']}</div>; // => 'ようこそ、世界へ!'
}

const root = createRoot(document.getElementById('app')!);
root.render(<App />);

一方で、 bundler はモジュールから辿れるものを片っ端からバンドルするので、時として「本当はバンドルされて欲しくないもの」までバンドルされてしまうことがあります。例えば ./locales/ja-JP.json に入っている文言に未解禁情報が含まれていて、それが bundle.js に含まれてしまい、ユーザにリークしてしまう… そんな状況が考えられます。

ブラウザにリークしているリソースを調べる方法

どうコードを書き換えたらリークを防げるのか、リークを未然に防ぐ仕組みはどうやったら作れるか、などこのテーマについて話せることは色々ありますが、今回は「ブラウザにリークしているリソースを調べる方法」について紹介します。

bundler の成果物を grep する

一番シンプルです。./dist 配下などを未公開情報をキーワードにして grep するだけです。

./dist 配下が全てブラウザに配信される」ことが分かっているのならこの手法で良いですが、場合によっては「./dist 配下にサーバーサイドで使われるものとブラウザに配信されるものが混在していて、単純に grep できない」こともあるかもしれません。bundler の出力先のディレクトリ構成を見直して grep しやすい形に整理したり、あるいは他の手法で調べたりすると良いと思います。

ちなみに Next.js は .next 配下 にサーバーサイドで使われるリソースとブラウザに配信されるリソースが混在している典型的な例ですが、このうちブラウザに配信されるリソースは .next/static 配下に集められています (ただし undocumented な挙動なので今後変わりうるかも)。Next.js なら .next/static 配下に対して grep すれば良さそうです。

この記事ではあまり深入りしませんが、CI 上で next build 後に .next/static 配下を grep すれば、デプロイ前にリークに気づけるような仕組みを作れます。興味があれば試してみてください。

Chrome devtools の Network パネルの検索機能を使う

Chrome devtools の Network パネルを使うと、ページ表示中に発生した全てのネットワークリクエストを覗き見ることができます。実はこの Network パネルには検索機能がついており、ページ表示中に発生した全てのネットワークリクエストを対象に、平文検索を実行できます。これを使うと、未公開情報のリークの有無を確認できます。

  1. Network パネルを開いて Command + F キーを押す (macOS の場合 / 他の OS でも検索ショートカット相当のやつでいけるはず)
    • 検索欄が出てくるはず
  2. 検索欄にリークの有無を確認したいキーワードを入力
    • 検索結果に結果が表示されればリークしてる
  3. 検索結果をクリックして、ファイルを開く
    • キーワードが埋め込まれたファイルの中身が表示される
    • minify されてるファイルだと読みづらいかも
      • 左下の {} ボタンを押すと、ファイルがフォーマットされてちょっと読みやすくなる

Network パネルを使ったリークの有無の確認方法の図解

一点注意しておきたいこととして、実際にページ表示中にブラウザで発生したネットワークリクエストのみを対象に検索するため、リクエストされなかったリソースは検索対象に含まれません。つまり、実行時に特定の条件に応じて fetch でリソースを DL してきて、それを表示するようなコンテンツがあった際に、そのコンテンツに非公開情報が含まれていても、ヒットしない可能性があります。ヒットしないものの、bundle.js 中にはそのリソースの URL が埋め込まれているはずで、ユーザその URL を直接アドレスバーに打ち込んでアクセスすれば、非公開情報を閲覧できてしまいます。

クライアントサイドで動的に出し分けているリソースについては、この手法ではリークの有無を完全に確認出来るわけではない、ということに気をつけましょう。


他にもリークの有無を確認する便利な方法があれば教えて下さい!

next lint は lint 対象のディレクトリが制限されている

ということに next lint を触っていて気づきました。公式ドキュメントにもそう書いてありました。

next lint runs ESLint for all files in the pages, components, and lib directories. It also provides a guided setup to install any required dependencies if ESLint is not already configured in your application.

(https://nextjs.org/docs/api-reference/cli#lint より引用)

これによると、next lintpages/, components/, lib/、そして上記ドキュメントでは漏れてますが src/ *1 を加えた 4 つのディレクトリが lint 対象となるようです。

これ以外のディレクトリを lint 対象に含めたい場合は、next lint --dir util/ のようにコマンドラインから渡すか、next.config.jseslint.dirs オプションにディレクトリを指定するよう案内されてます。

暗黙的に lint 対象を制限することの是非

実際のところこの挙動って嬉しいんでしょうかね。本当は util/ も lint されて欲しいのに何故か lint されなくて混乱する、みたいなことが直感的には起きそうな気がします。加えて vscode-eslint (ESLint の VSCode 拡張機能) は「4つのディレクトリ以外は lint 対象から外す」ことを知らないので、util/ 配下のファイルでも ESLint の警告を出してしまいます。エディタ上で見たときと、next lint で見たときで警告の内容に違いが出るので、これも混乱しそうです。

以上のような問題があるので、除外するファイルは .eslintignore に書きつつ、eslint . でカレントディレクトリ配下のファイル全てを lint 対象にするアプローチが id:mizdra は好きです。どのディレクトリが除外されるか明示的になりますし、vscode-eslint が .eslintignore を見て、どのファイルが lint 対象外か認識してくれるので、エディタ上/CLI上での警告に差がなくなります。

zenn.dev

まあでも予想外に lint 対象の範囲が広がるのを避けたいとか、ファイルが多くなりすぎて lint 速度が低下するのを避けたいみたいな事情があったりするのかな...? いやでも速度が問題になってから .eslintignore すれば良いような気も…

next lint の初期の PR のやり取りを見ると、Next.js アプリケーションで一般的なディレクトリに限って lint するという前提で最初から設計されているように見えるので、あまりそのあたりの拘りがないのかもしれません。

という訳で id:mizdra は以下のように、カレントディレクトリ配下のうち .eslintignore に含まれるものを除外したファイルを lint 対象とするアプローチが良いかなと思っています。

// package.json
{
  "scripts": {
    "lint": "next lint -d ."
  }
}

それよりも

デフォルトの lint 対象ディレクトリのうち、pages/, components/, src/ は順当だと思ったのですが、lib/ がそこに入っていたのはちょっとびっくりしました。Next.js 公式的には lib/ も「Next.js アプリケーションで一般的に見られるディレクトリ」ということなんですかね。id:mizdra はあんまりそういう印象なかったので「そうなの...?」となってます。公式ドキュメントでも lib/ が登場するのは next lint 関連での文脈でしかなさそうでした。

しかし Next.js の公式 Example 集を見ると、結構 lib/ が使われてるようでした。

公式ドキュメントでは触れられてないものの、公式 Example 集では使われているディレクトリみたいですね。

追記: 2023-01-14

Next.js v13 からサポートされた app/ ディレクトリも lint 対象に含めようとする PR が出ていました。この PR がマージされれば lint 対象のディレクトリが 5 つに増えることになりそうです。

github.com

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.