よくある表情解析LINE Botを作った

Qiita を筆頭にいろんなエンジニアの方が既に作っているもので何番煎じか分かりませんが、個人的にサーバレスアプリケーションを作るのが好きなのと、Golang に慣れることを目的として LINE チャネルに画像を送ると表情解析してメッセージを返してくれる Bot を作りました。

## できたもの

こういう感じで本田翼さんの笑顔の画像を送ると

<img width="375" alt="iOS の画像 (1).png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73844/c0a50c71-097e-1300-3560-9970be56bbad.png">

幸福度合いをポイント化して返してくれます。

<img width="375" alt="iOS の画像.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73844/9c376a69-154c-a378-a7ab-584ffeb13ef8.png">

ちなみに「詳細を見る」ボタンを押してもまだ何もありません。
ゆくゆくは解析結果の詳細データを見れる Web サイトを用意してそこに飛ばすようにしたいなと思っています。

## アーキテクチャ

全体のアーキテクチャはこんな感じで、簡単に流れを説明すると以下の通り。

1. LINE の Webhook を API Gateway で受け取る
2. API Gateway から Lambda に Webhook のイベントを渡し、LINE チャネルに送られた画像を S3 にアップロードする
3. S3 からのレスポンスにオブジェクトの情報が含まれているのでオブジェクトの URL を Dynamo DB に登録
4. S3 にアップロードされたことをトリガーに別の Lambda を起動し、アップロードされた画像を Rekognition に送る
5. Rekognition で解析された結果を加工し、DynamoDB に送る
6. DynamoDB にデータが書き込まれたことをトリガーに Lambda を起動し、Rekognition で解析された結果をメッセージにして LINE の Messaging API 経由でユーザに返信する

![Untitled (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73844/ed52a9f1-a2c0-9006-3569-ad688538d252.png)

結構シンプルな構成にできました。

## 要所要所のポイント

### API Gateway から送られてくる LINE Webhook イベントの情報

API Gateway から Lambda に渡されたときの Webhook のデータ形式は以下の通り。
`events` プロパティに全て格納されているので、以下のデータ構造に則った構造体を定義してマッピングしてあげれば自由に扱えます。

`userId` がメッセージを送信した LINE のユーザID になります。
`replyToken` はメッセージに対して返信する際に必要なトークンです。
今回、画像を送った際に「結果が出るまで少々お待ち下さい！」というメッセージを返しているので、これを利用しています。
`message` プロパティの中にある `id` は後述する LINE サーバ上から画像を取得するために必要な情報です。

```json
{
    "events": [
        {
            "type": "message",
            "replyToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "source": {
                "userId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
                "type": "user"
            },
            "timestamp": 1580041653273,
            "mode": "active",
            "message": {
                "type": "image",
                "id": "00000000000000",
                "contentProvider": {
                    "type": "line"
                }
            }
        }
    ],
    "destination": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```

### S3

S3 は Cloudfront 経由でしかアクセスできないように制限をかけ、ドメインを設定して https でアクセスできるように設定します。
これで画像を S3 にアップロードした後に `https://image.hogehoge.com/{obejct-id}.jpeg` といった URL でアクセスできるようになります。

参考：[CloudFront 経由で S3 のファイルにアクセスする](https://qiita.com/NaokiIshimura/items/46994e67b712831c3016)

### DynamoDB

DynamoDB のテーブル設計はこんな感じにしました。
もう少し詳細なデータを取っておきたいと思っているので今後フィールドを増やそうと思います。

| キー名 | 型 | 説明 |
|---|---|---|
| key | String | S3 にアップロードしたオブジェクトのファイル名 |
| emotion | String | Rekognition から受け取る表情分析の結果 |
| image_url | String | S3 にアップロードした画像の URL |
| created_at| String | レコードの生成時刻 |
| user_id | String | LINE のユーザID |

- Webhook を受け取る Lambda と結果を返す Lambda が異なる
- 結果を返す際に LINE のユーザIDを指定する必要がある

これらの理由からアップロードしたオブジェクト名をユニークなキーとし、レコードに LINE のユーザID を保持するようにしました。

### DynamoDB Stream から送られるデータ構造

DynamoDB Stream から Lambda に送られるデータ構造は以下の通り。
ちゃんと設計した通りの Key 名と型、値が入っています。配列で渡されるので for 文で1件ずつ取得する感じですね。

```json
[
    {
        "awsRegion": "ap-northeast-1",
        "dynamodb": {
            "ApproximateCreationDateTime": 1579855557,
            "Keys": {
                "key": {
                    "S": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.jpeg"
                }
            },
            "NewImage": {
                "created_at": {
                    "S": "2020-01-24 17:45:47.005448755 +0900 Asia/Tokyo"
                },
                "image_url": {
                    "S": "https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.jpeg"
                },
                "key": {
                    "S": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.jpeg"
                },
                "user_id": {
                    "S": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
                }
            },
            "SequenceNumber": "226975900000000037820548953",
            "SizeBytes": 287,
            "StreamViewType": "NEW_AND_OLD_IMAGES"
        },
        "eventID": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "eventName": "INSERT",
        "eventSource": "aws:dynamodb",
        "eventVersion": "1.1",
        "eventSourceARN": "arn:aws:dynamodb:ap-northeast-1:000000000000:table/{table_name}/stream/2019-12-12T15:59:27.210"
    }
]
```

`eventName` が `INSERT` なら新規追加、 `MODIFY` なら更新なのでそれを見て条件分岐させます。

### LINE に送った画像の取得

LINEチャネルに送信した画像は LINE のサーバにアップロードされるわけですが、リクエストヘッダに `Authorization: Bearer {AccessToken}` をセットして以下の URL でアップロードした画像にアクセスすることができます。

```
https://api-data.line.me/v2/bot/message/{messageID}/content
```

公式ドキュメントURL：https://developers.line.biz/ja/reference/messaging-api/#get-content

Golang で画像を取得する場合のサンプル

```go
// NOTE: LINE_URL = https://api.line.me/v2/bot/message/
// GetImageObject LINEに送信した画像を取得する
func (l *lineAPIImpl) GetImageObject(messageID string) (*bytes.Reader, error) {
	url := os.Getenv("LINE_URL") + messageID + "/content"
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("LINE_ACCESS_TOKEN"))
	client := new(http.Client)
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	bytesResp, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	reader := bytes.NewReader(bytesResp)
	return reader, nil
}
```

これで取得した画像を S3 にアップロードすることができます。

### 各 Lambda にトリガーを設定する

それぞれの Lambda を起動するタイミングを設定します。

- LINE の Webhook イベントを受ける Lambda -> トリガーに API Gateway を設定
  <img width="1626" alt="スクリーンショット 2020-01-26 21.38.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73844/683fcdbf-154f-9293-b669-b3c24a042462.png">
- Rekognition に画像をおくる Lambda -> トリガーに S3 の ObjectCreated を設定
  <img width="1626" alt="スクリーンショット 2020-01-26 21.39.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73844/52676224-ba49-282b-870a-498159c8b13c.png">
- LINE に結果を返す Lambda -> トリガーに DynamoDB Stream を設定
  <img width="1626" alt="スクリーンショット 2020-01-26 21.36.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73844/f774553c-8ee1-bafb-6f21-afdae10e4e1d.png">

## ここまで作り終えての所感

### DynamoDB の AWS の SDK が結構つらい

AWS SDK の DynamoDB のドキュメントを読みながら新規レコードの登録処理と、既存レコードの更新処理を書いたんですが、これが結構つらかった。

以下のコードは新規レコードを登録するときの処理ですが、これはまだ分かりやすい。
DynamoDB に登録したいフィールド名とその型、投入する値をセットして `*dynamodb.PutItemInput` 型のデータを作り、SDK の `PutItem` メソッドに渡してやればOKです。

```go
// Post DynamoDB に新規レコードを登録する
func (s *DynamoDBImpl) Post(request *model.DynamoPostRequest) (*dynamodb.PutItemOutput, error) {
	params := &dynamodb.PutItemInput{
		TableName: aws.String(os.Getenv("TABLE_NAME")),
		Item: map[string]*dynamodb.AttributeValue{
			"key": {
				S: aws.String(request.Key),
			},
			"image_url": {
				S: aws.String(request.ImageURL),
			},
			"user_id": {
				S: aws.String(request.UserID),
			},
			"created_at": {
				S: aws.String(request.CreatedAt),
			},
		},
	}
	return s.DynamoDB.PutItem(params)
}
```

続いて更新。こいつがよく分からない。
これは Rekognition から表情解析の結果を受け取った後にその情報を DynamoDB の既存レコードにセットして更新する時の処理です。

`*dynamodb.UpdateItemInput` 型のデータを作る時に必要なパラメータがいくつかあるんですが、公式ドキュメントを読んでもこれらのパラメータが何なのかよく理解できませんでした…。
公式ドキュメントのサンプルを見ながらひたすら埋めただけなのでここはちゃんと理解する必要があるなという気持ちでいっぱいです。

正直公式ドキュメントを読みながらこのコードを書いてる時に「自分は今何を書いてるんだろう…？」という気持ちになっていました。

```go
// Put DynamoDBのレコードを更新する
func (s *DynamoDBImpl) Put(request *model.DynamoPostRequest) (*dynamodb.UpdateItemOutput, error) {
	params := &dynamodb.UpdateItemInput{
		TableName: aws.String(os.Getenv("TABLE_NAME")),
		Key: map[string]*dynamodb.AttributeValue{
			"key": {
				S: aws.String(request.Key),
			},
		},
		ExpressionAttributeNames: map[string]*string{
			"#emotion": aws.String("emotion"),
		},
		ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
			":emotion_value": {
				S: aws.String(strconv.FormatFloat(*request.Emotion, 'f', 4, 64)),
			},
		},
		UpdateExpression:            aws.String("SET #emotion = :emotion_value"),
		ReturnConsumedCapacity:      aws.String("NONE"),
		ReturnItemCollectionMetrics: aws.String("NONE"),
		ReturnValues:                aws.String("NONE"),
	}
	return s.DynamoDB.UpdateItem(params)
}
```

ただ、Golang で DynamoDB を扱う際はサードパーティ製のライブラリがあるのでそちらを使った方がいいかもしれません。
※今回は AWS のリソースに対して何かする時はとりあえず公式の SDK だけを利用するように心がけたので利用しませんでした。

[気楽にDynamoDBを使おう](https://qiita.com/guregu/items/2e9ac305791935b86f5d)
[あえて aws-sdk-go で dynamoDB を使うときの基本操作](https://qiita.com/ezaki/items/2a9f10c53d958070ca95)

### 開発期間

開発期間としては、平日仕事終わりに帰宅して食事や諸々の家事を済ませてから寝るまでの間のおよそ2時間程度、休日は予定がない日のみ2〜4時間程度の時間を使って1ヶ月ちょっとでした。
ただ、まだ細かいところまで作りきれていないので、必要最低限動くものが完成するのに1ヶ月ちょっとですね。

### Go の特性を大いに感じた

Go は言語仕様がシンプルだと言われており、更に静的型付け言語でもあるのでコードを書いていて非常に分かりやすかったです。
特に AWS SDK に関しては公式ドキュメントが充実しているおかげでやりたいことを実装するハードルも低かったと感じます（前述の DynamoDB の部分は除く😓）

また、 Lambda にデプロイしてから Rekognition に画像を渡すところを実際に動作確認したところ、型安全性が担保されているおかげか、Rekognition へ画像を送信、結果の取得まで一発でうまく動作しました。

この辺りで静的型付け言語の型安全の恩恵を非常に感じました。

引き続きこのプロダクトに関してはブラッシュアップさせていきたいと思っているので、ちゃんと完成させたいと思います。