🧑‍🤝‍🧑

CoR パターンで実装する Next.js の middleware

に公開

GoF が提唱したデザインパターンのうち、 Chain of Responsibility パターン(以下、CoR パターン) というものがあります。責任の連鎖とも訳されますね。

CoR パターンはその名の通り、チェーンに見立てて処理を複数の関数へ順々に回していく仕組みです。
自分の関心事ではないと判断したら、次の関数に処理をバトンタッチしていきます。

良い面としては処理の追加・変更・削除などが比較的容易で、関心事に集中しやすくなります。
難しい面としては、処理がストップした場合にエラーの原因を追うのが少し大変だったり、常に処理する順番を意識する必要があります。

CoR パターン自体の説明は次の記事がとてもわかりやすいので、
興味がある方は下記も参照なさっていただければと思います。

実装イメージとしては記事中にあるこちらの図で伝わるかと思います。

引用: Chain of Responsibility

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

middleware を実装していると出てくる悩み

自分が参画していたプロジェクトは SEO の観点から、検索結果を表示する際に URL を任意の形式になるようリライトしていました。

また、ロギングや認証処理が挟まったりと色々な関心事が1つのファイルにまとめられており、変更する箇所を探すのが少し難しいと感じました。

今回はそんな経験から CoR パターンで middleware で行う処理を関心事別にファイルを分け、管理しやすい実装に持っていくサンプルを考えました。

CoR パターンで実装する

要素としては大きく2つを使って実装を進めていきます。

  • 取り決めたインターフェースを持ち、最後に次の処理に繋げるメソッドを発火させる個々の関数
  • 上記の個々の関数を配列として受け取り、再帰的に発火させるエントリポイントの関数

インターフェース

次のような共通のインターフェースを定義します。

middlewareRule.ts
import { NextRequest, NextResponse } from "next/server";

/**
 * next() を呼ぶと次のミドルウェアに遷移し、
 * 最終的に Promise<NextResponse> を返す関数型ミドルウェアの定義
 */
export type MiddlewareRule = (
  req: NextRequest,
  next: () => Promise<NextResponse>,
) => Promise<NextResponse>;

個々の関数

例えば Basic 認証であれば、上記の MiddlewareRule 型の関数で実装できます。

basic-auth.ts
import { NextResponse } from "next/server";
import type { MiddlewareRule } from "./middlewareRule";
import { env } from "./env.mjs";

export const basicAuth: MiddlewareRule = async (req, next) => {
  const authHeader = req.headers.get("authorization") ?? "";

  if (!authHeader || !authHeader.startsWith("Basic ")) {
    console.log("[BasicAuth] Unauthorized");
    return new NextResponse("Unauthorized", {
      status: 401,
      headers: {
        "WWW-Authenticate": "Basic realm=Authorization Required",
        "Cache-Control": "no-store",
        "Content-Type": "text/plain;charset=UTF-8",
      },
    });
  }

  const base64Credentials = authHeader.split(" ")[1];
  const credentials = atob(base64Credentials);
  const [username, password] = credentials.split(":");

  if (
    username !== env.BASIC_AUTH_USERNAME ||
    password !== env.BASIC_AUTH_PASSWORD
  ) {
    console.log("❌️[BasicAuth] Invalid credentials");
    return NextResponse.json("Unauthorized", {
      status: 401,
      headers: {
        "WWW-Authenticate": "Basic realm=Authorization Required",
        "Cache-Control": "no-store",
        "Content-Type": "text/plain;charset=UTF-8",
      },
    });
  }

  console.log("[BasicAuth] Authentication successful");
  return next();
};

引数のうち、next: () => Promise<NextResponse> が渡ってくるので、この関数を最後に発火させることで次の処理に渡すことが出来ます。

ユニットテスト例
import { describe, it, expect, vi } from "vitest";
import { basicAuth } from "./basic-auth";
import { NextRequest, NextResponse } from "next/server";

vi.mock("../../env.mjs", () => ({
  env: {
    BASIC_AUTH_USERNAME: "admin",
    BASIC_AUTH_PASSWORD: "password",
  },
}));

describe("BasicAuth Middleware", () => {
  it("should return 401 if no authorization header is present", async () => {
    const req = new NextRequest("http://localhost:3000/");
    const response = await basicAuth(req, () =>
      Promise.resolve(NextResponse.next()),
    );
    expect(response.status).toBe(401);
    expect(response.headers.get("WWW-Authenticate")).toBe(
      "Basic realm=Authorization Required",
    );
  });

  it("should return 401 if credentials are invalid", async () => {
    const credentials = Buffer.from("invalid:credentials").toString("base64");
    const req = new NextRequest("http://localhost:3000/", {
      headers: { authorization: `Basic ${credentials}` },
    });
    const response = await basicAuth(req, () =>
      Promise.resolve(NextResponse.next()),
    );
    expect(response.status).toBe(401);
  });

  it("should call next() if credentials are valid", async () => {
    const credentials = Buffer.from("admin:password").toString("base64");
    const req = new NextRequest("http://localhost:3000/", {
      headers: { authorization: `Basic ${credentials}` },
    });
    const nextMock = vi.fn(() => Promise.resolve(NextResponse.next()));

    const response = await basicAuth(req, nextMock);

    expect(nextMock).toHaveBeenCalled();
    expect(response).toEqual(NextResponse.next());
  });
});

createMiddleware

createMiddleware 関数は MiddlewareRule 型の配列を受け取り、
渡された配列の長さの分だけ dispatch を再帰的に繰り返します。

createMiddleware.ts
import { NextRequest, NextResponse } from "next/server";
import type { MiddlewareRule } from "./middlewareRule";

/**
 * rules 配列を受け取り、ミドルウェア関数を返します。
 * 途中で例外が投げられた場合はキャッチし、500 エラーを返す
 */
export function createMiddleware(
  rules: MiddlewareRule[],
): (req: NextRequest) => Promise<NextResponse> {
  const dispatch = async (
    req: NextRequest,
    i: number,
  ): Promise<NextResponse> => {
    if (i >= rules.length) {
      // すべてのルールが実行された場合、次の処理を続ける
      return NextResponse.next();
    }

    // 現在のルールを取得
    const rule = rules[i];
    // ルールを実行し、次のルールを呼び出す
    return rule(req, () => dispatch(req, i + 1));
  };

  // ミドルウェアのエントリポイント
  return async (req: NextRequest): Promise<NextResponse> => {
    try {
      // ルールを実行
      return await dispatch(req, 0);
    } catch (error) {
      console.error("[Middleware] Error:", error);
      return NextResponse.json(
        { message: "Internal Server Error" },
        {
          status: 500,
          headers: {
            "Content-Type": "application/json;charset=UTF-8",
            "Cache-Control": "no-store",
          },
        },
      );
    }
  };
}

dispatch で発火させる関数は同期処理・非同期処理かかわらず Promise でやりとりすることで、インターフェースを Promise<NextResponse> で統一させています。
これによりメンテナンス性が高くなったり、呼び出し側の型定義がシンプルになります。

また、エントリポイントを try/catch で囲むことで、例外があった場合でもここで補足して 500 エラーを返すような形にしています。

ユニットテスト例
import { describe, it, expect } from "vitest";
import { createMiddleware } from "./lib/middleware/createMiddleware";
import { NextRequest } from "next/server";
import type { MiddlewareRule } from "./lib/middleware/middlewareRule";

const testMiddleware: MiddlewareRule = async (req, next) => {
  const response = await next();
  response.headers.set("x-test", "test");
  return response;
};

describe("create middleware chain", () => {
  it("should add a header to the request", async () => {
    const chain = createMiddleware([testMiddleware]);
    const req = new NextRequest("http://localhost:3000/test");
    const response = await chain(req);
    expect(response.headers.get("x-test")).toBe("test");
  });
});

middleware

createMiddleware、basicAuth などをまとめてインポートして、 createMiddleware にそれぞれの関数を渡して実行するだけです。
一緒に logger なんかもある想定ですが、任意の処理を加えるとよいでしょう。

middleware.ts
import { logger } from "./lib/middleware/logger";
import { basicAuth } from "./lib/middleware/basic-auth";
import { createMiddleware } from "./lib/middleware/createMiddleware";

const middleware = createMiddleware([basicAuth, logger]);
export default middleware;

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

もし処理を増やしたりフローの順番を変更する場合はこの middleware.ts だけを変更すればよいので、影響範囲が限定的ですし、テストもしやすいですね。

まとめ

CoR パターンで実装例を作成してみました。
個人的には見通しもよく柔軟で気に入っていますが、middleware 側で発火する処理数が限定的であれば冗長かもしれません。
また、例外発生時にどこで何が起きているかの補足方法についても、どのような値をクライアント側へ返却するかの取り決めなども必要に見えました。
とはいえ有用なパターンなので自分はこの実装を発展させていけたらと思います。

Next.js の middleware には他にもこのような記事もあります。
もしよかったら一緒に読んでいただけると幸いです。

それでは。

https://zenn.dev/chot/articles/3e5e5dacd2ff48

chot Inc. tech blog

Discussion