今回は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
には以下のいずれかを指定します。- ALL_OLD 更新前のレコード全体を返却
- ALL_NEW 更新後のレコード全体を返却
- UPDATED_OLD 更新前の更新された項目だけを返却
- 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":{}}