🔄

SWR で SQL 発行量を節約する

2022/02/13に公開

最近 ORM の prisma を触る機会があり「フロントの設計次第で SQL 発行量が結構変わるなぁ」と改めて感じたのでメモ書きです(prisma に限らず、API 設計の際に考察ポイントになる内容です)。本稿では、次の様なアプリケーションを Next.js + prisma で実装するものとして話をすすめます。

  • 著者・カテゴリーが登録できる
  • 本を登録できる
  • 本には、登録ずみの著者が 1 名設定できる
  • 本には、登録ずみのカテゴリーが複数設定できる
  • ユーザー認証が必要で SSG は想定していない

タイトルのSWRは、こちらのライブラリを指します。

schema 定義

以下、雑にスキーマを定義します。Author・Category が親テーブル、Book が子テーブルです。

schema.prisma
model Author {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  name      String   @db.VarChar(255)
  books     Book[]
}

model Category {
  id     Int    @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  name   String @db.VarChar(255)
  books  Book[]
}

model Book {
  id          Int       @id @default(autoincrement())
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  title       String    @db.VarChar(255)
  author      Author    @relation(fields: [authorId], references: [id])
  authorId    Int
  categories  Category[] @relation(fields: [categoryIds], references: [id])
  categoryIds Int[]
}

以降、この Book テーブルに対する編集画面を中心にみていきます。

Query 発行

prisma は TypeScript フレンドリーな ORM で、Next.js のgetServerSideProps(以下 gSSP)や API Route からも(良し悪しはさておき)直接繋ぐことが可能です。Promise を介して SQL を発行、INNER JOIN なども対応しています。以下innerJoinedDataの様に select するだけで、結合された結果が返ってきます。

const queryData = await prisma.book.findUnique({
  where: { id: Number(id) },
  select: {
    id: true,
    title: true,
    price: true,
    authorId: true, // 外部キー
    categoryIds: true, // 外部キー
  },
});

const innerJoinedData = await prisma.book.findUnique({
  where: { id: Number(id) },
  select: {
    id: true,
    title: true,
    price: true,
    author: { select: { id: true } }, // innner join される
    categories: { select: { id: true } }, // innner join される
  },
});

何も考えずに gSSP だけで実装してみる

本の編集画面を見ていきましょう。編集画面なので、登録済みデータを初期値として gSSP で取得しています。「著者セレクトボックス・カテゴリーチェックボックス」を実装する必要があるため、テーブル内容を都度全取得しています。Author・Category の更新頻度が低く、数件〜百件程度で収まる場合、この都度発行している SQL が無駄に見えます。

pages/books/[id]/edit.tsx
export const getServerSideProps = async ({
  query,
}: GetServerSidePropsContext) => {
  const id = query["id"];
  if (typeof id !== "string" || isNaN(Number(id))) {
    return { props: { err: "invalid request" } };
  }
  const [book, authors, categories] = await Promise.all([
    prisma.book.findUnique({
      where: { id: Number(id) },
      select: {
        id: true,
        title: true,
        price: true,
        authorId: true,
        categoryIds: true,
      },
    }),
    prisma.author.findMany({ select: { id: true, name: true } }), // <- here
    prisma.category.findMany({ select: { id: true, name: true } }), // <- here
  ]);
  if (!book) return { props: { err: "not found" } };
  return { props: { book, authors, categories } };
};

SPA の場合、画面横断でブラウザ内インメモリキャッシュを引き回すことが可能です。この正規化された Author・Category のテーブル内容をキャッシュし、SQL 発行量を節約してみましょう。

API Route を生やす

ブラウザで選択肢データを全取得するための API を、API Route に生やします。gSSP で発行していた SQL(prisma.author.findMany)がこちらに移動したかたちですね。

pages/api/authors/index.ts
const getHandler: NextApiHandler = async (req, res) => {
  res.status(200).json(
    await prisma.author.findMany({
      select: { id: true, name: true },
    })
  );
};
const handler: NextApiHandler = (req, res) => {
  switch (req.method) {
    case "GET":
      getHandler(req, res);
      break;
    default:
      res
        .status(405)
        .json({ err: "Method Not Allowed" });
  }
};
export default handler;

※ サンプルコードはエラーハンドリング等省略しています

SWR を使う

SWRを使うと、コンポーネントマウントと同時に API を叩き、取得したデータをキャッシュします。useSWRImmutableは、アプリケーションがブラウザにマウントされたら初回のみ fetcher を実行する hook です。先の API Route からテーブル内容を全て取得、useAuthorsDataを使えば一度取得したデータを引き回せる、という状態にします。利用時はブラウザ側で、主キーと外部キーを突き合わせれば OK です。

caches/Author/index.tsx
import useSWRImmutable from "swr/immutable";

const KEY = "/api/authors";
export const useAuthorsData = () => {
  const { data } = useSWRImmutable<Author[]>(KEY, () =>
    fetch(KEY).then((res) => res.json())
  );
  return data;
};

同様にuseCategoriesDataを定義すれば、ページ表示初期データとして本当に必要な SQL だけを gSSP で発行する様に変更できました。Author・Category の全取得 SQL は UI が初めて表示された時のみ、つまり回遊するたびに発行されていた SQL が発行されなくなったということです。

pages/authors/[id]/edit.tsx
type SSP = InferGetServerSidePropsType<typeof getServerSideProps>;

export const getServerSideProps = async ({
  query,
}: GetServerSidePropsContext) => {
  const id = query["id"];
  if (typeof id !== "string" || isNaN(Number(id))) {
    return { props: { err: "invalid request" } };
  }
  const data = await prisma.book.findUnique({
    where: { id: Number(id) },
    select: {
      id: true,
      title: true,
      price: true,
      publisherId: true,
      authorId: true,
      categoryIds: true,
    },
  });
  if (!data) return { props: { err: "not found" } };
  return { props: { data } };
};

const PageBase = (
  props: SSP & {
    authors: Author[];
    categories: Category[];
  }
) => {
  return <div>...実装詳細省略)</div>;
};

const Page = (props: SSP) => {
  const authors = useAuthorsData();
  const categories = useCategoriesData();
  if (!authors || !categories) return <>...loading</>;
  return <PageBase {...props} authors={authors} categories={categories} />;
};

export default Page;

キャッシュを更新する

「Author・Category は更新頻度が低く、数件〜百件程度で収まる」という前提の話になっていましたが、このキャッシュを別画面で更新することもあるでしょう。回遊時にそれが反映されないのは困るので、useSWRImmutableでキャッシュしたデータはmutateで更新します。API Route に対し更新 API を叩き、SWR キャッシュを更新するまでを以下関数にまとめました。

caches/Author/index.tsx
export const updateAuthor = async (
  id: string,
  payload: { name?: string; email?: string }
) => {
  const res: Author = await fetch(`${KEY}/${id}`, {
    method: "PUT",
    body: JSON.stringify(payload),
    headers: { "Content-Type": "application/json" },
  }).then((res) => {
    if (!res.ok) throw res;
    return res.json();
  });
  await mutate(
    KEY,
    (prev?: Author[]) => {
      if (!prev) return;
      return [...prev].map((author) => {
        if (author.id !== res.id) return author;
        return res;
      });
    },
    false
  );
  return res;
};

この様な関数にまとめると、コンポーネント側でキャッシュ・SWR を意識不要とし、テストを書きやすくします。

pages/authors/[id]/edit.tsx
<form
  onSubmit={handleSubmit(async (values) => {
    try {
      await updateAuthor(`${data.id}`, values);
      router.push(`/authors/${data.id}`);
    } catch (err) {
      handleResponseError(err);
    }
  })}
>

まとめ

正規化されたテーブル内容をフロントにキャッシュし、引き回すことを紹介しました。注意点として、初回全取得することで主画面描画が遅れるケースや、ロジックがフロントが側に寄りすぎてしまうケースが懸念されます。また、SWR のキャッシュとは別に、同レコードが内部結合で何処かにキャッシュされており更新が漏れてしまった、という事にも注意しなければいけません。

しかし、システム全体の最適化につながるテクニックでもあるので、適材適所で活かせればと思います。フロント実装もどの様な SQL が発行されるのかという意識は大事にしたいですね。

Discussion