Next.js に Service層 を導入する
本稿は、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 }
に整形するのがおすすめ」という記事を以前書きましたが、プロジェクト定型規格を設けるメリットが、こういったところにも現れます。
import { getUsers } from "@/services/some.orm.client/users";
export const getServerSideProps: GetServerSideProps<Props> = async () => {
return { props: await getUsers() }; // 関数内部はORMに接続している
};
この Service 非同期関数はもちろん API Routes でも再利用できます。レスポンスに{ status }
を含めていた理由も、以下をみれば自明ですね。
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 である validationSchema
でparse(data)
してるのですが、このとき不正な入力値である場合、ZodError
が throw されます。それを catch しtransformValidationErrors
で、プロジェクト定型規格のエラーレスポンスに整形するという流れです。
この様に作り込んでおくことで、Service 非同期関数から返却される値は、あたかもリクエストを送ったかのようなPromise.resolve({ data, err, status })
を得られます。実際には手前のバリデーションでブロックされているので、不要なリクエストが、データ永続化層(Backend Services)に飛ぶことがありません。
この ZodObject は react-hook-form でも共有可能なので、さらにユーザーに近い場所(ブラウザ内部)からエラーを掲出することができます(この時リクエストは全く飛びません)
Service 層実装方法
接続するデータ永続化層が REST API を提供している前提で実装詳細を見ていきます。紹介する例ではfetcher
という、fetch API をラップした関数を使用しています。API Client は fetch API でも何でも構いません。重要なのは、内部でプロジェクト定型規格に変形していることと、事前バリデーションを行なっていることです。
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 関数まで含めた実装詳細
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 関数単体テスト詳細
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 の実装詳細はつぎのようなものにまで簡略化できます(もちろん最小限の例ではありますが)
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