ちょっと話題の記事

全文検索SaaSのAlgoliaを使って、DynamoDBのデータを柔軟に検索する

DynamoDBを使ったサーバーレスなWebAPIを題材にして、全文検索SaaSのAlgoliaを使って、DynamoDBのデータを全文検索できるようにする
2020.12.12

はじめに

この記事は Serverless Advent Calendar 2020 12日目の記事です。

CX事業本部の佐藤です。AWSのサーバーレスでアプリケーションを構築する際には、DynamoDBをデータベースとして選択するのはよくある構成かと思います。DynamoDBはNoSQLデータベースの一種なので、RDBMSのようなSQLを使った柔軟な検索などはできません。そのため、DynamoDBの検索機能だけでは要件を満たせない場合は、RDBを使う、DynamoDBとRDBを併用して使う、Elasticsearchなどの全文検索サービスを使うなどの方法があります。そこで今回は、DynamoDBを使ったサーバーレスWeb APIを題材に、全文検索SaaS のAlgoliaを使って、DynamoDBのデータを柔軟に検索できるようにしてみたいと思います。

この記事で作る構成

この記事では、以下のようなシンプルなユーザーを管理するアプリケーション構成を作成していきます。以下のような処理手順です。

  1. クライアントがユーザーを作成するAPIを叩くとLambdaが実行され、DynamoDBにユーザー情報を保存します。
  2. DynamoDBはユーザーが作成されたら、DynamoDB Stream経由でLambdaを起動し、AlgoliaのIndexに登録データを保存します。
  3. ユーザーを検索する APIを叩くとLambdaが実行され、AlgoliaのSearch APIを実行し検索結果をレスポンスします。

また、ソースサンプルはGitHubリポジトリに公開していますので、参考にしてください。記事についても以下のリポジトリをベースに進めていきます。

https://github.com/briete/algolia-full-text-search-sample

Algolia とは

Algolia とは 全文検索の機能をサービスとして提供するSaaSです。ブラウザベースの管理画面が用意されていて、検索ロジックを画面または Web API経由で設定することができます。保存したレコード数とAPIアクセス数による従量課金で、サーバーレス構成と相性が良いと思います。また、いろいろな言語向けにSDKも提供されています。

Algolia のアカウントの作成

まずは、Algolia のサイトにアクセスして、上部のFREE TRIALをクリックします。14日間まではトライアルとしてアカウントを使うことができます。

アカウントの作成

今回はGitHubアカウントで作成しました。あとは画面の指示どおりに進めます。

Indexを作成

ログインすると、algoliaの管理画面に遷移します。最初にalgoliaのindexを作成します。右側のメニューにQuickstart Assistantが表示されていると思いますので、 Create Index をクリックします。

適当な名前でインデックスを作成します。今回は、ユーザー情報をDBに格納していくので、usersというインデックスを作成しました。

インデックスを作成したら、algolia側の設定は終了です。次に、検索対象となるWeb APIを用意します。

アプリケーションIDとAPIキーを控えておく

後ほどソースコード上で必要になるため、Application IDとAdmin API Keyを控えておきます。

サーバーレスWeb APIを作成する

今回は、DynamoDBを使ってユーザー情報を登録・検索できるサーバーレスWeb APIを題材とします。AWS CDKを使って構築していきたいと思います。

環境

項目 バージョン
macOS Catalina 10.15.7
yarn 1.22.10
Node.js v14.15.1
AWS CDK 1.76.0 (build c207717)

AWS CDKプロジェクトを作成する

AWS CDKを使って、プロジェクトの雛形を作成します。

mkdir testapp
cdk init app --language=typescript

作成されたプロジェクトに移動して、yarnで以下のモジュールをインストールします。

yarn add -D @types/aws-lambda @aws-cdk/aws-lambda @aws-cdk/aws-apigatewayv2 @aws-cdk/apigatewayv2-integrations @aws-cdk/aws-dynamodb @aws-cdk/aws-lambda-event-sources
yarn add uuid aws-sdk algoliasearch

ユーザーを作成するLambda関数を書く

DynamoDBテーブルにユーザーを作成するLambda関数を書いていきます。

import * as AWS from 'aws-sdk';
import * as uuid from 'uuid';
import { APIGatewayProxyEventV2, Context } from 'aws-lambda';
import { DocumentClient } from 'aws-sdk/lib/dynamodb/document_client';
import PutItemInput = DocumentClient.PutItemInput;

const Region = process.env.AWS_REGION!;
const TableName = process.env.TABLE_NAME!;
const DynamoDbClient = new AWS.DynamoDB.DocumentClient({
    region: Region,
});

type CreateUserBody = {
    name: string;
    email: string;
};

/**
 * ユーザーを作成する
 * @param event
 * @param context
 */
export async function handler(
    event: APIGatewayProxyEventV2,
    context: Context,
): Promise<void> {
    try {
        console.log('event', JSON.stringify(event));
        console.log('context', JSON.stringify(context));

        // ユーザーをDynamoDBに登録する
        const body = JSON.parse(event.body!) as CreateUserBody;
        const param: PutItemInput = {
            TableName,
            Item: {
                userId: uuid.v4(),
                name: body.name,
                email: body.email,
            },
        };
        await DynamoDbClient.put(param).promise();
    } catch (e) {
        console.error(e);
    }
}

DynamoDB StreamのLambda関数を書く

DynamoDB テーブルへのPutをトリガーに起動するLambda関数を書いていきます。ここでは、作成したAlgoliaのIndexへ検索対象となるオブジェクトを登録していきます。

AlgoliaのIndexにオブジェクトを登録するために、 algoliasearch ライブラリを使用します。これはAlgoliaを操作するためのAPI Clientで、インデックスの登録・削除・検索などができるライブラリです。

import algoliasearch from 'algoliasearch';
import { DynamoDBStreamEvent, Context, DynamoDBRecord } from 'aws-lambda';

const AppId = process.env.ALGOLIA_APPLICATION_ID!;
const ApiKey = process.env.ALGOLIA_API_KEY!;
const IndexName = process.env.ALGOLIA_INDEX_NAME!;

// algolia clientを初期化
const AlgoliaClient = algoliasearch(AppId, ApiKey);
const AlgoliaIndex = AlgoliaClient.initIndex(IndexName);

/**
 * レコードごとに処理する
 * @param record
 */
async function recordProcessing(record: DynamoDBRecord): Promise<void> {
    if (record.eventName === 'INSERT') {
        // AlgoliaIndexにオブジェクトを保存する
        await AlgoliaIndex.saveObject({
            objectID: record!.dynamodb!.NewImage!.userId.S,
            name: record!.dynamodb!.NewImage!.name.S,
            email: record!.dynamodb!.NewImage!.email.S,
        });
    }
}

/**
 * DynamoDB Stream Handler
 * @param event
 * @param context
 */
export async function handler(
    event: DynamoDBStreamEvent,
    context: Context,
): Promise<void> {
    try {
        console.log('event', JSON.stringify(event));
        console.log('context', JSON.stringify(context));

        await Promise.all(event.Records.map((r) => recordProcessing(r)));
    } catch (e) {
        console.error(e);
        throw e;
    }
}

ユーザーを検索するLambda関数を書く

ユーザーを検索するLambda Functionを作成します。ここでは、Algolia Clientを使って、Algoliaに登録したオブジェクトを全文検索しています。

import algoliasearch from 'algoliasearch';
import { APIGatewayProxyEventV2, Context } from 'aws-lambda';

const AppId = process.env.ALGOLIA_APPLICATION_ID!;
const ApiKey = process.env.ALGOLIA_API_KEY!;
const IndexName = process.env.ALGOLIA_INDEX_NAME!;

// algolia clientを初期化
const AlgoliaClient = algoliasearch(AppId, ApiKey);
const AlgoliaIndex = AlgoliaClient.initIndex(IndexName);

type GetUsersResponse = {
    items: HitsObject[];
};

type HitsObject = {
    name: string;
    email: string;
};

/**
 * ユーザーを検索する
 * @param event
 * @param context
 */
export async function handler(
    event: APIGatewayProxyEventV2,
    context: Context,
): Promise<GetUsersResponse> {
    console.log('event', JSON.stringify(event));
    console.log('context', JSON.stringify(context));

    // クエリストリングを取得する
    const q = event.queryStringParameters!.q;
    // Algoliaのインデックスを検索する
    const searchRes = await AlgoliaIndex.search<HitsObject>(q);

    // 検索結果を返却する
    return {
        items: searchRes.hits.map((x) => {
            return { id: x.objectID, name: x.name, email: x.email };
        }),
    };
}

スタックを書く

CDKでスタックを書きます。API Gateway、DynamoDB、Lambda、DynamoDB Streamをデプロイします。

import * as path from 'path';

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import { StartingPosition } from '@aws-cdk/aws-lambda';
import * as eventSource from '@aws-cdk/aws-lambda-event-sources';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import {
    AttributeType,
    BillingMode,
    StreamViewType,
} from '@aws-cdk/aws-dynamodb';
import * as apiv2 from '@aws-cdk/aws-apigatewayv2';
import { HttpMethod } from '@aws-cdk/aws-apigatewayv2';
import * as integration from '@aws-cdk/aws-apigatewayv2-integrations';

export class InfraStack extends cdk.Stack {
    constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        // ユーザーデータを格納するDynamoDBを作成
        const userTable = new dynamodb.Table(this, 'UserTable', {
            partitionKey: {
                name: 'userId',
                type: AttributeType.STRING,
            },
            billingMode: BillingMode.PAY_PER_REQUEST,
            stream: StreamViewType.NEW_IMAGE, // DynamoDB Stream 有効化
        });

        // node_modules用のLambda layerを作成
        const nodeModuleLayer = new lambda.LayerVersion(
            this,
            'NodeModuleLayer',
            {
                compatibleRuntimes: [lambda.Runtime.NODEJS_12_X],
                code: lambda.Code.fromAsset(
                    path.join(process.cwd(), 'dist/layer'),
                ),
            },
        );

        // Algoliaのキー情報をLambdaの環境変数にセットしておく
        const commonEnv: { [key: string]: string } = {
            ALGOLIA_APPLICATION_ID: process.env.ALGOLIA_APPLICATION_ID!,
            ALGOLIA_API_KEY: process.env.ALGOLIA_API_KEY!,
            ALGOLIA_INDEX_NAME: process.env.ALGOLIA_INDEX_NAME!,
            USER_TABLE_NAME: userTable.tableName,
        };

        // ユーザー作成 Lambda Function
        const createUserFunction = new lambda.Function(
            this,
            'CreateUserFunction',
            {
                runtime: lambda.Runtime.NODEJS_12_X,
                code: lambda.Code.fromAsset(
                    path.join(process.cwd(), 'dist/src/handlers'),
                ),
                handler: 'create-user.handler',
                layers: [nodeModuleLayer],
                environment: commonEnv,
            },
        );
        // DynamoDBへのアクセスを許可する
        userTable.grantReadWriteData(createUserFunction);

        // DynamoDB Stream Lambda Function
        const streamFunction = new lambda.Function(this, 'UserStreamFunction', {
            runtime: lambda.Runtime.NODEJS_12_X,
            code: lambda.Code.fromAsset(
                path.join(process.cwd(), 'dist/src/handlers'),
            ),
            handler: 'dynamodb-stream-put-algolia-record.handler',
            layers: [nodeModuleLayer],
            environment: commonEnv,
        });
        userTable.grantReadWriteData(streamFunction);

        // DynamoDB StreamのEventSourceを追加
        // この設定でDynamoDBの保存をトリガーにLambdaが起動される
        streamFunction.addEventSource(
            new eventSource.DynamoEventSource(userTable, {
                startingPosition: StartingPosition.LATEST,
            }),
        );

        // ユーザー検索 Lambda Function
        const getUsersFunction = new lambda.Function(this, 'GetUsersFunction', {
            runtime: lambda.Runtime.NODEJS_12_X,
            code: lambda.Code.fromAsset(
                path.join(process.cwd(), 'dist/src/handlers'),
            ),
            handler: 'get-users.handler',
            layers: [nodeModuleLayer],
            environment: commonEnv,
        });
        userTable.grantReadWriteData(getUsersFunction);

                // API Gatewayを作成します
        const api = new apiv2.HttpApi(this, 'UserAPI', {});
        // GET /users
        api.addRoutes({
            integration: new integration.LambdaProxyIntegration({
                handler: getUsersFunction,
            }),
            path: '/users',
            methods: [HttpMethod.GET],
        });
        // POST /users
        api.addRoutes({
            integration: new integration.LambdaProxyIntegration({
                handler: createUserFunction,
            }),
            path: '/users',
            methods: [HttpMethod.POST],
        });
    }
}

デプロイ

以下のコマンドでAWS環境にデプロイします。事前に AWSのアクセスキーとシークレットアクセスキーは設定しておく必要があります。また、AlgoliaのApplicationIDとAPI KeyとIndex名を環境変数に登録しておきます。

yarn deploy

動作確認

AWS環境にデプロイできたら動作確認をします。以下のような2つのWebAPIが作成されていると思います。これを実行してみます。

ユーザーを作成するAPIを叩く

API Gatewayに以下のAPIが作成されているので、実行してみましょう。私はPostmanを使うことが多いので、こちらを使います。

POST https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/users

実行してDynamoDBにデータが保存されていればOKです。また、DynamoDB Stream経由でAlgoliaのIndexにもデータが保存されていると思います。これで検索をする準備が整いました。2,3回APIを叩いて複数のユーザーを登録しておきます。

ユーザーを検索するAPIを叩く

次にユーザーを検索するAPIを叩いてみます。登録したユーザー情報の中に classmethod が含まれるユーザーを検索してみます。以下のようにクエリストリングに検索条件を入力し登録した内容が検索されるかを確認します。

GET https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/user?q=classmethod

登録したメールアドレスの中に classmethod が含まれているので内容が取得されました。うまくいってそうです。

まとめ

Algoliaを使って簡単に全文検索機能を実装することができました!この記事ではAlgoliaの基本的な機能しか触っていませんが、他にもランキング・パーソナライゼーション・ページネーション機能など豊富な機能ががありますので、今後もいろいろと試していきたいと思いました。

参考

https://www.algolia.com/

https://www.algolia.com/doc/