Serverless Framework で DynamoDB を使う

[Serverless Framework](https://serverless.com/)で DynamoDB を使ってみます。

# 参考

- https://github.com/pmuens/serverless-crud
    - これをベースに真似ています。
- https://github.com/jch254/serverless-es6-dynamodb-webapi
    - babel で ES6 を使う場合、これをベースに真似るのが良いと思いました。この作りは好きです。
- https://github.com/99xt/serverless-react-boilerplate
    - 参考になりますが、これを真似るとかなり凝った作りになると思います。

# ソースリポジトリ

ソースは以下のリポジトリを参照してください。
https://github.com/katsuhiko/sls-dynamodb

# バージョン

| version | 
|:---|
| node 6.9.1 |
| npm 3.10.8 |
| serverless framework 1.1.0 |

Lambda が Node.js V4.3 なので、合わせるべきかもしれません。今はローカルでは動かしていないので合わしていません。

# DynamoDB を使う

## todos.js

気軽に使いたいので、あまりレイヤは設けたくはないのですが、Modelに相当する部分のみを　`todos.js` として切り出しました。

Lambda と DynamoDB を意識せずに作れると良いけど、がっつり DynamoDB を意識している作りです。

「[moment](https://github.com/moment/moment/)」は、日付を扱いやすくするライブラリです。
ユニークなキーを生成するのに「[uuid](https://github.com/defunctzombie/node-uuid)」を使っています。「[chance](https://github.com/chancejs/chancejs)」でも良いと思います。

DynamoDB のテーブル名にステージを prefix として追加しています。やり方は他にもあるかもしれませんが、各ステージごとに DynamoDB テーブルを作る仕組みを盛り込まないと実際に利用するときに困ると思います。今回は、ステージを prefix としてテーブル名につけることで対応しました。

Javascript からステージを参照できるようにするために Plugin「[serverless-plugin-write-env-vars](https://github.com/silvermine/serverless-plugin-write-env-vars)」と「[dotenv](https://github.com/motdotla/dotenv)」を使っています。コメントによると Serverless Framework で似た機能が提供されるらしいので、いずれ不要になると思います。

```js
'use strict';

const uuid = require('uuid'),
      moment = require('moment'),
      tableName = `${process.env.STAGE}-todos`;

module.exports.readAll = (db, callback) => {
  const params = {
    TableName: tableName
  };

  return db.scan(params, (err, data) => {
    if (err) {
      callback(err);
    } else {
      callback(err, data.Items);
    }
  });
};

module.exports.readOne = (db, id, callback) => {
  const params = {
    TableName: tableName,
    Key: {
      id: id
    }
  };

  return db.get(params, (err, data) => {
    if (err) {
      callback(err);
    } else {
      callback(err, data.Item);
    }
  });
};

module.exports.create = (db, data, callback) => {
  data.id = uuid.v1();
  data.updatedUtc = moment().utc().toISOString();

  const params = {
    TableName: tableName,
    Item: data
  };

  return db.put(params, (err, data) => {
    if (err) {
      callback(err);
    } else {
      callback(err, params.Item);
    }
  });
};

module.exports.update = (db, id, data, callback) => {
  data.id = id;
  data.updatedAt = moment().utc().toISOString();

  const params = {
    TableName : tableName,
    Item: data
  };

  return db.put(params, (err, data) => {
    if (err) {
      callback(err);
    } else {
      callback(err, params.Item);
    }
  });
};

module.exports.delete = (db, id, callback) => {
  const params = {
    TableName : tableName,
    Key: {
      id: id
    }
  };

  return db.delete(params, (err, data) => {
    if (err) {
      callback(err);
    } else {
      callback(err, params.Key);
    }
  });
};
```

## handler.js

Lambda Proxy に対応した返却を行っています。`context` ではなく `callback`　　を使っています。しっかりした情報を得ることはできませんでしたが、`callback` を使うのが良いのではないかと思います。

極力シンプルにしたいですが、HTTPステータスを返却するところがどうしても IF が入ってキレイにできなかったです。

```js
'use strict';

const AWS = require('aws-sdk'),
      dynamoDb = new AWS.DynamoDB.DocumentClient(),
      env = require('dotenv').config(),
      todos = require('./todos.js');

const createResponse = (statusCode, body) => (
  {
    statusCode,
    headers: {
      'Access-Control-Allow-Origin': '*', // Required for CORS support to work
    },
    body: JSON.stringify(body),
  }
);

module.exports.todosReadAll = (event, context, callback) => {
  todos.readAll(dynamoDb, (err, result) => {
    if (err) {
      callback(createResponse(500, { message: err.message }));
    } else {
      callback(null, createResponse(200, result));
    }
  });
};

module.exports.todosReadOne = (event, context, callback) => {
  const id = event.pathParameters.id;

  todos.readOne(dynamoDb, id, (err, result) => {
    if (err) {
      callback(createResponse(500, { message: err.message }));
    }  else if (!result) {
      callback(null, createResponse(404, { message: 'not found'}));
    } else {
      callback(null, createResponse(200, result));
    }
  });
};

module.exports.todosCreate = (event, context, callback) => {
  const data = JSON.parse(event.body);

  todos.create(dynamoDb, data, (err, result) => {
    if (err) {
      callback(createResponse(500, { message: err.message }));
    } else {
      callback(null, createResponse(201, result));
    }
  });
};

module.exports.todosUpdate = (event, context, callback) => {
  const id = event.pathParameters.id,
        data = JSON.parse(event.body);

  todos.update(dynamoDb, id, data, (err, result) => {
    if (err) {
      callback(createResponse(500, { message: err.message }));
    } else {
      callback(null, createResponse(200, result));
    }
  });
};

module.exports.todosDelete = (event, context, callback) => {
  const id = event.pathParameters.id;

  todos.delete(dynamoDb, id, (err, result) => {
    if (err) {
      callback(createResponse(500, { message: err.message }));
    } else {
      callback(null, createResponse(204));
    }
  });
};
```

## serverless.yml

`iamRoleStatements` と `Resources` を使って DynamoDB へアクセスする権限とテーブル作成を行っています。

この設定で作られた DynamoDB テーブルは `DeletionPolicy: Retain` を指定しているので、`sls remove` を実行しても削除されません。
不要な場合、AWS Console 等を使って別途削除する必要があります。

```yaml
service: sls-dynamodb

provider:
  name: aws
  runtime: nodejs4.3
  stage: ${opt:stage, self:custom.defaultStage}
  region: ${opt:region, self:custom.defaultRegion}
  profile: ${self:custom.profiles.${self:provider.stage}}

  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:DescribeTable
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.stage}-*"

custom:
  defaultStage: dev
  defaultRegion: ap-northeast-1
  profiles:
    dev: devSls
    prod: prodSls
  writeEnvVars:
    STAGE: ${self:provider.stage}

package:
  exclude:
    - .git/**
    - README.md
    - node_modules/serverless-plugin-write-env-vars/**

plugins:
  - serverless-plugin-write-env-vars

functions:
  todosReadAll:
    handler: handler.todosReadAll
    events:
      - http:
          path: todos
          method: get
          cors: true

  todosReadOne:
    handler: handler.todosReadOne
    events:
      - http:
          path: todos/{id}
          method: get
          cors: true

  todosCreate:
    handler: handler.todosCreate
    events:
      - http:
          path: todos
          method: post
          cors: true

  todosUpdate:
    handler: handler.todosUpdate
    events:
      - http:
          path: todos/{id}
          method: patch
          cors: true

  todosDelete:
    handler: handler.todosDelete
    events:
      - http:
          path: todos/{id}
          method: delete
          cors: true

resources:
  Resources:
    TodosDynamoDbTable:
      Type: 'AWS::DynamoDB::Table'
      DeletionPolicy: Retain
      Properties:
        AttributeDefinitions:
          -
            AttributeName: id
            AttributeType: S
        KeySchema:
          -
            AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        TableName: "${self:provider.stage}-todos"
```

# サンプルを動かしてみる

開発を始めるための Serverless Framework のインストール等については「[Serverless Framework で Hello World を作る](http://qiita.com/katsuhiko/items/de971929ffa750ec9b62)」を参照してください。

## サンプルのインストール／デプロイ

Serverless Framework のインストールと AWS への接続ができていたら、以下のコマンドでサンプルをインストール／デプロイできます。

```bash
serverless install --url https://github.com/katsuhiko/sls-dynamodb
cd sls-dynamodb
npm install
serverless deploy -v
```

## 実行

curl を使って実行します。「XXXX」部分は各環境に合わして変更してください。

### Read all

```bash
curl https://XXXX.execute-api.ap-northeast-1.amazonaws.com/dev/todos
```

### Read one

```bash
curl https://XXXX.execute-api.ap-northeast-1.amazonaws.com/dev/todos/<id>
```

### Create

```bash
curl -X POST https://XXXX.execute-api.ap-northeast-1.amazonaws.com/dev/todos --data '{ "content" : "Learn Serverless" }'
```

### Update

```bash
curl -X PATCH https://XXXX.execute-api.ap-northeast-1.amazonaws.com/dev/todos/<id> --data '{ "content" : "Understand Serverless" }'
```

### Delete

```bash
curl -X DELETE https://XXXX.execute-api.ap-northeast-1.amazonaws.com/dev/todos/<id>
```

## あと片付け

```bash
serverless remove -v
```

上記でも DynamoDB テーブルが残っているので、AWS Console 等から DynamoDB テーブルを削除します。


# 感想

DynamoDB を使うのは簡単でした。

気になった点は１点。

Lambda にデプロイされる zip の　node_modules に plugin に関するソースも含まれる点です。（細かいですが。）
serverless.yml で package.exclude していますが、package.json の　devDependencies は含めないようにする仕組みが欲しいと思いました。