👻

PrismaClientを上手に扱う

に公開

はじめに

今回の問題にあたって、こちらの記事がとても助かりました!
本記事を読む前にぜひご参照ください!
https://zenn.dev/uki/articles/0e89ff41880778

環境

  • node 18.18.0
  • prisma 5.18.0
  • @prisma/client 5.18.0
  • express 4.19.2

PrismaClientの仕様

Prismaのクライアントは通常、基本的にシングルトンとして定義して使うのが推奨されています。

import { PrismaClient } from '@prisma/client';
export default new PrismaClient();

このとき、このPrismaClientを使用したロジックは以下のようになります。

import prisma from '@/client';

const getUsers = async () => {
  return await prisma.users.findMany()
};

このuser取得のロジックをとあるtransaction内で共通して使用するとした場合、以下のようになります。

import prisma from '@/client';

const updateUser = async () => {
  return await prisma.$transaction(async (tx) => {
    const users = await getUsers(); //シングルトンで定義した外部のPrismaインスタンスを使用しているためtransactionに含まれない
    const users2 = await tx.users.findMany() //コールバック引数内の`TransactionClient`を使用しているためtransactionとして扱われる
  })
}

このように、共通処理を切り出しても、$transactionのコールバック内で呼び出すだけでは 同じトランザクション内として扱われません。

したがって、同一のトランザクションとして扱いたい場合にはtransaction用のものとtransaction用でないものを用意する必要が出てきてしまい、似たような関数が増えていきます。
これを該当するすべての関数で実現するととんでもない量になってしまいます。

Prismaのclientを保持する

以上のような場合は、参考記事にあったように、リクエストごとにTransactionClientを保持してそれを使用する仕組みにすることで解決しました。

では、リクエストごとにTransactionClientを管理するにはどうすればいいかというと、AsyncLocalStorageを使用することで可能になります。

AsyncLocalStorageとは

AsyncLocalStorageとは、非同期操作のコンテキストを作成し、そのコンテキスト内でデータを保持してくれるNode.jsの組み込みモジュールです。

インスタンス作成
const asyncLocalStorage = new AsyncLocalStorage<T>();
コンテキストの実行
asyncLocalStorage.run(store, () => {
  // コンテキスト内の処理
});
ストアの取得
const store = asyncLocalStorage.getStore();

主要なメソッドは以上になります。

具体例

const asyncLocalStorage = new AsyncLocalStorage<Map<string, string>>();

asyncLocalStorage.run(new Map(), () => {
  asyncLocalStorage.getStore()?.set('request', 'unique-request');

  console.log(asyncLocalStorage.getStore()?.get('request')); // unique-requestが出力される
})

console.log(asyncLocalStorage.getStore()?.get('request'))// undefinedが出力される

asyncLocalStorage.run(store, callback)のコールバック関数内では、現在のコンテキストに関連付けられたストアを取得することができますが、コンテキスト外で呼び出すとundefinedを返します。

実装

import { AsyncLocalStorage } from 'node:async_hooks';
import { Prisma } from '@prisma/client';

import prisma from '@/client';

const createTransactionManager = () => {
  const asyncLocalStorage = new AsyncLocalStorage<
    Map<string, Prisma.TransactionClient>
  >();
  const PRISMA_TRANSACTION_KEY = 'prismaTransactionKey' as const;

  return {
    async runInTransaction<T>(
      fn: () => Promise<T>,
      options?: {
        maxWait?: number;
        timeout?: number;
        isolationLevel?: Prisma.TransactionIsolationLevel;
      },
    ): Promise<T> {
      return asyncLocalStorage.run(new Map(), async () => {
        try {
          return await prisma.$transaction(async (tx) => {
            asyncLocalStorage.getStore()?.set(PRISMA_TRANSACTION_KEY, tx);
            const result = await fn();
            return result;
          }, options);
        } finally {
          asyncLocalStorage.getStore()?.delete(PRISMA_TRANSACTION_KEY);
        }
      });
    },

    getTransactionClient() {
      const transactionClient = asyncLocalStorage
        .getStore()
        ?.get(PRISMA_TRANSACTION_KEY);
      return transactionClient || prisma;
    },
  };
};

export const transactionManager = createTransactionManager();

実際に作成したものは以上になります。

使用例

const getUsers = async () => {
  const prisma = transactionManager.getTransactionClient();

  return await prisma.users.findMany()
};

共通関数では、直接PrismaClientを使用するのではなく、transactionManager.getTransactionClient()を経由してクライアントを取得します。
これにより、呼び出し元がトランザクション内であれば、同一のTransactionClientが使用されます。

次に、runInTransactionを使った処理の中で、この共通関数を呼び出してみます。

const updateUser = async () => {
  return await transactionManager.runInTransaction(async () => {
    const prisma = transactionManager.getTransactionClient();

    const users = await getUsers();// 同じトランザクション内で実行される
    const users2 = await prisma.users.findMany()// こちらも同様
  })
}

このようにrunInTransactionの内部でgetTransactionClient()を使用すると、取得されるのは TransactionClientになります。

そのため、getUsersのように外部に切り出された共通関数であっても、トランザクションの文脈内で呼び出すことで、トランザクションの一貫性を保ったまま処理を実行することができます。

まとめ

Prisma のトランザクション処理において、共通ロジックを切り出すと、トランザクションの文脈が失われてしまうという課題がありました。
この問題に対して、AsyncLocalStorageを用いることでリクエスト単位でTransactionClientを保持し、トランザクションのスコープを共有できるようにしました。

これにより、同一トランザクション内で安全に共通処理を実行できるようになり、重複したロジックの定義を避けることが可能になりました!

ユニフォームネクスト株式会社

Discussion