メインコンテンツまでスキップ
バージョン: 0.4.2

💾 カスタムデータストアの使用

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 }>;
}

➡️ 次のステップ