PrismaClientを上手に扱う
はじめに
今回の問題にあたって、こちらの記事がとても助かりました!
本記事を読む前にぜひご参照ください!
環境
- 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