LoginSignup
3
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-15

はじめに

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

Bot内容

下記のように、ユーザーが送ったタンパク質摂取量を記録できるbotをつくりました。
↓一応友達登録もできます。(思いついたタイミングで勝手に消去する可能性があります。)
スクリーンショット 2020-08-14 20.27.13.png

スクリーンショット 2020-08-14 19.17.13.png

やったこと

Serverless Flameworkを使って、ローカルPC上でサーバレスのAWSのバックエンドを構築した後デプロイし、LINEのbotをつくりました。
イメージはこんな感じです。
スクリーンショット 2020-08-14 21.36.24.png

手順

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

事前準備

LINEデベロッパー登録

こちらを参照ください。

Serverless Flameworkのインストール

  • ローカルPCにグローバルインストール
$ npm i -g serverless
  • 動作確認
$ sls -v

(slsserverlessのどちらのコマンドでも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

まずはIAMコンソールでアクセスキーを作成します。
このアクセスキーは慎重に扱い、くれぐれもGithubなどにupなさらぬよう。。
スクリーンショット 2020-08-15 9.46.07.png

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

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

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

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

serverless.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は相性が悪いという噂を聞いたので、今回はnoSQLであるDynamoDBを使います。

DynamoDBの設定

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

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

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の設定をします。

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」としました。

  • AttributeDefinitionsKeySchemaは非常に大事です。
    主キー(HASH)とソートキー(RANGE)を組み合わせて、一意に定まるように設定します。それ以外の記録情報(今回はタンパク質摂取量)は定義する必要はありません。ここら辺がSQLとの大きな違いですね。
    スクリーンショット 2020-08-15 11.59.52.png

テーブルをデプロイする

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

$ sls deploy

スクリーンショット 2020-08-15 12.06.11.png

API-GatewayでLambda関数を公開する

今回、Lambdaで実装したい関数をlogProteinとして、

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

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

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

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の概形を記述します。

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

Lambda関数を記述する

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

前回からの変更部分にコメントを加えていきます。

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メソッドを使います。
    詳細は公式ドキュメントを参照ください。

  • ユーザーが24時間以内に摂取したタンパク質の量ををDynamoDBから読み込む
    今回は、「条件に合致するデータすべて」を取得するために、queryメソッドを使っています。
    今回の条件とは、「①任意のユーザーの」「②24時間以内の」すべてのデータとなるため、下記のようにパラメータを設定しました。
    詳細は公式ドキュメントを参照ください。


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

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

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を設定すれば完成です。

Serverless Frameworkを使ってローカル環境で動作確認する

アプリ自体は、上記手順の後に、デプロイしてLINEのWebhookのurlを設定すれば完成なのですが、Serverless Flameworkの素晴らしい点は、なんと言ってもローカルで動作確認できる点です。
以下のように、AWSバックエンドでAPI-Gateway/Lambda/DynamoDBの動作をローカルで確認することができます。

ローカル開発用のライブラリをインストール

ライブラリをインストールし、プラグインの設定serverless.ymlに追記します。

$ yarn add -D serverless-dynamodb-local serverless-offline
serverless.yml
plugins:
  - serverless-dynamodb-local
  - serverless-offline

動作確認用データのseedファイルを作成

ルートディレクトリにseedsフォルダを作成してprotein.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インポート部分を修正します。

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] 
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を少し書き換えます。

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などを使ってhttp://localhost:8083/dev/proteinにリクエストを送ればローカルで動作確認することができます。
スクリーンショット 2020-08-15 15.22.06.png

最後に

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

参考にさせていただいた記事

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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2