Ubie で副業として Backend For Frontend (BFF) サーバーの開発を担当している nissy-dev です。
今回は、モジュラモノリスアーキテクチャにおける Prisma を利用した DB アクセスの課題と、その課題に対処するために作成した lint ルールについて詳しく解説します。
ユビーでは、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-import は メンテナンスが終了したので、新規で実装する場合は公式のprismaSchemaFolder
を利用するのが良いと思います。
Prisma Cleint のインスタンスは、アプリケーション内で複数作ることは推奨されていません。db/module.ts
では、Prisma Client のインスタンスを保持するサービスをエクスポートし、これを各モジュールで利用します。
@Injectable()exportclassDatabaseService{readonly client: PrismaClient;constructor(){this.client=newPrismaClient();}}@Module({ providers:[DatabaseService],export:[DatabaseService]})exportclassDatabaseModule{}
アーキテクチャの詳細については、次の記事も参考にしていただければと思います。
https://zenn.dev/ubie_dev/articles/53c5953b037e38
Prisma スキーマを分割して管理することで、データベースの観点でもモジュール間の独立性が確保されているように見えます。しかし実際には、次の 2 つの方法で他のモジュールのデータベースに直接アクセスできてしまう問題があります。
@relation
の利用Prisma Client を利用した他モジュールのテーブルへのアクセスついては、例えば次のようなコードが挙げられます。
// ❌ invalid@Injectable()exportclassUserService{constructor(private databaseService: DatabaseService){}asyncdoSomething(){// User モジュールのサービスから、Post モジュールのテーブルに直接アクセスするconst data=awaitthis.databaseService.client.post.findMany(...);}}
他のモジュールのデータが利用したい場合は、次のように対象となるモジュールからサービス経由で取得するようにしたいです。NestJS の仕組みを利用しながら、モジュールの依存関係の管理を明示的に扱うことが可能になります。
// ✅ valid// Post モジュールをインポートする@Module({ imports:[PostModule], providers:[UserService]})exportclassUserModule{}@Injectable()exportclassUserService{constructor(private postService: PostService){}asyncdoSomething(){// Post モジュールがエクスポートしている PostService を使ってデータを取得するconst data=awaitthis.postService.getPostsByUserId(...);}}
また、Prisma スキーマにおけるモジュール間での@relation
を利用してしまうと、次のようにモジュールをまたいだテーブルの JOIN が可能になってしまいます。
// ❌ invalid@Injectable()exportclassUserService{constructor(private databaseService: DatabaseService){}asyncdoSomething(){// JOIN を利用して、Post に関するデータを取得するconst data=awaitthis.databaseService.client.user.findMany({ include:{ posts:true},});}}
これらのモジュール間での DB アクセスの課題に対処するために、今回はそれぞれについて lint ルールを実装しました。
Prisma Client を利用した他モジュールのテーブルへのアクセスを禁止するルールは、ESLint のカスタムルールとして実装しました。
まず、各モジュールが所有するデータベースのテーブル名を収集する関数を定義します。この関数では、各モジュールの Prisma スキーマ内に含まれるテーブル名を正規表現を利用して検索し、モジュールごとにテーブル名の配列をマップとして返します。
const path=require("node:path");const{ readdirSync, readFileSync}=require("node:fs");const{ globSync}=require("glob");constROOT_DIR=/* プロジェクトのルートディレクトリへのパス */constTABLE_NAME_REGEX=newRegExp(/model\s+(\w+)\s+\{/g);/** * 各モジュールがアクセスできるテーブル名を収集する関数 *@return{Map<string, string[]>} モジュールパスとテーブル名の配列のマップ */functioncollectAccessibleTablesMaps(){const accessibleTablesMaps=newMap();// 各モジュールのディレクトリごとに走査するconst modulesDir= path.join(ROOT_DIR,"src/modules");for(const entryofreaddirSync(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 schemaFileof schemaFiles){const schema=readFileSync(schemaFile).toString();for(const matchof 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 fsfrom"node:fs";import*as pathfrom"node:path";functionmain(){// 1つ前で紹介した lint ルールで定義した関数をこちらでも利用するconst accessibleTablesMaps=collectAccessibleTablesMaps();// エラーがあった時に exit 1 するためのフラグlet hasInvalidSchema=false;const moduleDir="./src/modules"for(const entryof 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 schemaFileof schemaFiles){const schema= fs.readFileSync(schemaFile).toString();// lint の具体的なロジックcheckSchemaRealtion(schema, hasInvalidSchema)}}if(hasInvalidSchema){ process.exit(1);}}main();
lint ロジックでは、スキーマを 1 行ずつチェックして@relation
を含む行を探します。見つかった場合、その行から関連するテーブル名を抽出し、accessibleTables
に含まれていない場合にエラーメッセージを報告します。
functioncheckSchemaRealtion(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.mjsThe invalid relation is foundin post.prisma:line3The invalid relation is foundin user.prisma:line10
また、パフォーマンスなどの観点から例外的にモジュール間での@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 での循環参照を撲滅するために行なった試行錯誤についての記事を書きたいと思います。