⛓️

Next.jsのmiddlewareをカッコよく分割する

2024/08/16に公開

はじめに

こんにちは!まるべいじです!

みなさん、Next.jsは使ってますか?
Next.jsのApp routerがリリースされて、1年ちょいたちました。リリース当初はPages Routerを採用するプロジェクトが多かったですが、最近はApp routerを使用するプロジェクトが多くなってきたように感じます。

今回は、Next.js(App router)のmiddlewareを分割して管理する実装を説明していきたいと思います!
middlewareのテストについても記載していきます。

概要

Webアプリケーションでは、さまざまな種類のリクエストに対して共通の処理を行うことがよくあります。しかし、その処理を一つのファイルにまとめてしまうと、コードの見通しが悪くなり、メンテナンスが困難になることがあります。

この問題を回避し、リクエスト処理の柔軟性を高めるための手法として、Chain of Responsibilityパターンを使用することが有効です。このパターンを用いることで、処理を適切に分割し、各処理を独立して管理することができます。これにより、コードの可読性が向上し、メンテナンスもしやすくなります。

この記事では、Next.jsのApp Router機能を利用し、Chain of Responsibilityパターンを適用したmiddleware機構を実装するかについて図を使いながら解説します。

Chain of Responsibilityパターンとは

Chain of Responsibility(責任の連鎖)パターンは、オブジェクト指向設計におけるデザインパターンの一つで、リクエストを処理する複数のオブジェクトを連鎖的に結びつけることで、処理のフローを柔軟かつ拡張可能にする手法です。このパターンを使用することで、特定のリクエストに対してどのオブジェクトが処理を行うかを、動的に決定することができます。

引用:https://refactoring.guru/ja/design-patterns/chain-of-responsibility

パターンの概要
Chain of Responsibilityパターンは、リクエストを一連のハンドラー(処理オブジェクト)に渡していく構造を持っています。各ハンドラーは、リクエストを処理するか、処理できない場合には次のハンドラーにその処理を委譲します。この方法により、リクエストの送信者(クライアント)は、どのハンドラーがリクエストを処理するかを知る必要がなく、ハンドラー同士が疎結合であるため、システムの保守性と柔軟性が向上します。

最後のハンドラーが処理したパターン

途中のハンドラーが処理したパターン

たとえば、認証、ログ記録、データの検証などの複数の処理ステップが必要なシステムでは、Chain of Responsibilityパターンを使うことで、各処理ステップを個別のハンドラーとして定義し、それらを連鎖させて処理を行うことができます。これにより、処理の追加や変更が容易になり、システムの拡張がしやすくなります。

利用シーン
Chain of Responsibilityパターンが特に役立つシーンには、以下のようなものがあります:

  • エラーハンドリング
  • 認証・認可
  • リクエストのフィルタリング
  • 権限チェック

利点
このパターンの主な利点は以下の通りです:

柔軟性: 新しいハンドラーを簡単に追加したり、既存のハンドラーを変更することが可能で、システムの変更に対して柔軟に対応できる
再利用性: 各ハンドラーが独立しているため、他のプロジェクトや異なる処理フローでも再利用可能
単一責任の原則: 各ハンドラーが特定の責任を持つことで、コードの可読性が向上し、デバッグやテストが容易になる

Next.jsのApp Routerのmiddlewareについて

Middlewareは、リクエストとレスポンスの間に挟まる処理層であり、リクエストが最終的なハンドラー(例えば、PagesやRoute Handler)に到達する前に、リクエストを操作したり、処理を実行したりします。

ユースケース

機能 説明
認証と承認 特定のページまたはAPIルートへのアクセスを許可する前に、ユーザーのIDを確認し、セッションCookieを確認します。
サーバー側リダイレクト 特定の条件(ロケール、ユーザーロールなど)に基づいて、サーバーレベルでユーザーをリダイレクトします。
パスの書き換え リクエストのプロパティに基づいてAPIルートまたはページへのパスを動的に書き換えることで、A/Bテスト、機能のロールアウト、またはレガシーパスをサポートします。
ボット検出 ボットトラフィックを検出してブロックすることでリソースを保護します。
ログ記録と分析 ページまたはAPIで処理する前に、リクエストデータをキャプチャして分析し、洞察を得ます。
機能フラグ設定 シームレスな機能のロールアウトやテストのために、機能を動的に有効または無効にします。

以下は想定されてないケースなので、middlewareで行わない設計を考えるべきです。

機能 説明
複雑なデータの取得と操作 ミドルウェアは直接的なデータの取得や操作用に設計されていないため、代わりにルートハンドラーまたはサーバー側ユーティリティ内で実行する必要があります。
負荷の高い計算タスク ミドルウェアは軽量で応答が速い必要があります。そうでないと、ページの読み込みに遅延が生じる可能性があります。負荷の高い計算タスクや長時間実行されるプロセスは、専用のルートハンドラー内で実行する必要があります。
広範なセッション管理 ミドルウェアは基本的なセッションタスクを管理できますが、広範なセッション管理は専用の認証サービスまたはルートハンドラー内で管理する必要があります。
直接データベース操作 ミドルウェア内で直接データベース操作を実行することは推奨されません。データベースのやり取りは、ルートハンドラーまたはサーバー側ユーティリティ内で実行する必要があります。

引用:https://nextjs.org/docs/app/building-your-application/routing/middleware#use-cases

Next.jsのmiddlewareの注意点
Next.jsで動作するmiddlewareは、特有の制約があるので注意が必要です。

  • キャッシュされたコンテンツとルートが一致する前にも実行される
    • Next.jsはさまざまなものをキャッシュしてますが、キャッシュされたコンテンツへのリクエストでもmiddlwareの処理が実行されます
  • Edge ランタイムのみをサポートされていて、Node.js ランタイムは使用できない
  • ミドルウェアは直接的なデータの取得や操作用に設計されていない
    • データベースやRedisへアクセスは考慮した設計にしないと行えない

実装ステップへ

実装する内容

以下のミドルウェアを作成します。

  • リクエストとレスポンスのログを全てのリクエストで出力(HttpStatu, URL, リクエストUUID)
  • Botからのアクセスブロック
  • 認証チェック
  • Next.jsの処理を行ったらレスポンスに独自ヘッダーを追加

空のmiddlewareを作る

これをベースに変更していきます〜

  • matcherの正規表現引っかからないURLのリクエストで実行
  • middlewareが実行されたら文字列を出力

処理イメージ

src/middleware.ts
import { NextResponse } from "next/server";
import type { NextFetchEvent, NextRequest } from "next/server";

export function middleware(_req: NextRequest, _event: NextFetchEvent) {
  console.log("Hello Middleware");

  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * 次のもので始まるリクエストパスを除くすべてのリクエストパスにマッチさせます:
     * - api (APIルート)
     * - _next/static (静的ファイル)
     * - _next/image (画像最適化ファイル)
     * - favicon.ico (ファビコンファイル)
     */
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

各機能の処理を全てmiddleware.tsに書く

処理イメージ

途中でレスポンス返すパターンのイメージ

export function middleware(req: NextRequest, _event: NextFetchEvent) {
  const requestUUID = crypto.randomUUID();

  /**
   * アクセスログ(リクエスト)
   */
  console.log(
    `[${requestUUID}][${new Date().toISOString()}] [Request ]     ${
      req.method
    } ${req.url}`
  );

  /**
   * Botからのアクセスブロック
   */
  const userAgent = req.headers.get("user-agent");
  if (userAgent && userAgent.includes("bot")) {
    const response = new NextResponse("Access denied", { status: 403 });
    /**
     * アクセスログ(レスポンス)
     */
    console.log(
      `[${requestUUID}][${new Date().toISOString()}] [Response] ${
        response.status
      } ${req.method} ${req.url}`
    );
    return response;
  }

  /**
   * 認証
   */
  const sessionId = req.cookies.get("sessionId");
  const NO_AUTH_PATHS = ["/"];
  if (!sessionId && !NO_AUTH_PATHS.includes(req.nextUrl.pathname)) {
    const response = new NextResponse("Unauthorized", { status: 401 });
    /**
     * アクセスログ(レスポンス)
     */
    console.log(
      `[${requestUUID}][${new Date().toISOString()}] [Response] ${
        response.status
      } ${req.method} ${req.url}`
    );
    return response;
  }

  /**
   * レスポンスに独自ヘッダーを追加
   */
  const response = NextResponse.next();
  response.headers.set("x-custom-res-header", "some-value");

  return response;
}

サンプルコードでコード量が少なく共通化してなかったりがありますが、これだけでも問題となりそうな部分が見えてきます。

  • 認証チェックをプロダクションコードレベルで書くと見通しが悪くなる
  • アクセスログの処理が分散して書かれていて見通しが悪い(雑なコードで可読性が低くなっているのは目を瞑ってください...!!)
  • middlewareのテストを書くときに、テストケースと必要のない部分の設定が必要になる(importしている関数のモックなど)
    • 例)アクセスログが出力されるかのテストに認証で使っている関数のモック設定が必要になる

Chain of Responsibilityで書き直す

ミドルウェアを連続的に実行する関数を作成する

このコードは、Next.jsで複数のミドルウェアを順番に実行するためのチェーンを作成する機能を提供します。createMiddlewareChain関数を使用することで、複数のミドルウェアを順に適用し、最終的に NextResponseを返す一連の処理を構築できます。

処理イメージ

lib/middleware/createMiddlewareChain.ts
// 作成するミドルウェア関数の型
export type Middleware = (
  req: NextRequest,
  event: NextFetchEvent,
  next: () => Promise<NextResponse>
) => Promise<NextResponse>;

// Chain作成関数の型
type MiddlewareChain = (
  request: NextRequest,
  event: NextFetchEvent,
  next: () => Promise<NextResponse>
) => Promise<NextResponse>;

// Chain作成関数
export const createMiddlewareChain = (
  ...middlewares: Middleware[]
): MiddlewareChain => {
  // 実行する関数を返す
  return async (req, event, next) => {
    // ミドルウェアを順番に実行する関数を定義
    const executeMiddleware = (index: number): Promise<NextResponse> => {
      // ミドルウェアがある場合は実行する
      const middleware = middlewares[index];
      if (middleware) {
        return middleware(req, event, async () => executeMiddleware(index + 1));
      }

      // ミドルウェアがない場合は次の処理を実行する(= 全てのミドルウェアが実行済み)
      return next();
    };

    return executeMiddleware(0);
  };
};

各機能の処理をミドルウェアに分割する

middleware.tsに全ての処理が入っていたものを、処理内容によってミドルウェアに分割します。
こうすることによって、1ファイル内に1つの責務しか持たなくなるので、テストがしやすく見通しがよくなります。

今回は全体的に1つ1つの処理が少ないので恩恵を感じにくいですが、本来の認証処理などはもっとコード量が多くなります。
そのコードを分離できるのは大きなメリットです。

処理イメージ

アクセスログのミドルウェア

lib/middleware/middlewares/requestLogMiddleware.ts
export const requestLogMiddleware: Middleware = async (req, _event, next) => {
  const requestUUID = crypto.randomUUID();
  console.log(
    `[${requestUUID}][${new Date().toISOString()}] [Request ]     ${
      req.method
    } ${req.url}`
  );

  const response = await next();

  console.log(
    `[${requestUUID}][${new Date().toISOString()}] [Response] ${
      response.status
    } ${req.method} ${req.url}`
  );

  return response;
};

Botからのアクセスブロックのミドルウェア

lib/middleware/middlewares/blockBotMiddleware.ts
export const blockBotMiddleware: Middleware = async (req, _event, next) => {
  const userAgent = req.headers.get("user-agent");
  if (userAgent && userAgent.includes("bot")) {
    return new NextResponse("Access denied", { status: 403 });
  }

  return next();
};

認証のミドルウェア

lib/middleware/middlewares/authMiddleware.ts
export const authMiddleware: Middleware = async (req, _event, next) => {
  const sessionId = req.cookies.get("sessionId");
  const NO_AUTH_PATHS = ["/"];
  if (!sessionId && !NO_AUTH_PATHS.includes(req.nextUrl.pathname)) {
    return new NextResponse("Unauthorized", { status: 401 });
  }

  return next();
};

レスポンスに独自ヘッダーを追加するミドルウェア

lib/middleware/middlewares/addCustomHeaderMiddleware.ts
export const addCustomHeaderMiddleware: Middleware = async (
  _req,
  _event,
  next
) => {
  const response = await next();
  response.headers.set("x-custom-header", "some-value");
  return response;
};

createMiddlewareChainを使ってミドルウェア実行関数を作る

これをmiddleware.tsで使います。
PageとRoute Hnadlerで使うミドルウェアが異なることが多いので、pageMiddlewareChainto
apiMiddlewareChainのように分けて定義するのも良いと思います。

lib/middleware/index.ts
export const middlewareChain = createMiddlewareChain(
  requestLogMiddleware,
  blockBotMiddleware,
  authMiddleware,
  addCustomHeaderMiddleware
);

middleware.tsでChain関数を呼び出す

こんな感じでだいぶmiddleware.tsが薄くなりました!!!!

middleware.ts
import { middlewareChain } from "./lib/middleware";

export function middleware(req: NextRequest, event: NextFetchEvent) {

  const next = async () => {
    return NextResponse.next();
  };
  return middlewareChain(req, event, next);
}

リファクタリング内容のまとめ

リファクタリング後のコードでは、Chain of Responsibilityパターンを導入し、各処理を個別のmiddlewareとして分割しました。この手法には以下のメリットがあります。

可読性の向上
各middlewareが独立したファイルに分割されることで、それぞれの処理が明確に分離され、コード全体の可読性が大幅に向上しました。どの処理がどのmiddlewareで行われているのかが一目で分かるため、コードの理解が容易になります。

責務の分離
Chain of Responsibilityパターンにより、各middlewareが特定の機能に対して責任を持つようになりました。これにより、単一責任の原則が守られ、各middlewareの機能が明確に分離されました。たとえば、認証処理はauthMiddleware、ログ記録はrequestLogMiddlewareというように、処理内容ごとに責務が分かれています。

テストの容易さ
各middlewareが独立しているため、個々の処理を単独でテストすることが容易になりました。たとえば、認証処理のみをテストする場合は、authMiddlewareのテストに集中することができ、他の処理に影響されることがありません。これにより、テストの精度が向上し、バグの早期発見が可能になります。

拡張性の向上
新しい機能を追加したい場合、単に新しいmiddlewareを作成し、Chainに追加するだけで済みます。既存のコードに手を加えることなく機能を拡張できるため、システムの変更に柔軟に対応できます。

さいごに

今回の記事では、Next.jsのApp Routerでのmiddlewareの管理方法について、Chain of Responsibilityパターンを活用して実装する方法を解説しました。
Chain of Responsibilityを実装することで、コードとしての複雑度は上がるので、middlewareで行う処理の量や種類に合わせて導入を検討するのが良いと思います。

middleware実装の参考になれば幸いです。

では、良い開発ライフを!!

参考

今回のコード
https://github.com/salvage0707/app-router-msw/tree/9d1e636b3f0862388be0a8d0fdbec661823d2604

SMARTCAMP Engineer Blog

Discussion