serverless frameworkを使って本格的なAPIサーバーを構築(ハンズオン編)

serverless frameworkを使って本格的なAPIサーバーを構築(ハンズオン編)

最近、推しメンの serverless framework の記事の第2弾です。
保守メンテが楽になりつつも、実戦で速攻で構築ができます。

目次

framework_repo.png

前回、serverless frameworkの魅力の記事を書きました。
今回は、serverless frameworkで 「 lambda + APIGateway + DynamoDB 」 の構成で簡単なサンプルアプリを作成します。

この記事でできるようになること

  • REST FULLなAPIを構築する
  • DynamoDBと連携できる
  • スケージューリングで実行する
  • DynamoStreamが使えるようになる

前準備

  • serverlessをinstallしておく
$ npm install -g serverless
  • プロジェクトを生成する
$ serverless create --template aws-nodejs --path my-service

すると、以下のファイルが出来ているはずです。

$ ls
serverless.yml
handler.js

また、サービス用のテンプレートですが、以下の言語用のテンプレートが用意されています。

使える言語

aws-nodejs
aws-python
aws-java-maven
aws-java-gradle
aws-scala-sbt

まずは、単純にJSONを返してみる

  • 最初はHelloWorld!をJSONで返すAPIで表示してみます。
  • serverless create をした時点でhandler.jsはこのようになっているので、messageを hello world など適当に変えれば良いです。
handler.js
'use strict';

module.exports.hello = (event, context, callback) => {
  const response = {
    statusCode: 200,
    body: JSON.stringify({
      message: 'Go Serverless v1.0! Your function executed successfully!',
      input: event,
    }),
  };

  callback(null, response);

  // Use this code if you don't use the http event with the LAMBDA-PROXY integration
  // callback(null, { message: 'Go Serverless v1.0! Your function executed successfully!', event });
};

  • 最初のserverless.ymlを少し編集します
    • regionがdefaultでは us-east-1 なので ap-northeast-1 にする
    • getでアクセスできるように eventsを追加する
serverless.yml
service: my-service

provider:
  name: aws
  runtime: nodejs6.10

  stage: dev
  region: ap-northeast-1

functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: hello
          method: get
  • これでOKです!! さっそく、deployしてみましょう!!!!!!
$ sls deploy -v -s dev
  • -v は進捗を表示
  • -s はステージの名前を指示します(defaultはdev)
  • APIGatewayでは、プロジェクトからステージごとにデプロイしていましたが、serverlessではステージごとにプロジェクトが作られます。
Serverless: Stack update finished...
Service Information
service: XXXXXXXX
stage: dev
region: ap-northeast-1
api keys:
  None
endpoints:
  GET - https://XXXXXXXX.ap-northeast-1.amazonaws.com/dev/hello
functions:
  hello: XXXXXXXX
Stack Outputs

Serverless: Removing old service versions...

デプロイが成功すると、 APIGateWayには設定済みのプロジェクトと、endpoints を教えてくれるのでそこにアクセスしてみてhelloとJSONが表示されればOKです!!!

スクリーンショット 2017-09-23 15.28.55.png

スクリーンショット 2017-09-23 15.28.38.png

このように、GETでアクセスをするとさっき作成したJSONを返していることがわかります。
完璧だー 🍙🍙🍙

REST APIを作ってみる

  • 次は、DynamoDBと連携 して、get post put deleteを実装してみます。

  • serverless.ymlを編集します

  • REST APIのサンプルは、aws-node-rest-api-with-dynamodbにあります。

serverless.yml

serverless.yml
service: serverless-rest-api-with-dynamodb

frameworkVersion: ">=1.1.0 <2.0.0"

provider:
  name: aws
  runtime: nodejs4.3
  environment:
    DYNAMODB_TABLE: ${self:service}-${opt:stage, self:provider.stage}
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: "arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:provider.environment.DYNAMODB_TABLE}"

functions:
  create:
    handler: todos/create.create
    events:
      - http:
          path: todos
          method: post
          cors: true

  list:
    handler: todos/list.list
    events:
      - http:
          path: todos
          method: get
          cors: true

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

  update:
    handler: todos/update.update
    events:
      - http:
          path: todos/{id}
          method: put
          cors: true

  delete:
    handler: todos/delete.delete
    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.environment.DYNAMODB_TABLE}
  • serverless.ymlのポイント
    • environmentで、どのLambdaからでも DYNAMODB_TABLE を参照することができます。
    • todosというフォルダの中に create.js get.js list.js delete.js update.jsを置いておきます。
    • resourcesでDynamoDBを生成しています。

次に、それぞれのLambda関数を作成します

create.js

todos/create.js
'use strict';

const uuid = require('uuid');
const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies

const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.create = (event, context, callback) => {
  const timestamp = new Date().getTime();
  const data = JSON.parse(event.body);
  if (typeof data.text !== 'string') {
    console.error('Validation Failed');
    callback(null, {
      statusCode: 400,
      headers: { 'Content-Type': 'text/plain' },
      body: 'Couldn\'t create the todo item.',
    });
    return;
  }

  const params = {
    TableName: process.env.DYNAMODB_TABLE,
    Item: {
      id: uuid.v1(),
      text: data.text,
      checked: false,
      createdAt: timestamp,
      updatedAt: timestamp,
    },
  };

  // write the todo to the database
  dynamoDb.put(params, (error) => {
    // handle potential errors
    if (error) {
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 501,
        headers: { 'Content-Type': 'text/plain' },
        body: 'Couldn\'t create the todo item.',
      });
      return;
    }

    // create a response
    const response = {
      statusCode: 200,
      body: JSON.stringify(params.Item),
    };
    callback(null, response);
  });
};

delete.js

todos/delete.js
'use strict';

const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies

const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.delete = (event, context, callback) => {
  const params = {
    TableName: process.env.DYNAMODB_TABLE,
    Key: {
      id: event.pathParameters.id,
    },
  };

  // delete the todo from the database
  dynamoDb.delete(params, (error) => {
    // handle potential errors
    if (error) {
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 501,
        headers: { 'Content-Type': 'text/plain' },
        body: 'Couldn\'t remove the todo item.',
      });
      return;
    }

    // create a response
    const response = {
      statusCode: 200,
      body: JSON.stringify({}),
    };
    callback(null, response);
  });
};

get.js

todos/get.js
'use strict';

const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies

const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.get = (event, context, callback) => {
  const params = {
    TableName: process.env.DYNAMODB_TABLE,
    Key: {
      id: event.pathParameters.id,
    },
  };

  // fetch todo from the database
  dynamoDb.get(params, (error, result) => {
    // handle potential errors
    if (error) {
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 501,
        headers: { 'Content-Type': 'text/plain' },
        body: 'Couldn\'t fetch the todo item.',
      });
      return;
    }

    // create a response
    const response = {
      statusCode: 200,
      body: JSON.stringify(result.Item),
    };
    callback(null, response);
  });
};

list.js

todos/list.js
'use strict';

const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies

const dynamoDb = new AWS.DynamoDB.DocumentClient();
const params = {
  TableName: process.env.DYNAMODB_TABLE,
};

module.exports.list = (event, context, callback) => {
  // fetch all todos from the database
  dynamoDb.scan(params, (error, result) => {
    // handle potential errors
    if (error) {
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 501,
        headers: { 'Content-Type': 'text/plain' },
        body: 'Couldn\'t fetch the todos.',
      });
      return;
    }

    // create a response
    const response = {
      statusCode: 200,
      body: JSON.stringify(result.Items),
    };
    callback(null, response);
  });
};

update.js

todos/update.js
'use strict';

const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies

const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.update = (event, context, callback) => {
  const timestamp = new Date().getTime();
  const data = JSON.parse(event.body);

  // validation
  if (typeof data.text !== 'string' || typeof data.checked !== 'boolean') {
    console.error('Validation Failed');
    callback(null, {
      statusCode: 400,
      headers: { 'Content-Type': 'text/plain' },
      body: 'Couldn\'t update the todo item.',
    });
    return;
  }

  const params = {
    TableName: process.env.DYNAMODB_TABLE,
    Key: {
      id: event.pathParameters.id,
    },
    ExpressionAttributeNames: {
      '#todo_text': 'text',
    },
    ExpressionAttributeValues: {
      ':text': data.text,
      ':checked': data.checked,
      ':updatedAt': timestamp,
    },
    UpdateExpression: 'SET #todo_text = :text, checked = :checked, updatedAt = :updatedAt',
    ReturnValues: 'ALL_NEW',
  };

  // update the todo in the database
  dynamoDb.update(params, (error, result) => {
    // handle potential errors
    if (error) {
      console.error(error);
      callback(null, {
        statusCode: error.statusCode || 501,
        headers: { 'Content-Type': 'text/plain' },
        body: 'Couldn\'t fetch the todo item.',
      });
      return;
    }

    // create a response
    const response = {
      statusCode: 200,
      body: JSON.stringify(result.Attributes),
    };
    callback(null, response);
  });
};

これもデプロイをしてみてください!!

postmanなどで GET POST PUT DELETE すると確認ができるはずです。

スケジュールを設定して定期実行してみる

serverless.yml
  hoge:
    handler: functions/aggregate/index.handler
    events:
      - schedule:
          rate: cron(0 1 * * ? *)

serverless.yml
  hoge:
    handler: functions/aggregate/index.handler
    events:
      - schedule:
          rate: rate(5 minutes)

DynamoStreamを使ってputされたときに何か実行してみる

  • DynamoStreamとは、DynamoDBにputやupdateがあった場合に、そのイベントをトリガーにLambdaでまた処理させることができます。

  • DynamoのResourcesにStreamViewType を追加

serverless.yml
    HogeDynamoDbTable:
      Type: 'AWS::DynamoDB::Table'
      DeletionPolicy: Retain
      Properties:
        AttributeDefinitions:
          -
            AttributeName: column
            AttributeType: S
        KeySchema:
          -
            AttributeName: column
            KeyType: HASH

        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        TableName: "hoge-${self:provider.stage}"
        StreamSpecification:
          StreamViewType: KEYS_ONLY

そして、Streamでどの関数が呼ばれるかを指定します
以下のようにすると、二つのDynamoDBに変更がある場合に1つのLambda関数が呼ばれます。

serverless.yml
  hoge:
    handler: functions/hoge/index.handler
    events:
      - stream:
          type: dynamodb
          arn:
            Fn::GetAtt:
              - HogeDynamoDbTable
              - StreamArn
          batchSize: 1
      - stream:
          type: dynamodb
          arn:
            Fn::GetAtt:
              - HogeDynamoDbTable2
              - StreamArn
          batchSize: 1

batchSizeは1度にどれだけ項目がほしいか設定できます。

ちなみに、StreamViewTypeには

KEYS_ONLY => HASHキーのみ関数で取得できる
NEW_IMAGE =>  新しいものだけ取得できます
OLD_IMAGE => 古いものだけ取得できます
NEW_AND_OLD_IMAGES => 新旧のデータが取得できます

があります。

その他できること

まとめ

  • serverlessを使うとかんたんにAPIが作成できました!!
  • イベントの作成やオプションもまったく困らないと思います。
  • サンプルやプラグインが豊富で、ベストプラクティスには迷いません!

余談

  • 前回serverlessの魅了の紹介で、serverlessを使わないほうがいいときはないのかという質問を受けました。

serverlessを使わないほうがいいとき

  • 複雑なクエリを要するアプリを作るとき
    • 複雑なクエリを要するアプリを作るときは、DynamoDBでも設計を入念に行えばいいかと思いますが、whereなどのクエリが使えないので注意が必要です
    • MySQLクライアントもLambdaで使用することができますが、若干使いにくいようです。
  • Dynamoに書き込む容量が大きいとき

次回