AWSとSlackでサーバレスに今日の献立を教えてくれるのを作った

SREチームの[hisamura333](https://qiita.com/hisamura333)です。[クラウドワークス Advent Calendar 2019](https://qiita.com/advent-calendar/2019/crowdworks) 21日目の記事です。


みなさん日々たくさんの意思決定をされており、判断、決断することの難しさを感じていると思います。
私もそうです。

日々難題に迫られており、それをなんとか回避できないかと思って、作成したものを紹介できればと思います。
私の難題は、妻からの

「*今日の夕飯、何か食べたいのある？*」

。。。

# 作ったもの
この難題に私の代わりに、良い感じに答えてくれるものをAWSとSlackを使って作成してみました。

### 今日の献立候補を教えてくれる
まず使用のイメージです。
`/select` コマンドを入力すると、妻が作れるメニューの中から、今日の献立の候補をいくつか提案してくれます。
![fff2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/70617a94-4db8-5fe8-6c3c-3c8e493e5ce1.gif)




これが投稿されたのです。
こんな感じでランダムにメニューと、妻がよく使うレシピアプリのURLを表示します。
妻が作れるメニューを保存してあるので、そこからランダムに表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/7eec162d-6bd8-595e-6a1a-1a5baf5fb4c3.png)
一部のメニューは、アプリとは別のサイトでレシピを確認するそうなので、その時は指定のURLを表示します。
ちなみに言葉は複数パターン用意し、ランダムで表示するようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/c35fd013-491b-6576-80f1-dc87fa9b0f9f.png)
### 献立の追加

表示されるメニューは増やしていきたいですよね。

そんな時のために`/add 〇〇`コマンドを用意しました。
これで、私が介入することなくメニューを増やしていくことができます。
作れる料理が増えることは偉大なので、しっかりとリアクションを返すようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/fbf9bd0d-f81a-d890-6ae2-1cd2a092d4d1.png)
### 作れるメニューを全て表示

メニューも今の所は50弱登録されているので（えらい！）、一覧が見たい時もあります。
その時のために `/list` を入力で全てのメニューを表示できるようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/bc3f7030-ec9b-e1b5-8e43-1a6de9ebd923.png)
# 使用している技術
ここではまず、使用している技術の全体像を共有できればと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/5aab938e-35e6-cf66-133f-b5fdcac1143d.png)




Slackで特定の入力をすると、API Gatewayに通信し、紐づいているLambdaが起動します。
LambdaはDynamoDBと通信を行います。必要な処理を行ったらLambdaからSlackに通知をします。


一つずつ簡単にサービスの説明をさせてください。
### Slash Commands 

Slack APIの機能で、Slackから外部のサービスにメッセージを送信するためのものです。（次で説明するIncoming Webhookも、slack Appを作成して設定しています）
今回は、API Gatewayにメッセージを送信しています。

以下が設定画面で、コマンド、リクエスト先等を指定します。
これで特定のコマンドを打てば、指定したリクエスト先に通信できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/29fb1932-e127-5f9e-5793-d7699dc0f6c6.png)
https://api.slack.com/interactivity/slash-commands

### Incoming Webhook 

Slack APIの機能で、外部サービスからSlackにメッセージを送信するものです。
LambdaからSlackに送信するときに使用しています。
上記のSlash Commandsのレスポンスに、値を付与することも可能ですが、Slash Commandsのリクエストは3秒でタイムアウトするので、時間がかかる場合は値を表示することができません。
よって、LambdaからIncoming Webhookを使いリクエストを投げるようにしています。

デモを見たら、すぐにレスポンスが返ってきていないことに気付くかと思われますが、１回目はより時間がかかり、タイムアウトになることが多いです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/24d59985-432e-9ac8-277a-6542d00b1248.png)
https://api.slack.com/messaging/webhooks



### API gateway 

Amazon API GatewayはAWSが提供する、バックエンドシステムへのAPIを作成するためのサービスです。
バックエンドはLambdaだけでなく、他のAWSサービスやHTTP(AWS以外でもパブリックに公開されているエンドポイントが存在するサービス)、Mock が選択できます。

今回は3つのリソースを作成し（`add ` , `list`, `select`）、それぞれに対応するLambdaを紐づけています。



![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/2bbd662e-9a6c-8f91-3d4c-cab1d2af5b32.png)



今回の記事では認証を行わないため、URLさえ知っていれば誰でもアクセスできる状態になります。
認証をする際は、API Gateway自体や、別のサービスを利用した認証方法が提供されています。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-control-access-to-api.html


### Lambda 

AWS Lambdaは、AWSの提供するサーバーレスコンピューティングサービスです。プログラムはクラウド上で実行され、ユーザー自身でサーバーや仮想サーバーを利用することはありません。汎用性が高く、さまざまなアプリケーションやサービスを実行できます。

今回は3つのLambda関数をgo言語で、作成しています。(`menu-add` , `menu-list`, `menu-select`)
下記で説明するDynamoDBに接続するために、それぞれのLambda関数にDynamoDBにアクセスする権限が必要なので、付与しています。(`test-dynamo` というDynamoDBにアクセスできる権限を付与したロールを作成しています)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/230db320-9989-6676-0d8a-02d79612a5e3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/ddc786ed-fe9a-a77f-2751-264ba48b6050.png)

### DynamoDB 

Amazon DynamoDBは AWS が提供する NoSQL データベースサービスです。 

メニューのデータを保存するために使用しています。
nameをプライマリキー(PK, Primary Key)に、categoryとurlを設定して保存しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149318/986636fa-4a50-3d5a-3342-3d93de3e81a2.png)




### AWSでサーバーレスアプリケーションをする時の基本パターン
今回実装するにあたり、数あるAWSのサービスの中から、自分で一つずつサービスを選択したというわけでありません。
使用しているサービスはサーバレスなアプリを作る時には基本的なものです。

参考として、AWS SAM（サーバーレスアプリケーションモデル ）というものが存在します。
>AWS サーバーレスアプリケーションモデル (AWS SAM、以前は Project Flourish と呼ばれていました) は、AWS CloudFormation を拡張して、サーバーレスアプリケーションで必要な Amazon API Gateway API、AWS Lambda 関数、および Amazon DynamoDB テーブルを定義する方法を簡略化します。

https://aws.amazon.com/jp/about-aws/whats-new/2016/11/introducing-the-aws-serverless-application-model/

ここで書かれているように、サーバーレスアプリケーションで必要なサービスとして、今回使用したサービスが挙げられています。


# 献立の表示、追加の実装
ここからは肝であるLambda関数に絞って、実装したポイントを説明していきます。
go言語を使用し、`aws-sdk-go`とLambdaに特化した`aws-lambda-go` ライブラリを使用して実装しています。

https://github.com/aws/aws-sdk-go
https://github.com/aws/aws-lambda-go

### Lambda関数の書き方
まずLambda関数の記述の仕方です。

サンプルが以下になります。
`aws-lambda-go`のREADMEに書かれているのをそのまま載せています。
https://github.com/aws/aws-lambda-go/blob/master/events/README_ApiGatewayEvent.md

```go

package main

import (
	"context"
	"fmt"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	fmt.Printf("Processing request data for request %s.\n", request.RequestContext.RequestID)
	fmt.Printf("Body size = %d.\n", len(request.Body))

	fmt.Println("Headers:")
	for key, value := range request.Headers {
		fmt.Printf("    %s: %s\n", key, value)
	}

	return events.APIGatewayProxyResponse{Body: request.Body, StatusCode: 200}, nil
}

func main() {
	lambda.Start(handleRequest)
}
```

この記述を参考に、今回実装したポイントを説明していきます。


### API Gatewayからの値の取得
まず API Gatewayから値を取得する部分です。

メニューを追加する際など、Slackから送られきた値を取得する必要があります。
SlackからAPI Gateway経由で送信されたリクエストは`events.APIGatewayProxyRequest`で取得することができます。


APIGatewayProxyRequestは以下の構造体になっております。

```go

// APIGatewayProxyRequest contains data coming from the API Gateway proxy
type APIGatewayProxyRequest struct {
	Resource                        string                        `json:"resource"` // The resource path defined in API Gateway
	Path                            string                        `json:"path"`     // The url path for the caller
	HTTPMethod                      string                        `json:"httpMethod"`
	Headers                         map[string]string             `json:"headers"`
	MultiValueHeaders               map[string][]string           `json:"multiValueHeaders"`
	QueryStringParameters           map[string]string             `json:"queryStringParameters"`
	MultiValueQueryStringParameters map[string][]string           `json:"multiValueQueryStringParameters"`
	PathParameters                  map[string]string             `json:"pathParameters"`
	StageVariables                  map[string]string             `json:"stageVariables"`
	RequestContext                  APIGatewayProxyRequestContext `json:"requestContext"`
	Body                            string                        `json:"body"`
	IsBase64Encoded                 bool                          `json:"isBase64Encoded,omitempty"`
}
```

上のREADMEでも`Headers`や`Body`、`RequestContext `を使用しているのがわかります。

Slackから送信される時に以下のデータが送られ、この値が`Body`の中に入っています。
`/add カレー`とした時は`text`に`%E3%82%AB%E3%83%AC%E3%83%BC`が設定されます。


```
token=gIkuvaNzQIHg97ATvDxqgjtO
&team_id=T0001
&team_domain=example
&enterprise_id=E0001
&enterprise_name=Globular%20Construct%20Inc
&channel_id=C2147483705
&channel_name=test
&user_id=U2147483697
&user_name=Steve
&command=/weather
&text=%E3%82%AB%E3%83%AC%E3%83%BC
&response_url=https://hooks.slack.com/commands/1234/5678
&trigger_id=13345224609.738474920.8088930838d88f008e0
```
https://api.slack.com/interactivity/slash-commands#app_command_handling

よって、以下のようにして追加したいメニュー名を取得することができます。

```go

import (
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "net/url"
    ...
 　 
)

func select(request events.APIGatewayProxyRequest) (Response, error) {
	parseQuery, _ := url.ParseQuery(request.Body)
	requestMenu := parseQuery.Get("text")            //key名にtextを指定して、値を取得

    ...
 
}

func main() {
	lambda.Start(select)
}
```

### DynamoDBから値の取得
今回はDynamoDBにデータを保持しているので、そこから値を取得する必要があります。
DynamoDBからのデータ取得には3つの方法があります。（ `GetItem`, `Query`, `Scan`）

・テーブルから単一の項目取得
 `GetItem`
・テーブルから複数項目取得
`Query`　（キーを条件に特定範囲内の検索）
`Scan`    　（テーブル全体を検索 、 負荷が大きい）
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/SQLtoNoSQL.ReadData.html


今回は`scan`メソッドを使用し、データの取得を行いました。
まず全件を取得する場合です。

```go
import (
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
    ...
)


func list(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	sess, err := session.NewSession()              // 新しいセッションを作成します
	if err != nil {
       TODO: エラーハンドリング
	}

 
	svc := dynamodb.New(sess)                      //セッションを引数に指定し、新しいDynamoDBクライアントを作成します
	scanInput := &dynamodb.ScanInput{              //テーブル名をmenuと指定し、スキャン操作の入力を作成します
		TableName: aws.String("menu"),            
	}
	scanInputResult, err := svc.Scan(scanInput)    //スキャン操作の入力を元にスキャンを実行します

    ...
}
```
これで、menuテーブルのすべての値を取得することができます。


条件をつけて値を取得したい場合には`expression`を使用して、取得したい値の条件を設定します。
ここではcategoryがmainのものを取得するように条件を設定しています。（mainが主菜、subが副菜）

![image.png](https://crowdworks.qiita.com/files/a5432357-7eac-1107-ab26-f59e13e12cf8.png)





```go
import (
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/dynamodb"
    "github.com/aws/aws-sdk-go/service/dynamodb/expression"              //追加されています
    ...
)

func list(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	sess, err := session.NewSession()
	if err != nil {
      TODO: エラーハンドリング
	}


	filt := expression.Name("category").Equal(expression.Value("main"))  //categoryがmainに一致するものを指定
	expr, err := expression.NewBuilder().                                // ↑の条件を設定して、expressionを作成
		WithFilter(filt).
		Build()

	svc := dynamodb.New(sess)
	scanInput := &dynamodb.ScanInput{
		TableName: aws.String("menu"),
		ExpressionAttributeNames:  expr.Names(),              //ExpressionAttributeNamesを追加
		ExpressionAttributeValues: expr.Values(),             //ExpressionAttributeValuesを追加
		FilterExpression: expr.Filter(),                      //FilterExpressionを追加
	}
	scanInputResult, err := svc.Scan(scanInput)       
    ...
}                           
```
`github.com/aws/aws-sdk-go/service/dynamodb/expression`を使用して、DynamoDBの取得条件を設定することができます。

https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/expression/#pkg-overview


### DynamoDBへの値の追加
続いてはDynamoDBに値の追加の場合です。
取得には3種類のメソッドがありましたが、追加は`PutItem `だけです。
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/SQLtoNoSQL.WriteData.html

```go
import (
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/dynamodb"
    ...
)

func add(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	sess, err := session.NewSession()
	if err != nil {
      TODO: エラーハンドリング
	}

	svc := dynamodb.New(sess)
	putParams := &dynamodb.PutItemInput{                               //DynamoDBに追加する値を作成
		TableName: aws.String("menu"),
		Item: map[string]*dynamodb.AttributeValue{
			"name": {
				S : aws.String(requestMenu),                           //nameにはSlackで入力した値が入るrequestMenuを設定
			},
			"category" : {
				S : aws.String("main"),                                //categoryにはmainを設定
			},
		},
	}
	_, err = svc.PutItem(putParams)                                    //PutItemInputの入力を元にPutItemを実行します

    ...
}
```
DynamoDB の型は以下のようになっています。
menuとcategoryは文字列で保存するようにしているため、`S`の型をしています。

```
N（数値型）
S (文字列型)
BOOL (ブール型)、0 または 1。
B（バイナリ型）
S (文字列型)。Date の値は、ISO-8601 形式の文字列として格納されます。
SS（文字列セット）型、NS（数値セット）型、または BS（バイナリセット）型。
```
 https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBMapper.DataTypes.html 

### Slack(Incoming Webhook)への通知
最後に
必要な処理を終えたら、Slackに通知を行います。
selectを実行した時にメニューとURLを返したい時には以下のようにしています。

```go
import (
    "bytes"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "net/http"
    ...
)

func select(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    phrase := "今日のオススメは"                                          // ここではわかりやすさのために固定値を入れています
	firstItem := "カレー"                  
	secondItem := "グラタン"
    kurashiru := "https://www.kurashiru.com/search?query="            //妻がよく使うレシピアプリのURL

	message :=`{　　　　　　　　　                                 　 
        "text":"` + phrase +                                           //メッセージはjsonでkeyをtextにします 
		"*" + firstItem + "*\n" +
		"*" + secondItem + "*\n" +
		kurashiru + firstItem + "\n" +
		kurashiru + secondItem + `"
     }`

	req, err := http.NewRequest(
		"POST",
		"https://hooks.slack.com/services/XXXXXXXXXXXXXX",             //SlackのIncoming Webhookで取得した値を設定
		bytes.NewBuffer([]byte(message)),                              //messageにSlackに通知するための情報が入っています
	)
	if err != nil {
 	  TODO: エラーハンドリング
	}

	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		panic(errors.New(err.Error()))
	}

	defer resp.Body.Close()

    ...
}
```

postをする際のURLにIncoming Webhookで取得した値を設定することで、Slackに通知が飛ぶようになります。


# まとめ
SlackとAWSを使ったサーバーレスアプリの全体像と、go言語のLambda実装を記事にしてみました。
作ったものは単純なものですが、自分で手を動かすことで理解が深まりました。

同じようなものを作ろうとした時の参考になれば幸いです。



# 妻の使ってみた感想
良き。（以上です）