AWS Lambda + DynamoDB + CloudWatchでQiitaにストックした記事をSlackに定期通知する

## 概要
- AWSサービスを触って学びたい
- 毎週土曜日にメールで通知されるQiitaの「今週ストックした記事」をSlackに通知したい

## 使用するもの

|サービス|用途|
|---|---|
|**[AWS Lambda](https://aws.amazon.com/jp/lambda/)**|Qiita APIからストックした記事を取得してSlackに通知する用|
|**[Amazon DynamoDB](https://aws.amazon.com/jp/dynamodb/)**|Qiita APIから取得した記事には「いつストックしたか」の情報がないため通知情報が重複してしまう。これを防ぐために前回通知した情報を保持しておく|
|**[Amazon CloudWatch](https://aws.amazon.com/jp/cloudwatch/)**|Lambdaの定期実行用|
|**[AWS SAM（サーバレスアプリケーションモデル）](https://aws.amazon.com/jp/serverless/sam/)**|ローカルで構築したアプリケーションを各サービスにデプロイする用|
|**[Qiita API](https://qiita.com/api/v2/docs)**|ストックした情報を取得する用|

#### 全体図

↑をまとめると以下のようになります。
![Slack App (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/08c2057f-a538-33f4-7008-4ba8c2a973f2.png)

## アジェンダ
[0. 事前準備](#0-事前準備)
[1. テンプレートを作成](#1-テンプレートを作成)
[2. SAMを用いてデプロイ](#2-samを用いてデプロイ)
[3. Slackに通知](#3-slackに通知)
[4. Qiita APIを使用してストック情報をSlackに通知](#4-qiita-apiを使用してストック情報をslackに通知)
[5. DynamoDBを使用して通知履歴の管理](#5-dynamodbを使用して通知履歴の管理)
[6. CloudWatchで任意の時間にLambdaを定期実行させるようにする](#6-cloudwatchで任意の時間にlambdaを定期実行させるようにする)

## 0. 事前準備
#### AWS

以下が未作成、未インストールの場合は適宜行います。

- AWSアカウントの作成
- **AWS CLI** のインストール
    - [macOS に AWS CLI バージョン 1 をインストールする - AWS Command Line Interface](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/install-macos.html)
- **AWS SAM CLI** のインストール
    - [macOS への AWS SAM CLI のインストール - AWS サーバーレスアプリケーションモデル](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-sam-cli-install-mac.html)

#### Slack

通知させたいチャンネルに`webhook`アプリをインストールして動作確認しておきます。
参考: [SlackのWebhook URL取得手順 - Qiita](https://qiita.com/vmmhypervisor/items/18c99624a84df8b31008)

## 実装

## 1. テンプレートを作成

samコマンドでテンプレートを作成します。

```shell
$ sam init -n qiita-stocks-notifier -r ruby2.5
```

|オプション|説明|
|---|---|
|-n, --name TEXT|**init**コマンドで作成されるプロジェクトの名前。|
|-r, --runtime|ランタイムの指定。今回は**ruby**にしました。|

参考: [sam init - AWS サーバーレスアプリケーションモデル](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-init.html)

コンソール内で聞かれる質問はデフォルトを選択しました。
コマンドが完了すると以下の構造のテンプレートがディレクトリ配下に作成されています。

```shell
$ tree
.
├── Gemfile
├── README.md
├── events
│   └── event.json
├── hello_world
│   ├── Gemfile
│   └── app.rb
├── template.yaml
└── tests
    └── unit
        └── test_handler.rb
```

**hello_world/app.rb** の中身はメッセージを出力するだけになっています。

## 2. SAMを用いてデプロイ

テンプレートが作成出来たのでLambda上にデプロイしてみます。
参考: [チュートリアル: Hello World アプリケーションのデプロイ - AWS サーバーレスアプリケーションモデル](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-getting-started-hello-world.html)

```shell
$ sam build
$ sam deploy --guided
# 質問に回答していきます。
```

デプロイが正常に完了するとコンソールに文字列が出力され流ことが確認出来ます。

```shell
 {"message": "hello world"}
```

Lambdaの管理画面にデプロイしたアプリケーションが反映されていることを確認します。
![ダウンロード (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/3f560d2d-e102-134c-420a-bd4b4ca855fd.png)

## 3. Slackに通知

**hello_world/Gemfile**に`slack-notifier`を追加して**bundle install** します。
[stevenosloan/slack-notifier: A simple wrapper for posting to slack channels](https://github.com/stevenosloan/slack-notifier)

```ruby
gem "slack-notifier"
```

```shell
$ bundle
```

`app.rb`を以下のように`slack-notifier`にHooksURLを渡して初期化し、テスト文言を送信するようにしてみます。

```app.rb
require "json"
require "slack-notifier"

def lambda_handler(event:, context:)
  notifier = Slack::Notifier.new "https://hooks.slack.com/services/**/**" do
    defaults channel: "#qiita-slacks-notification"
  end
  notifier.ping "yeah"
end
```

#### 3.1 デプロイして動作確認
修正が完了したらビルド＆デプロイして動作確認します。

```shell
$ sam build
$ sam deploy
```

Lambdaの管理画面にて「テストイベントの選択」 -> 「テストイベントの設定」を選択します。
![スクリーンショット 2020-06-07 5.37.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/566757dc-fab3-1ac9-7b9f-c4bee32bf67c.png)

作成したアプリケーションへのパラメータを選択する画面になりますが、今回はパラメータによって挙動を変えるものではないので
デフォルトのままにしておきます。
![スクリーンショット 2020-06-07 5.38.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/7a5caed9-100c-2690-1c86-55e398a269bd.png)

「テストイベントの設定」が完了したら「テスト」を選択し、Slackにメッセージが送信されることを確認します。
![スクリーンショット 2020-06-07 1.02.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/c2bee203-c9dd-fcf8-962f-4db14e2dfbe0.png)

これで**Lambda**からSlackへの疎通確認は出来ました。

## 4. Qiita APIを使用してストック情報をSlackに通知

続いて`Qiita API`からユーザーのストックを取得して通知するようにします。

#### 4.1 APIドキュメントの確認
ユーザーのストックした記事を取得するAPIはこちらにありました。
[Qiita API v2ドキュメント - Qiita:Developer](https://qiita.com/api/v2/docs#get-apiv2usersuser_idstocks)

外部APIとのやり取りを楽に行うために **`Faraday`** gemを入れておきます。
参考: [lostisland/faraday: Simple, but flexible HTTP client library, with support for multiple backends.](https://github.com/lostisland/faraday)

```ruby
gem "faraday"
```

```shell
$ bundle
```

#### 4.2 通知部分の実装

実装部分の概要は以下になります。

- **Faraday** を使用してAPIからストック情報を取得
- レスポンスを **タイトル** と **URL** 毎に配列にまとめてSlackに通知

```app.rb
require 'json'
require 'faraday'
require 'slack-notifier'

USER_ID = "{自分のユーザーID}".freeze

def lambda_handler(event:, context:)
  # APIからストック情報を取得
  response = Faraday.get("https://qiita/api/v2/user/#{USER_ID}/stocks", page: 1, per_page: 10)

  # タイトルとURLを配列にまとめる
  messages = JSON.parse(response.body).each_with_object([]) do |res, ary|
    ary << [res["title"], res["url"]]
  end

  notifier = Slack::Notifier.new "https://hooks.slack.com/services/**/**" do
    defaults channel: "#qiita-slacks-notification"
  end

  notifier.ping "先週Qiitaでストックした記事を流すよ"
  messages.each do |message|
    notifier.ping message[0]
    notifier.ping message[1]
    notifier.ping "- - - - - - - - - - - - - - - - - - - - "
  end
end
```

修正が完了したら、ローカルでもアプリのテストが出来る`sam local invoke`を使用して動作確認してみます。

```shell
sam local invoke --no-event
```

|オプション|説明|
|---|---|
|--no-event|空のイベントを使用して関数を呼び出します。パラメータを渡して確認する場合は別のオプションを使用します。 |
参考: [sam local invoke - AWS サーバーレスアプリケーションモデル](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-invoke.html)

コマンドを実行するとSlackにストック情報が送信されていることを確認出来ます。

![スクリーンショット 2020-06-07 2.08.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/8ac16892-1d3a-5746-6de7-88876f6e759f.png)

## 5. DynamoDBを使用して通知履歴の管理
通知部分は出来ましたがこのままですと毎回同じ情報が流れてくる可能性があるので、一度通知した記事は通知させないようにしたいです。
**`DynamoDB`** を用いて通知情報の登録と、データベース内にある情報は通知させないようにします。

DynamoDBとのデータのやり取りは **`aws-record`** gemを使用します。
[aws/aws-sdk-ruby-record: Official repository for the aws-record gem, an abstraction for Amazon DynamoDB.](https://github.com/aws/aws-sdk-ruby-record)

#### 5.1 実装

`template.yml`

```diff
AWSTemplateFormatVersion: '2010-09-09'
...
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: ruby2.5
      Policies:
+     # DynamoDBへCreate/Read/Update/Deleteする権限を付与
+     - DynamoDBCrudPolicy:
+         TableName: !Ref NotifiedMessageDDBTable
+     # 環境変数
+     Environment:
+       Variables:
+         DDB_TABLE: !Ref NotifiedMessageDDBTable
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get
+ NotifiedMessageDDBTable:
+   Type: AWS::Serverless::SimpleTable
...
```

```app.rb
require 'aws-record'
require 'json'
require 'faraday'
require 'slack-notifier'

USER_ID = "{自分のユーザーID}".freeze

class NotifiedMessage
  include Aws::Record
  set_table_name ENV['DDB_TABLE']
  string_attr :id, hash_key: true
  string_attr :title
  string_attr :url
end

def lambda_handler(event:, context:)
  # 通知済みの情報を取得
  already_notified_messages = []
  NotifiedMessage.scan.each do |notified_message|
    already_notified_messages << notified_message.url
  end

  # APIからストックした記事を取得し、未通知のもののみを配列にまとめる
  response = Faraday.get("https://qiita.com/api/v2/users/#{USER_ID}/stocks", page: 1, per_page: 10)
  messages = JSON.parse(response.body).each_with_object([]) do |res, ary|
    ary << [res["title"], res["url"]] unless already_notified_messages.include? res["url"]
  end

  notifier = Slack::Notifier.new "https://hooks.slack.com/services/xx/xx" do
    defaults channel: "#qiita-stocks-notification"
  end
  notifier.ping "今週ストックした記事はなかったよ" and return if messages.size.zero?

  # Slackに通知とデータベースに保存
  notifier.ping "先週Qiitaでストックした記事を流すよ"
  messages.each do |message|
    notifier.ping message[0]
    notifier.ping message[1]
    notifier.ping "- - - - - - - - - - - - - - - - - - - -"

    notified_message = NotifiedMessage.new(id: SecureRandom.uuid, title: message["title"], url: message["url"])
    notified_message.save!
  end
end
```

これをデプロイしてテストすると、DynamoDBの管理画面にて追加したテーブルの確認、また最新のストック情報が１０件挿入されていることが出来ます。

![スクリーンショット 2020-06-07 3.05.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/88f12f4b-10f0-649b-ba52-d62f846af8e6.png)
![ダウンロード (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/72d81da2-3859-db83-0134-9dfcaf89ffb8.png)


## 6. CloudWatchで任意の時間にLambdaを定期実行させるようにする

最後に、毎週いい感じの時間にSlackへの通知が来るようします。

#### 6.1 スケジュールの設定

**`トリガーを追加`** を選択し、検索から「**CloudWatch Events/EventBridge**」を選択すると実行に関するルールを設定出来るので以下のように設定しました。
![スクリーンショット 2020-06-07 4.29.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/a4b6691e-b4a7-48fd-7f22-b57fcccba4cf.png)

スクショが漏れていましたが、**スケジュール式**の箇所は正しくは`cron(0 12 ? * SAT *)`（毎週土曜12時）で設定しました。 
参考: [Rate または Cron を使用したスケジュール式 - AWS Lambda](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/services-cloudwatchevents-expressions.html)

CloudWatchからschedulerを確認すると、以下のように**毎週土曜日12時**に実行されることが確認できます。
![スクリーンショット 2020-06-07 4.34.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239966/17d6fba2-6d3d-1b4b-3636-ac564e5831eb.png)

## まとめ
これで毎週Qiitaからのストック通知を受け取ることが出来ました。
しかしながら、このままですとDynamoDBの読み込み数が膨れ上がっていきお財布がピンチになるので`scan`に`query`を渡して取得する情報を絞り込む必要があります。
AWSのサービスは触ってみて始めて分かることも多いので、とても勉強になりました。

間違い等ありましたらご指摘頂けると幸いです。

## 参考記事
#### 公式ドキュメント
- [sam init - AWS サーバーレスアプリケーションモデル](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-init.html)
- [sam local invoke - AWS サーバーレスアプリケーションモデル](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-invoke.html)
- [Amazon DynamoDB の例 - AWS SDK for Ruby](https://docs.aws.amazon.com/ja_jp/sdk-for-ruby/v3/developer-guide/dynamo-examples.html)

#### 参考にさせて頂いたブログ
- [AWS LambdaでRubyがサポートされたのでSlackBotで超簡単に試す - Qiita](https://qiita.com/tachibanayu24/items/6acdc5d5b897a59d7c91)
- [RubyでLambdaからDynamoDBにデータ登録する - 虎の穴開発室ブログ](https://toranoana-lab.hatenablog.com/entry/2019/03/06/200454)