entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

関数合成と契約プログラミングで立ち向かう、カオスにならない SSR 実装

こんにちは。 ピクシブ百科事典の開発をしております、フロントエンドエンジニアの ahu です。

ピクシブ百科事典 は、「みんなでつくる百科事典」を合言葉に運営されている、オンライン百科事典サービスです。 今回は、百科事典の Next.js の根幹を支える SSR の仕組みを紹介します。

百科事典は Pages Router で実装されており、getServerSideProps という関数でデータを集めて React のコンポーネントに渡します。 そのため、getServerSideProps の実装では必然的に複数のドメインロジックを扱うことになり、素朴に書くとカオスになりがちです。

今回は、そんな getServerSideProps の特性に対して、関数合成と契約プログラミングの考え方を使った仕組みで対抗している様子を紹介します。 問題の切り分けが簡単になり追記もしやすくなるので、ぜひ挑戦してみてください!

getServerSideProps を素朴に書くと辛いこと

まず本題に入る前に、getServerSideProps の実装を素朴に手続き的に書いた場合、どんな辛さがあるのかを振り返ってみましょう。

  1. やることが多い
    ユーザー情報の取得、i18n 関連の初期化処理、ページの表示に必要な情報の取得(ページによって異なる)、UA などリクエスト情報の活用、ログ基盤との接続……などやることがかなり多いです。 これは getServerSideProps の特性であり役割でもあるので、対処自体は難しく前提として扱う話題だと思います。

  2. コードが長くなる
    やることが多いと、コードも長くなりがちです。 しかし getServerSideProps は、MVC でいうコントローラーからビューにデータを流し込む部分に相当するため、ここが分厚くなるのは避けたいです。 (見通しが悪くなり、データの取り違えなども起きやすくなる。) また getServerSideProps はページ毎に書く必要があるため、共通部分をコピペで同期するなどの方法だと運用と管理が大変そうで、できれば処理の単位を組み合わせて書きたいです。

  3. 処理を切り出すのに工夫が必要
    例えばページの表示に必要な情報を引くのにユーザー ID が必要な場合、先にユーザー情報の取得を終わらせておく必要があります。 このように、処理同士には暗黙の依存関係があり、getServerSideProps の実装では依存関係が満たされている必要があります。 よって共通部分を関数に切り出すなら、切り出し方や呼び出し方のルールも作り、「そのルールを守っているなら依存関係に問題はない」と保証できる仕組みを導入するのが望ましいです。

つまり、「処理の単位を組み合わせられるようにする」「その処理の単位が持つ依存関係を何かで表現 & チェックできるようにする」の2点が解決できれば、辛さは解消できるわけです。 ここからは、高階関数を使った関数合成と、契約プログラミングの考え方を使って、この2点を解消する方法を紹介していきます。

  • 関数合成: 処理の単位を関数として分離し、getServerSideProps をそれらの関数の組み合わせ(高階関数を関数合成した形)で書けるようにする
  • 契約プログラミング: 分離したそれぞれの関数に、「この関数は何を前提としていて(事前条件)、何を保証するか(事後条件)」を型として記述し、依存関係の違反を静的にチェックする

関数合成と、事前条件・事後条件

関数合成と事前条件・事後条件について、軽く一般的な紹介をしておきます。 馴染みのある方は読み飛ばしていただいて大丈夫だと思います。

関数合成

関数合成は、複数の関数を組み合わせて1つの関数を作る操作のことです。 一番素朴な形は、関数 f の出力を関数 g に入力する g(f(x)) のような呼び出しです。 getServerSideProps もこのような形で記述できるようにしていきます。

const double = (x: number) => x * 2;
const addOne = (x: number) => x + 1;
const doubleThenAddOne = (x: number) => addOne(double(x));

言語によっては、こうした合成を x |> f |> g のような中置演算子でより直接的に書けるようにしている場合もあります。 関数合成は、複数の小さな関数が状態を引き回すようなコードと相性がよく、データが段階的に変換されていく「パイプライン」の流れを簡潔に記述できます。

事前条件・事後条件

事前条件(precondition)と事後条件(postcondition)は、契約プログラミング(Design by Contract)という設計の考え方で使われる用語です。 それぞれ次のように定義されます。

  • 事前条件: 関数を呼び出す前に、呼び出し側が満たすべき条件
  • 事後条件: 関数の処理が終わった後に、関数側が保証すべき条件

例えば「整数を受け取ってその平方根(ルート)を返す関数」を考えると、事前条件は「引数は 0 以上の整数である」、事後条件は「戻り値は引数の平方根である」のように書き下せます。 これらが満たされていれば、呼び出し側と関数の間で正しいやり取りができる、という考え方です。 事前条件や事後条件は、型の他にコメントや実行時のアサートで表現されることもあります。

具体的な実装の紹介

それでは最後に、ここまでの内容を踏まえた具体的な実装を見ていきましょう。

型定義などは、mizdra さんの記事も参考にしています。 なお本記事の実装では、「処理の単位」のことを middleware と呼んでいます。 これは Koa や Redux の middleware と同様に、次の処理を引数として受け取り、それをラップした関数を返す高階関数です。

www.mizdra.net

/**
 * Next.js の getServerSideProps に2つ目の引数を増やした関数につく型
 * 各 middleware が読み書きする状態である、middlewareContext を引き回す
 */
type GSSPLike<
  Context,
  GSSPResult extends GetServerSidePropsResult<any>,
> = (
  context: GetServerSidePropsContext,
  middlewareContext: Context,
) => GSSPResult | Promise<GSSPResult>;

type GSSPLikeWithPropType<
  Context,
  PropType extends Record<string, any> = Record<string, any>,
> = GSSPLike<Context, GetServerSidePropsResult<PropType>>;

/**
 * 2引数関数である GSSPLike を GetServerSidePropsResult に戻す関数
 * 関数合成の一番外側で使われ、middlewareContext を埋めて1引数関数にする
 */
const normalizeGSSP =
  <PropType extends Record<string, any> = Record<string, any>>(
    gssp: GSSPLikeWithPropType<{}, PropType>,
  ): GetServerSideProps<PropType> =>
  async (context) =>
    gssp(context, {});

まず GSSPLike は、Next.js の getServerSideProps に2つ目の引数を増やした関数につく型です。 この2つ目の引数(middlewareContext)で引きまわされる状態を、各 middleware が読み書きします。

つまり、各 middleware は、GSSPLike を受け取って GSSPLike を返す関数として定義され、middleware のチェーン(合成関数)もまた GSSPLike を受け取って GSSPLike を返す関数になります。 normalizeGSSP 関数は、そんな合成関数の引数を (context) => gssp(context, {}) のように埋め、middleware のチェーンを起動しつつ1引数関数に戻すための関数です。

normalizeGSSP や各 middleware は、こんな感じで使います。

type GetServerSidePropsMiddlewareAdditionalContext =
  WithCatchErrorGSSPAdditionalContext &
  WithSerializableGSSPAdditionalContext &
  WithI18nAdditionalContext &
  WithCommonDataGSSPAdditionalContext;

/**
 * よくやる middleware の組み合わせをまとめた middleware のチェーンの例
 * innerGSSP の型は、`GetServerSidePropsResult<any>` のように、
 * 関数全体を推論させないとうまく返り値が推論されないので as で直す
 * 
 * - withCatchErrorMiddleware: これより内側で起きたエラーを捕捉して通知する
 * - withSerializableMiddleware: props をシリアライズできる形に変換する
 * - withI18nMiddleware: i18n 関連のリソースをロードし、t を提供する
 * - withCommonDataMiddleware: ユーザー情報など全ページで使うデータを取得する
 *
 * @example
 * export const getServerSideProps = getServerSidePropsMiddleware(
 *   // innerGSSP では、2引数目で middlewareContext にアクセスできる
 *   async (context, { t, user }) => {
 *     // ページ毎に、返したいデータを props などに詰めて返す
 *     if (!user.isLoggedIn) {
 *       return { notFound: true };
 *     }
 *     return { props: { swrFallback, meta, jsonLd } }
 *  })
 */
export function getServerSidePropsMiddleware<
  GSSPResult extends GetServerSidePropsResult<any>,
>(
  innerGSSP: GSSPLike<
    GetServerSidePropsMiddlewareAdditionalContext,
    GSSPResult
  >,
) {
  return normalizeGSSP(
    withCatchErrorMiddleware(
      withSerializableMiddleware(
        withI18nMiddleware(
          withCommonDataMiddleware(
            innerGSSP as GSSPLikeWithPropType<
              GetServerSidePropsMiddlewareAdditionalContext,
              ExtractPropType<GSSPResult>
            >,
          ),
        ),
      ),
    ),
  );
}

最後に middleware の定義を見てみましょう。 まず事前条件は RequiredContext で、Context extends の部分がこのチェックを担っています。

事後条件は2つあり、AdditionalContextmiddlewareContext に対する事後条件で、OuterPageProps は getServerSideProps の結果である props に対する事後条件です。 middlewareContext については、innerGSSPContext & が「下流の middleware には settings が増えた middlewareContext を渡す」という事後条件を表現しています。 OuterPageProps は最終的な計算結果に関する事後条件なので、withCommonDataMiddleware の返り値の型で表現されます。

type WithCommonDataGSSPRequiredContext = {};
type WithCommonDataGSSPAdditionalContext = { settings: Settings };
type WithCommonDataGSSPOuterPageProps<
  BaseProps extends Record<string, any>,
> = BaseProps & { user: User; swrFallbacks: Record<string, any> };

/**
 * middleware は、GSSPLike 型の関数を受け取り GSSPLike 型の関数を返す関数
 * innerGSSP はこの middleware より後に実行される middleware なので、
 * `Context & WithFooAdditionalContext` になる
 * `WithFooOuterPageProps` はこの middleware が返す PageProps を意味する
 */
function withCommonDataMiddleware<
  Context extends WithCommonDataGSSPRequiredContext,
  PropType extends Record<string, any> = Record<string, any>,
>(
  innerGSSP: GSSPLikeWithPropType<
    Context & WithCommonDataGSSPAdditionalContext,
    PropType
  >,
): GSSPLikeWithPropType<
  Context,
  WithCommonDataGSSPOuterPageProps<PropType>
> {
  return async (context, middlewareContext) => {
    /* データのリクエストの処理などは省略 */

    const res = await innerGSSP(context, {
      ...middlewareContext,
      settings,
    });

    // GetServerSidePropsResult には .props が必ずあるとは限らない
    if (!isPropResult(res)) {
      return res;
    }
    return { props: { ...(await res.props), user, swrFallbacks } };
  };
}

おわりに

以上、getServerSideProps がカオスになりがちな問題を、関数合成と事前条件・事後条件を使って解消する仕組みを紹介しました。 この仕組みには、以下のようなメリットがあります。

  • 責務をそれぞれの関数に閉じられる: 処理を middleware に分割することで、一度に複数のドメインロジックを意識する必要がなくなり、関数の単位で関心を分離できる
  • 依存関係を静的に保証できる: 処理を分割するだけでは処理同士の呼び出し順が安全か保証できないが、型として事前条件・事後条件を導入すれば依存関係を静的に保証できる
  • パイプラインとして見通しよく書ける: 関数合成を使うことで、getServerSideProps をパイプラインとして記述でき、middleware の適用順やデータの更新箇所が分かりやすい
  • 拡張や運用がやりやすい: 小さな処理を組み合わせるため、ページごとに getServerSideProps を定義しても煩雑にならず、特定のページにだけ新しい middleware を後から差し込むようなことも無理なくできる

実際、この仕組みを導入してすぐ getServerSideProps のバグの調査をする機会がありましたが、処理が middleware に分割されていることで大変デバッグしやすかったです。 Pages Router で getServerSideProps の複雑さに悩まされている方は、ぜひ試してみてください!

最後になりますが、本記事で紹介した仕組みのベースとなる実装は、26新卒であり入社以前からアルバイトとして活躍してくださっていた SSlime さんに書いてもらったものです。 百科事典チームから感謝と応援の気持ちを込めて、これからのさらなる活躍を楽しみにしています!

icon
ahu
フロントエンドエンジニアとして2021年に新卒入社。 ゲームとプログラミングが好き。
20191219021843
SSlime
2026年新卒入社のエンジニア。好きなものはパズルゲーム、謎解き、ローグライト。