AWS
DynamoDB
sam
serverless

AWS SAM CLI + DynamoDB localを使ってローカル上で完結するAPI開発

全部ローカルでやりたい

個人開発でゴニョニョやるぶんにはDynamoDBの利用料はそこまでかからないと思うのですが、トライ&エラーで頑張ってると速度が気になりますし、テスト用にデータを入れたり全消ししたりすることを考えると手元であれこれいじれるほうが便利かと思います。

今回作ったコードは以下に置いてあります。

https://github.com/jacoyutorius/sam-cli-node-bookshelf-api

AWS SAM CLI

先日のAWS Summit 2018でSAM CLI が発表されました。
AWS SAM Localというプロダクトが以前公開されていましたが、これの機能強化版と考えて良いと思います。
SAM Localにあった機能に加え、"init"等の新機能が追加されたようです。
SAM LocalではNodeで実装されていたのが、SAM CLIではPythonによる実装となったようで、SAM CLIをインストールする際には旧SAM Localをアンインストールする必要があります。

aws-sam-cli#installation

Bookshelf APIを作る

ということで、SAM CLIを使って全てローカル上でAPIを開発してみます。
BookshelfというDynamoDBをDBとしたシンプルなREST APIです。以下のような設計とします。

URL HTTP Method
/books GET Books一覧を取得する
/books/new POST Booksへ新規レコード追加
/books/:key GET Booksの指定したレコードを取得する
/books/:key PUT Booksの指定したレコードを更新する
/books/:key DELETE Booksの指定したレコードを削除する

※ 長くなりそうだったので、今回は上2つのBooks一覧とレコード新規登録までとします。

まずはinitでプロジェクトを作成します。

$ sam init --runtime nodejs --name bookshelf

[+] Initializing project structure...
[SUCCESS] - Read bookshelf/README.md for further instructions on how to proceed
[*] Project initialization is now complete


$ cd bookshelf

以下のようにファイルが生成されます。
hello_worldディレクトリはデフォルトで作られます。動作確認のために後ほどHelloWorldFunctionを実行してみますが、Bookshelf APIとは無関係となります。

$ cd bookshelf

$ tree
.
├── README.md
├── hello_world
│   ├── app.js
│   ├── package.json
│   └── tests
│       └── unit
│           └── test_handler.js
└── template.yaml

3 directories, 5 files

POSTの動作確認用にサンプルJSONを生成します。
generate-eventはLambdaのフックとなる各種AWSサービスから渡されるパラメータのサンプルが出力されます。

$ sam local generate-event api > post_event.json

post_event.json

現時点では何も変更しません。

{
    "body": "{ \"test\": \"body\"}",
    "httpMethod": "POST",
    "resource": "/{proxy+}",
    "queryStringParameters": {
        "foo": "bar"
    },

    ~省略~
}

HelloWorldFunctionの実行

デフォルトで定義されているHelloWorldFunctionを実行してみます。

$ sam local invoke HelloWorldFunction -e post_event.json
2018-06-06 11:05:13 Invoking app.lambda_handler (nodejs8.10)
2018-06-06 11:05:13 Found credentials in shared credentials file: ~/.aws/credentials

Fetching lambci/lambda:nodejs8.10 Docker container image......
2018-06-06 11:05:16 Mounting /Users/yuto-ogi/Work/aws_sam/bookshelf/hello_world as /var/task:ro inside runtime container
START RequestId: 66d3afbb-c233-19fd-9fb6-a26aeb92174b Version: $LATEST
Unable to import module 'app': Error
    at Function.Module._resolveFilename (module.js:547:15)
    at Function.Module._load (module.js:474:25)
    at Module.require (module.js:596:17)

エラーになります。
/hello_world/app.jsを見てみると、axiosパッケージが使用されています。これをインストールする必要があります。

$ cd hello_world

$ yarn install
yarn install v0.21.3
info No lockfile found.
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependenci

$ ../

インストールが終わったらhello_worldディレクトリを出て、元のbookshelfディレクトリにて再度invokeを実行。
うまく動作したようです。

$ sam local invoke HelloWorldFunction -e post_event.json
2018-06-06 11:08:34 Invoking app.lambda_handler (nodejs8.10)
2018-06-06 11:08:34 Found credentials in shared credentials file: ~/.aws/credentials

Fetching lambci/lambda:nodejs8.10 Docker container image......
2018-06-06 11:08:37 Mounting /Users/yuto-ogi/Work/aws_sam/bookshelf/hello_world as /var/task:ro inside runtime container
START RequestId: 0c2373c2-fd07-1929-11ce-147a97866e40 Version: $LATEST
END RequestId: 0c2373c2-fd07-1929-11ce-147a97866e40
REPORT RequestId: 0c2373c2-fd07-1929-11ce-147a97866e40  Duration: 789.05 ms Billed Duration: 800 ms Memory Size: 128 MB Max Memory Used: 34 MB

{"statusCode":200,"body":"{\"message\":\"hello world\",\"location\":\"122.249.204.181\"}"}

最初に生成されるhello_worldにはユニットテストまで自動作成されているので、テストを実装する際に参考にするのが良さそうです。

レコードの一覧を返すイベントを実装する

bookshelfディレクトリとコードを実装するapp.jsを作ります。

$ mkdir bookshelf
$ touch bookshelf/app.js

Books一覧イベントの実装

template.yaml

HelloWorldFunctionの下にBookshelfFunctionの定義を書きます。
まずはBooksテーブルのレコード一覧を返すイベントのみ定義しました。
(YAMLなのでインデントに注意です。↓のように数段インデントした形になると思います)

    BookshelfFunction:
        Type: AWS::Serverless::Function
        Properties:
            CodeUri: bookshelf/
            Handler: app.lambda_handler
            Runtime: nodejs8.10
            Environment:
            Events:
                ListBooks:
                    Type: Api
                    Properties:
                        Path: /bookshelf
                        Method: get

BookshelfFunctionの実装をやっていきます。
hello_world/app.js等を参考にしつつ、まずはGETイベントの実装をします。
適当にサンプルデータを記述しておきます(ここは後ほどDynamoDB Localにアクセスするように書き換えます)

bookshelf/app.js

let response;

exports.lambda_handler = (event, context, callback) => {
  try {
    switch (event.httpMethod) {
      case "GET":
        response = {
          "statusCode": 200,
          "body": JSON.stringify({
            books: [{
              title: "book1",
              category: "category1",
              author: "1"
            }, {
              title: "book2",
              category: "category2",
              author: "2"
            }]
          })
        }
        break;
      default:
        response = {
          "statusCode": 501
        }
    }
  } catch (err) {
    console.log(err);
    callback(err, null);
  }

  callback(null, response)
};

getイベント用のイベントJSONを作成します。
(ここではpost_event.jsonをコピーしましたが、generate_eventの--methodオプションや--bodyオプションを指定して作っても問題ありません)

$ cp post_event.json get_event.json

get_event.json

POSTになっているところをGETに書き換えただけです。bodyの値もここでは変更不要です。

{
    "body": "{ \"test\": \"body\"}",
    "httpMethod": "GET",
    "resource": "/{proxy+}",
    "queryStringParameters": {
        "foo": "bar"
    },
    "requestContext": {
        "httpMethod": "GET",

 ~略~
}

invokeでbook一覧イベントの動作確認

HelloWorldFunctionのときと同じようにinvokeでBookshelfFunctionを実行してみます。
httpMethodを"GET"に書き換えたget_event.jsonをeventオプションに指定することで、app.jsに実装したswitch文によってGET時の処理が実行されますね。

$ sam local invoke -e get_event.json BookshelfFunction
2018-06-06 11:40:49 Invoking app.lambda_handler (nodejs8.10)
2018-06-06 11:40:49 Found credentials in shared credentials file: ~/.aws/credentials

~

{"statusCode":200,"body":"{\"books\":[{\"title\":\"book1\",\"category\":\"category1\",\"author\":\"1\"},{\"title\":\"book2\",\"category\":\"category2\",\"author\":\"2\"}]}"}

DynamoDBのテーブル設計

ここで一旦Lambdaから離れてDBのことを考えます。
BookshelfFunctionという名の通り、本の情報を格納するBooksテーブルを定義します。
本のタイトルをハッシュキー、本の分類をレンジキーとして設計しました。

↓の表示はテーブル作成後にDynamoDB Localから出力したテーブル定義です。

{
  "AttributeDefinitions": [
    {
      "AttributeName": "title",
      "AttributeType": "S"
    },
    {
      "AttributeName": "category",
      "AttributeType": "S"
    }
  ],
  "TableName": "Books",
  "KeySchema": [
    {
      "AttributeName": "title",
      "KeyType": "HASH"
    },
    {
      "AttributeName": "category",
      "KeyType": "RANGE"
    }
  ],

  ~
}

DynamoDB Localをセットアップする

以下のリンク先を参考にDynamoDBLocalをダウンロードし、起動します。

DynamoDB ローカル (ダウンロード可能バージョン) のセットアップ

$ java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb

Initializing DynamoDB Local with the following configuration:
Port:   8000
InMemory:   false
DbPath: null
SharedDb:   true
shouldDelayTransientStatuses:   false
CorsParams: *

デフォルトは8000番ポートで起動します。/shellにアクセスするとJavascriptによるREPLが開きます。

http://localhost:8000/shell/

(補足)dynamodb-adminでDynamoDB LocalもGUIで操作する

DynamoDBはパラメータが多く、コマンドで操作するのがやや難解に感じます(意識が横道に逸らされるというか、、)
探してみるとDynamoDB Local用のGUIツールが公開されていたので使ってみました。

aaronshaf/dynamodb-admin

$ mkdir dynamodb-gui
$ cd dynamodb-gui
$ yarn install dynamodb-admin
$ export DYNAMO_ENDPOINT=http://localhost:8000
$ node node_modules/dynamodb-admin/bin/dynamodb-admin.js

dynamodb-admin
  listening on port 8001

http://localhost:8001/

細かいチューニング等はできませんが、一連のCRUD操作が直感的に行えるので重宝しています。

DynamoDB_Admin.jpg

Booksテーブルの作成

dynamodb-adminを使えばGUIでサラッと作れてしまいますが、shellから作成する場合は以下のようなコマンドを実行します。

var params = {
    TableName: 'Books',
    KeySchema: [ 
        { 
            AttributeName: 'title',
            KeyType: 'HASH',
        },
        { 
            AttributeName: 'category', 
            KeyType: 'RANGE', 
        }
    ],
    AttributeDefinitions: [
        {
            AttributeName: 'title',
            AttributeType: 'S',
        },
        {
            AttributeName: 'category',
            AttributeType: 'S',
        }
    ],
    ProvisionedThroughput: { 
        ReadCapacityUnits: 1, 
        WriteCapacityUnits: 1, 
    }
};
dynamodb.createTable(params, function(err, data) {
    if (err) ppJson(err);
    else ppJson(data);
});

listTablesでテーブルが作成できたか一応確認しておきますか。

dynamodb.listTables({}, function(err, data) {
    if (err) ppJson(err);
    else ppJson(data);
});

レコードも少し登録しておきますか。

var params = {
    TableName: 'Books',
    Item: {
        name: {
            "S": "海辺のカフカ"
        },
        category: {
            "S" : "小説"
        }
    }
};
docClient.put(params, function(err, data) {
    if (err) ppJson(err);
    else ppJson(data);
});

LambdaからDynamoDB Localへアクセスする

さて、DBの用意ができたのでDynamoDB Localからデータを取り出す処理を実装してきます。
DynamoDBへアクセスするにはaws-sdkが必要なのでインストールします。

$ yarn add aws-sdk
$ cd ../

app.js

DynamoDB Localへアクセスする場合には、endpointオプションにDynamoDB LocalのURLを指定する必要があります。
また、app.jsはDocker上で実行されるため、localhostを指定するとアクセスに失敗します。ifconfig等でホストマシンのグローバルIPを確認し、そのIPを指定します。

このような感じになります。

const AWS = require('aws-sdk')
let response;

var dynamoOpt = {
  apiVersion: '2012-08-10',
  endpoint: "http://192.168.20.106:8000"
};
var documentClient = new AWS.DynamoDB.DocumentClient(dynamoOpt);

exports.lambda_handler = (event, context, callback) => {
  try {
    switch (event.httpMethod) {
      case "GET":
        var params = {
          TableName: "Books"
        };
        documentClient.scan(params, (err, data) => {
          response = {
            "statusCode": 200,
            "body": JSON.stringify(data.Items)
          }
          callback(null, response);
          return;
        })
        break;
      default:
        response = {
          "statusCode": 501
        }
    }
  } catch (err) {
    console.log(err);
    callback(err, null);
  }
};

invokeで実行してみます。
前述の通りDockerプロセス上でapp.jsが実行されるため、事前にDockerを起動しておく必要があります。

$ sam local invoke -e get_event.json BookshelfFunction

2018-06-06 13:58:20 Invoking app.lambda_handler (nodejs8.10)
2018-06-06 13:58:20 Found credentials in shared credentials file: ~/.aws/credentials

~

{"statusCode":200,"body":"[{\"title\":\"それがぼくには楽しかったから\",\"category\":\"テクノロジー\",\"author\":\"リーナス トーバルズ\"},{\"title\":\"幼年期の終わり\",\"category\":\"SF\",\"author\":\"アーサー・C・クラーク\"},{\"title\":\"海辺のカフカ\",\"category\":\"小説\",\"author\":\"村上春樹\"}]"}

関数の実装については問題なさそうなので、start-apiを実行してホストから/bookshelfにアクセスしてみます。

$ sam local start-api

2018-06-06 15:49:31 Mounting BookshelfFunction at http://127.0.0.1:3000/bookshelf [GET]
2018-06-06 15:49:31 Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]
2018-06-06 15:49:31 You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2018-06-06 15:49:31  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
$ curl -X GET http://127.0.0.1:3000/bookshelf

[{"title":"それがぼくには楽しかったから","category":"テクノロジー","author":"リーナス トーバルズ"},{"title":"幼年期の終わり","category":"SF","author":"アーサー・C・クラーク"},{"title":"海辺のカフカ","category":"小説","author":"村上春樹"}]

良い感じですね。GETアクションについては実装できました。

レコードを新規登録する

次にPOSTによるレコードの新規登録機能を実装します。

template.yaml

NewBooksの項を追加しました。

    BookshelfFunction:
        Type: AWS::Serverless::Function
        Properties:
            CodeUri: bookshelf/
            Handler: app.lambda_handler
            Runtime: nodejs8.10
            Environment:
            Events:
                ListBooks:
                    Type: Api
                    Properties:
                        Path: /bookshelf
                        Method: get
                NewBooks:
                    Type: Api
                    Properties:
                        Path: /bookshelf/new
                        Method: post

bookshelf/app.js

event.httpMethodでPOSTが来た場合のcase文を追加しました。
場合によっては入力値のチェックをする必要があると思いますが、ここではそのまま更新パラメータとしてDBに渡しています。

exports.lambda_handler = (event, context, callback) => {
  try {
    switch (event.httpMethod) {

~~

      case "POST":
        var params = {
          TableName: "Books",
          Item: JSON.parse(event.body)
        }
        documentClient.put(params, (err, data) => {
          response = {
            "statusCode": 200,
            "body": JSON.stringify(data)
          }
          callback(null, response);
          return;
        })
        break;

~~

};

aws-sdkのdynamoDBClientのリファレンス を見るかぎりでは
documentClient.putのコールバックで更新したレコードが返ってくるようなのですが、
何故か入ってこないですね。。

$ sam local invoke -e post_event.json BookshelfFunction
2018-06-06 19:43:50 Invoking app.lambda_handler (nodejs8.10)
2018-06-06 19:43:50 Found credentials in shared credentials file: ~/.aws/credentials

{"statusCode":200,"body":"{}"}

リファレンスには、

the de-serialized data returned from the request. Set to null if a request error occurs. 

とあるので、何がしかのエラーが発生している様子。。とりあえずデータは登録できたので進めます。
start-apiを実行してHTTPリクエストでもデータ登録ができることを確認します。

curl -X POST -H "Content-Type: application/json" http://127.0.0.1:3000/bookshelf/new -d "{ \"title\": \"パーフェクトJavaScript (PERFECT SERIES 4)\", \"category\": \"テクノロジー\"}"

jsonファイルを用意してやったほうが楽ですかね。

data.json

{
  "title": "バウハウスとはなにか",
  "category": "美術"
}
$ curl -X POST -H "Content-Type: application/json" http://127.0.0.1:3000/bookshelf/new -d @data.json

まとめ

ということで、レコードの一覧と新規登録をするAPIが実装できました。
流れとしては今回のように、invokeで関数自体の動作検証&ユニットテストをしたのち、
start-apiで実際にHTTPアクセスして動作検証という流れになるのではないかと思います。

他の操作についても同じ要領で実装していけば良いと思います。APIの実装方法とSAM CLIでの動かし方を押さえてしまえばあとはaws-sdkでDynamoDBの操作を書くだけですね。
今回は正常系の動作しか確認していませんが、例外系はもっと作り込む必要があると思います。あとユニットテストもしっかり書いておきたいですね。

参考

AWS SAM CLIの記事はまだ少ないですが、発表後に早速試された方が記事を書いてくれたようです。
あとは公式のREADMEが充実しているので、それを参考にしつつやってみました。

awslabs/aws-sam-cli
aws-sam-local 改め aws-sam-cli の新機能 sam init を試す
aws-sam-localだって!?これは試さざるを得ない!