[AWS] Serverless FrameworkでRESTfulAPIを作成する (DynamoDB編)

今回はServerless FrameworkでRESTfulAPIを介しDynamoDBを触ります。

プロジェクトの設定ファイルであるserverless.ymlにCloud Formationの書式でいろいろ定義できるのでテーブルの作成やIAM周りの設定もすべて一元管理できます。テキストファイルでほとんどが完結するのほんと最高ですね。

serverless.ymlの設定

本題と直接関係ないのですが、最初にテーブル名を環境変数として定義しておきます。最終的に「(ステージ名)-user」といったステージ名を先頭に付けることができます。例えばproductionへデプロイする時には「production-user」と言った文字列となります。この環境変数YAML内以外にもプログラムからも参照できます。

provider:
  ## 環境変数
  environment:
    DYNAMODB_TABLE: ${self:provider.stage}-user

テーブルの定義

resourcesから下にCloud Formationの書式で必要なリソースを定義できます。というかデプロイするとCloud Formationが実行されるっぽいですねw 以下では先ほど定義した環境変数を利用しテーブル名を指定、主キーが「id」、文字列型で作成しています。キャパシティユニットもここで同時に指定可能です。

resources:
  Resources:
    DynamoDbTable:
      Type: AWS::DynamoDB::Table
      Properties:
        ## テーブル名
        TableName: ${self:provider.environment.DYNAMODB_TABLE}

        ## 主キーの名前と型(複数指定できます)
        AttributeDefinitions:
          -
            AttributeName: id
            AttributeType: S

        ## 主キーの形式(HASH or RANGE)
        KeySchema:
          -
            AttributeName: id
            KeyType: HASH

        ## キャパシティユニット
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

AttributeTypeにはカラムのデータ型を指定します。DynamoDBで利用できるデータ型は以下になります。

データ型 AttributeType
数値型 N
文字列型 S
真偽値 0 または 1
バイナリ B
文字列セット SS
数値セット NS
バイナリセット BS

SS, NS, BSは配列のような物です。文字列セットなら文字列型のデータを1つのカラムに複数保存できます。ただし重複した値は保存不可、順番も保証されないなどの制約もあるので利用する際は注意が必要です。

IAMの定義

さらにその下にIAMの設定を行います。ここではほぼ全力全開で使えるようになってますので必要に応じて権限や対象を絞っていただければ良いかなと。

    DynamoDBIamPolicy:
      Type: AWS::IAM::Policy
      DependsOn: DynamoDbTable
      Properties:
        PolicyName: lambda-dynamodb
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:Query
                - dynamodb:Scan
                - dynamodb:GetItem
                - dynamodb:PutItem
                - dynamodb:UpdateItem
                - dynamodb:DeleteItem
              Resource: arn:aws:dynamodb:*:*:table/*
        Roles:
          - Ref: IamRoleLambdaExecution

Node.jsからDynamoDBを操作する

追加 - Create

dynamo.put()でデータを挿入することができます。第2引数のコールバック関数で最終的なレスポンスを返します。

const AWS = require("aws-sdk");
const dynamo = new AWS.DynamoDB.DocumentClient();

module.exports.add = (event, context, callback) => {
  const params = {
    // 追加するテーブル名
    TableName: "user",

    // 追加するデータ
    Item: {
      id: "001",
      uname: "土間うまる"
    },
  };

  dynamo.put(params, (error) => {
    if( error ){
      console.error(error);
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:error}),
      });
    }
    else{
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true}),
      });
    }
  }
}

ちなみに当初はunameじゃなくてnameにしてたんですが、これ予約語です。見事に後工程でエラーになって気がつきましたw DynamoDBの予約語めっちゃあるんですよね。またいつか引っかかる自信があるw docs.aws.amazon.com

読み込み - Read

1件だけ取得する

dynamo.get()で1件ずつレコードを取得できます。もし存在しない場合は空のオブジェクトが返ってきます。

const AWS = require("aws-sdk");
const dynamo = new AWS.DynamoDB.DocumentClient();

module.exports.get = (event, context, callback) => {
  const params = {
    // テーブル名
    TableName: "user",

    // ほしいレコードのキー
    Key: {
      id: "001",
    },
  };

  dynamo.get(params, (error, result) => {
    if( error ){
      console.error(error);
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:error}),
      });
    }
    else{
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:result}),
      });
    }
  }
}

全件取得する

dynamo.scan()で全件取得します。全部です。すべてのレコードが返ってきますのでくれぐれもご注意を。

const AWS = require("aws-sdk");
const dynamo = new AWS.DynamoDB.DocumentClient();

module.exports.list = (event, context, callback) => {
  const params = {
    // テーブル名
    TableName: "user",
  };

  dynamo.scan(params, (error, result) => {
    if( error ){
      console.error(error);
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:error}),
      });
    }
    else{
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:result}),
      });
    }
  }
}

更新 - Update

dynamo.update()で更新できるのですが、このメソッドだけやたらパラメータが複雑です。

const AWS = require("aws-sdk");
const dynamo = new AWS.DynamoDB.DocumentClient();

module.exports.update = (event, context, callback) => {
  const params = {
    // テーブル名
    TableName: "user",

    // 更新対象の主キー(絞り込む)
    Key: {
      id: "001",
    },

    // 更新する値
    ExpressionAttributeValues: {
      ':uname': "土間 埋",
      ':id': "001"
    },

    // 具体的な更新内容
    UpdateExpression: 'SET uname = :uname',

    // 更新する際の条件
    ConditionExpression: 'id = :id',  //これを書かないと「アップサート」になる

    // 更新完了後に返す値
    ReturnValues: 'ALL_NEW'
  };

  dynamo.update(params, (error, result) => {
    if( error ){
      console.error(error);
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:error}),
      });
    }
    else{
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:result}),
      });
    }
  }
}
  • ExpressionAttributeValues はここから下で使用するハッシュを作成します。なのでキーに使用する名前は先頭にコロンが付いていれば自由に付けることができます。
  • UpdateExpressionに具体的にどの項目にどういった値をセットするかを指定します。SET以外にもいくつか機能が用意されています。また複数同時に項目を指定する場合はカンマ(,)で区切ります。
  • ConditionExpressionを指定しない場合、対象のレコードが無ければINSERT、存在すればUPDATEという処理(アップサート/UPSERT)になります。
  • ReturnValuesには以下のいずれかを指定します。
    1. ALL_OLD 更新前のレコード全体を返却
    2. ALL_NEW 更新後のレコード全体を返却
    3. UPDATED_OLD 更新前の更新された項目だけを返却
    4. UPDATED_NEW 更新後の更新された項目だけを返却

削除 - Delete

dynamo.delete()で削除できます。削除したレコードがあろうが無かろうがerrorには何も入らないのでちゃんと意図したレコードが消されたかチェックしたい場合はReturnValuesを指定します。

const AWS = require("aws-sdk");
const dynamo = new AWS.DynamoDB.DocumentClient();

module.exports.remove = (event, context, callback) => {
  const params = {
    // テーブル名
    TableName: "user",

    // 削除したいレコードの主キー
    Key: {
      id: "001",
    },

    // 削除したレコードを返却する
    ReturnValues: "ALL_OLD"
  };

  dynamo.delete(params, (error) => {
    if( error ){
      console.error(error);
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:error}),
      });
    }
    else{
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:result}),
      });
    }
  }
}

実際に試してみる

今回はDynamoDBへCRUDするサンプルを動かしてみたいと思います。

準備

基本的な設定方法については過去記事を参照ください。 blog.katsubemakito.net

最初に「user」という名前のプロジェクトを作成しました。

$ serverless 
Serverless: No project detected. Do you want to create a new one? Yes
Serverless: What do you want to make? AWS Node.js
Serverless: What do you want to call this project? user

作成されたディレクトリに入ってnpm initしておきます。

$ cd user
$ npm init

追加でモジュールのインストールを行います。

$ npm install aws-sdk query-string

サンプルコード

serverless.yml

プロジェクトの設定を行います。DynamoDBを準備しつつCRUDを行う合計5つのAPIを作成します。

service: user
provider:
  name: aws
  runtime: nodejs12.x
  # 環境変数
  environment:
    DYNAMODB_TABLE: ${self:provider.stage}-user

functions:
  get:
    handler: handler.get
    events:
      - http:
          path: user/get/{id}
          method: get
  list:
    handler: handler.list
    events:
      - http:
          path: user/list
          method: get
  add:
    handler: handler.add
    events:
      - http:
          path: user/add
          method: post
  update:
    handler: handler.update
    events:
      - http:
          path: user/update/{id}
          method: post
  remove:
    handler: handler.remove
    events:
      - http:
          path: user/remove/{id}
          method: post

resources:
  Resources:
    DynamoDbTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.DYNAMODB_TABLE}
        AttributeDefinitions:
          -
            AttributeName: id
            AttributeType: S
        KeySchema:
          -
            AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

    DynamoDBIamPolicy:
      Type: AWS::IAM::Policy
      DependsOn: DynamoDbTable
      Properties:
        PolicyName: lambda-dynamodb
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:Query
                - dynamodb:Scan
                - dynamodb:GetItem
                - dynamodb:PutItem
                - dynamodb:UpdateItem
                - dynamodb:DeleteItem
              Resource: arn:aws:dynamodb:*:*:table/*
        Roles:
          - Ref: IamRoleLambdaExecution

handler.js

実際のコードです。handler.jsの中に以下の関数をそのまま貼り付けます。

'use strict';

/**
 * DynamoDBでCRUDする
 *
 * @version 1.0.0
 * @author M.Katsube <katsubemakito@gmail.com>
 */

//----------------------------------------
// モジュール
//----------------------------------------
const AWS = require("aws-sdk");
const dynamo = new AWS.DynamoDB.DocumentClient();
const queryString = require("query-string");

//----------------------------------------
// 定数
//----------------------------------------
// テーブル名
const DYNAMO_TABLENAME = process.env.DYNAMODB_TABLE;  //YAML内で指定した環境変数


/**
 * ユーザー情報を取得する
 *   GET /(stage)/user/get/{id}
 *
 *   example)
 *     $ curl 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/user/get/1'
 */
module.exports.get = (event, context, callback) => {
  const params = {
    TableName: DYNAMO_TABLENAME,
    Key: {
      id: event.pathParameters.id,
    },
  };

  // DynamoDBから1件を取得する
  dynamo.get(params, (error, result) => {
    if ( ! error ) {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:result}),
      });
    }
    else{
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 500,
        body: JSON.stringify({status:false, message:`Could not scan item : ${error}`})
      });
    }
  })
};


/**
 * ユーザー一覧を取得する
 *   GET /(stage)/user/list
 *
 *   example)
 *     $ curl 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/user/list'
 */
module.exports.list = (event, context, callback) => {
  const params = {
    TableName: DYNAMO_TABLENAME
  };

  // DynamoDBから全件を取得する
  dynamo.scan(params, (error, result) => {
    if ( ! error ) {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:result}),
      });
    }
    else{
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 500,
        body: JSON.stringify({status:false, message:`Could not scan item : ${error}`})
      });
    }
  })
};


/**
 * ユーザーを追加する
 *   POST /(stage)/user/add
 *   id=1&name=xxxxx
 *
 *   example)
 *     $ curl -d 'id=1&uname=foo' -X POST 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/user/add'
 */
module.exports.add = (event, context, callback) => {
  //--------------------------
  // 引数チェック
  //--------------------------
  const body = queryString.parse(event.body);
  let err = [];
  if( ! ("id" in body && body.id.match(/^([0-9]{1,})$/)) ){
    err.push("invalid id")
  }
  if( ! ("uname" in body && body.uname.match(/^([a-zA-Z0-9]{1,})$/)) ){
    err.push("invalid uname")
  }

  // エラーがあれば終了
  if( err.length >= 1 ){
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({status:false, message:err, body:body}),
    });

    return(false);
  }

  //--------------------------
  // DynamoDBへ保存
  //--------------------------
  const params = {
    TableName: DYNAMO_TABLENAME,
    Item: {
      id: body.id,
      uname: body.uname
    },
  };

  dynamo.put(params, (error) => {
    if ( ! error ) {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:params.Item}),
      });
    }
    else{
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 500,
        body: JSON.stringify({status:false, message:`Could not create item : ${error}`})
      });
    }
  });
};


/**
 * ユーザー情報を更新する
 *   POST /(stage)/user/update/{id}
 *
 *   example)
 *     $ curl -d 'uname=bar' -X POST 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/user/update/1'
 */
module.exports.update = (event, context, callback) => {
  //--------------------------
  // 引数チェック
  //--------------------------
  const body = queryString.parse(event.body);
  if( ! ("uname" in body && body.uname.match(/^([a-zA-Z0-9]{1,})$/)) ){
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({status:false, message:"invalid uname"}),
    });
    return(false);
  }

  const id = event.pathParameters.id;
  const params = {
    TableName: DYNAMO_TABLENAME,
    Key: {
      id: id,
    },
    ExpressionAttributeValues: {
      ':uname': body.uname,
      ':id': id
    },
    UpdateExpression: 'SET uname = :uname',
    ConditionExpression: 'id = :id',
    ReturnValues: 'ALL_NEW'
  };

  // DynamoDBを更新する
  dynamo.update(params, (error, result) => {
    if ( ! error ) {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, result:result}),
      });
    }
    else{
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 500,
        body: JSON.stringify({status:false, message:`Could not update item : ${error}`})
      });
    }
  });
};


/**
 * ユーザーを削除する
 *   POST /(stage)/user/remove/{id}
 *
 *   example)
 *     $ curl -X POST 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/user/remove/1'
 */
module.exports.remove = (event, context, callback) => {
  const id = event.pathParameters.id;
  const params = {
    TableName: DYNAMO_TABLENAME,
    Key: {
      id: id,
    },
    ReturnValues: "ALL_OLD",
  };

  // DynamoDBから削除する
  dynamo.delete(params, (error, data) => {
    if ( ! error ) {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data}),
      });
    }
    else{
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 500,
        body: JSON.stringify({status:false, message:`Could not remove item : ${error}`})
      });
    }
  });
};

デプロイ

deployコマンドでAWSへ反映します。

$ serverless deploy

途中でURLが表示されるのでこれをメモします。

endpoints:
  GET - https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/get/{id}
  GET - https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/list
  POST - https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/add
  POST - https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/update/{id}
  POST - https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/remove/{id}

この時点ですでにテーブルが作成されています。AWSマネジメントコンソールから確認してみてください。表示されない場合はリージョンの確認を(YAMLなどで未指定の場合はバージニア北部にできています。)

実行する

curlコマンドで挙動を確認します。

追加 - Create

今のところデータが空っぽなのでまずは追加から。以下のようにPOSTでデータを送るとDynamoDBに挿入されます。

$ curl -d 'id=1&uname=foo' -X POST 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/add'
{"status":true,"data":{"id":"1","uname":"foo"}}

$ curl -d 'id=2&uname=bar' -X POST 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/add'
{"status":true,"data":{"id":"2","uname":"bar"}}

マネジメントコンソール上でも追加されてるのを確認しておきます。

読み込み - Read

挿入したデータの各レコードの内容を見てみましょう。存在しないidを指定すると空のオブジェクトが返ってきます。

$ curl 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/get/1'
{"status":true,"data":{"Item":{"id":"1","uname":"foo"}}}

$ curl 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/get/3'
{"status":true,"data":{}}

/user/listで全てのレコードを取得できます。jqコマンドでJSONを整形しています。

$ curl 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/list' | jq
{
  "status": true,
  "data": {
    "Items": [
      {
        "id": "2",
        "uname": "bar"
      },
      {
        "id": "1",
        "uname": "foo"
      }
    ],
    "Count": 2,
    "ScannedCount": 2
  }
}

更新 - Update

既存のデータを更新します。

$ curl -d 'uname=apple' -X POST 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/update/1'
{"status":true,"result":{"Attributes":{"id":"1","uname":"apple"}}}

マネジメントコンソール上でも更新が確認できました。

なお、存在しないidを指定するとエラーが返ってきます。ConditionExpressionを指定しない場合は新たにレコードが作成されエラーにはなりません(アップサート)

$ curl -d 'uname=apple' -X POST 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/update/99'
{"status":false,"message":"Could not update item : ConditionalCheckFailedException: The conditional request failed"}

削除 - Delete

削除も同様です。削除したデータが返ってきますのでこれで意図した物が消えているのか確認します。

$ curl -X POST 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/remove/1'
{"status":true,"data":{"Attributes":{"id":"1","uname":"apple"}}}

はい、こっちも消えてますね。

存在しないレコードを消そうとしてもエラーにはならない点に注意です。空のオブジェクトが返ってくるのでこれで確認する感じですかね。

$ curl -X POST 'https://02d1uqjbw3.execute-api.us-east-1.amazonaws.com/dev/user/remove/1'
{"status":true,"data":{}}

参考ページ