🏔️

Next.js に Service層 を導入する

2022/06/03に公開約13,000字

本稿は、Next.js で「getServerSideProps や API Routes」を利用するアプリケーション向け内容になります。重厚な作りになるので、要件に適合する・しないはあると思いますので、あしからず。

Next.js は薄いフレームワーク

Next.js は SPA 配信の最適化にフォーカスしており、Backend の機能面が十分とは言えません。pages の Page コンポーネントや API Routes は、controller としての機能を提供するのみです。ドキュメントを見てもわかるとおり、一連処理はあらかじめ middleware やラッパー関数を用意するのが常套手段かと思います。

NestJS にあるような Service 層が欲しい

Node.js Backend フレームワークとして、NestJS は有力な候補かと思います。レイヤーやモジュール・DI の構成がフレームワークで定められており、プロジェクトごとに構成が大きく違わないことが期待できます。NestJS では入口として「controller / resolver」がリクエストを受け取り「service」を利用してデータ永続化層へと接続します。

この「service」があることで「controller / resolver」からは、データ永続化層接続の実装詳細が分離されます。この責務分割に魅力を感じ、Next.js にも似たような機構が導入できないか考えたのが発端で、似た働きをする機構を本稿では Service 層と呼びます。

Next.js に Service 層を導入するとどうなるか

Next.js には Service 層相当の機構がありません。getServerSidePorpsなどのデータ取得関数は通常、以下の様な実装詳細となります。データ取得・データ整形の詳細が、getServerSidePorps内に露呈しています。

export const getServerSideProps: GetServerSideProps<Props> = async () => {
  const { data, err } = await fetch("https://api.example.com/api/users").then(
    async (res) => {
      const data = await res.json();
      if (!res.ok) return { err: data };
      return { data };
    }
  );
  return { props: { data, err } };
};

筆者のいう Service 層とは、以下「getUsers関数」の様な、作り込まれた非同期関数を指します(class と継承を使ったものではありません)上記コードと比較してgetServerSidePorpsが非常に端的になります。Http リクエストの結果は{ data, err }に内部で整形されているため、ダイレクトに props として返却できます。

import { getUsers } from "@/services/api.example.com/users";

export const getServerSideProps: GetServerSideProps<Props> = async () => {
  return { props: await getUsers() };
};

このような非同期関数を、当稿では 「Service 非同期関数」 と呼びます。

Service 層導入のメリット(データ取得編)

「Service 非同期関数」をモジュール分割しておくと、データの取得先が ORM であろうとも、実装詳細が露呈することはありません。「データ取得後は{ data, err }に整形するのがおすすめ」という記事を以前書きましたが、プロジェクト定型規格を設けるメリットが、こういったところにも現れます。

src/pages/users/index.tsx
import { getUsers } from "@/services/some.orm.client/users";

export const getServerSideProps: GetServerSideProps<Props> = async () => {
  return { props: await getUsers() }; // 関数内部はORMに接続している
};

この Service 非同期関数はもちろん API Routes でも再利用できます。レスポンスに{ status }を含めていた理由も、以下をみれば自明ですね。

src/pages/api/users/index.ts
import { getUsers } from "@/services/some.orm.client/users";

export default async function handler(req, res) {
  if (req.method === 'GET') {
    const { data, err, status } = await getUsers();
    if (data) res.status(status).json(data)
    if (err) res.status(status).json(err)
  } else {
    // ...
  }
}

後述しますが、この様な定型規格を定めておくことで、他にも多くのメリットが生まれます。

Service 層導入のメリット(データ永続化編)

NestJS の service には、validation モジュール(DTO)を中継し、入力値チェックを行う機構があります(ドキュメント中、CreateUserDto の件)本稿で紹介している「Service 非同期関数」も同様に、この仕組みを導入します。この入力値チェックが備わることで 「不正な入力値はリクエストを送らない」 という制御弁を設けられます。

form 入力値の検証のため、react-hook-form と zod のようなバリデーションライブラリを組み合わせる機会が増えたと思うので、プロジェクトで選んだバリデーションライブラリをここに利用します。zod を選んでいる前提で、詳細をみていきましょう。

try {
  if (validationSchema) {
    const data = JSON.parse(init?.body?.toString() || "");
    validationSchema.parse(data);
  }
} catch (err) {
  return transformValidationErrors(err, throwErr);
}
return fetch(input, init)
  .then(transformResponse<T>(throwErr))
  .catch((err) => transformError<T>(err));

ZodObject である validationSchemaparse(data)してるのですが、このとき不正な入力値である場合、ZodErrorが throw されます。それを catch しtransformValidationErrorsで、プロジェクト定型規格のエラーレスポンスに整形するという流れです。

この様に作り込んでおくことで、Service 非同期関数から返却される値は、あたかもリクエストを送ったかのようなPromise.resolve({ data, err, status }) を得られます。実際には手前のバリデーションでブロックされているので、不要なリクエストが、データ永続化層(Backend Services)に飛ぶことがありません。

Service 非同期関数

この ZodObject は react-hook-form でも共有可能なので、さらにユーザーに近い場所(ブラウザ内部)からエラーを掲出することができます(この時リクエストは全く飛びません)

Service 層実装方法

接続するデータ永続化層が REST API を提供している前提で実装詳細を見ていきます。紹介する例ではfetcherという、fetch API をラップした関数を使用しています。API Client は fetch API でも何でも構いません。重要なのは、内部でプロジェクト定型規格に変形していることと、事前バリデーションを行なっていることです。

src/services/api.example.com/users/index.ts
export const path = () => host(`/api/users`);

export const getUsers = (throwErr = false) =>
  fetcher<UsersData>(
    path(),
    { method: "GET", headers: defaultHeaders },
    undefined,
    throwErr
  );

export const createUser = (data: UserInput, throwErr = false) =>
  fetcher<UserData>(
    path(),
    {
      method: "POST",
      headers: defaultHeaders,
      body: JSON.stringify(data),
    },
    UserInputSchema,
    throwErr
  );

第三引数で与えているUserInputSchemaが、事前バリデーションを施すための ZodObject です。今回の例では zod を利用していますが、ここもプロジェクトで採用しているバリデーションライブラリを自由に選べます。

import { z } from "zod";

export const UserInputSchema = z.object({
  name: z.string().min(1, "ユーザー名を入力してください"),
  email: z
    .string()
    .min(1, "メールアドレスを入力してください")
    .email("不正なメールアドレス形式です"),
  password: z
    .string()
    .min(1, "パスワードを入力してください")
    .min(12, "12文字以上で入力してください"),
});

さきに紹介したとおり、UserInputSchemaを引数に受け取った fetcher 関数は、内部でバリデーションエラーを早期リターンしています。引数はそれぞれ、使用技術にのっとったものなので、適宜読み替えてください。

export function fetcher<T>(
  input: RequestInfo,
  init?: RequestInit,
  validationSchema?: ZodObject<any>,
  throwErr = false
) {
  try {
    if (validationSchema) {
      const data = JSON.parse(init?.body?.toString() || "");
      validationSchema.parse(data);
    }
  } catch (err) {
    return transformValidationErrors(err, throwErr);
  }
  // バリデーションエラー時はここに到達しない
  return fetch(input, init)
    .then(transformResponse<T>(throwErr))
    .catch((err) => transformError<T>(err));
}

実装詳細全容も以下に貼りましたのでご参考まで。

transform 関数まで含めた実装詳細
src/services/api.example.com/fetcher/index.ts
import { errors } from "@/errors";
import { ZodError, ZodObject } from "zod";
import { ErrResponse } from "../fetcher/type";
import type { Err, HttpResponse } from "./type";

export function transformValidationErrors(
  error: unknown,
  throwErr: boolean
): Promise<ErrResponse> {
  if (error instanceof ZodError) {
    const err: Err = {
      ...errors["VALIDATION"],
      errors: error.errors.map((issue) => ({
        code: issue.code,
        name: `${issue.path[0]}`,
        message: issue.message,
      })),
    };
    const response: ErrResponse = {
      data: null,
      err,
      status: errors["VALIDATION"].status,
    };
    if (throwErr) throw response;
    return Promise.resolve(response);
  }
  throw error;
}

export function transformResponse<T>(throwErr: boolean) {
  return async (res: Response): Promise<HttpResponse<T>> => {
    const json = await res.json();
    if (!res.ok) {
      const err: Err = { ...json, status: res.status };
      const response = { data: null, err, status: res.status };
      if (throwErr) throw response;
      return response;
    }
    return { data: json, err: null, status: res.status };
  };
}

export function transformError<T>(err: unknown): Promise<HttpResponse<T>> {
  if (err instanceof Error) {
    const error: ErrResponse = {
      data: null,
      err: { message: err.message, status: -1 },
      status: -1,
    };
    return Promise.resolve(error);
  }
  throw err;
}

export function fetcher<T>(
  input: RequestInfo,
  init?: RequestInit,
  validationSchema?: ZodObject<any>,
  throwErr = false
) {
  try {
    if (validationSchema) {
      const data = JSON.parse(init?.body?.toString() || "");
      validationSchema.parse(data);
    }
  } catch (err) {
    return transformValidationErrors(err, throwErr);
  }
  return fetch(input, init)
    .then(transformResponse<T>(throwErr))
    .catch((err) => transformError<T>(err));
}
fetcher 関数単体テスト詳細
src/services/api.example.com/fetcher/index.test.ts
import { errors } from "@/errors";
import { setupMockServer } from "@/tests/jest";
import { rest } from "msw";
import z, { ZodError } from "zod";
import {
  fetcher,
  transformError,
  transformResponse,
  transformValidationErrors,
} from ".";
import { DataResponse, Err, ErrResponse } from "./type";

describe("src/services/api.example.com/fetcher/index.test.ts", () => {
  const TestSchema = z.object({ name: z.string() });
  const server = setupMockServer();
  const path = "/api/example";
  const errStatus = 400;
  const expetedData = { message: "ok" };
  const expetedErr: Err = { message: "err", status: errStatus };
  const dataResponse: DataResponse<typeof expetedData> = {
    data: expetedData,
    err: null,
    status: 200,
  };
  const errResponse: ErrResponse = {
    data: null,
    err: expetedErr,
    status: errStatus,
  };
  const notHttpErrorResponse: ErrResponse = {
    data: null,
    err: { message: "err", status: -1 },
    status: -1,
  };
  const handler200 = rest.get(path, (_, res, ctx) =>
    res(ctx.json(expetedData))
  );
  const handler400 = rest.get(path, (_, res, ctx) =>
    res(ctx.status(errStatus), ctx.json(expetedErr))
  );
  const exampleFether = (throwErr = false) =>
    fetcher(path, { method: "GET" }, undefined, throwErr);

  describe("transformValidationErrors", () => {
    test("バリデーションエラーは、ErrResponse型に整形されること", async () => {
      try {
        TestSchema.parse({});
      } catch (err) {
        expect(err instanceof ZodError).toBeTruthy();
        const data = await transformValidationErrors(err, false);
        const expected: ErrResponse = {
          data: null,
          err: { ...errors["VALIDATION"] },
          status: 400,
        };
        expect(data).toMatchObject(expected);
      }
    });
    test("throwErr引数がtrueの場合、ErrResponse型をリスローすること", async () => {
      try {
        TestSchema.parse({});
      } catch (err) {
        expect(err instanceof ZodError).toBeTruthy();
        try {
          await transformValidationErrors(err, true);
        } catch (err) {
          const expected: ErrResponse = {
            data: null,
            err: { ...errors["VALIDATION"] },
            status: 400,
          };
          expect(err).toMatchObject(expected);
        }
      }
    });
    test("ZodErrorインタンス以外は、リスローすること", () => {
      try {
        throw new Error("test");
      } catch (err) {
        expect(() => transformValidationErrors(err, false)).toThrow();
      }
    });
  });
  describe("transformResponse", () => {
    test("正常系レスポンスの場合、DataResponse型に整形されること", async () => {
      server.use(handler200);
      const data = await fetch(path).then(transformResponse(false));
      expect(data).toMatchObject(dataResponse);
    });
    test("異常系レスポンスの場合、ErrResponse型に整形されること", async () => {
      server.use(handler400);
      const data = await fetch(path).then(transformResponse(false));
      expect(data).toMatchObject(errResponse);
    });
    test("throwErr引数がtrueで異常系レスポンスの場合、ErrResponseをスローすること", async () => {
      server.use(handler400);
      try {
        await fetch(path).then(transformResponse(true));
      } catch (err) {
        expect(err).toMatchObject(errResponse);
      }
    });
  });
  describe("transformError", () => {
    test("Errorインタンスは、ErrResponse型に整形されること", async () => {
      const res = await transformError(new Error("err"));
      expect(res).toMatchObject(notHttpErrorResponse);
    });
    test("Errorインタンス以外は、リスローすること", () => {
      try {
        throw "test";
      } catch (err) {
        try {
          transformError(err);
        } catch (_err) {
          expect(_err).toBe("test");
        }
      }
    });
  });
  describe("fetcher", () => {
    test("正常系レスポンスの場合、DataResponse型に整形されること", async () => {
      server.use(handler200);
      const data = await exampleFether();
      expect(data).toMatchObject(dataResponse);
    });
    test("異常系レスポンスの場合、ErrResponse型に整形されること", async () => {
      server.use(handler400);
      const data = await exampleFether();
      expect(data).toMatchObject(errResponse);
    });
    test("throwErr引数がtrueで異常系レスポンスの場合、ErrResponseをスローすること", async () => {
      server.use(handler400);
      try {
        await exampleFether(true);
      } catch (err) {
        expect(err).toMatchObject(errResponse);
      }
    });
  });
});

Service 層導入のメリット(Middleware 編)

Service 非同期関数を中継すると、一律規格を得られるので Middleware 処理が捗ります。記事が長くなりすぎてしまうので今回は割愛しますが、プロジェクト用に作り込んだ Middleware と Service 非同期関数を使うと、API Routes の実装詳細はつぎのようなものにまで簡略化できます(もちろん最小限の例ではありますが)

src/pages/api/users/index.ts
import { auth, combineHandlers, handle, methods } from "@/lib/next/api";
import { createUser, getUsers } from "@/services/api.example.com/users";

export default combineHandlers(
  auth,
  methods({
    GET: handle(() => getUsers()),
    POST: handle(({ body }) => createUser(body)),
  })
);

ここで書くべき単体テスト観点も「未認証時・認証時、GET と POST がそれぞれ叩けるか?」という程度のものになります(Service 非同期関数に対しては、別途単体テストを書きます)

Service 層導入のメリット(テスト編)

Service 非同期関数はモジュール分離されているので、モックすれば、各所単体テストが書きやすくなるのは察しのとおりです。

REST API や GraphQL を叩く Service 非同期関数なら、MSW ハンドラーファクトリー関数をパッケージし、さらに充実したテストを書けるようになります。この件についても、記事が長くなりすぎてしまうので今回は割愛します。

Discussion

ログインするとコメントできます