AWS Lambda で Node.js 4.3 の Promise を使ってみた

はじめに

 AWS Lambda から DynamoDB や S3 にアクセスするときなど、SDK の非同期呼出しのおかげで同期的に処理させるようなコードを書くとコールバック地獄に陥るのは有名な話です。
 Node.js 4.3 からは Q などの外部パッケージに頼らずとも、Promise を使ってコールバック地獄から抜け出せるようになったそうなので紹介します。

アプリの概要

 Todo なるオブジェクトを受け取って DynamoDB に保存する、いたってシンプルなもの。これだけではあまりコールバック地獄にならないので少し複雑な仕様にします。

(Todoオブジェクトはmessageというキーと文字列値を持つ)
{"message":"Hello Node.js and Promise."}
  1. 現在保存されているTodoアイテムの最後の番号を取得する
  2. 取得した番号に1を加えたものを自分用の番号としてインクリメントする
  3. Todoアイテムを追加する

 DynamoDB の料金節約を考慮して、1つのテーブルに収める構造にします。今回、DynamoDB テーブル名は「GeneralPurpose」、パーティションキーは「StorageName」で ‘SimpleTodoList’がこのサンプルアプリ専用のデータを表すことにします。さらにソートキー「DataType」でアプリ内部のアイテムを識別できるようにします。

項目説明サンプル
テーブル名GeneralPurposeTodoアプリのデータを格納するテーブルです。GeneralPurpose 固定
パーティションキーSimpleTodoListアプリの識別子です。SimpleTodoList 固定
ソートキーCount現在保存しているTodoアイテムの最後の番号です。15
ソートキーTodo_<番号>個々のTodoアイテムです。Todo_1、Todo_2、…、Todo_15

 DynamoDBテーブルのサンプル

StorageNameDataNameData
SimpleTodoListCount{“count”: {“N”: 15}}
SimpleTodoListTodo_1{“message”:{“S”: “Hello Node.js and Promise.”}}
SimpleTodoListTodo_2{“message”:{“S”: “todo No2.”}}
SimpleTodoListTodo_15{“message”:{“S”: “todo No15.”}}

handlerにベタ書き

 スケルトンをもとにゴリゴリ実装を進めたコードです。


'use strict';

const AWS = require('aws-sdk');
const client = new AWS.DynamoDB.DocumentClient();

exports.handler = (event, context, callback) => {
    // validation.
    if(!('message' in event && event.message !== '')) {
        callback(JSON.stringify({result: 'ng'}, null));
        return;
    }
    const todo = {
        message: event.message
    };
    
    // fetch current count.
    client.get({
        TableName: 'GeneralPurpose',
        Key: {
            StorageName: 'SimpleTodoList',
            DataName: 'Count'
        }
    }, (error, data) => {
        if (error) {
            callback(JSON.stringify({result: 'ng'}, null));
        } else {
            const count = data.Item.Data.count;
            //console.log('count: ' + count);
            // inclement count.
            client.update({
                TableName: 'GeneralPurpose',
                Key: {
                    StorageName: 'SimpleTodoList',
                    DataName: 'Count'
                },
                UpdateExpression: 'set #p1.#p2 = :plusone',
                ConditionExpression: '#p1.#p2 <= :lastvalue',
                ExpressionAttributeNames: {
                    '#p1': 'Data',
                    '#p2': 'count'
                },
                ExpressionAttributeValues: {
                    ':lastvalue': count,
                    ':plusone': count + 1
                },
                ReturnValues: 'UPDATED_NEW'
            }, (error, data) => {
                if (error) {
                    callback(JSON.stringify({result: 'ng'}, null));
                } else {
                    const inclementedCount = data.Attributes.Data.count;
                    //console.log('inclemented count: ' + inclementedCount + ', todo: ' + todo.message);
                    // put todo.
                    client.put({
                        TableName: 'GeneralPurpose',
                        Item: {
                            StorageName: 'SimpleTodoList',
                            DataName: 'Todo_' + inclementedCount,
                            Data: todo
                        },
                    }, (error, data) => {
                        if (error) {
                            callback(JSON.stringify({result: 'ng'}, null));
                        } else {
                            callback(null, JSON.stringify({result: 'ok'}));
                        }
                    });
                }
            });
        }
    });
};

 今回、3つの処理(client.get: 最後の番号を取得、client.update: 追加するTodoアイテム用にインクリメント、client.put: Todoアイテムを保存)の分だけインデントが深まっています。ようこそコールバック地獄へ。
 なお、client.update の部分では、同時に複数のプロセスが実行したときにデータが失われるのを防ぐ処理を組み込んでいます。ConditionExpression を使用して、同時に処理を開始した複数のプロセスのうち、最初のプロセスだけが保存に成功します。続いて保存しようとする2つ目以降のプロセスは値がすでにインクリメントされていて更新するための前提を満たさなくなり失敗します。

関数にして整理してみる

 インデントの深まりを解消するため、3つの処理をそれぞれ関数に分解してみました。インデントの深まりは解消されましたが、今度は引数にcallbackが付きまといます。また、スタートポイントのhandlerを見ても、fetchCurrentCount が呼び出されているだけで処理の全体像が見えません。


'use strict';

const AWS = require('aws-sdk');
const client = new AWS.DynamoDB.DocumentClient();

const fetchCurrentCount = (todo, callback) => {
    //console.log('enter fetchCurrentCount');
    const param = {
        TableName: 'GeneralPurpose',
        Key: {
            StorageName: 'SimpleTodoList',
            DataName: 'Count'
        }
    };
    client.get(param, (error, data) => {
        if (error) {
            callback(JSON.stringify({result: 'ng'}, null));
            return;
        } else {
            const count = data.Item.Data.count;
            inclementCount(count, todo, callback);
        }
    });
};

const inclementCount = (count, todo, callback) => {
    //console.log('enter inclementCount. count: ' + count);
    const param = {
        TableName: 'GeneralPurpose',
        Key: {
            StorageName: 'SimpleTodoList',
            DataName: 'Count'
        },
        UpdateExpression: 'set #p1.#p2 = :plusone',
        ConditionExpression: '#p1.#p2 <= :lastvalue',
        ExpressionAttributeNames: {
            '#p1': 'Data',
            '#p2': 'count'
        },
        ExpressionAttributeValues: {
            ':lastvalue': count,
            ':plusone': count + 1
        },
        ReturnValues: 'UPDATED_NEW'
    };
    client.update(param, (error, data) => {
        if (error) {
            callback(JSON.stringify({result: 'ng'}, null));
            return;
        } else {
            let inclementedCount = data.Attributes.Data.count;
            putTodo(inclementedCount, todo, callback);
        }
    });
};

const putTodo = (inclementedCount, todo, callback) => {
    //console.log('inclemented count: ' + inclementedCount + ', todo: ' + todo.message);
    const param = {
        TableName: 'GeneralPurpose',
        Item: {
            StorageName: 'SimpleTodoList',
            DataName: 'Todo_' + inclementedCount,
            Data: todo
        },
    };
    client.put(param, (error, data) => {
        if (error) {
            callback(JSON.stringify({result: 'ng'}, null));
            return;
        } else {
            callback(null, JSON.stringify({result: 'ok'}));
            return;
        }
    });
};

exports.handler = (event, context, callback) => {
    // validation.
    if(!('message' in event && event.message !== '')) {
        callback(JSON.stringify({result: 'ng'}, null));
        return;
    }
    const todo = {
        message: event.message
    };
    
    fetchCurrentCount(todo, callback);
};

 本来関数 fetchCurrentCount (最後の番号の取得) や関数 inclementCount (番号のインクリメント) では todo が不要なのですがこれも渡すハメになっています。
 非同期処理間の値のやりとりに、aws や client と同列に todo や inclementCount を設ける方法もあったのですが、どの関数が更新するのか、処理の途中の段階でどういう値が入っているのか、を把握するのが困難になりやすい、という経験上の理由で引数で伝搬するようにしています。

Promiseを使ってみる

 ここでPromiseを使ってみると、先のすべての問題点が解消されます。非同期処理の部分を関数にまとめましたので、改修は簡単です。


'use strict';

const AWS = require('aws-sdk');
const client = new AWS.DynamoDB.DocumentClient();

const fetchCurrentCount = () => {
    //console.log('enter fetchCurrentCount');
    const param = {
        TableName: 'GeneralPurpose',
        Key: {
            StorageName: 'SimpleTodoList',
            DataName: 'Count'
        }
    };
    return client.get(param).promise();
};

const inclementCount = (count) => {
    //console.log('enter inclementCount. count: ' + count);
    const param = {
        TableName: 'GeneralPurpose',
        Key: {
            StorageName: 'SimpleTodoList',
            DataName: 'Count'
        },
        UpdateExpression: 'set #p1.#p2 = :plusone',
        ConditionExpression: '#p1.#p2 <= :lastvalue',
        ExpressionAttributeNames: {
            '#p1': 'Data',
            '#p2': 'count'
        },
        ExpressionAttributeValues: {
            ':lastvalue': count,
            ':plusone': count + 1
        },
        ReturnValues: 'UPDATED_NEW'
    };
    return client.update(param).promise();
};

const putTodo = (inclementedCount, todo) => {
    //console.log('inclemented count: ' + inclementedCount + ', todo: ' + todo.message);
    const param = {
        TableName: 'GeneralPurpose',
        Item: {
            StorageName: 'SimpleTodoList',
            DataName: 'Todo_' + inclementedCount,
            Data: todo
        },
    };
    return client.put(param).promise();
};

exports.handler = (event, context, callback) => {
    // validation.
    if(!('message' in event && event.message !== '')) {
        callback(JSON.stringify({result: 'ng'}, null));
        return;
    }
    const todo = {
        message: event.message
    };
    
    fetchCurrentCount()
        .then((data) => {
                const count = data.Item.Data.count;
                return inclementCount(count);
            }, (error) => {
                callback(JSON.stringify({result: 'ng'}, null));
            })
        .then((data) => {
                const inclementedCount = data.Attributes.Data.count;
                return putTodo(inclementedCount, todo);
            }, (error) => {
                callback(JSON.stringify({result: 'ng'}, null));
            })
        .then(callback.bind(null, null, JSON.stringify({result: 'ok'})))
        .catch(callback.bind(null, JSON.stringify({result: 'ng'}, null)));
};

 非同期処理を実行する3つの関数はPromiseを返すように修正します。一連の処理の流れはすべてhandlerの中でインデントを増やすことなく記述できるようになり、見通しがよくなります。処理結果は (data) => {} を記述して受け取る事ができます。値を受け取る必要がなければ関数を直接渡すこともできます。

おわりに

 これまで、Promise を使うのに外部パッケージ Q が必要で、これを使うにはローカルでディレクトリにまとめてZIPをアップロードして…とやっていたものが不要になり、ソースコードをコンソールから直接編集できるようになったのが、実は一番大きな利点だったりします。
 最後になりましたが、参考にした投稿はコチラです。Support for Promises in the SDK

コメントを残す

メールアドレスが公開されることはありません。