モジュラモノリスにおける Prisma を利用した DB アクセスの秩序を保つ
Ubie で副業として Backend For Frontend (BFF) サーバーの開発を担当している nissy-dev です。
今回は、モジュラモノリスアーキテクチャにおける Prisma を利用した DB アクセスの課題と、その課題に対処するために作成した lint ルールについて詳しく解説します。
NestJS と Prisma で作るモジュラモノリス
ユビーでは、BFF の GraphQL サーバーを実装する際に、NestJS を利用したモジュラモノリスを採用しています。この BFF サーバーは、マイクロサービスを呼び出すだけではなく、Prisma を使用したデータベースへのアクセスも行います。
モジュラモノリスの設計において、モジュール間の独立性の確保は非常に重要です。「ソフトウェアアーキテクチャの基礎」にも次のような説明があります。
優れたモジュール性を維持することは、暗黙的なアーキテクチャ特性となっている。優れたモジュール分割やインターフェイスの実現をアーキテクトに要求するプロジェクトはほとんどないものの、持続可能なコードベースの実現には、秩序と一貫性が常に求められる。
この原則に従い、Prisma スキーマについては prisma-import を利用したモジュールごとの分割管理を実現しています。
src
├── libs
│ └── db
│ ├── module.ts // Prisma Client のインスタンスを保持する service を export する
│ ├── schema.prisma // prisma-import で結合されたスキーマ
│ └── base.prisma
└── modules
├── user
│ └── user.prisma
└── post
└── post.prisma
Prisma Cleint のインスタンスは、アプリケーション内で複数作ることは推奨されていません。db/module.ts
では、Prisma Client のインスタンスを保持するサービスをエクスポートし、これを各モジュールで利用します。
@Injectable()
export class DatabaseService {
readonly client: PrismaClient;
constructor() {
this.client = new PrismaClient();
}
}
@Module({ providers: [DatabaseService], export: [DatabaseService] })
export class DatabaseModule {}
アーキテクチャの詳細については、次の記事も参考にしていただければと思います。
DB アクセスのモジュール間越境
Prisma スキーマを分割して管理することで、データベースの観点でもモジュール間の独立性が確保されているように見えます。しかし実際には、次の 2 つの方法で他のモジュールのデータベースに直接アクセスできてしまう問題があります。
- Prisma Client を利用した他モジュールのテーブルへのアクセス
- Prisma スキーマにおけるモジュール間での
@relation
の利用
Prisma Client を利用した他モジュールのテーブルへのアクセスついては、例えば次のようなコードが挙げられます。
// ❌ invalid
@Injectable()
export class UserService {
constructor(private databaseService: DatabaseService) {}
async doSomething() {
// User モジュールのサービスから、Post モジュールのテーブルに直接アクセスする
const data = await this.databaseService.client.post.findMany(...);
}
}
他のモジュールのデータが利用したい場合は、次のように対象となるモジュールからサービス経由で取得するようにしたいです。NestJS の仕組みを利用しながら、モジュールの依存関係の管理を明示的に扱うことが可能になります。
// ✅ valid
// Post モジュールをインポートする
@Module({ imports: [PostModule], providers: [UserService] })
export class UserModule {}
@Injectable()
export class UserService {
constructor(private postService: PostService) {}
async doSomething() {
// Post モジュールがエクスポートしている PostService を使ってデータを取得する
const data = await this.postService.getPostsByUserId(...);
}
}
また、Prisma スキーマにおけるモジュール間での @relation
を利用してしまうと、次のようにモジュールをまたいだテーブルの JOIN が可能になってしまいます。
// ❌ invalid
@Injectable()
export class UserService {
constructor(private databaseService: DatabaseService) {}
async doSomething() {
// JOIN を利用して、Post に関するデータを取得する
const data = await this.databaseService.client.user.findMany({
include: { posts: true },
});
}
}
DB アクセスの秩序を保つ lint ルールの整備
これらのモジュール間での DB アクセスの課題に対処するために、今回はそれぞれについて lint ルールを実装しました。
Prisma Client を利用した他モジュールのテーブルへのアクセスを禁止する
Prisma Client を利用した他モジュールのテーブルへのアクセスを禁止するルールは、ESLint のカスタムルールとして実装しました。
まず、各モジュールが所有するデータベースのテーブル名を収集する関数を定義します。この関数では、各モジュールの Prisma スキーマ内に含まれるテーブル名を正規表現を利用して検索し、モジュールごとにテーブル名の配列をマップとして返します。
const path = require("node:path");
const { readdirSync, readFileSync } = require("node:fs");
const { globSync } = require("glob");
const ROOT_DIR = /* プロジェクトのルートディレクトリへのパス */
const TABLE_NAME_REGEX = new RegExp(/model\s+(\w+)\s+\{/g);
/**
* 各モジュールがアクセスできるテーブル名を収集する関数
* @return {Map<string, string[]>} モジュールパスとテーブル名の配列のマップ
*/
function collectAccessibleTablesMaps() {
const accessibleTablesMaps = new Map();
// 各モジュールのディレクトリごとに走査する
const modulesDir = path.join(ROOT_DIR, "src/modules");
for (const entry of readdirSync(modulesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const moduleDir = `${modulesDir}/${entry.name}`;
const schemaPath = path.join(`${moduleDir}/**/*.prisma`);
const schemaFiles = globSync(schemaPath);
if (schemaFiles.length === 0) continue;
// schema ファイルから正規表現を利用してテーブル名を抜き出す
let accessibleTables = [];
for (const schemaFile of schemaFiles) {
const schema = readFileSync(schemaFile).toString();
for (const match of schema.matchAll(TABLE_NAME_REGEX)) {
accessibleTables.push(match[1]);
}
}
accessibleTablesMaps.set(moduleDir, accessibleTables);
}
return accessibleTablesMaps;
}
この関数は、lint 対象のファイルごとに呼び出されます。既存の全てのファイルに対して collectAccessibleTablesMaps
を実行すると、I/O アクセスによって lint の処理速度が低下する可能性があります。一方で全てのファイルに対する lint 処理の実行は主に CI 環境で行われ、その環境では TypeScript の型チェックが処理時間の大半を占めるため、実用面での影響はほとんどないと判断しました。
この関数を使って、実際の ESLint のルールを実装します。ルールの実際のロジックに入る前に、lint 対象のファイルが所属するモジュールでアクセスできるテーブル (accessibleTables
) と全てのテーブル (allTables
) を取得しておきます。
/**
* @type {import('eslint').Rule.RuleModule}
*/
const rule = {
create(context) {
const accessibleTablesMaps = collectAccessibleTablesMaps();
const matchModule = [...accessibleTablesMaps.keys()].find((key) =>
context.filename.startsWith(key)
);
if (!matchModule) return {};
const accessibleTables = accessibleTablesMaps.get(matchModule);
const allTables = [...accessibleTablesMaps.values()].flat();
return {
// lint のロジックが続く
};
},
};
module.exports = rule;
取得した accessibleTables
と allTables
を利用して、実際の Lint ルールのロジックを実装します。この実装では、client.xxx
という MemberExpression について、xxx の部分が accessibleTables
にないテーブル名だった場合にエラーを報告するようにしています。
const rule = {
create(context) {
...
return {
MemberExpression(node) {
const { type, name } = node.property;
if (type === "Identifier" && name === "client") {
const parentType = node.parent.type;
const parentPropNode = node.parent.property;
if (parentType === "MemberExpression" && parentPropNode.type === "Identifier") {
const name = parentPropNode.name;
// テーブル名は先頭大文字で抽出したので、比較のために変換している
const tableName = name.charAt(0).toUpperCase() + name.slice(1);
if (
!accessibleTables.includes(tableName) &&
allTables.includes(tableName)
) {
context.report({ node: parentPropNode, message: `invalid access!`});
}
}
}
},
};
},
};
このカスタムルールを適用すると、他モジュールのテーブルへのアクセスしたときに次のようにエラーが出ます。
カスタムルールがエラーを報告している図
@relation
の利用を禁止する
モジュール間での Prisma スキーマにおけるモジュール間での @relation
の利用を禁止するルールについては、Node.js のスクリプトとして実装し、CI で実行するようにしました。
スクリプトでは、collectAccessibleTablesMaps
の関数と同様にモジュールごとに Prisma スキーマを解析します。
import * as fs from "node:fs";
import * as path from "node:path";
function main() {
// 1つ前で紹介した lint ルールで定義した関数をこちらでも利用する
const accessibleTablesMaps = collectAccessibleTablesMaps();
// エラーがあった時に exit 1 するためのフラグ
let hasInvalidSchema = false;
const moduleDir = "./src/modules"
for (const entry of fs.readdirSync(modulesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const moduleDir = `${modulesDir}/${entry.name}`;
const schemaPath = path.join(`${moduleDir}/**/*.prisma`);
const schemaFiles = fs.globSync(schemaPath);
const accessibleTables = accessibleTablesMaps.get(moduleDir);
for (const schemaFile of schemaFiles) {
const schema = fs.readFileSync(schemaFile).toString();
// lint の具体的なロジック
checkSchemaRealtion(schema, hasInvalidSchema)
}
}
if (hasInvalidSchema) {
process.exit(1);
}
}
main();
lint ロジックでは、スキーマを 1 行ずつチェックして @relation
を含む行を探します。見つかった場合、その行から関連するテーブル名を抽出し、accessibleTables
に含まれていない場合にエラーメッセージを報告します。
function checkSchemaRealtion(schema, hasInvalidSchema) {
const lines = schema.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// @relation を含む行を見つける
// 例: ` author User @relation(fields: [authorId], references: [id])`
if (line.includes("@relation")) {
// 正規表現を利用して、relation するテーブル名を抽出する
const tableName = line.match(/\s+\w+\s+([A-Za-z]+)/)?.[1];
if (tableName && !accessibleTables.includes(tableName)) {
hasInvalidSchema = true;
console.error(
`The invalid relation is not found in ${path.basename(
schemaFile
)}:line ${i + 1}`
);
}
}
}
}
このスクリプトを実行すると、モジュール間での @relation
の利用があった場合に次のようにエラーが表示されます。
> node ./lint-schema.mjs
The invalid relation is found in post.prisma:line 3
The invalid relation is found in user.prisma:line 10
また、パフォーマンスなどの観点から例外的にモジュール間での @relation
を許容したい場合があります。このような場合については、ESLint と同様の lint の抑制方法を考慮することで対応します。次のように // schema-lint-disable-next-line:
のコメントがある場合には、エラーを報告しないようにロジックを修正します。
const line = lines[i];
+ const prevLine = i > 0 ? lines[i - 1] : "";
if (line.includes("@relation")) {
const tableName = line.match(/\s+\w+\s+([A-Za-z]+)/)?.[1];
- if (tableName && !accessibleTables.includes(tableName)) {
+ if (
+ tableName &&
+ !accessibleTables.includes(tableName) &&
+ !prevLine.includes("// schema-lint-disable-next-line:")
+ ) {
まとめ
この記事では、モジュラモノリスアーキテクチャにおける Prisma を利用した DB アクセスの課題と、その課題に対処するために作成した lint ルールについて詳しく解説しました。
Prisma スキーマの解析には、今回は実装コストやメンテナンス面を考えて正規表現を利用しました。prisma-schema-parser や prisma-ast などを使うこともできますが、どちらも非公式なツールでありメンテナンス面を考えて採用しませんでした。Prisma engine を Rust から TypeScript に書き換えているようなので、この過程でスキーマを TypeScript で柔軟に扱えるツールが公式から出てくると嬉しいですね。
次は、NestJS での循環参照を撲滅するために行なった試行錯誤についての記事を書きたいと思います。
Discussion