【今日から始めるAWS】Serverless Frameworkを使ってLINEのbotをつくる


#はじめに
30代未経験からエンジニア転職をめざすコーディング初学者のYNと申します。お読みいただきありがとうございます。
[前に作ったLINEのオウム返しbot](https://qiita.com/theFirstPenguin/items/8168e8ba3d65e34c24e0)を少しだけ発展させた内容でbotを作りましたので学習ログとして投稿させていただきました。

#Bot内容
下記のように、ユーザーが送ったタンパク質摂取量を記録できるbotをつくりました。
↓一応友達登録もできます。（思いついたタイミングで勝手に消去する可能性があります。）
![スクリーンショット 2020-08-14 20.27.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/852d404a-644b-254c-f1e3-24b1181bc842.png)


![スクリーンショット 2020-08-14 19.17.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/b90e7db9-06ef-3efc-1b69-fa7a330265a5.png)



#やったこと

[Serverless Flamework](https://www.serverless.com/)を使って、ローカルPC上でサーバレスのAWSのバックエンドを構築した後デプロイし、LINEのbotをつくりました。
イメージはこんな感じです。
![スクリーンショット 2020-08-14 21.36.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/1b4c336e-82cb-09e1-b287-ddd8084f9797.png)



#手順

* 事前準備
* `Serverless Flamework`の初期設定
* DynamoDBでデータベースをつくる
* API-GatewayでLambda関数を公開する
* Lambda関数を記述する
* Serverless Frameworkを使ってローカル環境で動作確認する

#事前準備

###LINEデベロッパー登録
[こちら](https://developers.line.biz/ja/docs/messaging-api/getting-started/)を参照ください。


###Serverless Flameworkのインストール

* ローカルPCにグローバルインストール
 
```
$ npm i -g serverless
```

* 動作確認
 
```
$ sls -v
```
(`sls` と `serverless`のどちらのコマンドでもOKです）

#Serverless Flameworkの初期設定

### 雛形の作成
今回はnode.jsの雛形を作成します。

```
$ mkdir line-bot
$ cd line-bot
$ serverless create --template aws-nodejs
```

下記2つのファイルが作成されます。

* `handler.js` => `Lambda`で実行する関数の中身を記述します。
* `serverless.yml` => AWSで構築するバックエンドの設定を記述します。


### クレデンシャル情報の設定

初めてServerless Flameworkを使うとき、まず最初にAWSのクレデンシャル情報を設定する必要があります。
具体的には、ホームディレクトリにある`~/.aws/credentials`というファイルの中身を設定します。
（以前AWS-CLIをいじったことがある方は、すでに設定されているかもしれません。その場合は上書きすることが出来ます。）
![スクリーンショット 2020-08-15 10.04.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/09998662-8fce-91be-9621-0f7558428caf.png)

まずはIAMコンソールでアクセスキーを作成します。
（[このアクセスキーは慎重に扱い、くれぐれもGithubなどにupなさらぬよう。。](https://qiita.com/mochizukikotaro/items/a0e98ff0063a77e7b694)）
![スクリーンショット 2020-08-15 9.46.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/acca1e6f-70c2-70f1-07f2-f0dc6df1dd42.png)

次に、クレデンシャル情報を設定します。

```
$ serverless config credentials --provider aws --key aws_access_key_id --secret aws_secret_access_key
```

これで、クレデンシャル情報の設定は完了です。
クレデンシャル情報を変更する必要がない限り、この設定は初回のみで大丈夫です。

### とりあえず試しにデプロイ

`serverless.yml`にデプロイ先のリージョンの設定を追記すればとりあえずデプロイすることができます。


```yml:serverless.yml

# 抜粋
provider:
  name: aws
  runtime: nodejs12.x
  region: us-east-2 #ここにデプロイ先のリージョンを指定します。（今回はオハイオを選択） 

```

これで、下記デプロイコマンドを打てばデプロイできます。（簡単！）

```
$ sls deploy
```

この状態では、`handler.js`に記載された`Lambda`関数のみがデプロイされた状態になります。
`Severless Flamework`を使えば面倒なコンソール処理をしなくてもコマンド一発でAWSバックエンドをデプロイできます。これは`serverless.yml`ファイルに記載された設定をよみこみ、`Cloud Formation`というAWSのサービスを使って環境を構築しているということのようです。


# DynamoDBでデータベースをつくる
### そもそも、なぜDynamoDB？
ユーザー情報を管理するためにはデータベースが必要です。個人的にはSQLの方がユーザーデータを管理しやすいと思っているのですが、[LambdaとRDSは相性が悪いという噂](https://qiita.com/teradonburi/items/86400ea82a65699672ad)を聞いたので、今回はnoSQLである`DynamoDB`を使います。

### DynamoDBの設定
`serverless.yml`ファイルに設定を追記して`DynamoDB`の設定を行います。

まずは、AWS全体の設定についてです。

```yml:serverless.yml

# AWS周りの設定
provider:
  name: aws
  runtime: nodejs12.x 
  region: us-east-2　
  stage: dev
  environment: #環境変数をここに定義できます。今回は「DYNAMODB_TABLE」という環境変数を定義しています。
    DYNAMODB_TABLE: ${self:service}-${self:provider.stage} #「self」とは、このファイルに記載されているクラスそのものです。
  iamRoleStatements: #ここでは、Lambda関数にDynamoDBへのアクセス権限を与えています。
    - Effect: Allow
      Action:
        - dynamodb:Query #条件検索
        - dynamodb:Scan #全件取得
        - dynamodb:GetItem #一件取得
        - dynamodb:PutItem #一件登録
        - dynamodb:UpdateItem #修正
        - dynamodb:DeleteItem #削除
      Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.DYNAMODB_TABLE}*"

```

そして、`DynamoDB`の設定をします。

```yml:serverless.yml

# DynamoDBの設定
resources:
  Resources:
    Protein:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.DYNAMODB_TABLE}-protein 
        #作成するデーブル名です。どんな名前でもいいのですが、「line-bot-dev-protein」としています。
        AttributeDefinitions:
          - AttributeName: userId
            AttributeType: S
          - AttributeName: sentAt
            AttributeType: N
        KeySchema:
          - AttributeName: userId
            KeyType: HASH
          - AttributeName: sentAt
            KeyType: RANGE
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

```
* まずは適当なテーブル名を付けます。
今回は「line-bot-dev-protein」としました。

* `AttributeDefinitions`と`KeySchema`は非常に大事です。
主キー（HASH）とソートキー（RANGE）を組み合わせて、一意に定まるように設定します。それ以外の記録情報（今回はタンパク質摂取量）は定義する必要はありません。ここら辺がSQLとの大きな違いですね。
![スクリーンショット 2020-08-15 11.59.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/8eebcb31-a342-a63e-40cc-22f680cb0cd9.png)

### テーブルをデプロイする
追記した`serverless.yml`をデプロイするとテーブルが作成されているのが分かります。

```
$ sls deploy
```
![スクリーンショット 2020-08-15 12.06.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/b296d0ed-8f8c-28a5-427b-f268fde2ef7e.png)


# API-GatewayでLambda関数を公開する
今回、Lambdaで実装したい関数を`logProtein`として、

1. LINEでユーザーからメッセージ（タンパク質摂取量）が送られてくる
2. DynamoDBのデーブルからユーザーの情報（24時間以内のタンパク質摂取量）をよみとる
3. ユーザーにメッセージを返す


という内容としますが、Lambda関数を記述する前に、API-Gatewayを使ってLambda関数を使って外部から関数を呼び出せるようにします。

`serverless.yml`のLambda設定部分を記述します。

```yml:serverless.yml
# Lambdaの設定
functions:
  logProtein:
    handler: handler.logProtein # handler.jsにlogProteinという言う関数を定義してLambdaにデプロイします。
    environment: # Lambda関数で有効な環境変数を定義。handler.jsで参照する。
      TableName: ${self:provider.environment.DYNAMODB_TABLE}-protein
    events:
      - http:
          path: protein #アクセスするときのPath
          method: post #HTTPメソッドを指定
```
`logProtein`の概形を記述します。

```javascript:handler.js
"use strict";

module.exports.logProtein = async (event, context) => {
};

```

この状態でデプロイすると、

```
$ sls deploy
```

API-Gatewayに`dev-line-bot`というAPIが作成されており、`/protein`というエンドポイントに`logProtein`というLambda関数と統合されていることが分かります。
![スクリーンショット 2020-08-15 13.27.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/a5873233-4310-81bb-ba2d-6ca50e49b02f.png)

#Lambda関数を記述する
[前に作ったLINEのオウム返しbot](https://qiita.com/theFirstPenguin/items/8168e8ba3d65e34c24e0)を発展させて、
1. LINEでユーザーからメッセージ（タンパク質摂取量）が送られてくる
2. DynamoDBのデーブルからユーザーの情報（24時間以内のタンパク質摂取量）をよみとる
3. ユーザーにメッセージを返す
という内容を記述します。

[前回](https://qiita.com/theFirstPenguin/items/8168e8ba3d65e34c24e0#lambda%E3%81%AE%E8%A8%AD%E5%AE%9A)からの変更部分にコメントを加えていきます。

```javascript:handler.js
"use strict";

const line = require("@line/bot-sdk");
const crypto = require("crypto");
const client = new line.Client({
  channelAccessToken: process.env.ACCESSTOKEN,
});

const AWS = require("aws-sdk");
// AWSのSDKをインポート
const dynamo = new AWS.DynamoDB.DocumentClient();
// DynamoDBと接続
const TableName = process.env.TableName;
// 接続先のテーブル名を設定。ymlファイルに記述された環境変数を参照している。

module.exports.logProtein = async (event, context) => {
  const body = JSON.parse(event.body);

  const signature = crypto
    .createHmac("sha256", process.env.CHANNELSECRET)
    .update(event.body)
    .digest("base64");
  const checkHeader = (event.headers || {})["X-Line-Signature"];

  if (checkHeader === signature) {
    if (body.events[0].replyToken === "00000000000000000000000000000000") {
      let lambdaResponse = {
        statusCode: 200,
        headers: { "X-Line-Status": "OK" },
        body: '{"result":"connect check"}',
      };
      context.succeed(lambdaResponse);
      
    } else {
      try {
        const userId = body.events[0].source.userId;
        const sentAt = body.events[0].timestamp;
        const protein = Number(body.events[0].message.text);
        // ユーザーからのリクエストに含まれるWebhookイベントオブジェクトの情報をよみこむ。
        // https://developers.line.biz/ja/reference/messaging-api/#webhook-event-objectsを参照
        
        const yesterday = sentAt - 24 * 3600 * 1000;
        // リクエストが送られてきた24時間前のタイムスタンプを指定。

        const putParams = {
          TableName,
          Item: {
            userId,
            sentAt,
            protein,
          },
        };
        const putResult = await dynamo.put(putParams).promise();
        // リクエストに含まれるタンパク質摂取量をDynamoDBに書き込む。詳細下記。

        const queryParams = {
          TableName,
          ExpressionAttributeValues: { ":y": yesterday, ":u": userId },
          KeyConditionExpression: "userId = :u and sentAt > :y",
        };
        const result = await dynamo.query(queryParams).promise();
        const totalProtein = result.Items.map((item) => item.protein).reduce(
          (a, b) => a + b
        );
        // ユーザーが24時間以内に摂取したタンパク質の量ををDynamoDBから読み込む。詳細下記。

        const message1 = {
          type: "text",
          text: `この24時間で${totalProtein}gのタンパク質を摂取したぞ`,
        };
        const message2 =
          totalProtein < 100
            ? {
                type: "text",
                text: `引き続き、高タンパク/低脂質/低糖質の食事を心掛けろ`,
              }
            : {
                type: "text",
                text: `タンパク質の摂りすぎも禁物だ。過剰摂取は腸内環境を悪化させる危険があるぞ`,
              };

        return client
          .replyMessage(body.events[0].replyToken, [message1, message2]) //ユーザーにメッセージを返信する
          .then((response) => {
            let lambdaResponse = {
              statusCode: 200,
              headers: { "X-Line-Status": "OK" },
              body: '{"result":"completed"}',
            };
            context.succeed(lambdaResponse);
          })
          .catch((err) => console.log(err));
      } catch (error) {
        return {
          statusCode: error.statusCode,
          body: error.message,
        };
      }
    }
  } else {
    console.log("署名認証エラー");
  }
};


```

* リクエストに含まれるタンパク質摂取量をDynamoDBに書き込む
`DynamoDB`にデータを1件書き込むためには、`put`メソッドを使います。
詳細は[公式ドキュメント](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#put-property)を参照ください。

* ユーザーが24時間以内に摂取したタンパク質の量ををDynamoDBから読み込む
今回は、「条件に合致するデータすべて」を取得するために、`query`メソッドを使っています。
今回の条件とは、「①任意のユーザーの」「②24時間以内の」すべてのデータとなるため、下記のようにパラメータを設定しました。
詳細は[公式ドキュメント](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#query-property)を参照ください。

```javascript

const queryParams = {
  TableName,
  ExpressionAttributeValues: { ":y": yesterday, ":u": userId },
  KeyConditionExpression: "userId = :u and sentAt > :y",
};
```

最後に、LINEの`Messaging API`で使っているアクセストークンとチャンネルシークレットをymlファイルに追記します。
（Githubにあげる場合は、ymlファイルに書かずにLambdaコンソールで直接入力するのがいいと思います。）

```yml:serverless.yml
provider:
  name: aws
  runtime: nodejs12.x
  region: us-east-2
  stage: dev
  environment:
    DYNAMODB_TABLE: ${self:service}-${self:provider.stage}
    ACCESSTOKEN: your-access-token #ここにアクセストークンを記述
    CHANNELSECRET: your-channel-secret #ここにチャンネルシークレットを記述

```

これで、デプロイして[LINEのWebhookのurlを設定](https://qiita.com/theFirstPenguin/items/8168e8ba3d65e34c24e0#line-webhook%E3%81%AE%E8%A8%AD%E5%AE%9A)すれば完成です。

# Serverless Frameworkを使ってローカル環境で動作確認する
アプリ自体は、上記手順の後に、デプロイして[LINEのWebhookのurlを設定](https://qiita.com/theFirstPenguin/items/8168e8ba3d65e34c24e0#line-webhook%E3%81%AE%E8%A8%AD%E5%AE%9A)すれば完成なのですが、`Serverless Flamework`の素晴らしい点は、なんと言ってもローカルで動作確認できる点です。
以下のように、AWSバックエンドで`API-Gateway`/`Lambda`/`DynamoDB`の動作をローカルで確認することができます。

### ローカル開発用のライブラリをインストール
ライブラリをインストールし、プラグインの設定`serverless.yml`に追記します。

```
$ yarn add -D serverless-dynamodb-local serverless-offline
```

```yml:serverless.yml
plugins:
  - serverless-dynamodb-local
  - serverless-offline
```
###動作確認用データのseedファイルを作成
ルートディレクトリに`seeds`フォルダを作成して`protein.json`を作成します。

```json:seeds/protein.json
[
  {
    "userId": "Hanako",
    "sentAt": 1597126641204,
    "protein": 50
  },
  {
    "userId": "Taro",
    "sentAt": 1597126631204,
    "protein": 60
  },

  {
    "userId": "Jiro",
    "sentAt": 1597126241204,
    "protein": 70
  }
]
```

### ローカル開発のための設定
下記の設定を`serverless.yml`に追記し、`handler.js`のSDKインポート部分を修正します。

```yml:serverless.yml
custom:
  serverless-offline:
    httpPort: 8083 # http://localhost:8083にAPIのエンドポイントを設定する
  dynamodb:
    stages: dev
    start:
      port: 8082 # http://localhost:8082でデータベースと接続する
      inMemory: true
      migrate: true
      seed: true
    seed:
      protein:
        sources: # データベースと接続し、seedファイルのデータを書き込む
          - table: ${self:provider.environment.DYNAMODB_TABLE}-protein
            sources: [./seeds/protein.json] 
```

```javascript:handler.js
const options = process.env.LOCAL
  ? { region: "localhost", endpoint: "http://localhost:8082" }
  : {};
// 環境変数LOCALがtrueの場合は、ローカルにデータベースを作成してhttp://localhost:8082で接続する

const dynamo = new AWS.DynamoDB.DocumentClient(options);
```

その後、下記コマンドによりローカルでAPIの動作確認をすることができます。
（環境変数`LOCAL`にtrueを代入したのち、ローカル環境を構築します。）

```
$ LOCAL=true sls offline start
```
今回、AWSのバックエンドの動作確認をローカルですることができますが、LINEのWebhookの動作確認をオフラインですることが出来ないので、`handler.js`を少し書き換えます。

```javascript:handler.js
"use strict";

const line = require("@line/bot-sdk");
const crypto = require("crypto");
const client = new line.Client({
  channelAccessToken: process.env.ACCESSTOKEN,
  channelSecret: process.env.CHANNELSECRET,
});

const options = process.env.LOCAL
  ? { region: "localhost", endpoint: "http://localhost:8082" } 
  : {};

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

module.exports.logProtein = async (event, context) => {
  const body = JSON.parse(event.body);

  const signature = crypto
    .createHmac("sha256", process.env.CHANNELSECRET)
    .update(event.body)
    .digest("base64");
  const checkHeader = (event.headers || {})["X-Line-Signature"];

  // if (checkHeader === signature) {
  if (true) { // cryptの検証をしない
    if (body.events[0].replyToken === "00000000000000000000000000000000") {
      let lambdaResponse = {
        statusCode: 200,
        headers: { "X-Line-Status": "OK" },
        body: '{"result":"connect check"}',
      };
      context.succeed(lambdaResponse);
    } else {
      try {
        const userId = body.events[0].source.userId;
        const sentAt = body.events[0].timestamp;
        const protein = Number(body.events[0].message.text);
        const yesterday = sentAt - 24 * 3600 * 1000;
        const queryParams = {
          TableName,
          ExpressionAttributeValues: { ":y": yesterday, ":u": userId },
          KeyConditionExpression: "userId = :u and sentAt > :y",
        };

        const putParams = {
          TableName,
          Item: {
            userId,
            sentAt,
            protein,
          },
        };
        const putResult = await dynamo.put(putParams).promise();
        const result = await dynamo.query(queryParams).promise();
        const totalProtein = result.Items.map((item) => item.protein).reduce(
          (a, b) => a + b
        );

        const message1 = {
          type: "text",
          text: `この24時間で${totalProtein}gのタンパク質を摂取したぞ`,
        };
        const message2 =
          totalProtein < 100
            ? {
                type: "text",
                text: `引き続き、高タンパク/低脂質/低糖質の食事を心掛けろ`,
              }
            : {
                type: "text",
                text: `タンパク質の摂りすぎも禁物だ。過剰摂取は腸内環境を悪化させる危険があるぞ`,
              };

        
        // ユーザーへの返信をしない
        // return client
        //   .replyMessage(body.events[0].replyToken, [message1, message2])
        //   .then((response) => {
        //     let lambdaResponse = {
        //       statusCode: 200,
        //       headers: { "X-Line-Status": "OK" },
        //       body: '{"result":"completed"}',
        //     };
        //     context.succeed(lambdaResponse);
        //   })
        //   .catch((err) => console.log(err));

        // ユーザへの返信の代わりにレスポンスを返す 
        let lambdaResponse = {
          statusCode: 200,
          headers: { "X-Line-Status": "OK" },
          body: JSON.stringify([message1, message2]),
        };
        context.succeed(lambdaResponse);

      } catch (error) {
        return {
          statusCode: error.statusCode,
          body: error.message,
        };
      }
    }
  } else {
    console.log("署名認証エラー");
  }
};
```
### エンドポイントにリクエストを送って動作確認
[postman](https://www.postman.com/)などを使って`http://localhost:8083/dev/protein`にリクエストを送ればローカルで動作確認することができます。
![スクリーンショット 2020-08-15 15.22.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/dded958c-9a14-7313-64ae-3d1bdb39c11f.png)

# 最後に
ずいぶん長くなってしまいましたが、初学者にもやさしい`Serverless Flamework`を使ったAWSバックエンド開発の素晴らしさを伝えたかったです。
お読みいただきありがとうございました。

# 参考にさせていただいた記事
参考、というか下記の内容をそのままコピーした感じになってしまいました。
初学者にも分かりやすくまとめて頂いています。感謝です。

* [AWS Lambda入門①(Node編)〜関数をデプロイして動かす〜](https://qiita.com/ozaki25/items/883aec0ab4398aa4abbe)
* [AWS Lambda入門②(Node編)〜DynamoDBにアクセスする〜](https://qiita.com/ozaki25/items/d3c5b5e6954d5b4af95a)
* [AWS Lambda入門③(Node編)〜API Gatewayで関数を公開する〜](https://qiita.com/ozaki25/items/472f3419a527c0c7633c)
* [Serverless Frameworkの使い方まとめ](https://qiita.com/horike37/items/b295a91908fcfd4033a2)
* [【詳解】JavascriptでDynamoDBを操作する](https://qiita.com/Fujimon_fn/items/66be7b807a8329496899)