LoginSignup
26
8

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-20

SREチームのhisamura333です。クラウドワークス Advent Calendar 2019 21日目の記事です。

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

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

今日の夕飯、何か食べたいのある?

。。。

作ったもの

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

今日の献立候補を教えてくれる

まず使用のイメージです。
/select コマンドを入力すると、妻が作れるメニューの中から、今日の献立の候補をいくつか提案してくれます。
fff2.gif

これが投稿されたのです。
こんな感じでランダムにメニューと、妻がよく使うレシピアプリのURLを表示します。
妻が作れるメニューを保存してあるので、そこからランダムに表示されます。
image.png
一部のメニューは、アプリとは別のサイトでレシピを確認するそうなので、その時は指定のURLを表示します。
ちなみに言葉は複数パターン用意し、ランダムで表示するようにしています。
image.png

献立の追加

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

そんな時のために/add 〇〇コマンドを用意しました。
これで、私が介入することなくメニューを増やしていくことができます。
作れる料理が増えることは偉大なので、しっかりとリアクションを返すようにしています。
image.png

作れるメニューを全て表示

メニューも今の所は50弱登録されているので(えらい!)、一覧が見たい時もあります。
その時のために /list を入力で全てのメニューを表示できるようにしています。
image.png

使用している技術

ここではまず、使用している技術の全体像を共有できればと思います。

image.png

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

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

Slash Commands

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

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

image.png
https://api.slack.com/interactivity/slash-commands

Incoming Webhook

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

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

image.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

今回の記事では認証を行わないため、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
image.png

DynamoDB

Amazon DynamoDBは AWS が提供する NoSQL データベースサービスです。

メニューのデータを保存するために使用しています。
nameをプライマリキー(PK, Primary Key)に、categoryとurlを設定して保存しています。
image.png

AWSでサーバーレスアプリケーションをする時の基本パターン

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

参考として、AWS SAM(サーバーレスアプリケーションモデル )というものが存在します。

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

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

献立の表示、追加の実装

ここからは肝である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


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は以下の構造体になっております。


// 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でもHeadersBodyRequestContextを使用しているのがわかります。

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

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


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メソッドを使用し、データの取得を行いました。
まず全件を取得する場合です。

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

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の取得条件を設定することができます。

DynamoDBへの値の追加

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

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を返したい時には以下のようにしています。

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実装を記事にしてみました。
作ったものは単純なものですが、自分で手を動かすことで理解が深まりました。

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

妻の使ってみた感想

良き。(以上です)

26
8
2

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
26
8