この記事では、Amazon DynamoDB で全文検索を実装することで、コストを大幅に抑える方法をご紹介します。
この記事は、「AWS DynamoDB で超低コストな全文検索を実装しよう 〜設計編〜」 の続きです。
Serverless Framework の設定
Amazon DynamoDB と AWS Lambda を構築する必要があるので、Serverless Framework を利用します。
インストールしていない場合は、インストールします。
$ npm i -g serverless serverless-offline
それから、プロジェクトを初期化し、AWS にデプロイします。
$ serverless
Creating a new serverless project
? What do you want to make? AWS - Node.js - Express API with DynamoDB
? What do you want to call this project? dynamodb-fulltext-search
✔ Project successfully created in dynamodb-fulltext-search folder
? What org do you want to add this service to? michinosuke
? What application do you want to add this to? [create a new app]
? What do you want to name this application? dynamodb-fulltext-search
✔ Your project is ready to be deployed to Serverless Dashboard (org: "michinosuke", app: "dynamodb-fulltext-search")
? Do you want to deploy now? Yes
実装に必要なパッケージもいくつかあるので、インストールします。
$ npm i express nanoid serverless-http
$ npm i -D @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb serverless-offline
serverless.yml
AWS に Lambda と DynamoDB が適切に作られるように、serverless.yml
を以下の通り編集します。
org: michinosuke
app: dynamodb-fulltext-search
service: dynamodb-fulltext-search
frameworkVersion: "3"
custom:
tableName: "fulltext-search-${sls:stage}"
plugins:
- serverless-offline
provider:
name: aws
region: ap-northeast-1
runtime: nodejs18.x
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
Resource:
- Fn::GetAtt: [MainTable, Arn]
environment:
TABLE_NAME: ${self:custom.tableName}
functions:
api:
handler: index.handler
events:
- httpApi: "*"
resources:
Resources:
MainTable:
Type: AWS::DynamoDB::Table
Properties:
AttributeDefinitions:
- AttributeName: PK
AttributeType: S
- AttributeName: PostId
AttributeType: S
KeySchema:
- AttributeName: PK
KeyType: HASH
- AttributeName: PostId
KeyType: RANGE
GlobalSecondaryIndexes:
- IndexName: PostId
KeySchema:
- AttributeName: PostId
KeyType: HASH
Projection:
ProjectionType: ALL
BillingMode: PAY_PER_REQUEST
TableName: ${self:custom.tableName}
次に、全文検索を実現する Lambda のロジックを実装するために、index.js
を以下の通り編集します。
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
QueryCommand,
UpdateCommand,
BatchWriteCommand,
DeleteCommand,
} = require("@aws-sdk/lib-dynamodb");
const express = require("express");
const serverless = require("serverless-http");
const { nanoid } = require("nanoid");
const app = express();
const TABLE_NAME = process.env.TABLE_NAME;
const client = new DynamoDBClient();
const dynamoDbClient = DynamoDBDocumentClient.from(client);
// bigram で分割した文字列の配列を返す
const bigrams = (str) => {
str = str.replace(/[ ]/, "_"); // スペースをアンダーバーに置換
let arr = [...Array(str.length - 1)].map((_, i) => str.slice(i, i + 2));
arr = [...new Set(arr)]; // 重複削除
return arr;
};
// 渡された全ての配列に含まれる文字列を配列で返す
const intersection = (arrays) => {
const map = {};
for (const array of arrays) {
for (const bigram of array) {
if (map.hasOwnProperty(bigram)) map[bigram]++;
else map[bigram] = 1;
}
}
const set = Object.entries(map).flatMap(([key, value]) => {
return value === arrays.length ? [key] : [];
});
return set;
};
app.use(express.json());
// 検索
app.get("/posts/search", async (req, res) => {
let { q, target } = req.query;
target = target === "user" ? "USER" : "BODY";
const searchQueries = q.length === 1 ? [`_${q}`] : bigrams(q);
const invertedIndexesPromises = searchQueries.map(async (bigram) => {
const command = new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: "PK = :PK",
ExpressionAttributeValues: { ":PK": `${target}-${bigram}` },
});
const result = await dynamoDbClient.send(command);
return result.Items?.map((item) => item.PostId) ?? [];
});
const invertedIndexes = await Promise.all(invertedIndexesPromises);
const postIds = intersection(invertedIndexes);
const postsPromises = postIds.map(async (postId) => {
const command = new GetCommand({
TableName: TABLE_NAME,
Key: { PK: `POST-${postId}`, PostId: postId },
});
const { Item } = await dynamoDbClient.send(command);
return Item
? [{ postId: Item.PostId, body: Item.Body, user: Item.User }]
: [];
});
const posts = (await Promise.all(postsPromises)).flatMap((i) => i);
res.json(posts);
});
// 作成
app.get("/posts/create", async (req, res) => {
const { user, body } = req.query;
const id = nanoid();
const params = {
TableName: TABLE_NAME,
Item: { PK: `POST-${id}`, Body: body, User: user, PostId: id },
};
await dynamoDbClient.send(new PutCommand(params));
const bodyPromises = bigrams("_" + body).map(async (bigram) => {
await dynamoDbClient.send(
new PutCommand({
TableName: TABLE_NAME,
Item: { PK: `BODY-${bigram}`, PostId: id },
})
);
});
const userPromises = bigrams("_" + user, true).map(async (bigram) => {
await dynamoDbClient.send(
new PutCommand({
TableName: TABLE_NAME,
Item: { PK: `USER-${bigram}`, PostId: id },
})
);
});
await Promise.all(bodyPromises);
await Promise.all(userPromises);
res.json({ id, body, user });
});
// 更新
app.get("/posts/:postId/update", async (req, res) => {
const { postId } = req.params;
const { body } = req.query;
const queryCommand = new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: "PostId = :PostId",
ExpressionAttributeValues: { ":PostId": postId },
IndexName: "PostId",
});
const { Items } = await dynamoDbClient.send(queryCommand);
const items = Items.filter(({ PK }) => PK.startsWith("BODY-"));
for (let i = 0; i <= items.length / 25; i++) {
const array = items.slice(i * 25, i * 25 + 25);
if (array.length === 0) break;
const batchDeleteCommand = new BatchWriteCommand({
RequestItems: {
[TABLE_NAME]: array.map(({ PK, PostId }) => ({
DeleteRequest: { Key: { PK, PostId } },
})),
},
});
await dynamoDbClient.send(batchDeleteCommand);
}
const updateCommand = new UpdateCommand({
TableName: TABLE_NAME,
Key: { PK: `POST-${postId}`, PostId: postId },
UpdateExpression: "SET Body = :Body",
ExpressionAttributeValues: { ":Body": body },
});
await dynamoDbClient.send(updateCommand);
const bodyBigrams = bigrams("_" + body);
for (let i = 0; i <= bodyBigrams.length / 25; i++) {
const array = bodyBigrams.slice(i * 25, i * 25 + 25);
if (array.length === 0) break;
const batchPutCommand = new BatchWriteCommand({
RequestItems: {
[TABLE_NAME]: array.map((bigram) => ({
PutRequest: {
Item: { PK: `BODY-${bigram}`, PostId: postId },
},
})),
},
});
await dynamoDbClient.send(batchPutCommand);
}
res.json({ id: postId });
});
// 削除
app.get("/posts/:postId/delete", async (req, res) => {
const { postId } = req.params;
const queryCommand = new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: "PostId = :PostId",
ExpressionAttributeValues: { ":PostId": postId },
IndexName: "PostId",
});
const { Items } = await dynamoDbClient.send(queryCommand);
const items = Items.filter(({ PK }) => PK.startsWith("BODY-"));
for (let i = 0; i <= items.length / 25; i++) {
const array = items.slice(i * 25, i * 25 + 25);
if (array.length === 0) break;
const batchDeleteCommand = new BatchWriteCommand({
RequestItems: {
[TABLE_NAME]: array.map(({ PK, PostId }) => ({
DeleteRequest: { Key: { PK, PostId } },
})),
},
});
await dynamoDbClient.send(batchDeleteCommand);
}
const updateCommand = new DeleteCommand({
TableName: TABLE_NAME,
Key: { PK: `POST-${postId}`, PostId: postId },
});
await dynamoDbClient.send(updateCommand);
res.json({ id: null });
});
// 詳細
app.get("/posts/:postId", async (req, res) => {
const params = {
TableName: TABLE_NAME,
Key: { PK: `POST-${req.params.postId}`, PostId: req.params.postId },
};
const { Item } = await dynamoDbClient.send(new GetCommand(params));
if (!Item) return res.status(404);
const { PostId: id, Body: body, User: user } = Item;
res.json({ id, body, user });
});
app.use((req, res, next) => {
return res.status(404).json({ error: "Not Found" });
});
module.exports.handler = serverless(app);
簡略化のために例外処理やバリデーションは省略していたり、全ての操作を GET で実装したりしています。
ざっと一通り動くようにしただけなので、プロダクトに活用するときは注意してください。
AWS にデプロイするときは、次のコマンドを実行します。
$ serverless deploy
ローカルでAPIを立ち上げるときは、次のコマンドを実行します。(DynamoDB は AWS 環境)
$ serverless offline start
動かしてみる
作成
まずは、投稿してみます。
serverless offline start
した状態で、以下の URL を叩きます。
http://localhost:3000/posts/create?user=みちのすけ&body=技術記事を投稿するといいことがあります
すると、次のようなレスポンスが返ります。
{
"id": "loUwXtJPiYkqdBEAcjhP7",
"body": "技術記事を投稿するといいことがあります",
"user": "みちのすけ"
}
nanoid
で id が払い出されているのがわかります。
もう何個か投稿しておきます。
http://localhost:3000/posts/create?user=みちのすけ&body=Qiitaはとてもいい技術記事投稿サイトです
http://localhost:3000/posts/create?user=ゆうのすけ&body=プリンはとても美味しいです
検索
肝心の全文検索です。
まずは、本文に対して 技術記事
というキーワードで検索してみます。
[
{
"postId": "loUwXtJPiYkqdBEAcjhP7",
"body": "技術記事を投稿するといいことがあります",
"user": "みちのすけ"
},
{
"postId": "wKzA_gkLiuBl_orXDOE_w",
"body": "Qiitaはとてもいい技術記事投稿サイトです",
"user": "みちのすけ"
}
]
期待通りのレスポンスが返ってきていますね!
とても
で検索してみましょう。
http://localhost:3000/posts/search?q=とても
[
{
"postId": "EEEWLTFhLqb7gjo7iapFa",
"body": "プリンはとても美味しいです",
"user": "ゆうのすけ"
},
{
"postId": "wKzA_gkLiuBl_orXDOE_w",
"body": "Qiitaはとてもいい技術記事投稿サイトです",
"user": "みちのすけ"
}
]
いい感じですね
上の例では本文に対して検索を行いましたが、target=user
を指定することでユーザ名でも検索できるように実装してみました。 ユーザ名に対して うの
というキーワードで検索してみましょう。
http://localhost:3000/posts/search?q=うの&target=user
[
{
"postId": "EEEWLTFhLqb7gjo7iapFa",
"body": "プリンはとても美味しいです",
"user": "ゆうのすけ"
}
]
問題ないですね!
更新・削除
更新と削除はそれぞれ以下のURLで行えるようにしてみました。
更新: http://localhost:3000/posts/EEEWLTFhLqb7gjo7iapFa/update?body=QiitaのマスコットはQiitanです
削除: http://localhost:3000/posts/EEEWLTFhLqb7gjo7iapFa/delete
まとめ
簡易的なものですが、全文検索を実装することができました!
小規模なプロダクトにおいては、これで十分な場面は多いと思います。
実際に製品に組み込んだりする場合は、データが多くなったときのパフォーマンスと、クエリの柔軟性(読点を検索対象に入れるかなど)を考慮する必要があると感じました。
Lambda と DynamoDB で実装すると、API を叩かない限りコストはほぼ 0 円なのが素晴らしいなと思います。
次回以降、実際にフロントエンドを作って連携させて、ユーザ体験はどんな感じなのか確かめてみたいです!