はじめに (対象読者・この記事でわかること)

この記事は、JavaScriptとAWS DynamoDBを使用しているWeb開発者を対象にしています。特に、DynamoDBで部分一致検索を実装したいと考えている方に向けています。この記事を読むことで、DynamoDBで直接部分一致ができないという制約を理解し、JavaScriptとGSI(Global Secondary Index)を活用した部分一致検索の実装方法がわかります。また、実際のコード例を通じて、Begins Withクエリの使い方や実装時の注意点も学べます。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - JavaScriptの基本的な知識 - AWS SDK for JavaScriptの基本的な使い方 - DynamoDBの基本的な概念(テーブル、アイテム、パーティションキー、ソートキーなど)

DynamoDBで部分一致検索が必要な背景と課題

DynamoDBはAmazonが提供する高速で柔軟なNoSQLデータベースサービスですが、従来のリレーショナルデータベースとは異なる特性を持っています。特に、クエリ機能においては、完全一致検索や範囲検索は得意ですが、部分一致検索(LIKE検索のような機能)は直接サポートされていません。この制約は、ユーザー検索機能やタグ検索機能などを実装する際に障害となります。

この問題を解決するためには、DynamoDBの特性を理解し、適切なデータ構造とクエリ設計が必要です。本記事では、この制約を回避し、JavaScriptとDynamoDBの機能を組み合わせて部分一致検索を実現する方法を具体的なコード例と共に解説します。

JavaScriptを使ったDynamoDB部分一致検索の実装方法

部分一致検索を実現するためには、DynamoDBのGSI(Global Secondary Index)とBegins Withクエリを組み合わせるのが一般的です。以下に具体的な実装手順を示します。

ステップ1:テーブル設計の見直し

まず、部分一致検索を実現するために、テーブル設計を見直す必要があります。例えば、ユーザー名で部分一致検索を行いたい場合、ユーザー名の先頭文字をパーティションキーに、残りの文字列をソートキーにするGSIを作成します。

Javascript
const AWS = require('aws-sdk'); const dynamodb = new AWS.DynamoDB.DocumentClient(); // テーブル作成の例 const params = { TableName: 'Users', KeySchema: [ { AttributeName: 'userId', KeyType: 'HASH' } // パーティションキー ], AttributeDefinitions: [ { AttributeName: 'userId', AttributeType: 'S' }, { AttributeName: 'namePrefix', AttributeType: 'S' }, // GSI用の属性 { AttributeName: 'nameRest', AttributeType: 'S' } // GSI用の属性 ], GlobalSecondaryIndexes: [ { IndexName: 'NameIndex', KeySchema: [ { AttributeName: 'namePrefix', KeyType: 'HASH' }, { AttributeName: 'nameRest', KeyType: 'RANGE' } ], Projection: { ProjectionType: 'ALL' } } ], BillingMode: 'PAY_PER_REQUEST' }; dynamodb.createTable(params, (err, data) => { if (err) { console.error('Error creating table', err); } else { console.log('Table created', data); } });

この設計では、ユーザー名を「先頭部分」と「残りの部分」に分割して保存します。これにより、「山田太郎」という名前の場合、「山田」と「太郎」に分割して保存し、「山」で始まる名前を持つユーザーを検索できます。

ステップ2:データ登録時の処理

次に、ユーザーデータを登録する際に、名前を分割して保存する必要があります。例えば、「山田太郎」という名前の場合、「山田」と「太郎」に分割して保存します。

Javascript
// ユーザーデータ登録の例 const registerUser = async (userData) => { const { userId, name, email } = userData; // 名前を分割するロジック(例:最初の2文字を先頭部分とする) const namePrefix = name.substring(0, 2); const nameRest = name.substring(2); const params = { TableName: 'Users', Item: { userId, name, namePrefix, nameRest, email } }; try { await dynamodb.put(params).promise(); console.log('User registered successfully'); return { success: true }; } catch (err) { console.error('Error registering user', err); return { success: false, error: err }; } }; // 使用例 registerUser({ userId: 'user123', name: '山田太郎', email: 'yamada@example.com' });

このコードでは、名前を固定の長さ(この例では2文字)で分割しています。ただし、実際のアプリケーションでは、より高度な分割ロジック(例:姓と名の区切りなど)を実装する必要があります。

ステップ3:部分一致検索の実装

次に、部分一致検索を実装します。例えば、「山」で始まる名前を持つユーザーを検索する場合、以下のようにクエリを実行します。

Javascript
// 部分一致検索の例 const searchUsersByNamePrefix = async (prefix) => { const params = { TableName: 'Users', IndexName: 'NameIndex', KeyConditionExpression: 'namePrefix = :prefix', ExpressionAttributeValues: { ':prefix': prefix } }; try { const data = await dynamodb.query(params).promise(); console.log('Search results:', data.Items); return data.Items; } catch (err) { console.error('Error searching users', err); return []; } }; // 使用例 searchUsersByNamePrefix('山').then(users => { console.log('Found users:', users); });

このクエリでは、GSIのパーティションキー(namePrefix)に一致するアイテムを検索しています。これにより、「山」で始まる名前を持つすべてのユーザーを取得できます。

ステップ4:より高度な部分一致検索の実装

単純な先頭一致だけでなく、任意の位置での部分一致を実現するには、より複雑なアプローチが必要です。以下に、複数のGSIを作成して対応する方法を示します。

Javascript
// 複数のGSIを持つテーブル作成の例 const createAdvancedTable = () => { const params = { TableName: 'AdvancedUsers', KeySchema: [ { AttributeName: 'userId', KeyType: 'HASH' } ], AttributeDefinitions: [ { AttributeName: 'userId', AttributeType: 'S' }, { AttributeName: 'namePrefix1', AttributeType: 'S' }, // 1文字目 { AttributeName: 'namePrefix2', AttributeType: 'S' }, // 2文字目 { AttributeName: 'namePrefix3', AttributeType: 'S' } // 3文字目 ], GlobalSecondaryIndexes: [ { IndexName: 'NamePrefix1Index', KeySchema: [ { AttributeName: 'namePrefix1', KeyType: 'HASH' } ], Projection: { ProjectionType: 'ALL' } }, { IndexName: 'NamePrefix2Index', KeySchema: [ { AttributeName: 'namePrefix2', KeyType: 'HASH' } ], Projection: { ProjectionType: 'ALL' } }, { IndexName: 'NamePrefix3Index', KeySchema: [ { AttributeName: 'namePrefix3', KeyType: 'HASH' } ], Projection: { ProjectionType: 'ALL' } } ], BillingMode: 'PAY_PER_REQUEST' }; return dynamodb.createTable(params).promise(); }; // データ登録時の処理 const registerAdvancedUser = async (userData) => { const { userId, name, email } = userData; const namePrefix1 = name.substring(0, 1); const namePrefix2 = name.length > 1 ? name.substring(0, 2) : name; const namePrefix3 = name.length > 2 ? name.substring(0, 3) : name; const params = { TableName: 'AdvancedUsers', Item: { userId, name, namePrefix1, namePrefix2, namePrefix3, email } }; return dynamodb.put(params).promise(); }; // 任意の位置での部分一致検索 const searchUsersByPartialName = async (partialName) => { const results = []; // 部分一致に使用するGSIのリスト const indexes = [ { name: 'NamePrefix1Index', prefix: partialName.substring(0, 1) }, { name: 'NamePrefix2Index', prefix: partialName.substring(0, 2) }, { name: 'NamePrefix3Index', prefix: partialName.substring(0, 3) } ]; // 各GSIでクエリを実行 for (const index of indexes) { if (index.prefix.length > 0) { const params = { TableName: 'AdvancedUsers', IndexName: index.name, KeyConditionExpression: `namePrefix${index.prefix.length} = :prefix`, ExpressionAttributeValues: { ':prefix': index.prefix } }; try { const data = await dynamodb.query(params).promise(); results.push(...data.Items); } catch (err) { console.error(`Error searching with ${index.name}`, err); } } } // 重複を除去 const uniqueResults = Array.from(new Set(results.map(item => item.userId))) .map(userId => results.find(item => item.userId === userId)); return uniqueResults; }; // 使用例 registerAdvancedUser({ userId: 'user456', name: '佐藤花子', email: 'sato@example.com' }).then(() => { return searchUsersByPartialName('花'); }).then(users => { console.log('Found users with "花":', users); });

このアプローチでは、名前の先頭1文字、2文字、3文字それぞれに対してGSIを作成し、部分一致検索時に複数のGSIをクエリします。結果として、より柔軟な部分一致検索が可能になります。

ハマった点やエラー解決

実装中に遭遇する問題として、日本語の文字分割が思った通りに動作しないことがあります。例えば、「山田太郎」を「山田」と「太郎」に分割する場合、単純に先頭の2文字を取るだけでは不十分です。特に、名前の長さがバラバラの場合、適切に分割する必要があります。

また、部分一致検索を実装する際に、GSIのパーティションキーのサイズ制限(400文字)に注意する必要があります。長い文字列をパーティションキーとして使用すると、エラーが発生します。

さらに、DynamoDBのクエリ結果の件数に制限(デフォルトで1回のクエリで最大1MBのデータ)があるため、大量のデータを検索する場合にはページネーションの実装が必要です。

解決策

日本語の文字分割を正確に行うためには、文字列の長さではなく、意味のある単位で分割する必要があります。例えば、名前の場合、一般的に姓と名で分割するのが適切です。ただし、すべての名前が同じ形式であるとは限らないため、登録時にユーザーに姓と名を別々に入力してもらうのが最も確実です。

長い文字列をパーティションキーとして使用する必要がある場合は、文字列をハッシュ化して短くするか、複数のGSIを作成して対応する方法があります。ただし、これらの方法はパフォーマンスに影響を与える可能性があるため、慎重に検討する必要があります。

大量のデータを検索する場合のページネーション実装例を以下に示します。

Javascript
// ページネーション付き部分一致検索 const searchUsersWithPagination = async (prefix, pageSize = 10, exclusiveStartKey = null) => { const params = { TableName: 'Users', IndexName: 'NameIndex', KeyConditionExpression: 'namePrefix = :prefix', ExpressionAttributeValues: { ':prefix': prefix }, Limit: pageSize }; if (exclusiveStartKey) { params.ExclusiveStartKey = exclusiveStartKey; } try { const data = await dynamodb.query(params).promise(); return { items: data.Items, lastEvaluatedKey: data.LastEvaluatedKey, hasMore: !!data.LastEvaluatedKey }; } catch (err) { console.error('Error searching users with pagination', err); return { items: [], lastEvaluatedKey: null, hasMore: false }; } }; // 使用例 const searchAllUsers = async (prefix) => { let results = []; let lastEvaluatedKey = null; let hasMore = true; while (hasMore) { const response = await searchUsersWithPagination(prefix, 10, lastEvaluatedKey); results = results.concat(response.items); lastEvaluatedKey = response.lastEvaluatedKey; hasMore = response.hasMore; } return results; }; searchAllUsers('山').then(users => { console.log('All users with "山":', users); });

このコードでは、LastEvaluatedKeyを使用してクエリ結果をページ分割し、すべての結果を取得するまで繰り返しクエリを実行します。

まとめ

本記事では、DynamoDBで部分一致検索を実現するJavaScript実装について解説しました。GSIとBegins Withクエリを組み合わせることで、DynamoDBでも部分一致検索が可能になります。ただし、テーブル設計やデータ登録方法を見直す必要があるため、事前に十分な計画を立てることが重要です。

  • 要点1: DynamoDBでは直接部分一致検索ができないため、GSIとBegins Withクエリを組み合わせた実装が必要
  • 要点2: データを登録する際に、部分一致検索用に分割して保存する必要がある
  • 要点3: 複数のGSIを作成することで、より柔軟な部分一致検索が可能になる

この記事を通して、DynamoDBの制約を理解し、適切な代替手段を実装する方法がわかったかと思います。今後は、より高度な検索機能やパフォーマンス最適化についても記事にする予定です。

参考資料