[AWS AppSync] チュートリアル:複数データソースを組み合わせた GraphQL API の作成

こんにちは、菊池です。

先日、フルマネージドなGraphQLサービス、AWS AppSyncが正式リリースされました。

先のエントリにて、AppSyncによるGraphQL APIのデータソースとして、DynamoDB、Elasticsearch Serviceを利用した例を試してみました。

上記、Elasticsearch Service(以下、ES)を使った例では、ドキュメントにてベストプラクティスとして以下のように述べられています。

  • Amazon Elasticsearch ServiceはプライマリDBではなく、参照用として用いるべきである。

というわけで、今回は、データの特性によって複数のデータソース(DynamoDBとES)を組み合わせたAPIを作成して試してみます。今回も、公式ドキュメントのチュートリアルをベースにやってみました。

複数データソースを組み合わせた GraphQL API

構成

構築する構成は以下のようなイメージなります。

AppSyncでは、スキーマの各要素ごとに、リゾルバを設定することができます。データの書き込み(putPost)に対しては、DynamoDBにPutItemを行います。DynamoDBにデータが保存されると、DynamoDB StreamsをトリガーにLambda関数が起動し、非同期にESにデータを保存します。

検索のような参照要求(singlePost/searchContent/searchAuthor)に対しては、ESを割り当てることで、処理によって適切なデータソースを利用する構成となります。

環境の作成

それでは、検証のための環境を構築します。チュートリアルにあるCloudFormationテンプレートを利用することで、必要なリソースが一括して構築できます。(各リソースはオレゴンリージョンに作成されます。)

AWSマネジメントコンソールにログインした上で、リンクをクリックすればCloudFormationのコンソールに移動しますので、そのまま順に進みましょう。

任意のスタック名称を設定して、あとはそのまま作成します。

[CREATE COMPLETE] になるまで、しばらく待ちましょう。

スタックの作成が完了したら、作成されたリソースを確認してみます。

まずは、DynamoDBです。Streamも有効になっています。

ESドメインも作成されています。

DynamoDBに書き込まれたデータをESに同期するためのLambda関数です。

Lambda関数のソースはドキュメントにもありますが、以下のようになっています。

var AWS = require('aws-sdk');
var path = require('path');
var stream = require('stream');

var esDomain = {
    endpoint: process.env.ES_ENDPOINT,
    region: process.env.ES_REGION,
    index: 'id',
    doctype: 'post'
};

var endpoint = new AWS.Endpoint(esDomain.endpoint)
var creds = new AWS.EnvironmentCredentials('AWS');

function postDocumentToES(doc, context) {
    var req = new AWS.HttpRequest(endpoint);

    req.method = 'POST';
    req.path = '/_bulk';
    req.region = esDomain.region;
    req.body = doc;
    req.headers['presigned-expires'] = false;
    req.headers['Host'] = endpoint.host;

    // Sign the request (Sigv4)
    var signer = new AWS.Signers.V4(req, 'es');
    signer.addAuthorization(creds, new Date());

    // Post document to ES
    var send = new AWS.NodeHttpClient();
    send.handleRequest(req, null, function (httpResp) {
        var body = '';
        httpResp.on('data', function (chunk) {
            body += chunk;
        });
        httpResp.on('end', function (chunk) {
            console.log('Successful', body);
            context.succeed();
        });
    }, function (err) {
        console.log('Error: ' + err);
        context.fail();
    });
}

exports.handler = (event, context, callback) => {
    console.log("event => " + JSON.stringify(event));
    var posts = '';

    for (var i = 0; i < event.Records.length; i++) {
        var eventName = event.Records[i].eventName;
        var actionType = '';
        var image;
        var noDoc = false;
        switch (eventName) {
            case 'INSERT':
                actionType = 'create';
                image = event.Records[i].dynamodb.NewImage;
                break;
            case 'MODIFY':
                actionType = 'update';
                image = event.Records[i].dynamodb.NewImage;
                break;
            case 'REMOVE':
            actionType = 'delete';
                image = event.Records[i].dynamodb.OldImage;
                noDoc = true;
                break;
        }

        if (typeof image !== "undefined") {
            var postData = {};
            for (var key in image) {
                if (image.hasOwnProperty(key)) {
                    if (key === 'postId') {
                        postData['id'] = image[key].S;
                    } else {
                        var val = image[key];
                        if (val.hasOwnProperty('S')) {
                            postData[key] = val.S;
                        } else if (val.hasOwnProperty('N')) {
                            postData[key] = val.N;
                        }
                    }
                }
            }

            var action = {};
            action[actionType] = {};
            action[actionType]._index = 'id';
            action[actionType]._type = 'post';
            action[actionType]._id = postData['id'];
            posts += [
                JSON.stringify(action),
            ].concat(noDoc?[]:[JSON.stringify(postData)]).join('\n') + '\n';
        }
    }
    console.log('posts:',posts);
    postDocumentToES(posts, context);
};

最後に、AppSync APIです。

スキーマはこのように設定されています。

type Mutation {
	putPost(
		author: String!,
		title: String!,
		content: String!,
		url: String!
	): Post
}

type Post {
	id: ID!
	author: String!
	title: String!
	content: String!
	url: String!
}

type Query {
	singlePost(id: ID!): Post
	allPosts: [Post]
	searchContent(text: String): [Post]
	searchAuthor(name: String): [Post]
}

schema {
	query: Query
	mutation: Mutation
}

データソースに、DynamoDB、ESドメインが設定されています。

以上で、環境の準備が整いました。

クエリの実行

それでは、作成されたAPIにクエリを実行していきます。左メニューの[Queries]からクエリが実行可能です。

putPost

まずはデータのインサートです。

リクエスト
mutation add {
    putPost(author:"Nadia"
        title:"My first post"
        content:"This is some test content"
        url:"https://aws.amazon.com/appsync/"
    ){
        id
        title
    }
}
レスポンス
{
  "data": {
    "putPost": {
      "id": "3913a95b-da8d-41ec-a7f5-100ee285849c",
      "title": "My first post"
    }
  }
}

データがDynamoDBに追加されると、Lambda関数がトリガされ、ESにデータが同期されます。Lamdbaのログを見ると、ESにデータをPOSTしていることが確認できます。

searchName

それでは、参照系のクエリを試していきます。searchNameでは、nameに指定した値にてESにGETを行い検索が可能です。

リクエスト
query searchName{
    searchAuthor(name:"   Nadia   "){
        id
        title
        content
    }
}
レスポンス
{
  "data": {
    "searchAuthor": [
      {
        "id": "3913a95b-da8d-41ec-a7f5-100ee285849c",
        "title": "My first post",
        "content": "This is some test content"
      }
    ]
  }
}

searchContent

続いて、searchContentです。同様に、ESにGETを行い検索します。

リクエスト
query searchContent{
    searchContent(text:"test"){
        id
        title
        content
    }
}
レスポンス
{
  "data": {
    "searchContent": [
      {
        "id": "3913a95b-da8d-41ec-a7f5-100ee285849c",
        "title": "My first post",
        "content": "This is some test content"
      }
    ]
  }
}

singlePost

singlePostでは、IDを指定したデータを取得します。

リクエスト
query{
    singlePost(id: "3913a95b-da8d-41ec-a7f5-100ee285849c"){
        id
        title
        content
    }
}
レスポンス
{
  "data": {
    "singlePost": {
      "id": "3913a95b-da8d-41ec-a7f5-100ee285849c",
      "title": "My first post",
      "content": "This is some test content"
    }
  }
}

allPosts

最後に、allPostsです。このリクエストでは、ESではなく、DynamoDBにScanを実行して全データを取得します。

リクエスト
query{
    allPosts{
        id
        title
        content
    }
}
レスポンス
{
  "data": {
    "allPosts": [
      {
        "id": "3913a95b-da8d-41ec-a7f5-100ee285849c",
        "title": "My first post",
        "content": "This is some test content"
      }
    ]
  }
}

まとめ

AWS AppSyncのデータソースとして、DynamoDBとElasticsearch Service組み合わせたチュートリアルを実践してみました。

スキーマの要素ごとに、DynamoDB/ES/Lambdaといったデータソースを使い分けることができますので、データの操作に応じた適切なデータストアが利用可能です。パフォーマンスのよいAPIを設計するためには、データストアの特性を生かした選択が重要となるでしょう。