💾 カスタムデータストアの使用
NodeblocksはデフォルトでMongoDBの例を含んでいますが、ハンドラーが期待する必要なインターフェースを実装している限り、任意のストレージエンジンを使用できます。この柔軟性により、SQLデータベース、Redis、フラットファイル、またはテスト用のインメモリストレージも使用できます。
📋 必要なインターフェース
カスタムデータストアは以下のメソッドを実装する必要があります:
insertOne(doc)
→{ insertedId, acknowledged }
findOne(filter)
→ ドキュメントまたはnull
find(filter)
→{ toArray(): Promise<Record[]> }
updateOne(filter, { $set })
→{ modifiedCount }
deleteOne(filter)
→{ deletedCount }
以下では、この契約を示し、サービスとの統合方法を説明するJSONファイルアダプターを実装します。
1️⃣ アダプターの実装
datastore/jsonFileDataStore.ts
import {promises as fs} from 'fs';
interface DbRecord {
id: string;
[key: string]: unknown;
}
interface QueryFilter {
id?: string;
[key: string]: unknown;
}
interface UpdateOperation {
$set: Record<string, unknown>;
}
const dbFile = 'db.json';
export const jsonFileDataStore = {
/* 作成 */
async insertOne(doc: DbRecord) {
const records = await readAll();
records.push(doc);
await writeAll(records);
return { insertedId: doc.id, acknowledged: true };
},
/* 単一読み取り */
async findOne(query: QueryFilter) {
const records = await readAll();
return records.find((r) => match(r, query)) ?? null;
},
/* 複数読み取り */
find(query: QueryFilter = {}) {
return {
async toArray() {
const records = await readAll();
return records.filter((r) => match(r, query));
}
};
},
/* 更新 */
async updateOne(query: QueryFilter, operation: UpdateOperation) {
const records = await readAll();
const index = records.findIndex((r) => match(r, query));
if (index === -1) {
return { modifiedCount: 0 };
}
records[index] = { ...records[index], ...operation.$set };
await writeAll(records);
return { modifiedCount: 1 };
},
/* 削除 */
async deleteOne(query: QueryFilter) {
const records = await readAll();
const index = records.findIndex((r) => match(r, query));
if (index === -1) {
return { deletedCount: 0 };
}
records.splice(index, 1);
await writeAll(records);
return { deletedCount: 1 };
}
};
/* ヘルパー関数 */
// ファイルからすべてのレコードを読み取り
const readAll = async (): Promise<DbRecord[]> => {
try {
const data = await fs.readFile(dbFile, 'utf8');
return JSON.parse(data);
} catch (error) {
// ファイルが存在しない場合は空配列を返す
return [];
}
};
// すべてのレコードをファイルに書き込み
const writeAll = async (records: DbRecord[]): Promise<void> => {
await fs.writeFile(dbFile, JSON.stringify(records, null, 2), 'utf8');
};
// レコードがクエリにマッチするかチェック
const match = (record: DbRecord, query: QueryFilter): boolean => {
return Object.entries(query).every(([key, value]) => record[key] === value);
};
2️⃣ 複数コレクションのサポート
datastore/multiCollectionJsonStore.ts
import {promises as fs} from 'fs';
import path from 'path';
class JsonCollection {
constructor(private collectionName: string, private basePath: string = './data') {}
private get filePath() {
return path.join(this.basePath, `${this.collectionName}.json`);
}
async insertOne(doc: any) {
const records = await this.readAll();
records.push(doc);
await this.writeAll(records);
return { insertedId: doc.id, acknowledged: true };
}
async findOne(query: any) {
const records = await this.readAll();
return records.find(r => this.match(r, query)) ?? null;
}
find(query: any = {}) {
return {
toArray: async () => {
const records = await this.readAll();
return records.filter(r => this.match(r, query));
}
};
}
async updateOne(query: any, operation: any) {
const records = await this.readAll();
const index = records.findIndex(r => this.match(r, query));
if (index === -1) {
return { modifiedCount: 0 };
}
records[index] = { ...records[index], ...operation.$set };
await this.writeAll(records);
return { modifiedCount: 1 };
}
async deleteOne(query: any) {
const records = await this.readAll();
const index = records.findIndex(r => this.match(r, query));
if (index === -1) {
return { deletedCount: 0 };
}
records.splice(index, 1);
await this.writeAll(records);
return { deletedCount: 1 };
}
private async readAll(): Promise<any[]> {
try {
// ディレクトリが存在することを確認
await fs.mkdir(this.basePath, { recursive: true });
const data = await fs.readFile(this.filePath, 'utf8');
return JSON.parse(data);
} catch (error) {
return [];
}
}
private async writeAll(records: any[]): Promise<void> {
await fs.mkdir(this.basePath, { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(records, null, 2), 'utf8');
}
private match(record: any, query: any): boolean {
return Object.entries(query).every(([key, value]) => record[key] === value);
}
}
export const createJsonDataStore = (basePath: string = './data') => ({
collection: (name: string) => new JsonCollection(name, basePath)
});
3️⃣ サービスとの統合
src/server.ts
import express from 'express';
import { services, middlewares } from '@nodeblocks/backend-sdk';
import { createJsonDataStore } from './datastore/multiCollectionJsonStore';
const { userService } = services;
const { nodeBlocksErrorMiddleware } = middlewares;
// JSONファイルデータストアを作成
const jsonDataStore = createJsonDataStore('./data');
const dataStores = {
users: jsonDataStore.collection('users'),
identity: jsonDataStore.collection('identity')
};
const configuration = {
authSecrets: {
authEncSecret: process.env.AUTH_ENC_SECRET || 'your-encryption-secret',
authSignSecret: process.env.AUTH_SIGN_SECRET || 'your-signing-secret',
}
};
const app = express();
app.use(express.json());
app.use('/api', userService(dataStores, configuration));
app.use(nodeBlocksErrorMiddleware());
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 JSONファイルデータストアを使用してサーバーがポート${PORT}で起動しました`);
console.log(`📁 データファイルは ./data/ ディレクトリに保存されます`);
});
4️⃣ SQLデータベースアダプター
PostgreSQLアダプターの例:
datastore/postgresAdapter.ts
import { Pool } from 'pg';
interface PostgresConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
}
class PostgresCollection {
constructor(private pool: Pool, private tableName: string) {}
async insertOne(doc: any) {
const keys = Object.keys(doc);
const values = Object.values(doc);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const query = `
INSERT INTO ${this.tableName} (${keys.join(', ')})
VALUES (${placeholders})
RETURNING id
`;
const result = await this.pool.query(query, values);
return {
insertedId: result.rows[0].id,
acknowledged: true
};
}
async findOne(filter: any) {
const { whereClause, values } = this.buildWhereClause(filter);
const query = `SELECT * FROM ${this.tableName} ${whereClause} LIMIT 1`;
const result = await this.pool.query(query, values);
return result.rows[0] || null;
}
find(filter: any = {}) {
return {
toArray: async () => {
const { whereClause, values } = this.buildWhereClause(filter);
const query = `SELECT * FROM ${this.tableName} ${whereClause}`;
const result = await this.pool.query(query, values);
return result.rows;
}
};
}
async updateOne(filter: any, operation: any) {
const { whereClause, values: whereValues } = this.buildWhereClause(filter);
const updateData = operation.$set;
const setClause = Object.keys(updateData)
.map((key, i) => `${key} = $${whereValues.length + i + 1}`)
.join(', ');
const query = `
UPDATE ${this.tableName}
SET ${setClause}
${whereClause}
`;
const values = [...whereValues, ...Object.values(updateData)];
const result = await this.pool.query(query, values);
return { modifiedCount: result.rowCount || 0 };
}
async deleteOne(filter: any) {
const { whereClause, values } = this.buildWhereClause(filter);
const query = `DELETE FROM ${this.tableName} ${whereClause}`;
const result = await this.pool.query(query, values);
return { deletedCount: result.rowCount || 0 };
}
private buildWhereClause(filter: any) {
const keys = Object.keys(filter);
if (keys.length === 0) {
return { whereClause: '', values: [] };
}
const conditions = keys.map((key, i) => `${key} = $${i + 1}`);
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const values = Object.values(filter);
return { whereClause, values };
}
}
export const createPostgresDataStore = (config: PostgresConfig) => {
const pool = new Pool(config);
return {
collection: (tableName: string) => new PostgresCollection(pool, tableName),
close: () => pool.end()
};
};
5️⃣ Redisアダプター
Redisを使用したキー値ストアアダプター:
datastore/redisAdapter.ts
import Redis from 'ioredis';
class RedisCollection {
constructor(private redis: Redis, private prefix: string) {}
async insertOne(doc: any) {
const key = `${this.prefix}:${doc.id}`;
await this.redis.hset(key, doc);
await this.redis.sadd(`${this.prefix}:keys`, doc.id);
return { insertedId: doc.id, acknowledged: true };
}
async findOne(filter: any) {
if (filter.id) {
const key = `${this.prefix}:${filter.id}`;
const data = await this.redis.hgetall(key);
if (Object.keys(data).length === 0) {
return null;
}
return this.parseRedisData(data);
}
// ID以外のフィルターの場合は全体検索
const allRecords = await this.findAll();
return allRecords.find(record => this.match(record, filter)) || null;
}
find(filter: any = {}) {
return {
toArray: async () => {
const allRecords = await this.findAll();
if (Object.keys(filter).length === 0) {
return allRecords;
}
return allRecords.filter(record => this.match(record, filter));
}
};
}
async updateOne(filter: any, operation: any) {
const record = await this.findOne(filter);
if (!record) {
return { modifiedCount: 0 };
}
const key = `${this.prefix}:${record.id}`;
const updateData = operation.$set;
await this.redis.hmset(key, updateData);
return { modifiedCount: 1 };
}
async deleteOne(filter: any) {
const record = await this.findOne(filter);
if (!record) {
return { deletedCount: 0 };
}
const key = `${this.prefix}:${record.id}`;
await this.redis.del(key);
await this.redis.srem(`${this.prefix}:keys`, record.id);
return { deletedCount: 1 };
}
private async findAll() {
const keys = await this.redis.smembers(`${this.prefix}:keys`);
const records = [];
for (const id of keys) {
const key = `${this.prefix}:${id}`;
const data = await this.redis.hgetall(key);
records.push(this.parseRedisData(data));
}
return records;
}
private parseRedisData(data: any) {
// Redis値を適切な型に変換
const parsed = {};
for (const [key, value] of Object.entries(data)) {
try {
parsed[key] = JSON.parse(value as string);
} catch {
parsed[key] = value;
}
}
return parsed;
}
private match(record: any, filter: any): boolean {
return Object.entries(filter).every(([key, value]) => record[key] === value);
}
}
export const createRedisDataStore = (redisUrl: string) => {
const redis = new Redis(redisUrl);
return {
collection: (name: string) => new RedisCollection(redis, name),
close: () => redis.disconnect()
};
};
6️⃣ インメモリテストアダプター
テスト用のインメモリアダプター:
datastore/memoryAdapter.ts
class MemoryCollection {
private data: any[] = [];
async insertOne(doc: any) {
this.data.push({ ...doc });
return { insertedId: doc.id, acknowledged: true };
}
async findOne(filter: any) {
return this.data.find(record => this.match(record, filter)) || null;
}
find(filter: any = {}) {
return {
toArray: async () => {
return this.data.filter(record => this.match(record, filter));
}
};
}
async updateOne(filter: any, operation: any) {
const index = this.data.findIndex(record => this.match(record, filter));
if (index === -1) {
return { modifiedCount: 0 };
}
this.data[index] = { ...this.data[index], ...operation.$set };
return { modifiedCount: 1 };
}
async deleteOne(filter: any) {
const index = this.data.findIndex(record => this.match(record, filter));
if (index === -1) {
return { deletedCount: 0 };
}
this.data.splice(index, 1);
return { deletedCount: 1 };
}
// テスト用ヘルパーメソッド
clear() {
this.data = [];
}
size() {
return this.data.length;
}
all() {
return [...this.data];
}
private match(record: any, filter: any): boolean {
return Object.entries(filter).every(([key, value]) => record[key] === value);
}
}
export const createMemoryDataStore = () => ({
collection: (name: string) => new MemoryCollection()
});
7️⃣ テスト例
tests/datastore.test.ts
import { createMemoryDataStore } from '../datastore/memoryAdapter';
import { userService } from '@nodeblocks/backend-sdk';
describe('カスタムデータストアテスト', () => {
let dataStore;
let userCollection;
beforeEach(() => {
dataStore = createMemoryDataStore();
userCollection = dataStore.collection('users');
});
test('ユーザーの作成と取得', async () => {
const userData = {
id: 'user-123',
email: 'test@example.com',
name: 'テストユーザー'
};
// ユーザーを作成
const insertResult = await userCollection.insertOne(userData);
expect(insertResult.insertedId).toBe('user-123');
// ユーザーを取得
const foundUser = await userCollection.findOne({ id: 'user-123' });
expect(foundUser.email).toBe('test@example.com');
});
test('ユーザーの更新', async () => {
const userData = {
id: 'user-123',
email: 'test@example.com',
name: 'テストユーザー'
};
await userCollection.insertOne(userData);
// ユーザーを更新
const updateResult = await userCollection.updateOne(
{ id: 'user-123' },
{ $set: { name: '更新されたユーザー' } }
);
expect(updateResult.modifiedCount).toBe(1);
// 更新を確認
const updatedUser = await userCollection.findOne({ id: 'user-123' });
expect(updatedUser.name).toBe('更新されたユーザー');
});
});
🎯 ベストプラクティス
1. エラーハンドリング
// データストア操作でのエラーハンドリング
async insertOne(doc: any) {
try {
// 挿入ロジック
return { insertedId: doc.id, acknowledged: true };
} catch (error) {
console.error('挿入エラー:', error);
throw new Error('データの挿入に失敗しました');
}
}
2. 接続管理
// 適切な接続管理
class DataStoreManager {
private connections = new Map();
getConnection(type: string, config: any) {
if (!this.connections.has(type)) {
this.connections.set(type, this.createConnection(type, config));
}
return this.connections.get(type);
}
async closeAll() {
for (const connection of this.connections.values()) {
if (connection.close) {
await connection.close();
}
}
this.connections.clear();
}
}
3. 型安全性
// TypeScriptインターフェースの定義
interface DataStoreCollection<T = any> {
insertOne(doc: T): Promise<{ insertedId: any; acknowledged: boolean }>;
findOne(filter: Partial<T>): Promise<T | null>;
find(filter?: Partial<T>): { toArray(): Promise<T[]> };
updateOne(filter: Partial<T>, operation: { $set: Partial<T> }): Promise<{ modifiedCount: number }>;
deleteOne(filter: Partial<T>): Promise<{ deletedCount: number }>;
}
➡️ 次のステップ
- カスタムサービスでサービスロジックをカスタマイズ
- スキーマオーバーライドで検証ルールを調整
- 複合サービスでサービス統合パターンを学習